Skip to content

Commit 04a89fd

Browse files
feat: add a --include-beta filter to CLI scan/list/status commands
The CLI showed beta-only firmware updates unfiltered (no channel awareness at all), unlike the web UI's "Show beta updates" toggle. Adds an --include-beta flag to scan, device list, and device status, off by default: a beta-only update now reports as "no update needed" in the table view, and the detail view says so plainly instead of listing it, mirroring the web app's default behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VApHaH9KZ6BPV9SwvUAVLH
1 parent 7e4173a commit 04a89fd

8 files changed

Lines changed: 314 additions & 19 deletions

File tree

packages/cli/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ shelly-manager scan --target 192.168.1.0/24 --export csv --export-file devices.c
8383
- `--use-mdns`: Use mDNS service discovery
8484
- `--timeout`: Timeout per device (default: 3.0s)
8585
- `--workers`: Concurrent workers (default: 50)
86+
- `--include-beta`: Count beta-only firmware updates as available (hidden by default)
8687
- `--export`: Export format (json, csv)
8788
- `--export-file`: Output file path
8889

@@ -94,12 +95,19 @@ shelly-manager scan --target 192.168.1.0/24 --export csv --export-file devices.c
9495
# Check device status
9596
shelly-manager device status 192.168.1.100
9697
shelly-manager device status 192.168.1.100 192.168.1.101
98+
shelly-manager device status 192.168.1.100 --include-beta # Also surface beta-only updates
99+
100+
# List known devices in a table
101+
shelly-manager device list 192.168.1.0/24
102+
shelly-manager device list 192.168.1.0/24 --include-beta
97103

98104
# Reboot devices
99105
shelly-manager device reboot 192.168.1.100
100106
shelly-manager device reboot 192.168.1.100 --force # Skip confirmation
101107
```
102108

109+
By default, `scan`, `device list`, and `device status` report a device as up to date when the only firmware update available is on the beta channel — pass `--include-beta` to see it. A beta release is always installable explicitly regardless of this flag, via `device update --channel beta`.
110+
103111
**Device Status Output:**
104112

105113
```

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ def device_commands() -> None:
3535
@device_commands.command()
3636
@click.argument("targets", nargs=-1)
3737
@click.option("--use-mdns", is_flag=True, help="Use mDNS to discover devices")
38+
@click.option(
39+
"--include-beta",
40+
is_flag=True,
41+
help="Count beta-only firmware updates as available (hidden by default)",
42+
)
3843
@device_targeting_options
3944
@common_options
4045
@click.pass_context
@@ -44,6 +49,7 @@ async def scan(
4449
targets: tuple[str, ...],
4550
targets_opt: tuple[str, ...],
4651
use_mdns: bool,
52+
include_beta: bool,
4753
timeout: int,
4854
workers: int,
4955
) -> None:
@@ -55,6 +61,7 @@ async def scan(
5561
shelly-manager scan 192.168.1.0/24
5662
shelly-manager scan -t 192.168.1.100 -t 192.168.1.101
5763
shelly-manager scan --use-mdns
64+
shelly-manager scan 192.168.1.0/24 --include-beta
5865
"""
5966
console = ctx.obj.console
6067
container = ctx.obj.container
@@ -70,11 +77,16 @@ async def scan(
7077
)
7178

7279
devices_found = await scan_use_case.execute(request)
73-
scan_use_case.display_results(devices_found)
80+
scan_use_case.display_results(devices_found, include_beta=include_beta)
7481

7582

7683
@device_commands.command("list")
7784
@click.argument("targets", nargs=-1)
85+
@click.option(
86+
"--include-beta",
87+
is_flag=True,
88+
help="Count beta-only firmware updates as available (hidden by default)",
89+
)
7890
@device_targeting_options
7991
@common_options
8092
@click.pass_context
@@ -83,15 +95,17 @@ async def list_devices(
8395
ctx: click.Context,
8496
targets: tuple[str, ...],
8597
targets_opt: tuple[str, ...],
98+
include_beta: bool,
8699
timeout: int,
87100
workers: int,
88101
) -> None:
89102
"""
90103
Similar to scan but optimized for listing known devices with full details in a table format.
91104
92105
Examples:
93-
shelly-manager list 192.168.1.0/24
94-
shelly-manager list -t 192.168.1.100 -t 192.168.1.101
106+
shelly-manager device list 192.168.1.0/24
107+
shelly-manager device list -t 192.168.1.100 -t 192.168.1.101
108+
shelly-manager device list 192.168.1.0/24 --include-beta
95109
"""
96110
console = ctx.obj.console
97111
container = ctx.obj.container
@@ -108,13 +122,20 @@ async def list_devices(
108122
devices_found = await scan_use_case.execute(request)
109123

110124
if devices_found:
111-
scan_use_case.display_results(devices_found, show_table=True)
125+
scan_use_case.display_results(
126+
devices_found, show_table=True, include_beta=include_beta
127+
)
112128
else:
113129
console.print(f"\n{Messages.warning('No devices found')}")
114130

115131

116132
@device_commands.command()
117133
@click.argument("targets", nargs=-1, required=False)
134+
@click.option(
135+
"--include-beta",
136+
is_flag=True,
137+
help="Include beta-only firmware updates in the update list (hidden by default)",
138+
)
118139
@device_targeting_options
119140
@common_options
120141
@click.pass_context
@@ -123,6 +144,7 @@ async def status(
123144
ctx: click.Context,
124145
targets: tuple[str, ...],
125146
targets_opt: tuple[str, ...],
147+
include_beta: bool,
126148
timeout: int,
127149
workers: int,
128150
) -> None:
@@ -133,6 +155,7 @@ async def status(
133155
Examples:
134156
shelly-manager status 192.168.1.100 192.168.1.101
135157
shelly-manager status -t 192.168.1.0/24
158+
shelly-manager status 192.168.1.100 --include-beta
136159
"""
137160
console = ctx.obj.console
138161
container = ctx.obj.container
@@ -156,7 +179,7 @@ async def status(
156179
"shelly-manager device status 192.168.1.0/24",
157180
)
158181
sys.exit(EXIT_VALIDATION)
159-
status_use_case.display_results(results)
182+
status_use_case.display_results(results, include_beta=include_beta)
160183

161184

162185
@click.group()

packages/cli/src/cli/use_cases/common/result_formatting.py

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
from core.domain.entities import DeviceStatus, DiscoveredDevice
8+
from core.domain.enums.enums import Status
89
from rich.console import Console
910
from rich.table import Table
1011

@@ -21,27 +22,45 @@ def __init__(self, console: Console):
2122
self._console = console
2223

2324
def format_device_table(
24-
self, devices: list[Any], title: str = "Shelly Devices"
25+
self,
26+
devices: list[Any],
27+
title: str = "Shelly Devices",
28+
include_beta: bool = False,
2529
) -> None:
2630
"""
2731
Format and display a table of devices.
2832
2933
Args:
3034
devices: List of device objects or dictionaries
3135
title: Table title
36+
include_beta: Whether beta-only updates should be surfaced as
37+
available, instead of reported as up to date
3238
"""
3339
if not devices:
3440
return
3541

3642
if devices and isinstance(devices[0], DiscoveredDevice):
37-
self._format_discovered_devices_table(devices, title)
43+
self._format_discovered_devices_table(devices, title, include_beta)
3844
elif devices and isinstance(devices[0], DeviceStatus):
39-
self._format_device_status_table(devices)
45+
self._format_device_status_table(devices, include_beta)
4046
else:
4147
self._format_legacy_device_table(devices, title)
4248

49+
def _effective_status(self, device: DiscoveredDevice, include_beta: bool) -> str:
50+
"""The status to display, downgrading a beta-only update to "no
51+
update needed" when beta visibility is off — mirrors the same
52+
allowed-channels rule the web UI applies."""
53+
status = device.status
54+
if (
55+
not include_beta
56+
and str(status) == Status.UPDATE_AVAILABLE.value
57+
and getattr(device, "available_firmware_channel", None) == "beta"
58+
):
59+
return Status.NO_UPDATE_NEEDED.value
60+
return status
61+
4362
def _format_discovered_devices_table(
44-
self, devices: list[DiscoveredDevice], title: str
63+
self, devices: list[DiscoveredDevice], title: str, include_beta: bool = False
4564
) -> None:
4665
"""Format table for DiscoveredDevice entities."""
4766
table = Table(title=title)
@@ -61,17 +80,19 @@ def _format_discovered_devices_table(
6180
device.model_name or device.device_type or "Unknown",
6281
device.device_name or "Unknown",
6382
device.firmware_version or "Unknown",
64-
format_device_status(device.status),
83+
format_device_status(self._effective_status(device, include_beta)),
6584
response_time,
6685
)
6786

6887
self._console.print(table)
6988

70-
def _format_device_status_table(self, devices: list[DeviceStatus]) -> None:
89+
def _format_device_status_table(
90+
self, devices: list[DeviceStatus], include_beta: bool = False
91+
) -> None:
7192
"""Format table for DeviceStatus entities."""
7293

7394
for device_status in devices:
74-
self.format_detailed_device_status(device_status)
95+
self.format_detailed_device_status(device_status, include_beta)
7596

7697
def _format_legacy_device_table(self, devices: list[Any], title: str) -> None:
7798
"""Legacy format for backward compatibility."""
@@ -106,7 +127,9 @@ def _format_legacy_device_table(self, devices: list[Any], title: str) -> None:
106127

107128
self._console.print(table)
108129

109-
def format_detailed_device_status(self, device_status: DeviceStatus) -> None:
130+
def format_detailed_device_status(
131+
self, device_status: DeviceStatus, include_beta: bool = False
132+
) -> None:
110133
"""Format detailed component information for a single device."""
111134
from rich.columns import Columns
112135
from rich.panel import Panel
@@ -132,18 +155,33 @@ def format_detailed_device_status(self, device_status: DeviceStatus) -> None:
132155
# Show available firmware updates with version information
133156
if system_info.available_updates:
134157
device_summary = device_status.get_device_summary()
135-
available_updates = device_summary.get("available_updates", {})
158+
raw_available_updates = device_summary.get("available_updates", {})
159+
available_updates = (
160+
raw_available_updates
161+
if include_beta
162+
else {
163+
channel: info
164+
for channel, info in raw_available_updates.items()
165+
if channel != "beta"
166+
}
167+
)
136168

137169
if available_updates:
138170
system_content.append("[yellow]Updates Available:[/yellow]")
139171
for update_type, update_info in available_updates.items():
140172
version = update_info.get("version", "Unknown")
141173
name = update_info.get("name", update_type) or update_type
142174
system_content.append(f" [cyan]• {name}:[/cyan] {version}")
143-
else:
175+
elif not raw_available_updates:
176+
# The raw component data has entries but none carried a
177+
# usable version, so get_device_summary filtered
178+
# everything out — unrelated to beta visibility.
144179
system_content.append(
145180
f"[yellow]Updates Available:[/yellow] {len(system_info.available_updates)}"
146181
)
182+
# else: only a beta release exists and beta visibility is
183+
# off — show nothing, exactly like a device with no
184+
# updates at all. No partial hint.
147185

148186
system_panel = Panel(
149187
"\n".join(system_content),

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,6 @@ async def _check_device_status(
9797

9898
return results
9999

100-
def display_results(self, results: list[Any]) -> None:
100+
def display_results(self, results: list[Any], include_beta: bool = False) -> None:
101101
"""Display status results to console."""
102-
self._result_formatter.format_device_table(results)
102+
self._result_formatter.format_device_table(results, include_beta=include_beta)

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,23 @@ async def execute(self, request: DeviceScanRequest) -> list[Any]:
6060
return devices_found
6161

6262
def display_results(
63-
self, devices_found: list[Any], show_table: bool = True
63+
self,
64+
devices_found: list[Any],
65+
show_table: bool = True,
66+
include_beta: bool = False,
6467
) -> None:
6568
"""
6669
Display scan results to console.
6770
6871
Args:
6972
devices_found: List of discovered devices
7073
show_table: Whether to show device table
74+
include_beta: Whether beta-only updates count as available
7175
"""
7276
if show_table:
73-
self._result_formatter.format_device_table(devices_found)
77+
self._result_formatter.format_device_table(
78+
devices_found, include_beta=include_beta
79+
)
7480

7581
if devices_found:
7682
self._console.print(

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,28 @@ def test_scan_with_custom_timeout_and_workers(
134134
assert call_args.timeout == 5.0
135135
assert call_args.max_workers == 20
136136

137+
def test_scan_help_documents_include_beta(self, cli_context):
138+
runner = CliRunner()
139+
result = runner.invoke(device_commands, ["scan", "--help"], obj=cli_context)
140+
141+
assert result.exit_code == 0
142+
assert "--include-beta" in result.output
143+
144+
def test_scan_accepts_include_beta_flag(
145+
self, cli_context_with_scan, sample_devices, mock_scan_interactor
146+
):
147+
mock_scan_interactor.execute.return_value = sample_devices
148+
149+
runner = CliRunner()
150+
result = runner.invoke(
151+
device_commands,
152+
["scan", "192.168.1.1-50", "--include-beta"],
153+
obj=cli_context_with_scan,
154+
)
155+
156+
assert result.exit_code == 0
157+
mock_scan_interactor.execute.assert_called_once()
158+
137159

138160
class TestListCommand:
139161

@@ -178,6 +200,28 @@ def test_list_no_devices(self, cli_context_with_list, mock_scan_interactor):
178200
assert result.exit_code == 0
179201
mock_scan_interactor.execute.assert_called_once()
180202

203+
def test_list_help_documents_include_beta(self, cli_context):
204+
runner = CliRunner()
205+
result = runner.invoke(device_commands, ["list", "--help"], obj=cli_context)
206+
207+
assert result.exit_code == 0
208+
assert "--include-beta" in result.output
209+
210+
def test_list_accepts_include_beta_flag(
211+
self, cli_context_with_list, sample_devices, mock_scan_interactor
212+
):
213+
mock_scan_interactor.execute.return_value = sample_devices
214+
215+
runner = CliRunner()
216+
result = runner.invoke(
217+
device_commands,
218+
["list", "10.0.0.1", "--include-beta"],
219+
obj=cli_context_with_list,
220+
)
221+
222+
assert result.exit_code == 0
223+
mock_scan_interactor.execute.assert_called_once()
224+
181225

182226
class TestStatusCommand:
183227

@@ -296,6 +340,34 @@ def test_status_verbose_error_output(
296340
assert result.exit_code == 0
297341
mock_status_interactor.execute.assert_called_once()
298342

343+
def test_status_help_documents_include_beta(self, cli_context):
344+
runner = CliRunner()
345+
result = runner.invoke(device_commands, ["status", "--help"], obj=cli_context)
346+
347+
assert result.exit_code == 0
348+
assert "--include-beta" in result.output
349+
350+
def test_status_accepts_include_beta_flag(
351+
self,
352+
cli_context_with_status,
353+
mock_status_interactor,
354+
mock_scan_interactor_for_status,
355+
sample_devices,
356+
sample_device,
357+
):
358+
mock_scan_interactor_for_status.execute.return_value = sample_devices
359+
mock_status_interactor.execute.return_value = sample_device
360+
361+
runner = CliRunner()
362+
result = runner.invoke(
363+
device_commands,
364+
["status", "192.168.1.100", "192.168.1.101", "--include-beta"],
365+
obj=cli_context_with_status,
366+
)
367+
368+
assert result.exit_code == 0
369+
assert mock_status_interactor.execute.call_count == 2
370+
299371

300372
class TestDeviceRebootCommand:
301373

packages/cli/tests/unit/use_cases/common/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)