Compare commits

...

5 Commits

Author SHA1 Message Date
Erik 813fa922e2 Include issue tracker link in custom integration analytics data 2026-04-30 10:24:44 +02:00
Erik Montnemery 4b28928702 Remove scripts from DATA_SCRIPTS on unload (#169415) 2026-04-29 18:09:49 +02:00
A. Gideonse 859ce55c96 Bump indevolt-api to 1.6.5 (#169406) 2026-04-29 16:57:53 +01:00
MohamedBarrak3 9a9f19cb9e Fix Schlage add_code service failing when code is passed as integer (#168399)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-29 17:42:16 +02:00
Heikki Henriksen d8b1bfb268 prusalink: populate serial number and firmware version in device info (#169309)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:38:32 +02:00
16 changed files with 138 additions and 14 deletions
@@ -76,6 +76,7 @@ from .const import (
ATTR_HEALTHY,
ATTR_INTEGRATION_COUNT,
ATTR_INTEGRATIONS,
ATTR_ISSUE_TRACKER,
ATTR_OPERATING_SYSTEM,
ATTR_PROTECTED,
ATTR_RECORDER,
@@ -414,6 +415,7 @@ class Analytics:
custom_integrations.append(
{
ATTR_DOMAIN: integration.domain,
ATTR_ISSUE_TRACKER: integration.issue_tracker,
ATTR_VERSION: integration.version,
}
)
@@ -36,6 +36,7 @@ ATTR_HEALTHY = "healthy"
ATTR_INSTALLATION_TYPE = "installation_type"
ATTR_INTEGRATION_COUNT = "integration_count"
ATTR_INTEGRATIONS = "integrations"
ATTR_ISSUE_TRACKER = "issue_tracker"
ATTR_ONBOARDED = "onboarded"
ATTR_OPERATING_SYSTEM = "operating_system"
ATTR_PREFERENCES = "preferences"
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["indevolt-api==1.6.4"]
"requirements": ["indevolt-api==1.6.5"]
}
@@ -27,6 +27,7 @@ from .coordinator import (
PrusaLinkConfigEntry,
PrusaLinkUpdateCoordinator,
StatusCoordinator,
VersionUpdateCoordinator,
)
PLATFORMS: list[Platform] = [
@@ -54,6 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PrusaLinkConfigEntry) ->
"status": StatusCoordinator(hass, entry, api),
"job": JobUpdateCoordinator(hass, entry, api),
"info": InfoUpdateCoordinator(hass, entry, api),
"version": VersionUpdateCoordinator(hass, entry, api),
}
for coordinator in coordinators.values():
await coordinator.async_config_entry_first_refresh()
@@ -16,6 +16,7 @@ from pyprusalink import (
PrinterInfo,
PrinterStatus,
PrusaLink,
VersionInfo,
)
from pyprusalink.types import InvalidAuth, PrusaLinkError
@@ -32,7 +33,7 @@ _LOGGER = logging.getLogger(__name__)
# rapidly-changing metrics.
_MINIMUM_REFRESH_INTERVAL = 1.0
T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo)
T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo, VersionInfo)
type PrusaLinkConfigEntry = ConfigEntry[dict[str, PrusaLinkUpdateCoordinator]]
@@ -124,3 +125,11 @@ class InfoUpdateCoordinator(PrusaLinkUpdateCoordinator[PrinterInfo]):
async def _fetch_data(self) -> PrinterInfo:
"""Fetch the printer data."""
return await self.api.get_info()
class VersionUpdateCoordinator(PrusaLinkUpdateCoordinator[VersionInfo]):
"""Version update coordinator."""
async def _fetch_data(self) -> VersionInfo:
"""Fetch the version data."""
return await self.api.get_version()
@@ -17,9 +17,14 @@ class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]):
@property
def device_info(self) -> DeviceInfo:
"""Return device information about this PrusaLink device."""
coordinators = self.coordinator.config_entry.runtime_data
info_data = coordinators["info"].data or {}
version_data = coordinators["version"].data or {}
return DeviceInfo(
identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)},
name=self.coordinator.config_entry.title,
manufacturer="Prusa",
serial_number=info_data.get("serial"),
sw_version=version_data.get("firmware"),
configuration_url=self.coordinator.api.client.host,
)
+1 -1
View File
@@ -36,7 +36,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
entity_domain=LOCK_DOMAIN,
schema={
vol.Required("name"): cv.string,
vol.Required("code"): cv.matches_regex(r"^\d{4,8}$"),
vol.Required("code"): vol.All(cv.string, cv.matches_regex(r"^\d{4,8}$")),
vol.Optional("notify_on_use", default=True): cv.boolean,
},
func=SERVICE_ADD_CODE,
+21 -8
View File
@@ -137,7 +137,7 @@ DEFAULT_MAX_EXCEEDED = "WARNING"
ATTR_CUR = "current"
ATTR_MAX = "max"
DATA_SCRIPTS: HassKey[list[ScriptData]] = HassKey("helpers.script")
DATA_SCRIPTS: HassKey[dict[int, ScriptData]] = HassKey("helpers.script")
DATA_SCRIPT_BREAKPOINTS: HassKey[dict[str, dict[str, set[str]]]] = HassKey(
"helpers.script_breakpoints"
)
@@ -1367,7 +1367,9 @@ async def _async_stop_scripts_after_shutdown(
"""Stop running Script objects started after shutdown."""
hass.data[DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED] = None
running_scripts = [
script for script in hass.data[DATA_SCRIPTS] if script["instance"].is_running
script
for script in hass.data[DATA_SCRIPTS].values()
if script["instance"].is_running
]
if running_scripts:
names = ", ".join([script["instance"].name for script in running_scripts])
@@ -1386,7 +1388,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) ->
running_scripts = [
script
for script in hass.data[DATA_SCRIPTS]
for script in hass.data[DATA_SCRIPTS].values()
if script["instance"].is_running and script["started_before_shutdown"]
]
if running_scripts:
@@ -1467,16 +1469,17 @@ class Script:
enabled attribute is only used for non-top-level scripts.
"""
if not (all_scripts := hass.data.get(DATA_SCRIPTS)):
all_scripts = hass.data[DATA_SCRIPTS] = []
if (all_scripts := hass.data.get(DATA_SCRIPTS)) is None:
all_scripts = hass.data[DATA_SCRIPTS] = {}
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, partial(_async_stop_scripts_at_shutdown, hass)
)
self.top_level = top_level
if top_level:
all_scripts.append(
{"instance": self, "started_before_shutdown": not hass.is_stopping}
)
all_scripts[id(self)] = {
"instance": self,
"started_before_shutdown": not hass.is_stopping,
}
if DATA_SCRIPT_BREAKPOINTS not in hass.data:
hass.data[DATA_SCRIPT_BREAKPOINTS] = {}
@@ -1786,6 +1789,12 @@ class Script:
started_action: Callable[..., Any] | None = None,
) -> ScriptRunResult | None:
"""Run script."""
# Prevent running an unloaded script
if self._unloaded:
raise RuntimeError(
f"Cannot run script '{self.name}' after it has been unloaded"
)
if context is None:
self._log(
"Running script requires passing in a context", level=logging.WARNING
@@ -1919,6 +1928,10 @@ class Script:
)
self._unloaded = True
# Remove from global script registry
if self.top_level:
del self._hass.data[DATA_SCRIPTS][id(self)]
for cond in self._condition_cache.values():
cond.async_unload()
self._condition_cache.clear()
+1 -1
View File
@@ -1329,7 +1329,7 @@ imgw_pib==2.1.1
incomfort-client==0.7.0
# homeassistant.components.indevolt
indevolt-api==1.6.4
indevolt-api==1.6.5
# homeassistant.components.influxdb
influxdb-client==1.50.0
+1 -1
View File
@@ -1181,7 +1181,7 @@ imgw_pib==2.1.1
incomfort-client==0.7.0
# homeassistant.components.indevolt
indevolt-api==1.6.4
indevolt-api==1.6.5
# homeassistant.components.influxdb
influxdb-client==1.50.0
@@ -5,6 +5,7 @@
'custom_integrations': list([
dict({
'domain': 'test_package',
'issue_tracker': 'http://github.com/test/test_package/issues',
'version': <AwesomeVersion SemVer '1.2.3'>,
}),
]),
+1
View File
@@ -33,6 +33,7 @@ def mock_version_api() -> Generator[dict[str, str]]:
"server": "2.1.2",
"text": "PrusaLink",
"hostname": "PrusaXL",
"firmware": "6.1.2+11023",
}
with patch("pyprusalink.PrusaLink.get_version", return_value=resp):
yield resp
+17 -1
View File
@@ -12,7 +12,7 @@ from homeassistant.components.prusalink.config_flow import ConfigFlow
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.util.dt import utcnow
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -20,6 +20,22 @@ from tests.common import MockConfigEntry, async_fire_time_changed
pytestmark = pytest.mark.usefixtures("mock_api")
async def test_device_info(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test device info is populated with serial number and firmware version."""
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
device = device_registry.async_get_device(
identifiers={(DOMAIN, mock_config_entry.entry_id)}
)
assert device is not None
assert device.serial_number == "serial-1337"
assert device.sw_version == "6.1.2+11023"
async def test_unloading(
hass: HomeAssistant,
mock_config_entry: ConfigEntry,
+29
View File
@@ -139,6 +139,35 @@ async def test_add_code_service(
assert call_args.notify_on_use == notify_on_use
async def test_add_code_service_integer_code(
hass: HomeAssistant,
mock_lock: Mock,
mock_added_config_entry: MockSchlageConfigEntry,
) -> None:
"""Test add_code service with an integer code."""
mock_lock.access_codes = {}
mock_lock.add_access_code = Mock()
await hass.services.async_call(
DOMAIN,
SERVICE_ADD_CODE,
service_data={
"entity_id": "lock.vault_door",
"name": "test_user",
"code": 1234,
},
blocking=True,
)
await hass.async_block_till_done()
mock_lock.refresh_access_codes.assert_called_once()
mock_lock.add_access_code.assert_called_once()
call_args = mock_lock.add_access_code.call_args[0][0]
assert isinstance(call_args, AccessCode)
assert call_args.name == "test_user"
assert call_args.code == "1234"
async def test_add_code_service_default_notify_on_use_value(
hass: HomeAssistant,
mock_lock: Mock,
+44
View File
@@ -7143,3 +7143,47 @@ async def test_async_unload_raises_if_running(hass: HomeAssistant) -> None:
# Should succeed now
script_obj.async_unload()
async def test_async_unload_removes_from_data_scripts(hass: HomeAssistant) -> None:
"""Test that async_unload removes the script from hass.data[DATA_SCRIPTS]."""
sequence = cv.SCRIPT_SCHEMA([{"event": "test_event"}])
script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
all_scripts = hass.data[script.DATA_SCRIPTS]
assert any(s["instance"] is script_obj for s in all_scripts.values())
script_obj.async_unload()
assert not any(s["instance"] is script_obj for s in all_scripts.values())
async def test_async_unload_non_top_level_does_not_touch_data_scripts(
hass: HomeAssistant,
) -> None:
"""Test that async_unload on a non-top-level script doesn't touch DATA_SCRIPTS."""
sequence = cv.SCRIPT_SCHEMA([{"event": "test_event"}])
script_obj = script.Script(
hass, sequence, "Sub Script", "test_domain", top_level=False
)
all_scripts = hass.data[script.DATA_SCRIPTS]
count_before = len(all_scripts)
# Should not raise and should not modify DATA_SCRIPTS
script_obj.async_unload()
assert len(all_scripts) == count_before
async def test_async_run_raises_if_unloaded(hass: HomeAssistant) -> None:
"""Test that async_run raises RuntimeError if the script has been unloaded."""
sequence = cv.SCRIPT_SCHEMA([{"event": "test_event"}])
script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
script_obj.async_unload()
with pytest.raises(
RuntimeError, match="Cannot run script.*after it has been unloaded"
):
await script_obj.async_run(context=Context())
@@ -5,5 +5,6 @@
"requirements": [],
"dependencies": [],
"codeowners": [],
"issue_tracker": "http://github.com/test/test_package/issues",
"version": "1.2.3"
}