diff --git a/packages/api/src/api/controllers/devices.py b/packages/api/src/api/controllers/devices.py index 5e6ee8e..cd8e87d 100644 --- a/packages/api/src/api/controllers/devices.py +++ b/packages/api/src/api/controllers/devices.py @@ -19,6 +19,7 @@ from core.use_cases.check_device_status import CheckDeviceStatusUseCase from core.use_cases.execute_component_action import ExecuteComponentActionUseCase from core.use_cases.get_component_actions import GetComponentActionsUseCase +from core.use_cases.get_local_firmware_releases import GetLocalFirmwareReleases from core.use_cases.scan_devices import ScanDevicesUseCase from core.use_cases.update_device_from_local import UpdateDeviceFromLocal from litestar import Router, get, post @@ -274,13 +275,8 @@ async def update_device( update_device_from_local_interactor = _require( "update_device_from_local_interactor", update_device_from_local_interactor ) - if channel is not UpdateChannel.STABLE: - raise HTTPException( - status_code=400, - detail="Local updates support the stable channel only", - ) result = await update_device_from_local_interactor.execute( - BaseDeviceRequest(device_ip=ip) + BaseDeviceRequest(device_ip=ip), channel=channel.value ) else: execute_component_action_interactor = _require( @@ -307,6 +303,47 @@ async def update_device( } +@get( + "/{ip:str}/firmware-releases", + tags=["Devices"], + summary="Preview Firmware Releases for Local Update", +) +async def get_firmware_releases( + ip: str, + local_firmware_releases_interactor: GetLocalFirmwareReleases | None = None, +) -> dict: + """ + Show what firmware a local (via manager) update would install, per channel. + + The versions come from the manager's own index lookup, so they are + accurate for devices without internet access. Channels the index + publishes nothing for are null, as is every channel for a device local + updates cannot serve (Gen1 or no reported app name). + + Args: + ip: Device IP address (e.g., "192.168.1.100") + + Returns: + dict: Release metadata by channel name, e.g. + {"stable": {"version": ..., "build_id": ...}, "beta": null} + """ + local_firmware_releases_interactor = _require( + "local_firmware_releases_interactor", local_firmware_releases_interactor + ) + + releases = await local_firmware_releases_interactor.execute( + BaseDeviceRequest(device_ip=ip) + ) + return { + channel: ( + None + if release is None + else {"version": release.version, "build_id": release.build_id} + ) + for channel, release in releases.items() + } + + @post("/{ip:str}/reboot", status_code=200, tags=["Devices"], summary="Reboot Device") async def reboot_device( ip: str, @@ -463,6 +500,7 @@ async def bulk_apply_config( execute_component_action, get_device_status, update_device, + get_firmware_releases, reboot_device, execute_bulk_operations, bulk_export_config, diff --git a/packages/api/src/api/dependencies/container.py b/packages/api/src/api/dependencies/container.py index 57f16ad..5bc0157 100644 --- a/packages/api/src/api/dependencies/container.py +++ b/packages/api/src/api/dependencies/container.py @@ -82,6 +82,10 @@ def get_dependencies(container: APIContainer) -> dict: lambda: container.get_update_device_from_local_interactor(), sync_to_thread=False, ), + "local_firmware_releases_interactor": Provide( + lambda: container.get_local_firmware_releases_interactor(), + sync_to_thread=False, + ), "manage_firmware_use_case": Provide( lambda: container.get_manage_firmware_interactor(), sync_to_thread=False, diff --git a/packages/api/tests/unit/controllers/test_devices.py b/packages/api/tests/unit/controllers/test_devices.py index 7012160..3635cc1 100644 --- a/packages/api/tests/unit/controllers/test_devices.py +++ b/packages/api/tests/unit/controllers/test_devices.py @@ -7,6 +7,7 @@ execute_component_action, get_component_actions, get_device_status, + get_firmware_releases, scan_devices, update_device, ) @@ -20,6 +21,7 @@ from core.use_cases.check_device_status import CheckDeviceStatusUseCase from core.use_cases.execute_component_action import ExecuteComponentActionUseCase from core.use_cases.get_component_actions import GetComponentActionsUseCase +from core.use_cases.get_local_firmware_releases import GetLocalFirmwareReleases from core.use_cases.scan_devices import ScanDevicesUseCase from core.use_cases.update_device_from_local import UpdateDeviceFromLocal from litestar.di import Provide @@ -188,8 +190,9 @@ class MockUpdateDeviceFromLocal(UpdateDeviceFromLocal): def __init__(self): pass - async def execute(self, request): + async def execute(self, request, channel="stable"): captured["request"] = request + captured["channel"] = channel return ActionResult( device_ip=request.device_ip, success=True, @@ -213,27 +216,24 @@ async def execute(self, request): assert data["source"] == "local" assert data["channel"] == "stable" assert captured["request"].device_ip == "192.168.1.100" + assert captured["channel"] == "stable" - def test_update_device_rejects_an_unknown_source(self): - with create_test_client(route_handlers=[update_device]) as client: - response = client.post("/192.168.1.100/update", json={"source": "usb"}) - - assert response.status_code == 400 - - def test_update_device_rejects_an_unknown_channel(self): - with create_test_client( - route_handlers=[update_device], - exception_handlers=EXCEPTION_HANDLERS, - ) as client: - response = client.post("/192.168.1.100/update", json={"channel": "nightly"}) - - assert response.status_code == 400 + def test_update_device_from_local_source_passes_the_channel(self): + captured = {} - def test_update_device_rejects_a_beta_local_update(self): class MockUpdateDeviceFromLocal(UpdateDeviceFromLocal): def __init__(self): pass + async def execute(self, request, channel="stable"): + captured["channel"] = channel + return ActionResult( + device_ip=request.device_ip, + success=True, + message="Update executed successfully on shelly", + action_type="shelly.Update", + ) + with create_test_client( route_handlers=[update_device], dependencies={ @@ -247,6 +247,81 @@ def __init__(self): json={"source": "local", "channel": "beta"}, ) + assert response.status_code == 200 + assert response.json()["channel"] == "beta" + assert captured["channel"] == "beta" + + def test_get_firmware_releases_reports_each_channel(self): + from core.domain.value_objects.firmware_release import FirmwareRelease + + class MockGetLocalFirmwareReleases(GetLocalFirmwareReleases): + def __init__(self): + pass + + async def execute(self, request): + return { + "stable": FirmwareRelease( + app_name="Plus2PM", + version="1.8.0", + build_id="20250611-100000/1.8.0-g1234567", + download_url="https://fwcdn.example.test/Plus2PM.zip", + ), + "beta": None, + } + + with create_test_client( + route_handlers=[get_firmware_releases], + dependencies={ + "local_firmware_releases_interactor": Provide( + lambda: MockGetLocalFirmwareReleases(), sync_to_thread=False + ) + }, + ) as client: + response = client.get("/192.168.1.100/firmware-releases") + + assert response.status_code == 200 + assert response.json() == { + "stable": { + "version": "1.8.0", + "build_id": "20250611-100000/1.8.0-g1234567", + }, + "beta": None, + } + + def test_get_firmware_releases_maps_an_index_failure_to_422(self): + class MockGetLocalFirmwareReleases(GetLocalFirmwareReleases): + def __init__(self): + pass + + async def execute(self, request): + raise FirmwareError("Firmware index request failed for Plus2PM") + + with create_test_client( + route_handlers=[get_firmware_releases], + dependencies={ + "local_firmware_releases_interactor": Provide( + lambda: MockGetLocalFirmwareReleases(), sync_to_thread=False + ) + }, + exception_handlers=EXCEPTION_HANDLERS, + ) as client: + response = client.get("/192.168.1.100/firmware-releases") + + assert response.status_code == 422 + + def test_update_device_rejects_an_unknown_source(self): + with create_test_client(route_handlers=[update_device]) as client: + response = client.post("/192.168.1.100/update", json={"source": "usb"}) + + assert response.status_code == 400 + + def test_update_device_rejects_an_unknown_channel(self): + with create_test_client( + route_handlers=[update_device], + exception_handlers=EXCEPTION_HANDLERS, + ) as client: + response = client.post("/192.168.1.100/update", json={"channel": "nightly"}) + assert response.status_code == 400 def test_update_device_maps_firmware_misconfiguration_to_500(self): @@ -256,7 +331,7 @@ class MockUpdateDeviceFromLocal(UpdateDeviceFromLocal): def __init__(self): pass - async def execute(self, request): + async def execute(self, request, channel="stable"): raise FirmwareConfigurationError( "Local updates need SHELLY_FIRMWARE_ADVERTISED_BASE_URL set" ) @@ -280,8 +355,8 @@ class MockUpdateDeviceFromLocal(UpdateDeviceFromLocal): def __init__(self): pass - async def execute(self, request): - raise FirmwareError("No firmware published for app 'Plus2PM'") + async def execute(self, request, channel="stable"): + raise FirmwareError("No stable firmware published for app 'Plus2PM'") with create_test_client( route_handlers=[update_device], diff --git a/packages/core/src/core/dependencies/container_base.py b/packages/core/src/core/dependencies/container_base.py index 9fdc274..737e29c 100644 --- a/packages/core/src/core/dependencies/container_base.py +++ b/packages/core/src/core/dependencies/container_base.py @@ -43,6 +43,7 @@ from core.use_cases.check_device_status import CheckDeviceStatusUseCase from core.use_cases.execute_component_action import ExecuteComponentActionUseCase from core.use_cases.get_component_actions import GetComponentActionsUseCase +from core.use_cases.get_local_firmware_releases import GetLocalFirmwareReleases from core.use_cases.manage_backup_schedules import ManageBackupSchedulesUseCase from core.use_cases.manage_firmware import ManageFirmware from core.use_cases.manage_provisioning_profiles import ( @@ -82,6 +83,7 @@ def __init__(self) -> None: self._firmware_gateway: ShellyCloudFirmwareGateway | None = None self._acquire_firmware_interactor: AcquireFirmware | None = None self._update_device_from_local_interactor: UpdateDeviceFromLocal | None = None + self._local_firmware_releases_interactor: GetLocalFirmwareReleases | None = None self._manage_firmware_interactor: ManageFirmware | None = None # Every slot above is a device-scoped cache cleared by # _reset_device_caches(); slots below survive close(). @@ -180,6 +182,7 @@ def get_scan_interactor(self) -> ScanDevicesUseCase: device_gateway=self.get_device_gateway(), mdns_client=self.get_mdns_client(), auth_state_cache=self.get_auth_state_cache(), + firmware_gateway=self.get_firmware_gateway(), ) return self._scan_interactor @@ -230,6 +233,14 @@ def get_update_device_from_local_interactor(self) -> UpdateDeviceFromLocal: ) return self._update_device_from_local_interactor + def get_local_firmware_releases_interactor(self) -> GetLocalFirmwareReleases: + if self._local_firmware_releases_interactor is None: + self._local_firmware_releases_interactor = GetLocalFirmwareReleases( + device_gateway=self.get_device_gateway(), + firmware_gateway=self.get_firmware_gateway(), + ) + return self._local_firmware_releases_interactor + def get_manage_firmware_interactor(self) -> ManageFirmware: if self._manage_firmware_interactor is None: self._manage_firmware_interactor = ManageFirmware( diff --git a/packages/core/src/core/domain/entities/discovered_device.py b/packages/core/src/core/domain/entities/discovered_device.py index b13b817..2f6f196 100644 --- a/packages/core/src/core/domain/entities/discovered_device.py +++ b/packages/core/src/core/domain/entities/discovered_device.py @@ -17,6 +17,9 @@ class DiscoveredDevice(BaseModel): status: Status = Field(..., description="Current device status") device_id: str | None = Field(None, description="Unique device identifier") device_type: str | None = Field(None, description="Device model/type") + app_name: str | None = Field( + None, description="Device app name firmware is looked up by, e.g. 'Plus2PM'" + ) firmware_version: str | None = Field(None, description="Current firmware version") device_name: str | None = Field(None, description="User-defined device name") auth_required: bool = Field( diff --git a/packages/core/src/core/domain/value_objects/firmware_release.py b/packages/core/src/core/domain/value_objects/firmware_release.py index 53f555d..5c96d5e 100644 --- a/packages/core/src/core/domain/value_objects/firmware_release.py +++ b/packages/core/src/core/domain/value_objects/firmware_release.py @@ -13,3 +13,19 @@ class FirmwareRelease(BaseModel): build_id: str = Field(..., description="Full build identifier") download_url: str = Field(..., description="Direct download URL for the bundle") channel: str = Field(default="stable", description="Release channel (stable/beta)") + + def is_installed_on(self, firmware_version: str | None) -> bool: + """Whether a device reporting this fw_id already runs exactly this build. + + A fw_id and the index's build id name one exact build, so they decide + this whenever the device reports one. Comparing versions instead would + call a device current when the same version has been republished under + a new build, which is how a reissued fix would never install. A device + that reports a bare version has no build to compare, so its version has + to settle it, otherwise it would be reflashed on every run. + """ + if not firmware_version: + return False + if "/" in firmware_version: + return firmware_version == self.build_id + return firmware_version.split("-g", 1)[0] == self.version diff --git a/packages/core/src/core/gateways/device/shelly_device_gateway.py b/packages/core/src/core/gateways/device/shelly_device_gateway.py index 2feda1b..7e687e7 100644 --- a/packages/core/src/core/gateways/device/shelly_device_gateway.py +++ b/packages/core/src/core/gateways/device/shelly_device_gateway.py @@ -90,6 +90,7 @@ async def discover_device( status=Status.DETECTED, device_id=device_data.get("id"), device_type=device_data.get("model"), + app_name=device_data.get("app"), device_name=device_data.get("name"), firmware_version=device_data.get("fw_id"), auth_required=auth_required, diff --git a/packages/core/src/core/gateways/firmware/firmware.py b/packages/core/src/core/gateways/firmware/firmware.py index 12b00e8..ce97ac7 100644 --- a/packages/core/src/core/gateways/firmware/firmware.py +++ b/packages/core/src/core/gateways/firmware/firmware.py @@ -7,8 +7,10 @@ class FirmwareGateway(ABC): @abstractmethod - async def get_latest(self, app_name: str) -> FirmwareRelease | None: - """Latest stable release for an app, or ``None`` when the index has none.""" + async def get_latest( + self, app_name: str, channel: str = "stable" + ) -> FirmwareRelease | None: + """Latest release for an app on a channel, or ``None`` when the index has none.""" pass @abstractmethod diff --git a/packages/core/src/core/gateways/firmware/shelly_cloud_firmware_gateway.py b/packages/core/src/core/gateways/firmware/shelly_cloud_firmware_gateway.py index 30cc61f..4fc2d4a 100644 --- a/packages/core/src/core/gateways/firmware/shelly_cloud_firmware_gateway.py +++ b/packages/core/src/core/gateways/firmware/shelly_cloud_firmware_gateway.py @@ -59,7 +59,9 @@ def __init__( verify=verify, ) - async def get_latest(self, app_name: str) -> FirmwareRelease | None: + async def get_latest( + self, app_name: str, channel: str = "stable" + ) -> FirmwareRelease | None: if not _is_safe_app_name(app_name): raise FirmwareError( f"Refusing firmware lookup for unsafe app name '{app_name}'", @@ -80,7 +82,7 @@ async def get_latest(self, app_name: str) -> FirmwareRelease | None: {"app_name": app_name}, ) from e - return _release_from_index_entry(app_name, data) + return _release_from_index_entry(app_name, data, channel) async def download( self, release: FirmwareRelease, dest_path: str @@ -254,16 +256,18 @@ def _reject_oversized(response: httpx.Response, release: FirmwareRelease) -> Non ) -def _release_from_index_entry(app_name: str, data: Any) -> FirmwareRelease | None: +def _release_from_index_entry( + app_name: str, data: Any, channel: str +) -> FirmwareRelease | None: if not isinstance(data, dict): return None - stable = data.get("stable") - if not isinstance(stable, dict): + entry = data.get(channel) + if not isinstance(entry, dict): return None - version = stable.get("version") - build_id = stable.get("build_id") - download_url = stable.get("url") + version = entry.get("version") + build_id = entry.get("build_id") + download_url = entry.get("url") if not ( isinstance(version, str) and version @@ -279,5 +283,5 @@ def _release_from_index_entry(app_name: str, data: Any) -> FirmwareRelease | Non version=version, build_id=build_id, download_url=download_url, - channel="stable", + channel=channel, ) diff --git a/packages/core/src/core/use_cases/get_local_firmware_releases.py b/packages/core/src/core/use_cases/get_local_firmware_releases.py new file mode 100644 index 0000000..b4b5dae --- /dev/null +++ b/packages/core/src/core/use_cases/get_local_firmware_releases.py @@ -0,0 +1,53 @@ +"""Use case for previewing the firmware a local update would install.""" + +from core.domain.entities.exceptions import DeviceNotFoundError +from core.domain.enums.enums import UpdateChannel +from core.domain.value_objects.base_device_request import BaseDeviceRequest +from core.domain.value_objects.firmware_release import FirmwareRelease +from core.gateways.device import DeviceGateway +from core.gateways.firmware import FirmwareGateway + + +class GetLocalFirmwareReleases: + """Resolve what the firmware index publishes for one device, per channel. + + Backs the channel choice in the update dialog: an offline device cannot + report what is available, so the manager answers from the same index a + local update would install from. + """ + + def __init__( + self, + device_gateway: DeviceGateway, + firmware_gateway: FirmwareGateway, + ): + self._device_gateway = device_gateway + self._firmware_gateway = firmware_gateway + + async def execute( + self, request: BaseDeviceRequest + ) -> dict[str, FirmwareRelease | None]: + """Releases by channel name, ``None`` where the index publishes nothing. + + A device local updates cannot serve (Gen1, or one reporting no app + name) gets all-``None``: the dialog needs "nothing to offer", not an + error, to disable the start button. + + Raises: + DeviceNotFoundError: If the device is unreachable. + FirmwareError: If the index cannot be queried. + """ + ip = request.device_ip + status = await self._device_gateway.get_device_status(ip) + if status is None: + raise DeviceNotFoundError(ip) + + if status.gen == 1 or not status.app_name: + return {channel.value: None for channel in UpdateChannel} + + return { + channel.value: await self._firmware_gateway.get_latest( + status.app_name, channel.value + ) + for channel in UpdateChannel + } diff --git a/packages/core/src/core/use_cases/scan_devices.py b/packages/core/src/core/use_cases/scan_devices.py index 1ed7020..ffaf0ab 100644 --- a/packages/core/src/core/use_cases/scan_devices.py +++ b/packages/core/src/core/use_cases/scan_devices.py @@ -10,8 +10,10 @@ ValidationError, ) from ..domain.enums.enums import Status +from ..domain.value_objects.firmware_release import FirmwareRelease from ..domain.value_objects.scan_request import ScanRequest from ..gateways.device import DeviceGateway +from ..gateways.firmware import FirmwareGateway from ..gateways.network import MDNSGateway logger = logging.getLogger(__name__) @@ -24,10 +26,12 @@ def __init__( device_gateway: DeviceGateway, mdns_client: MDNSGateway | None = None, auth_state_cache: Any | None = None, + firmware_gateway: FirmwareGateway | None = None, ): self._device_gateway = device_gateway self._mdns_client = mdns_client self._auth_state_cache = auth_state_cache + self._firmware_gateway = firmware_gateway async def execute(self, request: ScanRequest) -> list[DiscoveredDevice]: """ @@ -68,8 +72,55 @@ async def execute(self, request: ScanRequest) -> list[DiscoveredDevice]: ]: discovered_devices.append(result) + await self._settle_update_status(discovered_devices) + return discovered_devices + async def _settle_update_status(self, devices: list[DiscoveredDevice]) -> None: + """Answer stalled update checks from the manager's own index lookup. + + A device that cannot reach Shelly's cloud fails its own update check + and stays DETECTED, which the UI reads as an open question even though + the manager can still serve it firmware locally. The index the manager + queries here is the same one local updates install from, so the badge + and the via-manager path cannot disagree. Stable only: it is what an + unqualified "update available" means everywhere else in the app. + """ + firmware_gateway = self._firmware_gateway + if firmware_gateway is None: + return + + pending: dict[str, list[DiscoveredDevice]] = {} + for device in devices: + if device.status == Status.DETECTED and device.app_name: + pending.setdefault(device.app_name, []).append(device) + if not pending: + return + + apps = sorted(pending) + lookups = await asyncio.gather( + *(self._lookup_release(firmware_gateway, app) for app in apps) + ) + + for app, release in zip(apps, lookups, strict=True): + if release is None: + continue + for device in pending[app]: + device.status = ( + Status.NO_UPDATE_NEEDED + if release.is_installed_on(device.firmware_version) + else Status.UPDATE_AVAILABLE + ) + + async def _lookup_release( + self, firmware_gateway: FirmwareGateway, app_name: str + ) -> FirmwareRelease | None: + try: + return await firmware_gateway.get_latest(app_name) + except Exception as e: + logger.warning("Firmware index lookup failed for app %s: %s", app_name, e) + return None + def _validate_scan_request(self, request: ScanRequest) -> None: """Validate scan request.""" if request.use_mdns: diff --git a/packages/core/src/core/use_cases/update_device_from_local.py b/packages/core/src/core/use_cases/update_device_from_local.py index fd585b6..5871d41 100644 --- a/packages/core/src/core/use_cases/update_device_from_local.py +++ b/packages/core/src/core/use_cases/update_device_from_local.py @@ -10,7 +10,6 @@ ) from core.domain.value_objects.action_result import ActionResult from core.domain.value_objects.base_device_request import BaseDeviceRequest -from core.domain.value_objects.firmware_release import FirmwareRelease from core.gateways.device import DeviceGateway from core.gateways.firmware import FirmwareGateway from core.settings import FirmwareSettings @@ -41,14 +40,16 @@ def __init__( self._acquire_firmware = acquire_firmware self._settings = settings - async def execute(self, request: BaseDeviceRequest) -> ActionResult: + async def execute( + self, request: BaseDeviceRequest, channel: str = "stable" + ) -> ActionResult: """Point the device at a locally cached copy of its latest firmware. Raises: DeviceNotFoundError: If the device is unreachable. FirmwareError: If the advertised base URL is unset, the device is - Gen1 or reports no app name, no release is published, or a - mandatory intermediate update is missing. + Gen1 or reports no app name, no release is published on the + channel, or a mandatory intermediate update is missing. """ base_url = (self._settings.advertised_base_url or "").strip() if not base_url: @@ -74,15 +75,15 @@ async def execute(self, request: BaseDeviceRequest) -> ActionResult: {"device_ip": ip}, ) - release = await self._firmware_gateway.get_latest(status.app_name) + release = await self._firmware_gateway.get_latest(status.app_name, channel) if release is None: raise FirmwareError( - f"No firmware published for app '{status.app_name}'", - {"app_name": status.app_name}, + f"No {channel} firmware published for app '{status.app_name}'", + {"app_name": status.app_name, "channel": channel}, ) installed = _installed_version(status.firmware_version) - if _already_runs(status.firmware_version, installed, release): + if release.is_installed_on(status.firmware_version): return ActionResult( device_ip=ip, action_type="shelly.Update", @@ -137,25 +138,6 @@ def _installed_version(firmware_version: str | None) -> str | None: return (after_build_date or firmware_version).split("-g", 1)[0] -def _already_runs( - firmware_version: str | None, installed: str | None, release: FirmwareRelease -) -> bool: - """Whether the device already runs exactly what the index publishes. - - A fw_id and the index's build id name one exact build, so they decide this - whenever the device reports one. Comparing versions instead would call a - device current when the same version has been republished under a new - build, which is how a reissued fix would never install. A device that - reports a bare version has no build to compare, so its version has to - settle it, otherwise it would be reflashed on every run. - """ - if not firmware_version: - return False - if "/" in firmware_version: - return firmware_version == release.build_id - return installed is not None and installed == release.version - - def _version_key(version: str) -> tuple[int, ...] | None: """Comparable form of a version, or ``None`` when it cannot be read.""" key = [] diff --git a/packages/core/tests/unit/dependencies/test_container_base.py b/packages/core/tests/unit/dependencies/test_container_base.py index ff91dc4..f880621 100644 --- a/packages/core/tests/unit/dependencies/test_container_base.py +++ b/packages/core/tests/unit/dependencies/test_container_base.py @@ -93,6 +93,7 @@ def test_it_wires_the_auth_service_to_the_credentials_factory(self, container): "get_firmware_gateway", "get_acquire_firmware_interactor", "get_update_device_from_local_interactor", + "get_local_firmware_releases_interactor", "get_manage_firmware_interactor", "get_ap_device_detector", "get_mdns_client", diff --git a/packages/core/tests/unit/gateways/device/test_shelly_device_gateway.py b/packages/core/tests/unit/gateways/device/test_shelly_device_gateway.py index 4dadb77..7f2a550 100644 --- a/packages/core/tests/unit/gateways/device/test_shelly_device_gateway.py +++ b/packages/core/tests/unit/gateways/device/test_shelly_device_gateway.py @@ -35,6 +35,7 @@ async def test_it_discovers_device_successfully(self, gateway, mock_rpc_client): device_info = { "id": "shelly1pm-001", "model": "SHPM-1", + "app": "Plus1PM", "name": "Living Room Switch", "fw_id": "20230913-114010/v1.14.0-gcb84623", } @@ -51,6 +52,7 @@ async def test_it_discovers_device_successfully(self, gateway, mock_rpc_client): assert result.status == Status.NO_UPDATE_NEEDED assert result.device_id == "shelly1pm-001" assert result.device_type == "SHPM-1" + assert result.app_name == "Plus1PM" assert result.device_name == "Living Room Switch" assert result.firmware_version == "20230913-114010/v1.14.0-gcb84623" assert result.response_time == 0.15 diff --git a/packages/core/tests/unit/gateways/firmware/test_shelly_cloud_firmware_gateway.py b/packages/core/tests/unit/gateways/firmware/test_shelly_cloud_firmware_gateway.py index 8c1f989..d213a1c 100644 --- a/packages/core/tests/unit/gateways/firmware/test_shelly_cloud_firmware_gateway.py +++ b/packages/core/tests/unit/gateways/firmware/test_shelly_cloud_firmware_gateway.py @@ -56,6 +56,46 @@ def handler(request): assert release.download_url == "https://fwcdn.example.test/Plus2PM.zip" assert release.channel == "stable" + async def test_it_returns_the_beta_release_when_asked(self): + def handler(request): + return httpx.Response( + 200, + json={ + "stable": { + "version": "1.7.5", + "build_id": "20250611-100000/1.7.5-g1234567", + "url": "https://fwcdn.example.test/Plus2PM.zip", + }, + "beta": { + "version": "1.8.0-beta2", + "build_id": "20250701-100000/1.8.0-beta2-g89abcde", + "url": "https://fwcdn.example.test/Plus2PM-beta.zip", + }, + }, + ) + + release = await _gateway(handler).get_latest("Plus2PM", "beta") + + assert release is not None + assert release.version == "1.8.0-beta2" + assert release.build_id == "20250701-100000/1.8.0-beta2-g89abcde" + assert release.channel == "beta" + + async def test_it_returns_none_when_the_channel_has_no_entry(self): + def handler(request): + return httpx.Response( + 200, + json={ + "stable": { + "version": "1.7.5", + "build_id": "20250611-100000/1.7.5-g1234567", + "url": "https://fwcdn.example.test/Plus2PM.zip", + } + }, + ) + + assert (await _gateway(handler).get_latest("Plus2PM", "beta")) is None + async def test_it_returns_none_when_the_index_has_no_entry(self): def handler(request): return httpx.Response(404) diff --git a/packages/core/tests/unit/use_cases/test_get_local_firmware_releases.py b/packages/core/tests/unit/use_cases/test_get_local_firmware_releases.py new file mode 100644 index 0000000..72fd18a --- /dev/null +++ b/packages/core/tests/unit/use_cases/test_get_local_firmware_releases.py @@ -0,0 +1,106 @@ +"""Tests for the local firmware release preview use case.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, call + +import pytest +from core.domain.entities.exceptions import DeviceNotFoundError, FirmwareError +from core.domain.value_objects.base_device_request import BaseDeviceRequest +from core.domain.value_objects.firmware_release import FirmwareRelease +from core.use_cases.get_local_firmware_releases import GetLocalFirmwareReleases + +IP = "192.168.1.100" + + +def _status(gen=2, app_name="Plus2PM"): + return SimpleNamespace(gen=gen, app_name=app_name) + + +def _release(channel, version="1.8.0"): + return FirmwareRelease( + app_name="Plus2PM", + version=version, + build_id=f"20250611-100000/{version}-g1234567", + download_url="https://fwcdn.example.test/Plus2PM.zip", + channel=channel, + ) + + +class TestGetLocalFirmwareReleases: + @pytest.fixture + def mock_firmware_gateway(self): + gateway = AsyncMock() + gateway.get_latest = AsyncMock( + side_effect=lambda app_name, channel: _release(channel) + ) + return gateway + + @pytest.fixture + def use_case(self, mock_device_gateway, mock_firmware_gateway): + mock_device_gateway.get_device_status = AsyncMock(return_value=_status()) + return GetLocalFirmwareReleases( + device_gateway=mock_device_gateway, + firmware_gateway=mock_firmware_gateway, + ) + + async def test_it_returns_a_release_per_channel( + self, use_case, mock_firmware_gateway + ): + releases = await use_case.execute(BaseDeviceRequest(device_ip=IP)) + + assert set(releases) == {"stable", "beta"} + assert releases["stable"].channel == "stable" + assert releases["beta"].channel == "beta" + mock_firmware_gateway.get_latest.assert_has_awaits( + [call("Plus2PM", "stable"), call("Plus2PM", "beta")] + ) + + async def test_it_passes_through_a_channel_without_a_release( + self, use_case, mock_firmware_gateway + ): + mock_firmware_gateway.get_latest.side_effect = lambda app_name, channel: ( + _release(channel) if channel == "stable" else None + ) + + releases = await use_case.execute(BaseDeviceRequest(device_ip=IP)) + + assert releases["stable"] is not None + assert releases["beta"] is None + + async def test_it_raises_when_the_device_is_unreachable( + self, use_case, mock_device_gateway + ): + mock_device_gateway.get_device_status = AsyncMock(return_value=None) + + with pytest.raises(DeviceNotFoundError): + await use_case.execute(BaseDeviceRequest(device_ip=IP)) + + async def test_it_offers_nothing_for_a_gen1_device( + self, use_case, mock_device_gateway, mock_firmware_gateway + ): + mock_device_gateway.get_device_status = AsyncMock(return_value=_status(gen=1)) + + releases = await use_case.execute(BaseDeviceRequest(device_ip=IP)) + + assert releases == {"stable": None, "beta": None} + mock_firmware_gateway.get_latest.assert_not_awaited() + + async def test_it_offers_nothing_without_an_app_name( + self, use_case, mock_device_gateway, mock_firmware_gateway + ): + mock_device_gateway.get_device_status = AsyncMock( + return_value=_status(app_name=None) + ) + + releases = await use_case.execute(BaseDeviceRequest(device_ip=IP)) + + assert releases == {"stable": None, "beta": None} + mock_firmware_gateway.get_latest.assert_not_awaited() + + async def test_it_propagates_an_index_failure( + self, use_case, mock_firmware_gateway + ): + mock_firmware_gateway.get_latest.side_effect = FirmwareError("index down") + + with pytest.raises(FirmwareError): + await use_case.execute(BaseDeviceRequest(device_ip=IP)) diff --git a/packages/core/tests/unit/use_cases/test_scan_devices.py b/packages/core/tests/unit/use_cases/test_scan_devices.py index 0f78370..b303e57 100644 --- a/packages/core/tests/unit/use_cases/test_scan_devices.py +++ b/packages/core/tests/unit/use_cases/test_scan_devices.py @@ -4,6 +4,7 @@ from core.domain.entities.discovered_device import DiscoveredDevice from core.domain.entities.exceptions import ConfigurationError from core.domain.enums.enums import Status +from core.domain.value_objects.firmware_release import FirmwareRelease from core.domain.value_objects.scan_request import ScanRequest from core.gateways.network import MDNSGateway from core.use_cases.scan_devices import ScanDevicesUseCase @@ -228,3 +229,144 @@ async def test_it_scans_with_mdns_client_exception( assert result == [] mock_mdns_client.discover_device_ips.assert_called_once_with(timeout=5.0) + + +class TestScanSettlesUpdateStatus: + """A stalled device-side update check is answered from the manager's index.""" + + @pytest.fixture + def single_ip_request(self): + return ScanRequest( + targets=["192.168.1.1"], + use_predefined=False, + use_mdns=False, + timeout=3.0, + max_workers=10, + ) + + def _device(self, status=Status.DETECTED, **kwargs): + base = { + "ip": "192.168.1.1", + "status": status, + "device_id": "1", + "device_type": "SNSW-102P16EU", + "app_name": "Plus2PM", + "firmware_version": "20240101-000000/1.7.5-gabc", + } + base.update(kwargs) + return DiscoveredDevice(**base) + + def _use_case(self, mock_device_gateway, release): + firmware_gateway = AsyncMock() + if isinstance(release, Exception): + firmware_gateway.get_latest = AsyncMock(side_effect=release) + else: + firmware_gateway.get_latest = AsyncMock(return_value=release) + use_case = ScanDevicesUseCase( + device_gateway=mock_device_gateway, + firmware_gateway=firmware_gateway, + ) + return use_case, firmware_gateway + + async def test_it_marks_an_update_the_index_publishes( + self, mock_device_gateway, single_ip_request + ): + mock_device_gateway.discover_device = AsyncMock(return_value=self._device()) + use_case, firmware_gateway = self._use_case( + mock_device_gateway, + FirmwareRelease( + app_name="Plus2PM", + version="1.8.0", + build_id="20250611-100000/1.8.0-g1234567", + download_url="https://fwcdn.example.test/Plus2PM.zip", + ), + ) + + result = await use_case.execute(single_ip_request) + + assert result[0].status == Status.UPDATE_AVAILABLE + firmware_gateway.get_latest.assert_awaited_once_with("Plus2PM") + + async def test_it_marks_a_device_already_on_the_published_build( + self, mock_device_gateway, single_ip_request + ): + build_id = "20250611-100000/1.7.5-g1234567" + mock_device_gateway.discover_device = AsyncMock( + return_value=self._device(firmware_version=build_id) + ) + use_case, _ = self._use_case( + mock_device_gateway, + FirmwareRelease( + app_name="Plus2PM", + version="1.7.5", + build_id=build_id, + download_url="https://fwcdn.example.test/Plus2PM.zip", + ), + ) + + result = await use_case.execute(single_ip_request) + + assert result[0].status == Status.NO_UPDATE_NEEDED + + async def test_it_leaves_detected_when_the_index_has_no_release( + self, mock_device_gateway, single_ip_request + ): + mock_device_gateway.discover_device = AsyncMock(return_value=self._device()) + use_case, _ = self._use_case(mock_device_gateway, None) + + result = await use_case.execute(single_ip_request) + + assert result[0].status == Status.DETECTED + + async def test_it_leaves_detected_when_the_index_is_unreachable( + self, mock_device_gateway, single_ip_request + ): + mock_device_gateway.discover_device = AsyncMock(return_value=self._device()) + use_case, _ = self._use_case(mock_device_gateway, Exception("index down")) + + result = await use_case.execute(single_ip_request) + + assert result[0].status == Status.DETECTED + + async def test_it_leaves_a_settled_status_alone( + self, mock_device_gateway, single_ip_request + ): + mock_device_gateway.discover_device = AsyncMock( + return_value=self._device(status=Status.NO_UPDATE_NEEDED) + ) + use_case, firmware_gateway = self._use_case(mock_device_gateway, None) + + result = await use_case.execute(single_ip_request) + + assert result[0].status == Status.NO_UPDATE_NEEDED + firmware_gateway.get_latest.assert_not_awaited() + + async def test_it_skips_a_device_without_an_app_name( + self, mock_device_gateway, single_ip_request + ): + mock_device_gateway.discover_device = AsyncMock( + return_value=self._device(app_name=None) + ) + use_case, firmware_gateway = self._use_case(mock_device_gateway, None) + + result = await use_case.execute(single_ip_request) + + assert result[0].status == Status.DETECTED + firmware_gateway.get_latest.assert_not_awaited() + + async def test_it_asks_the_index_once_per_app(self, mock_device_gateway): + request = ScanRequest( + targets=["192.168.1.1", "192.168.1.2"], + use_predefined=False, + use_mdns=False, + timeout=3.0, + max_workers=10, + ) + mock_device_gateway.discover_device = AsyncMock( + side_effect=lambda ip, timeout: self._device(ip=ip) + ) + use_case, firmware_gateway = self._use_case(mock_device_gateway, None) + + await use_case.execute(request) + + firmware_gateway.get_latest.assert_awaited_once_with("Plus2PM") diff --git a/packages/core/tests/unit/use_cases/test_update_device_from_local.py b/packages/core/tests/unit/use_cases/test_update_device_from_local.py index 577a25c..6ba98c7 100644 --- a/packages/core/tests/unit/use_cases/test_update_device_from_local.py +++ b/packages/core/tests/unit/use_cases/test_update_device_from_local.py @@ -179,11 +179,33 @@ async def test_it_raises_when_no_release_is_published( ): mock_firmware_gateway.get_latest = AsyncMock(return_value=None) - with pytest.raises(FirmwareError, match="No firmware published"): + with pytest.raises(FirmwareError, match="No stable firmware published"): await use_case.execute(BaseDeviceRequest(device_ip=IP)) mock_device_gateway.execute_component_action.assert_not_awaited() + async def test_it_asks_the_index_for_the_requested_channel( + self, use_case, mock_firmware_gateway + ): + await use_case.execute(BaseDeviceRequest(device_ip=IP), channel="beta") + + mock_firmware_gateway.get_latest.assert_awaited_once_with("Plus2PM", "beta") + + async def test_it_asks_the_index_for_stable_by_default( + self, use_case, mock_firmware_gateway + ): + await use_case.execute(BaseDeviceRequest(device_ip=IP)) + + mock_firmware_gateway.get_latest.assert_awaited_once_with("Plus2PM", "stable") + + async def test_it_names_the_channel_that_has_no_release( + self, use_case, mock_firmware_gateway + ): + mock_firmware_gateway.get_latest = AsyncMock(return_value=None) + + with pytest.raises(FirmwareError, match="No beta firmware published"): + await use_case.execute(BaseDeviceRequest(device_ip=IP), channel="beta") + async def test_it_short_circuits_when_already_up_to_date( self, use_case, mock_device_gateway, mock_firmware_gateway, mock_acquire ): diff --git a/packages/web/src/components/device-detail/device-actions.tsx b/packages/web/src/components/device-detail/device-actions.tsx index 3d88429..54d8741 100644 --- a/packages/web/src/components/device-detail/device-actions.tsx +++ b/packages/web/src/components/device-detail/device-actions.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Download, @@ -70,12 +70,7 @@ export function DeviceActions({ }: { channel: UpdateChannel; source: UpdateSource; - }) => - deviceApi.updateDevice( - ip, - source === "local" ? "stable" : channel, - source, - ), + }) => deviceApi.updateDevice(ip, channel, source), onSuccess: (result) => { if (result.success) { toast.success( @@ -131,6 +126,23 @@ export function DeviceActions({ const selectedRelease = getChannelRelease(availableUpdates, updateChannel); const hasUpdates = hasAnyRelease(availableUpdates); + // The device's own available_updates answer for the internet source; an + // offline device reports none, so the local source asks the manager what + // its index can serve instead. + const localReleasesQuery = useQuery({ + queryKey: ["local-firmware-releases", ip], + queryFn: () => deviceApi.getFirmwareReleases(ip), + enabled: updateDialogOpen && updateSource === "local", + staleTime: 60_000, + }); + // On a failed refetch the query keeps its previous data next to isError; + // stale availability must not offer an update the manager may no longer + // have, so an errored query means "unknown", not "last known". + const localReleases = localReleasesQuery.isError + ? undefined + : localReleasesQuery.data; + const selectedLocalRelease = localReleases?.[updateChannel] ?? null; + return ( @@ -220,39 +232,35 @@ export function DeviceActions({ - {updateSource === "internet" && ( -
- - -
- )} +
+ + +
{updateSource === "internet" && selectedRelease && (
@@ -285,6 +293,29 @@ export function DeviceActions({ )}
)} + + {updateSource === "local" && selectedLocalRelease && ( +
+ {t("deviceDetail.dialogs.updateFirmware.version")}:{" "} + {selectedLocalRelease.version} +
+ )} + + {updateSource === "local" && + localReleases && + !selectedLocalRelease && ( +
+ {t( + "deviceDetail.dialogs.updateFirmware.noUpdateOnChannel", + )} +
+ )} + + {updateSource === "local" && localReleasesQuery.isError && ( +
+ {handleApiError(localReleasesQuery.error)} +
+ )} @@ -303,7 +334,8 @@ export function DeviceActions({ } disabled={ updateMutation.isPending || - (updateSource === "internet" && !selectedRelease) + (updateSource === "internet" && !selectedRelease) || + (updateSource === "local" && !selectedLocalRelease) } > {updateMutation.isPending diff --git a/packages/web/src/i18n/en.json b/packages/web/src/i18n/en.json index 40722ec..c819e91 100644 --- a/packages/web/src/i18n/en.json +++ b/packages/web/src/i18n/en.json @@ -401,7 +401,6 @@ "updateSource": "Update Source", "sourceInternet": "Internet", "sourceLocal": "Via manager", - "sourceLocalHint": "The manager downloads the official firmware once and serves it to the device over the local network. Useful for devices without internet access. Stable channel only.", "internetStep1": "The device is told to update itself", "internetStep2": "It downloads the firmware from Shelly and installs it", "localStep1": "The manager asks Shelly which version is current", diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 335e113..9a9c488 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -24,6 +24,7 @@ import type { UpdateBackupScheduleRequest, ScheduleRunResult, UpdateSource, + LocalFirmwareReleases, } from "@/types/api"; import { loadAppSettings } from "./settings"; import { parseResponse } from "./schemas/parse"; @@ -163,6 +164,11 @@ export const deviceApi = { return response.data; }, + getFirmwareReleases: async (ip: string): Promise => { + const response = await apiClient.get(`/devices/${ip}/firmware-releases`); + return response.data; + }, + updateDevice: async ( ip: string, channel: "stable" | "beta" = "stable", diff --git a/packages/web/src/types/api.ts b/packages/web/src/types/api.ts index 9504e36..ea289bc 100644 --- a/packages/web/src/types/api.ts +++ b/packages/web/src/types/api.ts @@ -335,6 +335,12 @@ export interface DeviceStatusError { export type UpdateSource = "internet" | "local"; +/** What the manager itself can serve for a local update, per channel. */ +export type LocalFirmwareReleases = Record< + string, + { version: string; build_id: string } | null +>; + export interface ActionResult { ip: string; success: boolean;