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>
This commit is contained in:
Paulus Schoutsen
2026-04-17 15:17:06 -04:00
parent 07654bfe8b
commit 77db7ddeca
19 changed files with 574 additions and 5 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,59 @@
"""Config flow for the Honeywell String Lights integration."""
from __future__ import annotations
from typing import Any
from rf_protocols import HoneywellStringLightsTurnOn
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
_SAMPLE_COMMAND = HoneywellStringLightsTurnOn()
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."""
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,78 @@
"""Light platform for Honeywell String Lights."""
from __future__ import annotations
from typing import Any
from rf_protocols import (
HoneywellStringLightsTurnOff,
HoneywellStringLightsTurnOn,
RadioFrequencyCommand,
)
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.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import CONF_TRANSMITTER, DOMAIN
PARALLEL_UPDATES = 1
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(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_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize the light."""
self._transmitter = config_entry.data[CONF_TRANSMITTER]
self._attr_unique_id = config_entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="Honeywell",
model="String Lights",
name=config_entry.title,
)
self._attr_is_on = 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(HoneywellStringLightsTurnOn())
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(HoneywellStringLightsTurnOff())
self._attr_is_on = False
self.async_write_ha_state()
async def _async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command using the configured transmitter."""
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==0.1.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==0.1.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==0.1.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==0.1.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==0.1.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,91 @@
"""Tests for the Honeywell String Lights light platform."""
from __future__ import annotations
from rf_protocols import HoneywellStringLightsTurnOff, HoneywellStringLightsTurnOn
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
command = mock_transmitter.send_command_calls[0]
assert isinstance(command, HoneywellStringLightsTurnOn)
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 isinstance(
mock_transmitter.send_command_calls[1], HoneywellStringLightsTurnOff
)
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