feat: replace MQTT bridge with localthings HA custom component

Convert from MQTT bridge architecture to a proper Home Assistant
integration (custom_components/localthings/):

- Config flow: IP-only entry, probes ports [49154,49155], cert/key
  reused from first device on subsequent adds
- Coordinator: DataUpdateCoordinator wrapping blocking DTLS session
  via async_add_executor_job; discovery on first poll; dynamic 5s/30s
  interval based on active_when
- Entity platforms: sensor, binary_sensor, switch, number, select
- Library cleanup: delete bridge.py, main.py, config.py, logger.py,
  keepalive.py, poll_scheduler.py, sensors.py, state_cache.py
- adapter.py stripped to flatten(), is_active(), _key()
- _parse_device0_batch moved to samsung_appliance/batch.py
This commit is contained in:
Marc Billow committed 2026-06-30 16:34:06 -05:00
1 parent 785154eb83
commit f7b58f00ee
34 files changed
+776 -1925

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
"""Local Things — Samsung appliance local control integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
coordinator = LocalThingsCoordinator(hass, entry)
try:
await coordinator.async_config_entry_first_refresh()
except Exception as err:
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
hass.data[DOMAIN][entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
await hass.async_add_executor_job(coordinator._close_session)
return unloaded
@@ -0,0 +1,38 @@
"""Binary sensor platform for Local Things."""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from samsung_appliance.registry.entities import BinarySensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsBinarySensor(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, BinarySensorDesc)
)
class LocalThingsBinarySensor(LocalThingsEntity, BinarySensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: BinarySensorDesc = bound.desc
self._attr_device_class = desc.device_class
@property
def is_on(self):
return (self.coordinator.data or {}).get(self._state_key)
@@ -0,0 +1,169 @@
"""Config flow for Local Things integration."""
from __future__ import annotations
import logging
import os
import tempfile
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN, CONF_HOST, CONF_PORT, CONF_CERT_PEM, CONF_KEY_PEM, PROBE_PORTS
_LOGGER = logging.getLogger(__name__)
class CannotConnect(Exception):
pass
def _probe_and_validate(host: str, cert_pem: str, key_pem: str) -> dict:
"""Try each known port; return {port, serial} on first success.
DtlsCoapSession only accepts file paths, so PEM strings are written
to a temp-file pair and cleaned up in the finally block.
"""
import cbor2
from samsung_appliance.coap_dtls import DtlsCoapSession
from samsung_appliance.batch import parse_device0_batch
# Write PEM strings to temporary files — DtlsCoapSession requires
# cert_path / key_path rather than in-memory PEM strings.
cert_file = tempfile.NamedTemporaryFile(
mode='w', suffix='.pem', delete=False)
key_file = tempfile.NamedTemporaryFile(
mode='w', suffix='.pem', delete=False)
try:
cert_file.write(cert_pem)
cert_file.flush()
cert_file.close()
key_file.write(key_pem)
key_file.flush()
key_file.close()
last_exc = None
for port in PROBE_PORTS:
sess = None
try:
sess = DtlsCoapSession(
host, port,
cert_path=cert_file.name,
key_path=key_file.name,
)
sess.connect()
sess.start_reader()
code, payload = sess.get(['device', '0'], timeout=15.0)
if code != 0x45 or not payload:
raise CannotConnect(
f"port {port}: unexpected code {code:#04x}")
body = cbor2.loads(payload)
resources = (
parse_device0_batch(body) if isinstance(body, list)
else {}
)
serial = (
resources
.get('/information/vs/0', {})
.get('x.com.samsung.da.serialNum', '')
)
if not serial:
serial = (
resources
.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('firmwareVersion', '')
)
if not serial:
serial = f"{host}:{port}"
return {"port": port, "serial": serial}
except CannotConnect:
raise
except Exception as exc:
last_exc = exc
_LOGGER.debug("port %d failed: %s", port, exc)
finally:
if sess is not None:
try:
sess.close()
except Exception:
pass
raise CannotConnect(f"no port responded on {host}: {last_exc}")
finally:
for path in (cert_file.name, key_file.name):
try:
os.unlink(path)
except OSError:
pass
class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
self._host: str = ""
self._cert_pem: str = ""
self._key_pem: str = ""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
existing = self.hass.config_entries.async_entries(DOMAIN)
has_creds = bool(existing)
errors: dict[str, str] = {}
if user_input is not None:
self._host = user_input[CONF_HOST].strip()
if has_creds:
self._cert_pem = existing[0].data[CONF_CERT_PEM]
self._key_pem = existing[0].data[CONF_KEY_PEM]
else:
self._cert_pem = user_input[CONF_CERT_PEM].strip()
self._key_pem = user_input[CONF_KEY_PEM].strip()
try:
info = await self.hass.async_add_executor_job(
_probe_and_validate,
self._host,
self._cert_pem,
self._key_pem,
)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error during device probe")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(
f"localthings_{info['serial']}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"Samsung Appliance ({self._host})",
data={
CONF_HOST: self._host,
CONF_PORT: info["port"],
CONF_CERT_PEM: self._cert_pem,
CONF_KEY_PEM: self._key_pem,
},
)
if has_creds:
schema = vol.Schema({vol.Required(CONF_HOST): str})
reuse_note = "Certificate and key will be reused from the existing device."
else:
schema = vol.Schema({
vol.Required(CONF_HOST): str,
vol.Required(CONF_CERT_PEM): str,
vol.Required(CONF_KEY_PEM): str,
})
reuse_note = ""
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
description_placeholders={"reuse_note": reuse_note},
)
+13
View File
@@ -0,0 +1,13 @@
DOMAIN = "localthings"
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select"]
CONF_HOST = "host"
CONF_PORT = "port"
CONF_CERT_PEM = "cert_pem"
CONF_KEY_PEM = "key_pem"
PROBE_PORTS = [49154, 49155]
ACTIVE_INTERVAL_S = 5.0
IDLE_INTERVAL_S = 30.0
@@ -0,0 +1,221 @@
"""Coordinator for Local Things integration."""
from __future__ import annotations
import logging
import tempfile
import os
from datetime import timedelta
from typing import Any
import cbor2
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.device_registry import DeviceInfo
from samsung_appliance.batch import parse_device0_batch
from samsung_appliance.coap_dtls import DtlsCoapSession
from samsung_appliance.registry.by_type import for_device
from samsung_appliance.registry.discovery import discover, BoundEntity
from samsung_appliance.registry import CAPABILITIES
from samsung_appliance.registry.adapter import flatten, is_active
from samsung_appliance.registry.identity import read_identity, DeviceIdentity
from .const import (
DOMAIN, CONF_HOST, CONF_PORT, CONF_CERT_PEM, CONF_KEY_PEM,
ACTIVE_INTERVAL_S, IDLE_INTERVAL_S,
)
_LOGGER = logging.getLogger(__name__)
_SEED_PATH = ['device', '0']
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Manages one Samsung appliance: session, discovery, polling."""
bound: list[BoundEntity]
device_info: DeviceInfo
device_serial: str
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
update_interval=timedelta(seconds=IDLE_INTERVAL_S),
)
self._entry = entry
self._session: DtlsCoapSession | None = None
self._identity: DeviceIdentity | None = None
self._discovered = False
self.bound = []
self._last_resources: dict[str, dict] = {}
self.device_serial = entry.data[CONF_HOST] # placeholder until first poll
self.device_info = DeviceInfo(
identifiers={(DOMAIN, entry.data[CONF_HOST])},
name=f"Samsung Appliance ({entry.data[CONF_HOST]})",
manufacturer="Samsung",
)
# ------------------------------------------------------------------
# Session management (all blocking — must run in executor)
# ------------------------------------------------------------------
def _connect_session(self) -> None:
host = self._entry.data[CONF_HOST]
port = self._entry.data[CONF_PORT]
cert_pem: str = self._entry.data[CONF_CERT_PEM]
key_pem: str = self._entry.data[CONF_KEY_PEM]
cert_f = tempfile.NamedTemporaryFile(suffix='.pem', delete=False, mode='w')
key_f = tempfile.NamedTemporaryFile(suffix='.pem', delete=False, mode='w')
try:
cert_f.write(cert_pem); cert_f.flush(); cert_f.close()
key_f.write(key_pem); key_f.flush(); key_f.close()
sess = DtlsCoapSession(host, port,
cert_path=cert_f.name,
key_path=key_f.name)
sess.connect()
sess.start_reader()
self._session = sess
_LOGGER.debug("DTLS connected to %s:%d", host, port)
try:
self._identity = read_identity(sess, None)
except Exception as e:
_LOGGER.debug("read_identity failed: %s", e)
self._identity = None
finally:
for f in (cert_f.name, key_f.name):
try:
os.unlink(f)
except OSError:
pass
def _close_session(self) -> None:
sess = self._session
self._session = None
if sess is not None:
try:
sess.close()
except Exception:
pass
def _poll_once(self) -> dict[str, dict]:
"""GET /device/0, return parsed resources. Blocking."""
if self._session is None:
self._connect_session()
sess = self._session
try:
code, payload = sess.get(_SEED_PATH, timeout=15.0)
except Exception as e:
self._close_session()
raise RuntimeError(f"poll GET failed: {e}") from e
if code != 0x45 or not payload:
self._close_session()
raise RuntimeError(f"poll: unexpected code {code:#04x}")
try:
body = cbor2.loads(payload)
except Exception as e:
raise RuntimeError(f"poll cbor decode: {e}") from e
return parse_device0_batch(body) if isinstance(body, list) else {}
# ------------------------------------------------------------------
# Discovery (runs once on first successful poll)
# ------------------------------------------------------------------
def _run_discovery(self, resources: dict[str, dict]) -> None:
one_ui = (resources.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', ''))
reg = for_device(one_ui) if one_ui else None
if reg is not None:
_LOGGER.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
else:
_LOGGER.warning("unknown device type oneUiVersion=%r; using common caps", one_ui)
bound = discover(resources, CAPABILITIES)
self.bound = bound
serial = (resources.get('/information/vs/0', {})
.get('x.com.samsung.da.serialNum', ''))
if not serial:
serial = self._entry.data[CONF_HOST]
self.device_serial = serial
ident = self._identity
model = (ident.model if ident else '') or (reg.name.title() if reg else 'Appliance')
name = (ident.name if ident else '') or f"Samsung {model}"
mfr = (ident.manufacturer if ident else '') or 'Samsung'
self.device_info = DeviceInfo(
identifiers={(DOMAIN, serial)},
name=name,
manufacturer=mfr,
model=model,
)
self._discovered = True
_LOGGER.info("discovered %d entities (serial=%s)", len(bound), serial)
# ------------------------------------------------------------------
# DataUpdateCoordinator hook
# ------------------------------------------------------------------
async def _async_update_data(self) -> dict[str, Any]:
try:
resources = await self.hass.async_add_executor_job(self._poll_once)
except Exception as e:
# One reconnect attempt
_LOGGER.warning("poll failed, reconnecting: %s", e)
try:
await self.hass.async_add_executor_job(self._close_session)
resources = await self.hass.async_add_executor_job(self._poll_once)
except Exception as e2:
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
self._last_resources = resources
if not self._discovered:
self._run_discovery(resources)
# Adjust polling interval dynamically
active = is_active(self.bound, resources)
self.update_interval = timedelta(
seconds=ACTIVE_INTERVAL_S if active else IDLE_INTERVAL_S
)
return flatten(self.bound, resources)
# ------------------------------------------------------------------
# Command dispatch (called by entity platforms in Task 5)
# ------------------------------------------------------------------
async def async_send_command(self, bound_entity: BoundEntity,
payload: Any) -> None:
"""Write a value to the device. Fire-and-forget style."""
desc = bound_entity.desc
write_fn = getattr(desc, 'write_fn', None)
if write_fn is None:
return
href = bound_entity.href
rep = self._last_resources.get(href or '', {})
result = write_fn(payload, rep, href)
if result is None:
_LOGGER.warning("write_fn rejected payload %r for %s", payload, href)
return
path_segs, body = result
def _do_put():
sess = self._session
if sess is None:
raise RuntimeError("no session")
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
_LOGGER.info("PUT %s → code %#04x", href, code)
try:
await self.hass.async_add_executor_job(_do_put)
except Exception as e:
_LOGGER.error("command failed for %s: %s", href, e)
else:
await self.async_request_refresh()
+31
View File
@@ -0,0 +1,31 @@
"""Base entity for Local Things."""
from __future__ import annotations
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.device_registry import DeviceInfo
from samsung_appliance.registry.adapter import _key
from samsung_appliance.registry.discovery import BoundEntity
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
"""Base class for all Local Things entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: LocalThingsCoordinator, bound: BoundEntity) -> None:
super().__init__(coordinator)
self._bound = bound
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
self._attr_name = bound.desc.name
self._attr_icon = bound.desc.icon
self._attr_entity_category = bound.desc.entity_category
self._attr_entity_registry_enabled_default = bound.desc.enabled_default
@property
def device_info(self) -> DeviceInfo:
return self.coordinator.device_info
@@ -0,0 +1,10 @@
{
"domain": "localthings",
"name": "Local Things",
"version": "0.1.0",
"config_flow": true,
"iot_class": "local_polling",
"requirements": ["cbor2>=5.4.6", "pyOpenSSL>=22.0.0"],
"dependencies": [],
"codeowners": []
}
+50
View File
@@ -0,0 +1,50 @@
"""Number platform for Local Things."""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from samsung_appliance.registry.entities import NumberDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsNumber(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, NumberDesc)
)
class LocalThingsNumber(LocalThingsEntity, NumberEntity):
_attr_mode = NumberMode.BOX
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: NumberDesc = bound.desc
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = desc.device_class
if desc.native_min is not None:
self._attr_native_min_value = desc.native_min
if desc.native_max is not None:
self._attr_native_max_value = desc.native_max
if desc.step is not None:
self._attr_native_step = desc.step
@property
def native_value(self):
return (self.coordinator.data or {}).get(self._state_key)
async def async_set_native_value(self, value: float) -> None:
await self.coordinator.async_send_command(self._bound, value)
+42
View File
@@ -0,0 +1,42 @@
"""Select platform for Local Things."""
from __future__ import annotations
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from samsung_appliance.registry.entities import SelectDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsSelect(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, SelectDesc)
)
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SelectDesc = bound.desc
opts = desc.options
self._attr_options = list(opts() if callable(opts) else opts)
@property
def current_option(self):
return (self.coordinator.data or {}).get(self._state_key)
async def async_select_option(self, option: str) -> None:
await self.coordinator.async_send_command(self._bound, option)
+40
View File
@@ -0,0 +1,40 @@
"""Sensor platform for Local Things."""
from __future__ import annotations
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from samsung_appliance.registry.entities import SensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsSensor(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, SensorDesc)
)
class LocalThingsSensor(LocalThingsEntity, SensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SensorDesc = bound.desc
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = desc.device_class
self._attr_state_class = desc.state_class
@property
def native_value(self):
return (self.coordinator.data or {}).get(self._state_key)
@@ -0,0 +1,22 @@
{
"config": {
"step": {
"user": {
"title": "Add Samsung Appliance",
"description": "Enter the IP address of your Samsung appliance. {reuse_note}",
"data": {
"host": "IP Address",
"cert_pem": "Certificate (PEM)",
"key_pem": "Private Key (PEM)"
}
}
},
"error": {
"cannot_connect": "Cannot connect to the device. Check the IP address and credentials.",
"unknown": "Unexpected error."
},
"abort": {
"already_configured": "This device is already configured."
}
}
}
+44
View File
@@ -0,0 +1,44 @@
"""Switch platform for Local Things."""
from __future__ import annotations
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from samsung_appliance.registry.entities import SwitchDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsSwitch(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, SwitchDesc)
)
class LocalThingsSwitch(LocalThingsEntity, SwitchEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SwitchDesc = bound.desc
self._attr_device_class = desc.device_class
@property
def is_on(self):
return (self.coordinator.data or {}).get(self._state_key)
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, 'On')
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, 'Off')