Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
.pytest_cache/
2 changes: 1 addition & 1 deletion custom_components/dscriptmodule/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
64 changes: 61 additions & 3 deletions custom_components/dscriptmodule/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
create_entity_unique_id
)
from .utils import (
async_dScript_GetEntityByUniqueID,
async_dScript_setup_entry,
ProgrammingDebug,
async_ProgrammingDebug,
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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):
Expand Down
17 changes: 17 additions & 0 deletions custom_components/dscriptmodule/brand/LICENSE.txt
Original file line number Diff line number Diff line change
@@ -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..
11 changes: 11 additions & 0 deletions custom_components/dscriptmodule/brand/README.md
Original file line number Diff line number Diff line change
@@ -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..
Binary file added custom_components/dscriptmodule/brand/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added custom_components/dscriptmodule/brand/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion custom_components/dscriptmodule/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
12 changes: 10 additions & 2 deletions custom_components/dscriptmodule/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
13 changes: 7 additions & 6 deletions custom_components/dscriptmodule/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
69 changes: 44 additions & 25 deletions custom_components/dscriptmodule/sensor_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +30,7 @@
CATTR_PROTOCOL,
CATTR_SW_TYPE,
DOMAIN,
SIGNAL_BOARD_STATUS,
)

from .utils import(
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading