diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..78f0a40 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,22 @@ +name: Tests + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + pytest: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements_test.txt + - name: Run tests + run: pytest -v diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..9d86a1d --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,26 @@ +name: Validate + +on: + push: + pull_request: + schedule: + - cron: "0 3 * * 1" + workflow_dispatch: + +jobs: + hassfest: + name: Hassfest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: home-assistant/actions/hassfest@master + + hacs: + name: HACS + runs-on: ubuntu-latest + steps: + - uses: hacs/action@main + with: + category: integration + # the integration is not (yet) listed in https://github.com/home-assistant/brands + ignore: brands diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a160f49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ diff --git a/custom_components/dscriptmodule/__init__.py b/custom_components/dscriptmodule/__init__.py index 65e6571..5cd93c5 100644 --- a/custom_components/dscriptmodule/__init__.py +++ b/custom_components/dscriptmodule/__init__.py @@ -141,7 +141,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id].pop(CONF_ADD_ENTITIES) if all_ok: - _LOGGER.debug("%s - async_unload_entry: Unload option updates listener: %s.%s ", entry.entry_id, FUNC_OPTION_UPDATES) + _LOGGER.debug("%s - async_unload_entry: Unload option updates listener: %s", entry.entry_id, FUNC_OPTION_UPDATES) hass.data[DOMAIN][entry.entry_id][FUNC_OPTION_UPDATES]() _LOGGER.debug("%s - async_unload_entry: Remove data store: %s.%s ", entry.entry_id, DOMAIN, entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id) diff --git a/custom_components/dscriptmodule/board.py b/custom_components/dscriptmodule/board.py index f192a2f..cd18051 100644 --- a/custom_components/dscriptmodule/board.py +++ b/custom_components/dscriptmodule/board.py @@ -30,6 +30,7 @@ create_entity_unique_id ) from .utils import ( + async_dScript_GetEntityByUniqueID, async_dScript_setup_entry, ProgrammingDebug, async_ProgrammingDebug, @@ -42,7 +43,8 @@ async def async_setup_dScriptBoard(hass: HomeAssistant, entry: ConfigEntry, tcp_ """Set up a new dScriptBoard.""" try: _LOGGER.debug("%s - %s: async_setup_dScriptBoard: setup board", entry.entry_id, tcp_ip) - dSBoard = dScriptBoardHA(entry.entry_id, tcp_ip, tcp_port, protocol, aeskey) + # board init does blocking network I/O (DNS + GetStatus + GetConfig) - never run it inside the event loop + dSBoard = await hass.async_add_executor_job(dScriptBoardHA, entry.entry_id, tcp_ip, tcp_port, protocol, aeskey) entry_data=hass.data[DOMAIN][entry.entry_id] if not entry_data[KNOWN_DATA].get(dSBoard.MACAddress, None) is None and not entry_data[KNOWN_DATA][dSBoard.MACAddress].get(CONF_PYOJBECT, None) is None: _LOGGER.warning("%s - %s: async_setup_dScriptBoard: board already exists: %s", entry.entry_id, tcp_ip, dSBoard.name) @@ -102,7 +104,7 @@ async def async_dScript_ValidateBoardConfig(hass: HomeAssistant, entry: ConfigEn elif counts_pre[platform] > count_post: for identifier in list(range(int(count_post)+1,int(counts_pre[platform])+1)): uniqueid = create_entity_unique_id(dSBoard, identifier, platform) - entity_object = async_dScript_GetEntityByUniqueID(hass, entry.entry_id, uniqueid, dSBoard.MACAddress) + entity_object = await async_dScript_GetEntityByUniqueID(hass, entry.entry_id, uniqueid, dSBoard.MACAddress) if entity_object is None: _LOGGER.warning("%s - %s: async_dScript_ValidateBoardConfig: unable to find remove entity: %s", entry.entry_id, dSBoard.name, uniqueid) continue @@ -148,6 +150,9 @@ class dScriptBoardHA(dScriptBoard): friendlyname = None MACAddress = '00:00:00:00:00:00' _ConnectedBoardSensors = 1 #set this fixed to 1 as we have a single board status sensor implemented in HA + _InstrPerSec = None #dScript instructions per second (last second) - firmware >= 3.9 via status.htm only + _InstrPerSecMax = None #maximum of _InstrPerSec within the last minute + _StatusPageSupported = None #None = unknown, True = status.htm available (firmware >= 3.9), False = older firmware def __init__(self, entry_id: str, tcp_ip, tcp_port=DEFAULT_PORT, protocol=DEFAULT_PROTOCOL, aeskey=DEFAULT_AESKEY): """Initialize the object.""" @@ -166,8 +171,10 @@ def __init__(self, entry_id: str, tcp_ip, tcp_port=DEFAULT_PORT, protocol=DEFAUL def check_available(self): + """Blocking availability check - only call from an executor thread""" try: _LOGGER.debug("%s - %s: dScriptBoardHA check_available: connect", self._HostName, self.IP) + self._SystemFirmwareMajor = 0 #reset to detect a board which is no longer reachable self.InitBoard() if self._SystemFirmwareMajor == 0: #no firmware data means not connected _LOGGER.error("%s - %s: dScriptBoardHA check_available: not connected", self._HostName, self.IP) @@ -177,7 +184,31 @@ def check_available(self): _LOGGER.error("%s - %s: dScriptBoardHA check_available: connect failed: %s (%s.%s)", self._HostName, self.IP, str(e), e.__class__.__module__, type(e).__name__) self.available = False return False + return self._check_available_post_process() + + async def async_check_available(self, full=True): + """Async availability check - full=False only checks GetStatus (lightweight, used for heartbeats)""" + try: + _LOGGER.debug("%s - %s: dScriptBoardHA async_check_available: connect (full: %s)", self._HostName, self.IP, full) + self._SystemFirmwareMajor = 0 #reset to detect a board which is no longer reachable + if full: + await self.async_InitBoard() + else: + await self.async_GetStatus() + if self._SystemFirmwareMajor == 0: #no firmware data means not connected + _LOGGER.error("%s - %s: dScriptBoardHA async_check_available: not connected", self._HostName, self.IP) + self.available = False + return False + except Exception as e: + _LOGGER.error("%s - %s: dScriptBoardHA async_check_available: connect failed: %s (%s.%s)", self._HostName, self.IP, str(e), e.__class__.__module__, type(e).__name__) + self.available = False + return False + return self._check_available_post_process() + + + def _check_available_post_process(self): + """Post-process board data after a successful connection""" try: _LOGGER.debug("%s - %s: dScriptBoardHA check_available: post-process", self._HostName, self.IP) self.MACAddress = str(self._MACAddress) @@ -188,9 +219,36 @@ def check_available(self): return False _LOGGER.debug("%s - %s: dScriptBoardHA check_available complete: FW: %s.%s | App: %s.%s | Custom: %s | MAC: %s | IP: %s | Prot: %s", - self._HostName, self._SystemFirmwareMajor, self._SystemFirmwareMinor, + self._HostName, self.IP, self._SystemFirmwareMajor, self._SystemFirmwareMinor, self._ApplicationFirmwareMajor, self._ApplicationFirmwareMinor, self._CustomFirmeware, self.MACAddress, self.IP, self._Protocol) self.available = True + return True + + + def update_from_status_page(self, text) -> bool: + """Take over the values of the board page status.htm (key=value lines, firmware >= 3.9)""" + try: + values = {} + for line in str(text).splitlines(): + key, sep, value = line.strip().partition('=') + if sep: + values[key.strip()] = value.strip() + temperature = int(values['temperature']) + voltage = int(values['voltage']) + instr = int(values['instr_per_sec']) + instr_max = int(values['instr_per_sec_max']) + except (KeyError, ValueError) as e: + _LOGGER.debug("%s - %s: dScriptBoardHA update_from_status_page: unexpected content: %s (%s.%s)", self._HostName, self.IP, str(e), e.__class__.__module__, type(e).__name__) + return False + app_major, sep, app_minor = values.get('app', '').partition('.') + if sep and app_major.isdigit() and app_minor.isdigit(): # keep the version current like the former GetStatus every 10th poll did + self._ApplicationFirmwareMajor = int(app_major) + self._ApplicationFirmwareMinor = int(app_minor) + self._Temperature = temperature / 10.0 + self._Volts = voltage / 10.0 + self._InstrPerSec = instr + self._InstrPerSecMax = instr_max + return True def _cleanup_macaddress(self): diff --git a/custom_components/dscriptmodule/brand/LICENSE.txt b/custom_components/dscriptmodule/brand/LICENSE.txt new file mode 100644 index 0000000..6f47868 --- /dev/null +++ b/custom_components/dscriptmodule/brand/LICENSE.txt @@ -0,0 +1,17 @@ +The brand assets in this directory, including the dScript +logo and related artwork, are not covered by the +Apache License 2.0 applicable to the source code of this project. + +dScript and associated logos are trademarks and/or +copyrighted materials of Devantech Ltd. and/or +its affiliates. + +No license to these trademarks or copyrighted materials is +granted by this repository. + +The brand assets are included solely for identification of +compatible dScript products. + +This project is an independent, unofficial Home Assistant +integration and is not affiliated with, endorsed by, or +sponsored by Devantech Ltd.. \ No newline at end of file diff --git a/custom_components/dscriptmodule/brand/README.md b/custom_components/dscriptmodule/brand/README.md new file mode 100644 index 0000000..b45e04c --- /dev/null +++ b/custom_components/dscriptmodule/brand/README.md @@ -0,0 +1,11 @@ +# Brand assets + +The images in this directory contain the dScript brand/logo. + +dScirpt and associated logos are trademarks and/or copyrighted materials of Devantech Ltd. and/or its affiliates. + +These brand assets are not licensed under the Apache License 2.0 that applies to the source code of this project. + +They are included solely to identify the dScript products supported by this unofficial Home Assistant integration. + +This project is independent from and is not affiliated with, endorsed by, or sponsored by Devantech Ltd.. \ No newline at end of file diff --git a/custom_components/dscriptmodule/brand/icon.png b/custom_components/dscriptmodule/brand/icon.png new file mode 100644 index 0000000..f77dd45 Binary files /dev/null and b/custom_components/dscriptmodule/brand/icon.png differ diff --git a/custom_components/dscriptmodule/brand/logo.png b/custom_components/dscriptmodule/brand/logo.png new file mode 100644 index 0000000..f77dd45 Binary files /dev/null and b/custom_components/dscriptmodule/brand/logo.png differ diff --git a/custom_components/dscriptmodule/config_flow.py b/custom_components/dscriptmodule/config_flow.py index 17f1fe8..4f300d6 100644 --- a/custom_components/dscriptmodule/config_flow.py +++ b/custom_components/dscriptmodule/config_flow.py @@ -79,7 +79,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" _LOGGER.debug("%s - OptionsFlowHandler: __init__: %s", DOMAIN, config_entry) - self.config_entry = config_entry + if not isinstance(getattr(type(self), "config_entry", None), property): + self.config_entry = config_entry # only needed for HA < 2024.11 - newer versions provide config_entry themselves (setting it raises since 2025.12) async def async_step_init(self, user_input: Dict[str, Any] = None) -> Dict[str, Any]: """Manage the options for the custom component.""" diff --git a/custom_components/dscriptmodule/const.py b/custom_components/dscriptmodule/const.py index c4e4caa..339ca52 100644 --- a/custom_components/dscriptmodule/const.py +++ b/custom_components/dscriptmodule/const.py @@ -39,7 +39,10 @@ "getshutter": "cover", "getmotion": "sensor_motion", "getbutton": "sensor_button", - "getboard_dummy": "sensor_board" + "getboard_dummy": "sensor_board", + "getboardtemperature_dummy": "sensor_temperature", + "getboardvoltage_dummy": "sensor_voltage", + "getboardload_dummy": "sensor_load" } DSCRIPT_ENTITYTYPETOCOUNTATTR: Final = { @@ -49,5 +52,10 @@ "cover": "_ConnectedShutters", "sensor_motion": "_ConnectedMotionSensors", "sensor_button": "_ConnectedButtons", - "sensor_board": "_ConnectedBoardSensors" + "sensor_board": "_ConnectedBoardSensors", + "sensor_temperature": "_ConnectedBoardSensors", + "sensor_voltage": "_ConnectedBoardSensors", + "sensor_load": "_ConnectedBoardSensors" } + +SIGNAL_BOARD_STATUS: Final = DOMAIN + "_board_status_{}" # format with board MAC address - sent after new temperature / voltage / load values diff --git a/custom_components/dscriptmodule/manifest.json b/custom_components/dscriptmodule/manifest.json index b9f3a2c..3803a25 100644 --- a/custom_components/dscriptmodule/manifest.json +++ b/custom_components/dscriptmodule/manifest.json @@ -1,11 +1,12 @@ { "domain": "dscriptmodule", "name": "robot-electronics dScript modules", - "version": "3.6.2", - "config_flow": "true", - "documentation": "https://github.com/mk-maddin/dScriptModule-HA", - "requirements": ["dScriptModule>=3.4.0", "getmac>=0.9.2", "aiofiles>=23.2.1"], - "dependencies": ["light","cover","switch", "sensor", "binary_sensor"], "codeowners": ["@mk-maddin"], - "iot_class": "local_push" + "config_flow": true, + "dependencies": ["light", "cover", "switch", "sensor", "binary_sensor"], + "documentation": "https://github.com/mk-maddin/dScriptModule-HA", + "iot_class": "local_push", + "issue_tracker": "https://github.com/mk-maddin/dScriptModule-HA/issues", + "requirements": ["dScriptModule>=3.5.0", "getmac>=0.9.2", "aiofiles>=23.2.1"], + "version": "3.8.0" } diff --git a/custom_components/dscriptmodule/sensor_board.py b/custom_components/dscriptmodule/sensor_board.py index f207f0e..81fc22e 100644 --- a/custom_components/dscriptmodule/sensor_board.py +++ b/custom_components/dscriptmodule/sensor_board.py @@ -4,17 +4,16 @@ from typing import Final import logging import asyncio -import urllib.request -import socket +import aiohttp from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.const import ( ATTR_MODEL, - ATTR_VOLTAGE, - ATTR_TEMPERATURE, ATTR_DEVICE_ID, ATTR_SW_VERSION, CONF_UNIQUE_ID, @@ -31,6 +30,7 @@ CATTR_PROTOCOL, CATTR_SW_TYPE, DOMAIN, + SIGNAL_BOARD_STATUS, ) from .utils import( @@ -51,18 +51,18 @@ class dScriptBoardSensor(dScriptPlatformEntity): _icon = 'mdi:developer-board' _platform = PLATFORM - _firmware = STATE_UNKNOWN - _software = STATE_UNKNOWN _onlineurl = STATE_UNKNOWN _configurl = STATE_UNKNOWN _NoGetUpdateCounter = 999 + _statusurl = STATE_UNKNOWN + _StatusPageRetryCounter = 0 + _StatusPageRetryPolls = 20 #polls until status.htm is tried again on a board without it (firmware update) def _init_platform_specific(self, **kwargs): """Platform specific init actions""" _LOGGER.debug("%s - %s %s%s: _init_platform_specific", self._entry_id, self._board.name, self._dSEntityType, self._identifier) - self._firmware = str(self._board._SystemFirmwareMajor) + "." + str(self._board._SystemFirmwareMinor) - self._software = str(self._board._ApplicationFirmwareMajor) + "." + str(self._board._ApplicationFirmwareMinor) self._onlineurl= "http://" + self._board.IP + "/index.htm" + self._statusurl= "http://" + self._board.IP + "/status.htm" self._configurl= "http://" + self._board.IP + "/_config.htm" # def _state_post_process(self, state): @@ -74,11 +74,9 @@ def extra_state_attributes(self): """Return the state attributes of the sensor.""" return { ATTR_MODEL: self._board._ModuleID, - ATTR_VOLTAGE: self._board._Volts, - ATTR_TEMPERATURE: self._board._Temperature, ATTR_DEVICE_ID: self._board.MACAddress, - ATTR_SW_VERSION: self._software, - CATTR_FW_VERSION: self._firmware, + ATTR_SW_VERSION: str(self._board._ApplicationFirmwareMajor) + "." + str(self._board._ApplicationFirmwareMinor), # read live - changes after a firmware update + CATTR_FW_VERSION: str(self._board._SystemFirmwareMajor) + "." + str(self._board._SystemFirmwareMinor), CATTR_IP_ADDRESS: self._board.IP, CATTR_SW_TYPE: self._board._CustomFirmeware, CATTR_PROTOCOL: self._board._Protocol @@ -102,23 +100,44 @@ async def async_local_poll(self) -> None: """Async: Poll the latest status from device""" try: _LOGGER.debug("%s - %s.%s: async_local_poll", self._entry_id, self._board.name, self.uniqueid) - state = await self.hass.async_add_executor_job(urllib.request.urlopen,self._onlineurl) - state = state.getcode() - if self._NoGetUpdateCounter >= 10: - self._NoGetUpdateCounter = 0 - await self._board.async_GetStatus() - #await self.hass.async_add_executor_job(self._board.GetStatus) - else: self._NoGetUpdateCounter += 1 - except urllib.error.URLError: state = 404 - except socket.timeout: state = 408 - except OSError: state = 113 - except urllib.error.HTTPError as e: state = e.code + session = async_get_clientsession(self.hass) + state = None + if self._board._StatusPageSupported is not False or self._StatusPageRetryCounter >= self._StatusPageRetryPolls: + # firmware >= 3.9: small page with temperature, voltage and load - no extra GetStatus connection needed + self._StatusPageRetryCounter = 0 + async with session.get(self._statusurl, timeout=aiohttp.ClientTimeout(total=10)) as response: # always closes the connection to the board + state = response.status + text = await response.text(errors='replace') + if state == 200: + self._board._StatusPageSupported = self._board.update_from_status_page(text) + elif state == 404: + _LOGGER.debug("%s - %s: async_local_poll: no status.htm (firmware < 3.9) - using index.htm", self._board.friendlyname, self._name) + self._board._StatusPageSupported = False + state = None + else: + self._StatusPageRetryCounter += 1 + if state is None: + async with session.get(self._onlineurl, timeout=aiohttp.ClientTimeout(total=10)) as response: # always closes the connection to the board + state = response.status + await response.read() + if not self._board._StatusPageSupported: + if self._NoGetUpdateCounter >= 10: + self._NoGetUpdateCounter = 0 + await self._board.async_GetStatus() + #await self.hass.async_add_executor_job(self._board.GetStatus) + else: self._NoGetUpdateCounter += 1 + async_dispatcher_send(self.hass, SIGNAL_BOARD_STATUS.format(self._board.MACAddress)) + except asyncio.TimeoutError: state = 408 + except aiohttp.ClientResponseError as e: state = e.status + except aiohttp.ClientConnectionError: state = 113 + except aiohttp.ClientError: state = 404 + except OSError: state = 113 except Exception as e: _LOGGER.error("%s - %s.%s: async_local_poll failed: %s (%s.%s)", self._entry_id, self._board.name, self.uniqueid, str(e), e.__class__.__module__, type(e).__name__) return None try: - if not state == 200 and self._board.available == True: self._board.check_available() - elif state == 200 and self._board.available == False: self._board.check_available() + if not state == 200 and self._board.available == True: await self._board.async_check_available() + elif state == 200 and self._board.available == False: await self._board.async_check_available() else: _LOGGER.debug("%s - %s: async_local_poll board available unchanged: %s", self._board.friendlyname, self._name, self._board.available) self._state = str(state) diff --git a/custom_components/dscriptmodule/sensor_diagnostic.py b/custom_components/dscriptmodule/sensor_diagnostic.py new file mode 100644 index 0000000..a453f8a --- /dev/null +++ b/custom_components/dscriptmodule/sensor_diagnostic.py @@ -0,0 +1,95 @@ +"""Diagnostic sensors of a dScriptBoard: temperature, voltage and load (disabled by default).""" + +from __future__ import annotations +from typing import Final +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import ( + UnitOfElectricPotential, + UnitOfTemperature, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory + +from .entities import dScriptPlatformEntity +from .const import SIGNAL_BOARD_STATUS + +_LOGGER: Final = logging.getLogger(__name__) +PLATFORM = 'sensor' + + +class dScriptBoardDiagnosticSensor(SensorEntity, dScriptPlatformEntity): + """Base class: shows a value of the board object, updated when the board sensor signals new values.""" + + _platform = PLATFORM + _board_attr = None + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + _attr_state_class = SensorStateClass.MEASUREMENT + + @property + def should_poll(self) -> bool: + """No polling - the board sensor reads the values and signals updates.""" + return False + + @property + def native_value(self): + """Return the current value of the board.""" + return getattr(self._board, self._board_attr, None) + + @property + def available(self) -> bool: + """Available while the board is reachable and provides the value.""" + return bool(self._board.available) and self.native_value is not None + + async def async_added_to_hass(self) -> None: + """Subscribe to new board values.""" + self.async_on_remove(async_dispatcher_connect(self.hass, SIGNAL_BOARD_STATUS.format(self._board.MACAddress), self._async_board_status_updated)) + + @callback + def _async_board_status_updated(self) -> None: + """Write the new board values.""" + self.async_write_ha_state() + + async def async_local_poll(self) -> None: + """Async: values are read by the board sensor - just write the current state""" + self.async_write_ha_state() + + async def async_local_push(self, state=None) -> None: + """Async: values are read by the board sensor - just write the current state""" + self.async_write_ha_state() + + +class dScriptTemperatureSensor(dScriptBoardDiagnosticSensor): + """Internal board temperature.""" + + _board_attr = '_Temperature' + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + +class dScriptVoltageSensor(dScriptBoardDiagnosticSensor): + """Supplied voltage of the board.""" + + _board_attr = '_Volts' + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + + +class dScriptLoadSensor(dScriptBoardDiagnosticSensor): + """dScript instructions per second - maximum of the last minute (firmware >= 3.9).""" + + _board_attr = '_InstrPerSecMax' + _icon = 'mdi:speedometer' + _attr_native_unit_of_measurement = 'instr/s' + + @property + def extra_state_attributes(self): + """Value of the last second in addition to the maximum of the last minute.""" + return {'last_second': self._board._InstrPerSec} diff --git a/custom_components/dscriptmodule/server.py b/custom_components/dscriptmodule/server.py index 08d7d6b..09a6568 100644 --- a/custom_components/dscriptmodule/server.py +++ b/custom_components/dscriptmodule/server.py @@ -123,7 +123,7 @@ async def async_dSBoardHeartbeat(self, sender, event) -> None: self.hass.async_create_task(async_setup_dScriptBoard(self.hass, self._entry, sender.sender)) else: _LOGGER.debug("%s - async_dSBoardHearbeat: known board %s", sender.sender, dSBoard.friendlyname) - if not dSBoard.check_available() == True: + if not await dSBoard.async_check_available(full=False) == True: # GetStatus only - config is validated below _LOGGER.warning("%s - async_dSBoardHearbeat: board unavailable %s", sender.sender, dSBoard.friendlyname) if dSBoard._CustomFirmeware: self.hass.async_create_task(self.async_dSBoardGetConfig(sender, event)) diff --git a/custom_components/dscriptmodule/services.py b/custom_components/dscriptmodule/services.py index dab428d..6d47059 100644 --- a/custom_components/dscriptmodule/services.py +++ b/custom_components/dscriptmodule/services.py @@ -97,12 +97,12 @@ async def async_service_HeartbeatKnownBoards(hass: HomeAssistant, call) -> None return False for board_entry in list(entry_data.get(KNOWN_DATA, [])): - _LOGGER.debug("%s - async_service_HeartbeatKnownBoards: processing board entry: %s ", call, DOMAIN, board_entry) + _LOGGER.debug("%s - %s: async_service_HeartbeatKnownBoards: processing board entry: %s", DOMAIN, call, board_entry) board_entry = entry_data[KNOWN_DATA][board_entry] ip_address = board_entry.get(CONF_IP_ADDRESS, None) if ip_address is None: continue - _LOGGER.debug("%s - async_service_HeartbeatKnownBoards: processing board entry: %s ", call, DOMAIN, ip_address) + _LOGGER.debug("%s - %s: async_service_HeartbeatKnownBoards: heartbeat board IP: %s", DOMAIN, call, ip_address) sender = DummySender(ip_address) await BuiltInServer.async_dSBoardHeartbeat(sender, 'service_heartbeat') #hass.async_create_task(async_setup_dScriptBoard(hass, entry, ip_address)) diff --git a/custom_components/dscriptmodule/services.yaml b/custom_components/dscriptmodule/services.yaml index c8074c8..86a3b13 100644 --- a/custom_components/dscriptmodule/services.yaml +++ b/custom_components/dscriptmodule/services.yaml @@ -1,4 +1,4 @@ -# Describes the format for available HomeKit services +# Describes the format for available dscriptmodule services serverstop: name: Stop dScriptServer @@ -10,7 +10,7 @@ serverstart: updatebutton: name: Update dScriptButton - dscription: Trigger update of virtual Button entity from dScriptBoard + description: Trigger update of virtual Button entity from dScriptBoard fields: entity_id: name: Entity @@ -20,12 +20,12 @@ updatebutton: - sensor.ds_speis_button1 selector: entity: - integration: sensor - domain: dscriptmodule + integration: dscriptmodule + domain: sensor heartbeatknownboards: name: Hearbeat KnownBoards - dscription: Trigger heartbeat to each known/cached dScriptBoard + description: Trigger heartbeat to each known/cached dScriptBoard fields: entry_id: name: Config entry ID diff --git a/custom_components/dscriptmodule/utils.py b/custom_components/dscriptmodule/utils.py index 4c4a5bc..f635fdc 100644 --- a/custom_components/dscriptmodule/utils.py +++ b/custom_components/dscriptmodule/utils.py @@ -155,6 +155,7 @@ async def async_dScript_setup_entry(hass: HomeAssistant, entry: ConfigEntry, asy from .sensor_board import dScriptBoardSensor from .sensor_button import dScriptButtonSensor from .sensor_motion import dScriptMotionSensor + from .sensor_diagnostic import dScriptLoadSensor, dScriptTemperatureSensor, dScriptVoltageSensor DSCRIPT_ENTITYTYPETOOBJECT: Final = { "light": dScriptLight, @@ -163,7 +164,10 @@ async def async_dScript_setup_entry(hass: HomeAssistant, entry: ConfigEntry, asy "cover": dScriptCover, "sensor_motion": dScriptMotionSensor, "sensor_button": dScriptButtonSensor, - "sensor_board": dScriptBoardSensor + "sensor_board": dScriptBoardSensor, + "sensor_temperature": dScriptTemperatureSensor, + "sensor_voltage": dScriptVoltageSensor, + "sensor_load": dScriptLoadSensor } try: diff --git a/hacs.json b/hacs.json index ea5315b..97c7043 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,5 @@ { "name": "Robot-Electronics dScript Modules", - "iot_class": "Local Push", "homeassistant": "2024.6.0", "hide_default_branch": false, "hacs": "1.34.0" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..bfa884c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +log_level = DEBUG diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..f0ef3c8 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,4 @@ +pytest-homeassistant-custom-component==0.13.316 # Home Assistant 2026.2.3 +dScriptModule>=3.5.0 +getmac>=0.9.2 +aiofiles>=23.2.1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..362009b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the dscriptmodule integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..99cd604 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,78 @@ +"""Fixtures for dscriptmodule tests.""" +import asyncio +import socket + +import pytest + +from custom_components.dscriptmodule.const import DEFAULT_PORT + +BOARD_IP = "127.0.0.2" # boards and the integrated dScriptServer need different IPs (both use port 17123) +STATUS = bytes([34, 4, 13, 3, 8, 120, 1, 11]) # dS2824, FW 4.13, App 3.8 +CONFIG = bytes([0, 1, 2, 0xAB, 4, 5, 24, 2, 1, 1, 1, 1]) # 24 relays, 2 lights, 1 shutter, 1 socket, 1 motion, 1 button + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """Enable custom integrations in all tests.""" + yield + + +def free_port(ip="127.0.0.1"): + with socket.socket() as s: + s.bind((ip, 0)) + return s.getsockname()[1] + + +class FakeBoard: + """Emulates a dS2824 running dScriptRoomControl (one request per TCP connection).""" + + def __init__(self): + self.requests = [] + self.lights = {1: 0, 2: 0} + self.server = None + + def response(self, data): + cmd = data[0] + if cmd == 0x30: return STATUS + if cmd == 0x50: return CONFIG + if cmd == 0x40: + self.lights[data[1]] = data[2] + return bytes([0]) + if cmd == 0x51: return bytes([self.lights.get(data[1], 0)]) + if cmd == 0x52: return bytes([100, 0]) + if cmd == 0x53: return bytes([0]) + if cmd in (0x54, 0x55): return bytes([0]) + return bytes([0]) + + async def handle(self, reader, writer): + try: + data = await reader.read(100) + if data: + self.requests.append(bytes(data)) + writer.write(self.response(data)) + await writer.drain() + finally: + writer.close() + + async def start(self): + self.server = await asyncio.start_server(self.handle, BOARD_IP, DEFAULT_PORT) + return self + + async def stop(self): + self.server.close() + await self.server.wait_closed() + + async def trigger(self, server_port, payload): + """Send a status trigger to the integrated dScriptServer like the firmware does.""" + reader, writer = await asyncio.open_connection("127.0.0.1", server_port, local_addr=(BOARD_IP, 0)) + writer.write(payload) + await writer.drain() + await reader.read(10) + writer.close() + + +@pytest.fixture +async def fake_board(): + board = await FakeBoard().start() + yield board + await board.stop() diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..8a66b1d --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,41 @@ +"""Tests for the config and options flow.""" +from homeassistant import config_entries +from homeassistant.const import CONF_FRIENDLY_NAME, CONF_PORT, CONF_PROTOCOL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.dscriptmodule.const import CONF_AESKEY, CONF_LISTENIP, DOMAIN + +USER_INPUT = { + CONF_FRIENDLY_NAME: "My dScript", + CONF_LISTENIP: "127.0.0.1", + CONF_PORT: 17999, + CONF_PROTOCOL: "binary", + CONF_AESKEY: "", +} + + +async def test_user_flow_creates_entry(hass: HomeAssistant) -> None: + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "resource" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], USER_INPUT) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "My dScript" + assert result["data"] == USER_INPUT + + +async def test_options_flow_updates_entry_data(hass: HomeAssistant) -> None: + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT, source=config_entries.SOURCE_USER) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "resource" + + new_input = {**USER_INPUT, CONF_PORT: 18000} + result = await hass.config_entries.options.async_configure(result["flow_id"], new_input) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert entry.data[CONF_PORT] == 18000 diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..224ea3c --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,189 @@ +"""End-to-end tests with an emulated board: setup, heartbeat, control, push updates, unload.""" +import asyncio + +import pytest +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_FRIENDLY_NAME, CONF_PORT, CONF_PROTOCOL, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.dscriptmodule.const import CONF_AESKEY, CONF_LISTENIP, CONF_SERVER, CONF_PYOJBECT, DOMAIN + +from .conftest import BOARD_IP, free_port + +pytestmark = pytest.mark.usefixtures("socket_enabled") + + +@pytest.fixture(autouse=True) +def allow_board_ip(socket_enabled): + """The emulated board listens on BOARD_IP - allow connections to it.""" + import pytest_socket + pytest_socket.socket_allow_hosts(["127.0.0.1", BOARD_IP], allow_unix_socket=True) + + +async def wait_for(predicate, timeout=15.0): + for _ in range(int(timeout / 0.1)): + if predicate(): + return True + await asyncio.sleep(0.1) + return predicate() + + +async def setup_entry(hass: HomeAssistant): + port = free_port() + entry = MockConfigEntry(domain=DOMAIN, title="test", data={ + CONF_FRIENDLY_NAME: "test", CONF_LISTENIP: "127.0.0.1", CONF_PORT: port, + CONF_PROTOCOL: "binary", CONF_AESKEY: ""}) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry, port + + +def light_entity_ids(hass): + registry = er.async_get(hass) + return sorted(e.entity_id for e in registry.entities.values() if e.platform == DOMAIN and e.domain == "light") + + +async def test_setup_and_unload_without_boards(hass: HomeAssistant) -> None: + entry, port = await setup_entry(hass) + assert entry.state is ConfigEntryState.LOADED + server = hass.data[DOMAIN][entry.entry_id][CONF_SERVER][CONF_PYOJBECT] + assert server.dScriptServer.State is True + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert server.dScriptServer.State is False + + +async def test_heartbeat_creates_board_entities_and_controls_light(hass: HomeAssistant, fake_board) -> None: + entry, port = await setup_entry(hass) + + # a new board announces itself with a heartbeat trigger (cmd 0, state -1) + await fake_board.trigger(port, bytes([0, 0, 255])) + assert await wait_for(lambda: len(light_entity_ids(hass)) == 2), light_entity_ids(hass) + await hass.async_block_till_done() + light = light_entity_ids(hass)[0] + + # turning the light on sends SetLight (0x40) to the board + await hass.services.async_call("light", "turn_on", {"entity_id": light}, blocking=True) + assert any(r[0] == 0x40 and r[2] == 1 for r in fake_board.requests) + + # the firmware pushes the new state (GetLight trigger) - entity follows without polling + await fake_board.trigger(port, bytes([81, 1, 1])) + assert await wait_for(lambda: hass.states.get(light) is not None and hass.states.get(light).state == STATE_ON) + await fake_board.trigger(port, bytes([81, 1, 0])) + assert await wait_for(lambda: hass.states.get(light).state == STATE_OFF) + + # a second heartbeat of a known board must not create duplicates + await fake_board.trigger(port, bytes([0, 0, 255])) + await asyncio.sleep(0.5) + await hass.async_block_till_done() + assert len(light_entity_ids(hass)) == 2 + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_requests_to_board_are_never_parallel(hass: HomeAssistant, fake_board) -> None: + entry, port = await setup_entry(hass) + active = 0 + max_active = 0 + original = fake_board.handle + + async def counting_handle(reader, writer): + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + try: + await asyncio.sleep(0.02) + await original(reader, writer) + finally: + active -= 1 + + fake_board.handle = counting_handle + await fake_board.stop() + await fake_board.start() + + await fake_board.trigger(port, bytes([0, 0, 255])) + assert await wait_for(lambda: len(light_entity_ids(hass)) == 2) + await hass.async_block_till_done() + lights = light_entity_ids(hass) + max_active = 0 + await asyncio.gather(*[hass.services.async_call("light", "toggle", {"entity_id": lights}, blocking=True) for _ in range(5)]) + assert max_active == 1 + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +MAC = "00:01:02:ab:04:05" +STATUS_PAGE = "app=3.9\ntemperature=267\nvoltage=121\ninstr_per_sec=1200\ninstr_per_sec_max=1500\n" + + +def board_entity(hass, entry, dSEntityType): + devices = hass.data[DOMAIN][entry.entry_id]["devices"] + return devices.get(MAC, {}).get("dscriptmodule_" + MAC.replace(":", "") + "_" + dSEntityType + "1") + + +async def test_status_page_feeds_diagnostic_sensors(hass: HomeAssistant, fake_board, aioclient_mock) -> None: + aioclient_mock.get("http://" + BOARD_IP + "/status.htm", text=STATUS_PAGE) + registry = er.async_get(hass) + entry, port = await setup_entry(hass) + # diagnostic sensors are disabled by default - enable temperature and load before they are created + for sensor in ("sensor_temperature", "sensor_load"): + registry.async_get_or_create("sensor", DOMAIN, "dscriptmodule_" + MAC.replace(":", "") + "_" + sensor + "1", config_entry=entry) + + await fake_board.trigger(port, bytes([0, 0, 255])) + assert await wait_for(lambda: board_entity(hass, entry, "sensor_board") is not None) + await hass.async_block_till_done() + + requests_before = len(fake_board.requests) + await board_entity(hass, entry, "sensor_board").async_local_poll() + await hass.async_block_till_done() + + temperature = board_entity(hass, entry, "sensor_temperature").entity_id + load = board_entity(hass, entry, "sensor_load").entity_id + assert hass.states.get(temperature).state == "26.7" + assert hass.states.get(load).state == "1500" + assert hass.states.get(load).attributes["last_second"] == 1200 + assert board_entity(hass, entry, "sensor_board")._board._StatusPageSupported is True + assert len(fake_board.requests) == requests_before, "status.htm replaces the extra GetStatus connection" + board_state = hass.states.get(board_entity(hass, entry, "sensor_board").entity_id) + assert "temperature" not in board_state.attributes and "voltage" not in board_state.attributes # separate entities now + assert board_state.attributes["sw_version"] == "3.9" # app version taken over from status.htm (GetStatus reported 3.8) + + voltage = registry.async_get_entity_id("sensor", DOMAIN, "dscriptmodule_" + MAC.replace(":", "") + "_sensor_voltage1") + voltage_entry = registry.async_get(voltage) + assert voltage_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert voltage_entry.entity_category == "diagnostic" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_older_firmware_without_status_page(hass: HomeAssistant, fake_board, aioclient_mock) -> None: + aioclient_mock.get("http://" + BOARD_IP + "/status.htm", status=404) + aioclient_mock.get("http://" + BOARD_IP + "/index.htm", text="") + entry, port = await setup_entry(hass) + await fake_board.trigger(port, bytes([0, 0, 255])) + assert await wait_for(lambda: board_entity(hass, entry, "sensor_board") is not None) + await hass.async_block_till_done() + + board_sensor = board_entity(hass, entry, "sensor_board") + requests_before = len(fake_board.requests) + await board_sensor.async_local_poll() + await hass.async_block_till_done() + + assert board_sensor._board._StatusPageSupported is False + assert hass.states.get(board_sensor.entity_id).state == "200" + assert any(r[0] == 0x30 for r in fake_board.requests[requests_before:]), "temperature/voltage still come from GetStatus" + assert board_sensor._board._InstrPerSecMax is None + + calls = aioclient_mock.call_count + await board_sensor.async_local_poll() # status.htm is not requested again on every poll + assert aioclient_mock.call_count == calls + 1 + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/test_logging_calls.py b/tests/test_logging_calls.py new file mode 100644 index 0000000..622bf43 --- /dev/null +++ b/tests/test_logging_calls.py @@ -0,0 +1,31 @@ +"""Every logging call must have as many arguments as its format string has placeholders. + +A mismatch only shows up when the message is formatted (e.g. with debug logging enabled) - pytest then raises, +in production the log line is lost. This already broke board setup once (10 placeholders, 9 arguments). +""" +import ast +import os +import re + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +LEVELS = {"debug", "info", "warning", "error", "exception", "critical"} +PLACEHOLDER = re.compile(r"%(?!%)[-#0 +]*\d*(?:\.\d+)?[sdifrx]") + + +def test_logging_placeholders_match_arguments(): + mismatches = [] + for base, _dirs, files in os.walk(os.path.join(ROOT, "custom_components")): + for name in files: + if not name.endswith(".py"): + continue + path = os.path.join(base, name) + with open(path, encoding="utf-8") as fh: + tree = ast.parse(fh.read(), path) + for node in ast.walk(tree): + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in LEVELS + and node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str)): + need = len(PLACEHOLDER.findall(node.args[0].value)) + have = len(node.args) - 1 + if need != have: + mismatches.append("%s:%s: %s placeholders, %s arguments" % (os.path.relpath(path, ROOT), node.lineno, need, have)) + assert mismatches == []