Skip to content

Commit 3c87044

Browse files
committed
Make via-manager updates channel-aware and settle offline badges
A device without internet access fails its own Shelly.CheckForUpdate, so scans left it stuck at Detected while a via-manager update for the same device reported it already latest. Scans now settle those devices against the manager's own index lookup, the same source local updates install from, so the badge and the via-manager path cannot disagree. The local source also silently forced the stable channel. The update dialog now offers the channel choice for both sources, backed by a new firmware-releases endpoint that answers from the manager's index, since an offline device cannot report what is available on either channel.
1 parent b0a1f5a commit 3c87044

22 files changed

Lines changed: 702 additions & 106 deletions

File tree

packages/api/src/api/controllers/devices.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from core.use_cases.check_device_status import CheckDeviceStatusUseCase
2020
from core.use_cases.execute_component_action import ExecuteComponentActionUseCase
2121
from core.use_cases.get_component_actions import GetComponentActionsUseCase
22+
from core.use_cases.get_local_firmware_releases import GetLocalFirmwareReleases
2223
from core.use_cases.scan_devices import ScanDevicesUseCase
2324
from core.use_cases.update_device_from_local import UpdateDeviceFromLocal
2425
from litestar import Router, get, post
@@ -274,13 +275,8 @@ async def update_device(
274275
update_device_from_local_interactor = _require(
275276
"update_device_from_local_interactor", update_device_from_local_interactor
276277
)
277-
if channel is not UpdateChannel.STABLE:
278-
raise HTTPException(
279-
status_code=400,
280-
detail="Local updates support the stable channel only",
281-
)
282278
result = await update_device_from_local_interactor.execute(
283-
BaseDeviceRequest(device_ip=ip)
279+
BaseDeviceRequest(device_ip=ip), channel=channel.value
284280
)
285281
else:
286282
execute_component_action_interactor = _require(
@@ -307,6 +303,47 @@ async def update_device(
307303
}
308304

309305

306+
@get(
307+
"/{ip:str}/firmware-releases",
308+
tags=["Devices"],
309+
summary="Preview Firmware Releases for Local Update",
310+
)
311+
async def get_firmware_releases(
312+
ip: str,
313+
local_firmware_releases_interactor: GetLocalFirmwareReleases | None = None,
314+
) -> dict:
315+
"""
316+
Show what firmware a local (via manager) update would install, per channel.
317+
318+
The versions come from the manager's own index lookup, so they are
319+
accurate for devices without internet access. Channels the index
320+
publishes nothing for are null, as is every channel for a device local
321+
updates cannot serve (Gen1 or no reported app name).
322+
323+
Args:
324+
ip: Device IP address (e.g., "192.168.1.100")
325+
326+
Returns:
327+
dict: Release metadata by channel name, e.g.
328+
{"stable": {"version": ..., "build_id": ...}, "beta": null}
329+
"""
330+
local_firmware_releases_interactor = _require(
331+
"local_firmware_releases_interactor", local_firmware_releases_interactor
332+
)
333+
334+
releases = await local_firmware_releases_interactor.execute(
335+
BaseDeviceRequest(device_ip=ip)
336+
)
337+
return {
338+
channel: (
339+
None
340+
if release is None
341+
else {"version": release.version, "build_id": release.build_id}
342+
)
343+
for channel, release in releases.items()
344+
}
345+
346+
310347
@post("/{ip:str}/reboot", status_code=200, tags=["Devices"], summary="Reboot Device")
311348
async def reboot_device(
312349
ip: str,
@@ -463,6 +500,7 @@ async def bulk_apply_config(
463500
execute_component_action,
464501
get_device_status,
465502
update_device,
503+
get_firmware_releases,
466504
reboot_device,
467505
execute_bulk_operations,
468506
bulk_export_config,

packages/api/src/api/dependencies/container.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ def get_dependencies(container: APIContainer) -> dict:
8282
lambda: container.get_update_device_from_local_interactor(),
8383
sync_to_thread=False,
8484
),
85+
"local_firmware_releases_interactor": Provide(
86+
lambda: container.get_local_firmware_releases_interactor(),
87+
sync_to_thread=False,
88+
),
8589
"manage_firmware_use_case": Provide(
8690
lambda: container.get_manage_firmware_interactor(),
8791
sync_to_thread=False,

packages/api/tests/unit/controllers/test_devices.py

Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
execute_component_action,
88
get_component_actions,
99
get_device_status,
10+
get_firmware_releases,
1011
scan_devices,
1112
update_device,
1213
)
@@ -20,6 +21,7 @@
2021
from core.use_cases.check_device_status import CheckDeviceStatusUseCase
2122
from core.use_cases.execute_component_action import ExecuteComponentActionUseCase
2223
from core.use_cases.get_component_actions import GetComponentActionsUseCase
24+
from core.use_cases.get_local_firmware_releases import GetLocalFirmwareReleases
2325
from core.use_cases.scan_devices import ScanDevicesUseCase
2426
from core.use_cases.update_device_from_local import UpdateDeviceFromLocal
2527
from litestar.di import Provide
@@ -188,8 +190,9 @@ class MockUpdateDeviceFromLocal(UpdateDeviceFromLocal):
188190
def __init__(self):
189191
pass
190192

191-
async def execute(self, request):
193+
async def execute(self, request, channel="stable"):
192194
captured["request"] = request
195+
captured["channel"] = channel
193196
return ActionResult(
194197
device_ip=request.device_ip,
195198
success=True,
@@ -213,27 +216,24 @@ async def execute(self, request):
213216
assert data["source"] == "local"
214217
assert data["channel"] == "stable"
215218
assert captured["request"].device_ip == "192.168.1.100"
219+
assert captured["channel"] == "stable"
216220

217-
def test_update_device_rejects_an_unknown_source(self):
218-
with create_test_client(route_handlers=[update_device]) as client:
219-
response = client.post("/192.168.1.100/update", json={"source": "usb"})
220-
221-
assert response.status_code == 400
222-
223-
def test_update_device_rejects_an_unknown_channel(self):
224-
with create_test_client(
225-
route_handlers=[update_device],
226-
exception_handlers=EXCEPTION_HANDLERS,
227-
) as client:
228-
response = client.post("/192.168.1.100/update", json={"channel": "nightly"})
229-
230-
assert response.status_code == 400
221+
def test_update_device_from_local_source_passes_the_channel(self):
222+
captured = {}
231223

232-
def test_update_device_rejects_a_beta_local_update(self):
233224
class MockUpdateDeviceFromLocal(UpdateDeviceFromLocal):
234225
def __init__(self):
235226
pass
236227

228+
async def execute(self, request, channel="stable"):
229+
captured["channel"] = channel
230+
return ActionResult(
231+
device_ip=request.device_ip,
232+
success=True,
233+
message="Update executed successfully on shelly",
234+
action_type="shelly.Update",
235+
)
236+
237237
with create_test_client(
238238
route_handlers=[update_device],
239239
dependencies={
@@ -247,6 +247,81 @@ def __init__(self):
247247
json={"source": "local", "channel": "beta"},
248248
)
249249

250+
assert response.status_code == 200
251+
assert response.json()["channel"] == "beta"
252+
assert captured["channel"] == "beta"
253+
254+
def test_get_firmware_releases_reports_each_channel(self):
255+
from core.domain.value_objects.firmware_release import FirmwareRelease
256+
257+
class MockGetLocalFirmwareReleases(GetLocalFirmwareReleases):
258+
def __init__(self):
259+
pass
260+
261+
async def execute(self, request):
262+
return {
263+
"stable": FirmwareRelease(
264+
app_name="Plus2PM",
265+
version="1.8.0",
266+
build_id="20250611-100000/1.8.0-g1234567",
267+
download_url="https://fwcdn.example.test/Plus2PM.zip",
268+
),
269+
"beta": None,
270+
}
271+
272+
with create_test_client(
273+
route_handlers=[get_firmware_releases],
274+
dependencies={
275+
"local_firmware_releases_interactor": Provide(
276+
lambda: MockGetLocalFirmwareReleases(), sync_to_thread=False
277+
)
278+
},
279+
) as client:
280+
response = client.get("/192.168.1.100/firmware-releases")
281+
282+
assert response.status_code == 200
283+
assert response.json() == {
284+
"stable": {
285+
"version": "1.8.0",
286+
"build_id": "20250611-100000/1.8.0-g1234567",
287+
},
288+
"beta": None,
289+
}
290+
291+
def test_get_firmware_releases_maps_an_index_failure_to_422(self):
292+
class MockGetLocalFirmwareReleases(GetLocalFirmwareReleases):
293+
def __init__(self):
294+
pass
295+
296+
async def execute(self, request):
297+
raise FirmwareError("Firmware index request failed for Plus2PM")
298+
299+
with create_test_client(
300+
route_handlers=[get_firmware_releases],
301+
dependencies={
302+
"local_firmware_releases_interactor": Provide(
303+
lambda: MockGetLocalFirmwareReleases(), sync_to_thread=False
304+
)
305+
},
306+
exception_handlers=EXCEPTION_HANDLERS,
307+
) as client:
308+
response = client.get("/192.168.1.100/firmware-releases")
309+
310+
assert response.status_code == 422
311+
312+
def test_update_device_rejects_an_unknown_source(self):
313+
with create_test_client(route_handlers=[update_device]) as client:
314+
response = client.post("/192.168.1.100/update", json={"source": "usb"})
315+
316+
assert response.status_code == 400
317+
318+
def test_update_device_rejects_an_unknown_channel(self):
319+
with create_test_client(
320+
route_handlers=[update_device],
321+
exception_handlers=EXCEPTION_HANDLERS,
322+
) as client:
323+
response = client.post("/192.168.1.100/update", json={"channel": "nightly"})
324+
250325
assert response.status_code == 400
251326

252327
def test_update_device_maps_firmware_misconfiguration_to_500(self):
@@ -256,7 +331,7 @@ class MockUpdateDeviceFromLocal(UpdateDeviceFromLocal):
256331
def __init__(self):
257332
pass
258333

259-
async def execute(self, request):
334+
async def execute(self, request, channel="stable"):
260335
raise FirmwareConfigurationError(
261336
"Local updates need SHELLY_FIRMWARE_ADVERTISED_BASE_URL set"
262337
)
@@ -280,8 +355,8 @@ class MockUpdateDeviceFromLocal(UpdateDeviceFromLocal):
280355
def __init__(self):
281356
pass
282357

283-
async def execute(self, request):
284-
raise FirmwareError("No firmware published for app 'Plus2PM'")
358+
async def execute(self, request, channel="stable"):
359+
raise FirmwareError("No stable firmware published for app 'Plus2PM'")
285360

286361
with create_test_client(
287362
route_handlers=[update_device],

packages/core/src/core/dependencies/container_base.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from core.use_cases.check_device_status import CheckDeviceStatusUseCase
4444
from core.use_cases.execute_component_action import ExecuteComponentActionUseCase
4545
from core.use_cases.get_component_actions import GetComponentActionsUseCase
46+
from core.use_cases.get_local_firmware_releases import GetLocalFirmwareReleases
4647
from core.use_cases.manage_backup_schedules import ManageBackupSchedulesUseCase
4748
from core.use_cases.manage_firmware import ManageFirmware
4849
from core.use_cases.manage_provisioning_profiles import (
@@ -82,6 +83,7 @@ def __init__(self) -> None:
8283
self._firmware_gateway: ShellyCloudFirmwareGateway | None = None
8384
self._acquire_firmware_interactor: AcquireFirmware | None = None
8485
self._update_device_from_local_interactor: UpdateDeviceFromLocal | None = None
86+
self._local_firmware_releases_interactor: GetLocalFirmwareReleases | None = None
8587
self._manage_firmware_interactor: ManageFirmware | None = None
8688
# Every slot above is a device-scoped cache cleared by
8789
# _reset_device_caches(); slots below survive close().
@@ -180,6 +182,7 @@ def get_scan_interactor(self) -> ScanDevicesUseCase:
180182
device_gateway=self.get_device_gateway(),
181183
mdns_client=self.get_mdns_client(),
182184
auth_state_cache=self.get_auth_state_cache(),
185+
firmware_gateway=self.get_firmware_gateway(),
183186
)
184187
return self._scan_interactor
185188

@@ -230,6 +233,14 @@ def get_update_device_from_local_interactor(self) -> UpdateDeviceFromLocal:
230233
)
231234
return self._update_device_from_local_interactor
232235

236+
def get_local_firmware_releases_interactor(self) -> GetLocalFirmwareReleases:
237+
if self._local_firmware_releases_interactor is None:
238+
self._local_firmware_releases_interactor = GetLocalFirmwareReleases(
239+
device_gateway=self.get_device_gateway(),
240+
firmware_gateway=self.get_firmware_gateway(),
241+
)
242+
return self._local_firmware_releases_interactor
243+
233244
def get_manage_firmware_interactor(self) -> ManageFirmware:
234245
if self._manage_firmware_interactor is None:
235246
self._manage_firmware_interactor = ManageFirmware(

packages/core/src/core/domain/entities/discovered_device.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ class DiscoveredDevice(BaseModel):
1717
status: Status = Field(..., description="Current device status")
1818
device_id: str | None = Field(None, description="Unique device identifier")
1919
device_type: str | None = Field(None, description="Device model/type")
20+
app_name: str | None = Field(
21+
None, description="Device app name firmware is looked up by, e.g. 'Plus2PM'"
22+
)
2023
firmware_version: str | None = Field(None, description="Current firmware version")
2124
device_name: str | None = Field(None, description="User-defined device name")
2225
auth_required: bool = Field(

packages/core/src/core/domain/value_objects/firmware_release.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,19 @@ class FirmwareRelease(BaseModel):
1313
build_id: str = Field(..., description="Full build identifier")
1414
download_url: str = Field(..., description="Direct download URL for the bundle")
1515
channel: str = Field(default="stable", description="Release channel (stable/beta)")
16+
17+
def is_installed_on(self, firmware_version: str | None) -> bool:
18+
"""Whether a device reporting this fw_id already runs exactly this build.
19+
20+
A fw_id and the index's build id name one exact build, so they decide
21+
this whenever the device reports one. Comparing versions instead would
22+
call a device current when the same version has been republished under
23+
a new build, which is how a reissued fix would never install. A device
24+
that reports a bare version has no build to compare, so its version has
25+
to settle it, otherwise it would be reflashed on every run.
26+
"""
27+
if not firmware_version:
28+
return False
29+
if "/" in firmware_version:
30+
return firmware_version == self.build_id
31+
return firmware_version.split("-g", 1)[0] == self.version

packages/core/src/core/gateways/device/shelly_device_gateway.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ async def discover_device(
9090
status=Status.DETECTED,
9191
device_id=device_data.get("id"),
9292
device_type=device_data.get("model"),
93+
app_name=device_data.get("app"),
9394
device_name=device_data.get("name"),
9495
firmware_version=device_data.get("fw_id"),
9596
auth_required=auth_required,

packages/core/src/core/gateways/firmware/firmware.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
class FirmwareGateway(ABC):
99
@abstractmethod
10-
async def get_latest(self, app_name: str) -> FirmwareRelease | None:
11-
"""Latest stable release for an app, or ``None`` when the index has none."""
10+
async def get_latest(
11+
self, app_name: str, channel: str = "stable"
12+
) -> FirmwareRelease | None:
13+
"""Latest release for an app on a channel, or ``None`` when the index has none."""
1214
pass
1315

1416
@abstractmethod

0 commit comments

Comments
 (0)