Compare commits

...

6 Commits

Author SHA1 Message Date
Paulus Schoutsen
ca80fe9642 Bump rf-protocols to 2.0.0
2.0.0 renames load_codes to get_codes. Update the import and call
site in light.py accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 07:21:55 -04:00
Paulus Schoutsen
0776d6f4f7 Bump rf-protocols to 1.0.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:14:06 -04:00
Paulus Schoutsen
34d5e341ed Bump rf-protocols to 1.0.0 and load codes from disk
The 1.0.0 release replaces the per-device command classes with a
filesystem-backed loader. Load the device codes once at module level in
light.py and resolve commands lazily through an executor when needed.

The conftest mock command also follows the new list[int] timings
contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 23:37:04 -04:00
Paulus Schoutsen
2b39c053b9 Remove __init__ method from Honeywell light component
Removed the __init__ method from the light class.
2026-04-17 19:10:35 -04:00
Paulus Schoutsen
75612381a2 Adopt availability tracking from lg_infrared 2026-04-17 18:21:59 -04:00
Paulus Schoutsen
77db7ddeca Add honeywell_string_lights integration
Introduce a new Honeywell String Lights integration that drives the
lights over the radio_frequency entity platform. The OOK turn on/off
commands are provided by the rf-protocols library.

Bump the rf-protocols requirement to 0.1.0 across radio_frequency and
honeywell_string_lights.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:17:06 -04:00
21 changed files with 638 additions and 8 deletions

2
CODEOWNERS generated
View File

@@ -752,6 +752,8 @@ CLAUDE.md @home-assistant/core
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/honeywell_string_lights/ @balloob
/tests/components/honeywell_string_lights/ @balloob
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r

View File

@@ -1,5 +1,5 @@
{
"domain": "honeywell",
"name": "Honeywell",
"integrations": ["lyric", "evohome", "honeywell"]
"integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"]
}

View File

@@ -0,0 +1,20 @@
"""The Honeywell String Lights integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Honeywell String Lights from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,61 @@
"""Config flow for the Honeywell String Lights integration."""
from __future__ import annotations
from typing import Any
from rf_protocols import RadioFrequencyCommand
import voluptuous as vol
from homeassistant.components.radio_frequency import async_get_transmitters
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
from .const import CONF_TRANSMITTER, DOMAIN
from .light import COMMANDS
class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Honeywell String Lights."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
sample_command: RadioFrequencyCommand = await self.hass.async_add_executor_job(
COMMANDS.load_command, "turn_on"
)
try:
transmitters = async_get_transmitters(
self.hass, sample_command.frequency, sample_command.modulation
)
except HomeAssistantError:
return self.async_abort(reason="no_transmitters")
if not transmitters:
return self.async_abort(reason="no_compatible_transmitters")
if user_input is not None:
registry = er.async_get(self.hass)
entity_entry = registry.async_get(user_input[CONF_TRANSMITTER])
assert entity_entry is not None
await self.async_set_unique_id(entity_entry.id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Honeywell String Lights",
data={CONF_TRANSMITTER: entity_entry.id},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_TRANSMITTER): selector.EntitySelector(
selector.EntitySelectorConfig(include_entities=transmitters),
),
}
),
)

View File

@@ -0,0 +1,9 @@
"""Constants for the Honeywell String Lights integration."""
from __future__ import annotations
from typing import Final
DOMAIN: Final = "honeywell_string_lights"
CONF_TRANSMITTER: Final = "transmitter"

View File

@@ -0,0 +1,77 @@
"""Common entity for Honeywell String Lights integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_TRANSMITTER, DOMAIN
_LOGGER = logging.getLogger(__name__)
class HoneywellStringLightsEntity(Entity):
"""Honeywell String Lights base entity."""
_attr_has_entity_name = True
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the entity."""
self._transmitter = entry.data[CONF_TRANSMITTER]
self._attr_unique_id = entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Honeywell",
model="String Lights",
name=entry.title,
)
async def async_added_to_hass(self) -> None:
"""Subscribe to transmitter entity state changes."""
await super().async_added_to_hass()
transmitter_entity_id = er.async_validate_entity_id(
er.async_get(self.hass), self._transmitter
)
@callback
def _async_transmitter_state_changed(
event: Event[EventStateChangedData],
) -> None:
"""Handle transmitter entity state changes."""
new_state = event.data["new_state"]
transmitter_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
if transmitter_available != self.available:
_LOGGER.info(
"Transmitter %s used by %s is %s",
transmitter_entity_id,
self.entity_id,
"available" if transmitter_available else "unavailable",
)
self._attr_available = transmitter_available
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass,
[transmitter_entity_id],
_async_transmitter_state_changed,
)
)
# Set initial availability based on current transmitter entity state
transmitter_state = self.hass.states.get(transmitter_entity_id)
self._attr_available = (
transmitter_state is not None
and transmitter_state.state != STATE_UNAVAILABLE
)

View File

@@ -0,0 +1,64 @@
"""Light platform for Honeywell String Lights."""
from __future__ import annotations
from typing import Any
from rf_protocols import get_codes
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.components.radio_frequency import async_send_command
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .entity import HoneywellStringLightsEntity
PARALLEL_UPDATES = 1
COMMANDS = get_codes("honeywell/string_lights")
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell String Lights light platform."""
async_add_entities([HoneywellStringLight(config_entry)])
class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEntity):
"""Representation of a Honeywell String Lights set controlled via RF."""
_attr_assumed_state = True
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_is_on = False
_attr_name = None
_attr_should_poll = False
async def async_added_to_hass(self) -> None:
"""Restore last known state."""
await super().async_added_to_hass()
if (last_state := await self.async_get_last_state()) is not None:
self._attr_is_on = last_state.state == STATE_ON
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
await self._async_send_command("turn_on")
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self._async_send_command("turn_off")
self._attr_is_on = False
self.async_write_ha_state()
async def _async_send_command(self, name: str) -> None:
"""Load the named command and send it via the configured transmitter."""
command = await self.hass.async_add_executor_job(COMMANDS.load_command, name)
await async_send_command(self.hass, self._transmitter, command)

View File

@@ -0,0 +1,12 @@
{
"domain": "honeywell_string_lights",
"name": "Honeywell String Lights",
"codeowners": ["@balloob"],
"config_flow": true,
"dependencies": ["radio_frequency"],
"documentation": "https://www.home-assistant.io/integrations/honeywell_string_lights",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze",
"requirements": ["rf-protocols==2.0.0"]
}

View File

@@ -0,0 +1,124 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not register custom service actions.
appropriate-polling:
status: exempt
comment: |
This integration transmits RF commands and does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not register custom service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data:
status: exempt
comment: |
This integration does not use runtime data.
test-before-configure:
status: exempt
comment: |
RF transmission is a one-way broadcast with no device to contact.
test-before-setup:
status: exempt
comment: |
RF transmission is a one-way broadcast with no device to contact.
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration has no options.
docs-installation-parameters: todo
entity-unavailable:
status: exempt
comment: |
RF transmission is a one-way broadcast; the light uses assumed state.
integration-owner: done
log-when-unavailable:
status: exempt
comment: |
RF transmission is a one-way broadcast; the light uses assumed state.
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not authenticate.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
RF devices cannot be discovered.
docs-data-update:
status: exempt
comment: |
RF transmission is one-way; there is no data update.
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Each config entry represents a single static device.
entity-category:
status: exempt
comment: |
The single entity represents the primary device function.
entity-device-class:
status: exempt
comment: |
Light entities do not have device classes.
entity-disabled-by-default:
status: exempt
comment: |
The single entity represents the primary device function.
entity-translations:
status: exempt
comment: |
The entity uses the device name.
exception-translations: todo
icon-translations:
status: exempt
comment: |
Light uses the default icon for its state.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
No known repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry represents a single static device.
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
This integration does not use a web session.
strict-typing: todo

View File

@@ -0,0 +1,19 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.",
"no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first."
},
"step": {
"user": {
"data": {
"transmitter": "Radio frequency transmitter"
},
"data_description": {
"transmitter": "The radio frequency transmitter used to control the Honeywell String Lights."
}
}
}
}
}

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["rf-protocols==0.0.1"]
"requirements": ["rf-protocols==2.0.0"]
}

View File

@@ -309,6 +309,7 @@ FLOWS = {
"homewizard",
"homeworks",
"honeywell",
"honeywell_string_lights",
"hr_energy_qube",
"html5",
"huawei_lte",

View File

@@ -2963,6 +2963,12 @@
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Honeywell Total Connect Comfort (US)"
},
"honeywell_string_lights": {
"integration_type": "device",
"config_flow": true,
"iot_class": "assumed_state",
"name": "Honeywell String Lights"
}
}
},

2
requirements.txt generated
View File

@@ -47,7 +47,7 @@ python-slugify==8.0.4
PyTurboJPEG==2.2.0
PyYAML==6.0.3
requests==2.33.1
rf-protocols==0.0.1
rf-protocols==2.0.0
securetar==2026.4.1
SQLAlchemy==2.0.49
standard-aifc==3.13.0

3
requirements_all.txt generated
View File

@@ -2827,8 +2827,9 @@ renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.19.1
# homeassistant.components.honeywell_string_lights
# homeassistant.components.radio_frequency
rf-protocols==0.0.1
rf-protocols==2.0.0
# homeassistant.components.idteck_prox
rfk101py==0.0.1

View File

@@ -2408,8 +2408,9 @@ renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.19.1
# homeassistant.components.honeywell_string_lights
# homeassistant.components.radio_frequency
rf-protocols==0.0.1
rf-protocols==2.0.0
# homeassistant.components.rflink
rflink==0.0.67

View File

@@ -0,0 +1 @@
"""Tests for the Honeywell String Lights integration."""

View File

@@ -0,0 +1,56 @@
"""Common fixtures for the Honeywell String Lights tests."""
from __future__ import annotations
import pytest
from homeassistant.components.honeywell_string_lights.const import (
CONF_TRANSMITTER,
DOMAIN,
)
from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity
@pytest.fixture
async def mock_transmitter(hass: HomeAssistant) -> MockRadioFrequencyEntity:
"""Set up the radio_frequency component and register a mock transmitter."""
assert await async_setup_component(hass, RF_DOMAIN, {})
await hass.async_block_till_done()
entity = MockRadioFrequencyEntity("test_rf_transmitter")
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([entity])
return entity
@pytest.fixture
def mock_config_entry(
hass: HomeAssistant, mock_transmitter: MockRadioFrequencyEntity
) -> MockConfigEntry:
"""Return a mock config entry for Honeywell String Lights."""
entity_registry = er.async_get(hass)
entity_entry = entity_registry.async_get(mock_transmitter.entity_id)
assert entity_entry is not None
return MockConfigEntry(
domain=DOMAIN,
title="Honeywell String Lights",
data={CONF_TRANSMITTER: entity_entry.id},
unique_id=entity_entry.id,
)
@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> MockConfigEntry:
"""Set up the Honeywell String Lights integration."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -0,0 +1,89 @@
"""Test the Honeywell String Lights config flow."""
from __future__ import annotations
from homeassistant.components.honeywell_string_lights.const import (
CONF_TRANSMITTER,
DOMAIN,
)
from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity
async def test_user_flow(
hass: HomeAssistant, mock_transmitter: MockRadioFrequencyEntity
) -> None:
"""Test the user config flow creates an entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_TRANSMITTER: mock_transmitter.entity_id},
)
entity_registry = er.async_get(hass)
entity_entry = entity_registry.async_get(mock_transmitter.entity_id)
assert entity_entry is not None
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Honeywell String Lights"
assert result["data"] == {CONF_TRANSMITTER: entity_entry.id}
assert result["result"].unique_id == entity_entry.id
async def test_unique_id_already_configured(
hass: HomeAssistant,
mock_transmitter: MockRadioFrequencyEntity,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test aborting when the same transmitter is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_TRANSMITTER: mock_transmitter.entity_id},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_no_transmitters(hass: HomeAssistant) -> None:
"""Test the flow aborts when no RF transmitters are registered at all."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_transmitters"
async def test_no_compatible_transmitters(hass: HomeAssistant) -> None:
"""Test aborting when transmitters exist but none support 433.92 MHz OOK."""
assert await async_setup_component(hass, RF_DOMAIN, {})
await hass.async_block_till_done()
incompatible = MockRadioFrequencyEntity(
"incompatible", frequency_ranges=[(868_000_000, 869_000_000)]
)
await hass.data[DATA_COMPONENT].async_add_entities([incompatible])
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_compatible_transmitters"

View File

@@ -0,0 +1,87 @@
"""Tests for the Honeywell String Lights light platform."""
from __future__ import annotations
from homeassistant.components.honeywell_string_lights.light import COMMANDS
from homeassistant.components.light import (
DOMAIN as LIGHT_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant, State
from tests.common import MockConfigEntry, mock_restore_cache
from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity
ENTITY_ID = "light.honeywell_string_lights"
async def test_turn_on_off_sends_commands(
hass: HomeAssistant,
mock_transmitter: MockRadioFrequencyEntity,
init_integration: MockConfigEntry,
) -> None:
"""Test turning the light on and off sends the correct RF commands."""
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_OFF
assert state.attributes[ATTR_ASSUMED_STATE] is True
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
assert hass.states.get(ENTITY_ID).state == STATE_ON
assert len(mock_transmitter.send_command_calls) == 1
assert mock_transmitter.send_command_calls[0] is COMMANDS.load_command("turn_on")
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
assert hass.states.get(ENTITY_ID).state == STATE_OFF
assert len(mock_transmitter.send_command_calls) == 2
assert mock_transmitter.send_command_calls[1] is COMMANDS.load_command("turn_off")
async def test_restore_state(
hass: HomeAssistant,
mock_transmitter: MockRadioFrequencyEntity,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the light restores its previous on state."""
mock_restore_cache(hass, [State(ENTITY_ID, STATE_ON)])
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_ON
async def test_unload_entry(
hass: HomeAssistant, init_integration: MockConfigEntry
) -> None:
"""Test unloading the config entry removes the entity."""
assert hass.states.get(ENTITY_ID) is not None
assert await hass.config_entries.async_unload(init_integration.entry_id)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE

View File

@@ -3,7 +3,7 @@
from typing import override
import pytest
from rf_protocols import ModulationType, RadioFrequencyCommand, Timing
from rf_protocols import ModulationType, RadioFrequencyCommand
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.components.radio_frequency.const import DOMAIN
@@ -34,9 +34,9 @@ class MockRadioFrequencyCommand(RadioFrequencyCommand):
)
@override
def get_raw_timings(self) -> list[Timing]:
def get_raw_timings(self) -> list[int]:
"""Return mock timings."""
return [Timing(high_us=350, low_us=1050), Timing(high_us=350, low_us=350)]
return [350, -1050, 350, -350]
class MockRadioFrequencyEntity(RadioFrequencyTransmitterEntity):