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:
1 parent
785154eb83
commit
f7b58f00ee
34 files changed
+776
-1925
No files matched your search
@@ -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},
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
Reference in new issue
Block a user