Skip to content

Commit 90dcfe5

Browse files
committed
Add the via-manager source to bulk firmware updates
The bulk update dialog only offered the device's own internet path, so an offline fleet could not be updated in one go. The bulk endpoint now accepts the same source field as the single-device route and runs the local path device by device, reusing the cached bundle per app. The dialog gains the source selector, resets its update choices on close, and uses timeout and progress estimates that fit the slower sequential path. The CLI's local update now honours --channel too: the stable-only guard predated the channel-aware interactor and its loop never forwarded the choice.
1 parent e7fae42 commit 90dcfe5

19 files changed

Lines changed: 418 additions & 46 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ Earlier images wrote to `/app/data` instead. If you carried that over with a mou
230230

231231
**Scheduled backups** run on the API server itself. When `SHELLY_BACKUP_SCHEDULER_ENABLED` is `true` (the default), an in-process poller captures backups for any due schedules every `SHELLY_BACKUP_POLL_INTERVAL_SECONDS` (default 60). Because the timer lives in-process, run the API as a single worker (the default); see the [API README](packages/api/README.md) for the full setting reference.
232232

233-
**Local firmware updates** let a device that cannot reach the internet still be updated. Ask for one with `"source": "local"` on the update endpoint, or `--source local` from the CLI: the manager downloads the official firmware from Shelly once, keeps it, and tells the device to fetch it from the manager instead. The same copy serves every device running that model, so only the manager needs internet access.
233+
**Local firmware updates** let a device that cannot reach the internet still be updated. Ask for one with `"source": "local"` on the update endpoint (single-device or bulk), or `--source local` from the CLI: the manager downloads the official firmware from Shelly once, keeps it, and tells the device to fetch it from the manager instead. The same copy serves every device running that model, so only the manager needs internet access.
234234

235235
Set `SHELLY_FIRMWARE_ADVERTISED_BASE_URL` to a URL your devices can reach, for example `http://192.168.1.50:8000`. There is no default and a local update fails immediately without it, because the manager cannot work out its own device-facing address. The devices fetch that URL unauthenticated, so it has to be reachable from the device network.
236236

packages/api/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,10 @@ POST /api/devices/{ip}/update # Update device firmware
6969

7070
POST /api/devices/{ip}/reboot # Reboot device
7171

72-
POST /api/devices/bulk/update # Bulk firmware updates
73-
# Body: {"device_ips": ["192.168.1.100", "192.168.1.101"], "channel": "stable"}
72+
POST /api/devices/bulk # Bulk operations (update, reboot, factory_reset)
73+
# Body: {"device_ips": ["192.168.1.100", "192.168.1.101"], "operation": "update",
74+
# "channel": "stable", "source": "internet"}
75+
# update takes the same channel and source fields as the single-device route
7476

7577
# Component Actions
7678
GET /api/devices/{ip}/components/actions # Discover available actions
@@ -244,7 +246,7 @@ curl -X POST http://localhost:8000/api/devices/192.168.1.100/update \
244246

245247
For a device with no internet access, ask the manager to serve the firmware.
246248
It downloads the official bundle once, keeps it, and hands the device a URL on
247-
this host. Requires `SHELLY_FIRMWARE_ADVERTISED_BASE_URL`; stable channel only.
249+
this host. Requires `SHELLY_FIRMWARE_ADVERTISED_BASE_URL`.
248250

249251
```bash
250252
curl -X POST http://localhost:8000/api/devices/192.168.1.100/update \

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,9 @@ async def execute_bulk_operations(
397397
returned for each device individually.
398398
399399
Args:
400-
data: Request containing device_ips list and operation type
400+
data: Request containing device_ips list, operation type, and for
401+
updates an optional "channel" (stable/beta) and "source"
402+
(internet/local, default internet)
401403
402404
Returns:
403405
list[dict]: Operation results for each device with success status
@@ -424,9 +426,20 @@ async def execute_bulk_operations(
424426

425427
if operation == "update":
426428
channel = data.get("channel", "stable")
427-
results = await bulk_operations_use_case.execute_bulk_update(
428-
device_ips, channel
429-
)
429+
source = data.get("source", "internet")
430+
if source not in ("internet", "local"):
431+
raise HTTPException(
432+
status_code=400,
433+
detail=f"Unsupported source: {source}. Supported: internet, local",
434+
)
435+
if source == "local":
436+
results = await bulk_operations_use_case.execute_bulk_local_update(
437+
device_ips, channel
438+
)
439+
else:
440+
results = await bulk_operations_use_case.execute_bulk_update(
441+
device_ips, channel
442+
)
430443
elif operation == "reboot":
431444
results = await bulk_operations_use_case.execute_bulk_reboot(device_ips)
432445
elif operation == "factory_reset":

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,109 @@ def __init__(self):
571571

572572
assert response.status_code == 400
573573

574+
def test_bulk_operations_update_via_local_source(self):
575+
from core.use_cases.bulk_operations import BulkOperationsUseCase
576+
577+
class MockBulkOperationsUseCase(BulkOperationsUseCase):
578+
def __init__(self):
579+
pass
580+
581+
async def execute_bulk_update(self, device_ips, channel="stable"):
582+
raise AssertionError("local source must not use the device path")
583+
584+
async def execute_bulk_local_update(self, device_ips, channel="stable"):
585+
return [
586+
ActionResult(
587+
device_ip=ip,
588+
success=True,
589+
message=f"Updating to {channel}",
590+
action_type="shelly.Update",
591+
)
592+
for ip in device_ips
593+
]
594+
595+
with create_test_client(
596+
route_handlers=[execute_bulk_operations],
597+
dependencies={
598+
"bulk_operations_use_case": Provide(
599+
lambda: MockBulkOperationsUseCase(), sync_to_thread=False
600+
)
601+
},
602+
) as client:
603+
response = client.post(
604+
"/bulk",
605+
json={
606+
"device_ips": ["192.168.1.100", "192.168.1.101"],
607+
"operation": "update",
608+
"channel": "beta",
609+
"source": "local",
610+
},
611+
)
612+
613+
assert response.status_code == 200
614+
data = response.json()
615+
assert len(data) == 2
616+
assert all(result["success"] for result in data)
617+
assert all(result["source"] == "local" for result in data)
618+
assert all(result["channel"] == "beta" for result in data)
619+
620+
def test_bulk_operations_local_update_reports_missing_configuration(self):
621+
from core.domain.entities.exceptions import FirmwareConfigurationError
622+
from core.use_cases.bulk_operations import BulkOperationsUseCase
623+
624+
class MockBulkOperationsUseCase(BulkOperationsUseCase):
625+
def __init__(self):
626+
pass
627+
628+
async def execute_bulk_local_update(self, device_ips, channel="stable"):
629+
raise FirmwareConfigurationError("Advertised base URL unset")
630+
631+
with create_test_client(
632+
route_handlers=[execute_bulk_operations],
633+
dependencies={
634+
"bulk_operations_use_case": Provide(
635+
lambda: MockBulkOperationsUseCase(), sync_to_thread=False
636+
)
637+
},
638+
exception_handlers=EXCEPTION_HANDLERS,
639+
) as client:
640+
response = client.post(
641+
"/bulk",
642+
json={
643+
"device_ips": ["192.168.1.100"],
644+
"operation": "update",
645+
"source": "local",
646+
},
647+
)
648+
649+
assert response.status_code == 500
650+
651+
def test_bulk_operations_update_rejects_an_unknown_source(self):
652+
from core.use_cases.bulk_operations import BulkOperationsUseCase
653+
654+
class MockBulkOperationsUseCase(BulkOperationsUseCase):
655+
def __init__(self):
656+
pass
657+
658+
with create_test_client(
659+
route_handlers=[execute_bulk_operations],
660+
dependencies={
661+
"bulk_operations_use_case": Provide(
662+
lambda: MockBulkOperationsUseCase(), sync_to_thread=False
663+
)
664+
},
665+
) as client:
666+
response = client.post(
667+
"/bulk",
668+
json={
669+
"device_ips": ["192.168.1.100"],
670+
"operation": "update",
671+
"source": "cloud",
672+
},
673+
)
674+
675+
assert response.status_code == 400
676+
574677
def test_bulk_operations_reboot_successfully(self):
575678
from core.use_cases.bulk_operations import BulkOperationsUseCase
576679

packages/cli/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ With `--source local` the manager downloads the official firmware once and the
161161
device fetches it from the manager, so only this host needs internet access.
162162
It reads the same firmware store as the API and requires
163163
`SHELLY_FIRMWARE_ADVERTISED_BASE_URL` to be set to a URL your devices can
164-
reach; the API must serve that URL. Stable channel only.
164+
reach; the API must serve that URL.
165165

166166
### Configuration Management
167167

packages/cli/src/cli/commands/device_commands.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
DeviceScanRequest,
1616
DeviceStatusRequest,
1717
)
18-
from ..exceptions import EXIT_USAGE, EXIT_VALIDATION
18+
from ..exceptions import EXIT_VALIDATION
1919
from ..presentation.styles import Messages
2020
from ..use_cases.device.component_actions import ComponentActionsUseCase
2121
from ..use_cases.device.device_status import DeviceStatusUseCase
@@ -340,12 +340,6 @@ async def update_firmware(
340340
shelly-manager device update 192.168.1.0/24 --channel beta
341341
shelly-manager device update -t 192.168.1.100 --source local
342342
"""
343-
if source == "local" and channel != UpdateChannel.STABLE.value:
344-
ctx.obj.console.print(
345-
Messages.error("Local updates support the stable channel only")
346-
)
347-
sys.exit(EXIT_USAGE)
348-
349343
request = ComponentActionRequest(
350344
targets=list(targets) + list(targets_opt),
351345
component_key="shelly",
@@ -362,6 +356,7 @@ async def update_firmware(
362356
"shelly-manager device update -t 192.168.1.100",
363357
"shelly-manager device update 192.168.1.0/24 --channel beta",
364358
from_local_store=source == "local",
359+
channel=channel,
365360
)
366361

367362

@@ -416,21 +411,21 @@ async def _run_component_action(
416411
request: ComponentActionRequest,
417412
*examples: str,
418413
from_local_store: bool = False,
414+
channel: str = "stable",
419415
) -> None:
420416
"""Run one component action across every requested device.
421417
422418
``from_local_store`` serves a firmware update out of this host's own
423-
firmware store rather than leaving each device to fetch from the internet.
419+
firmware store rather than leaving each device to fetch from the
420+
internet; ``channel`` picks the release it serves.
424421
"""
425422
console = ctx.obj.console
426423
actions_use_case = ComponentActionsUseCase(ctx.obj.container, console)
427-
execute = (
428-
actions_use_case.execute_local_update
429-
if from_local_store
430-
else actions_use_case.execute_action
431-
)
432424
try:
433-
results = await execute(request)
425+
if from_local_store:
426+
results = await actions_use_case.execute_local_update(request, channel)
427+
else:
428+
results = await actions_use_case.execute_action(request)
434429
except ValueError as e:
435430
_print_usage_error(console, e, *examples)
436431
sys.exit(EXIT_VALIDATION)

packages/cli/src/cli/use_cases/device/component_actions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ async def execute_action(
133133
return results
134134

135135
async def execute_local_update(
136-
self, request: ComponentActionRequest
136+
self, request: ComponentActionRequest, channel: str = "stable"
137137
) -> list[ComponentActionResult]:
138138
"""Update devices with firmware served from the manager's local store.
139139
@@ -184,7 +184,7 @@ async def execute_local_update(
184184
for device_ip in device_ips:
185185
try:
186186
action_result = await update_interactor.execute(
187-
BaseDeviceRequest(device_ip=device_ip)
187+
BaseDeviceRequest(device_ip=device_ip), channel
188188
)
189189

190190
result = ComponentActionResult(

packages/cli/tests/unit/commands/test_device_commands.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -583,28 +583,52 @@ def test_device_update_local_source(self, cli_context, sample_device):
583583

584584
assert result.exit_code == 0
585585
mock_local_interactor.execute.assert_called_once()
586-
request = mock_local_interactor.execute.call_args.args[0]
586+
request, channel = mock_local_interactor.execute.call_args.args
587587
assert request.device_ip == sample_device.ip
588+
assert channel == "stable"
589+
590+
def test_device_update_local_source_passes_the_beta_channel(
591+
self, cli_context, sample_device
592+
):
593+
"""Test that a beta local update reaches the interactor with its channel."""
594+
from core.domain.value_objects.action_result import ActionResult
595+
596+
mock_scan_interactor = cli_context.container.get_scan_interactor.return_value
597+
mock_scan_interactor.execute.return_value = [sample_device]
598+
cli_context.container.initialize_database = AsyncMock()
599+
600+
mock_local_interactor = AsyncMock()
601+
mock_local_interactor.execute.return_value = ActionResult(
602+
device_ip=sample_device.ip,
603+
action_type="shelly.Update",
604+
success=True,
605+
message="Update executed successfully on shelly",
606+
)
607+
cli_context.container.get_update_device_from_local_interactor.return_value = (
608+
mock_local_interactor
609+
)
588610

589-
def test_device_update_local_source_rejects_beta(self, cli_context):
590-
"""Test that a beta local update is refused."""
591611
runner = CliRunner()
592612
result = runner.invoke(
593613
device_commands,
594614
[
595615
"update",
596616
"-t",
597-
"192.168.1.100",
617+
sample_device.ip,
598618
"--source",
599619
"local",
600620
"--channel",
601621
"beta",
622+
"--force",
602623
],
603624
obj=cli_context,
604625
)
605626

606-
assert result.exit_code != 0
607-
cli_context.container.get_update_device_from_local_interactor.assert_not_called()
627+
assert result.exit_code == 0
628+
mock_local_interactor.execute.assert_called_once()
629+
request, channel = mock_local_interactor.execute.call_args.args
630+
assert request.device_ip == sample_device.ip
631+
assert channel == "beta"
608632

609633
def test_actions_execute_help(self, cli_context):
610634
"""Test actions execute command help."""

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,8 @@ def get_status_interactor(self) -> CheckDeviceStatusUseCase:
210210
def get_bulk_operations_interactor(self) -> BulkOperationsUseCase:
211211
if self._bulk_operations_interactor is None:
212212
self._bulk_operations_interactor = BulkOperationsUseCase(
213-
device_gateway=self.get_device_gateway()
213+
device_gateway=self.get_device_gateway(),
214+
update_device_from_local=self.get_update_device_from_local_interactor(),
214215
)
215216
return self._bulk_operations_interactor
216217

0 commit comments

Comments
 (0)