Skip to content

Commit 1ef8f78

Browse files
feat: add a beta firmware update visibility setting
Beta and stable releases were shown identically everywhere (dashboard, device detail, update dialog, and the CLI), with no way to hide beta-only updates by default. Adds a "Show beta updates" preference (off by default) that suppresses beta-only update signals across the app, while keeping the explicit manual channel picker (web dialog, `device update --channel beta`) able to select beta regardless of the setting. Backend: new available_firmware_channel field on DiscoveredDevice, tracking which channel an available update came from, set by both the RPC scan gateway (Gen2+) and the legacy Gen1 gateway (previously never checked there at all), and serialized through the /scan API response. Web: new showBetaUpdates setting (default off) in Settings; dashboard table, device header, and device actions card all filter through it; channel badges (beta orange, stable blue) where both channels are relevant; the update dialog's channel select only lists Beta when the setting is on; fixes an "Updates: Available" indicator that read raw summary.has_updates unfiltered, a channel select that always defaulted to stable instead of the channel that explains the button's state, and a button/badge overflow at narrower viewport widths. CLI: new --include-beta flag on scan, device list, and device status (off by default). A beta-only update reports as no update needed in table output, and the detail view's "Updates Available" line is omitted entirely when the only release is a hidden beta, matching how an already-up-to-date device displays today. BREAKING CHANGE: scan, device list, and device status now hide a beta-only update by default where they previously always showed it — scripts/automation parsing CLI output may observe a behavior change with no flag change on their end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VApHaH9KZ6BPV9SwvUAVLH
1 parent b55f917 commit 1ef8f78

25 files changed

Lines changed: 612 additions & 54 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ async def scan_devices(
8686
"device_name": device.device_name,
8787
"firmware_version": device.firmware_version,
8888
"available_firmware_version": device.available_firmware_version,
89+
"available_firmware_channel": device.available_firmware_channel,
8990
"response_time": device.response_time,
9091
"error_message": device.error_message,
9192
"last_seen": device.last_seen.isoformat() if device.last_seen else None,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ async def execute(self, scan_request):
4343
device_name="Test Device",
4444
firmware_version="1.0.0",
4545
available_firmware_version="1.2.0",
46+
available_firmware_channel="stable",
4647
response_time=0.5,
4748
last_seen=datetime.now(),
4849
)
@@ -64,6 +65,7 @@ async def execute(self, scan_request):
6465
assert data[0]["ip"] == "192.168.1.100"
6566
assert data[0]["status"] == "detected"
6667
assert data[0]["available_firmware_version"] == "1.2.0"
68+
assert data[0]["available_firmware_channel"] == "stable"
6769
assert data[0]["device_type"] == "SHSW-PM"
6870
assert data[0]["model_name"] == "Shelly 1PM"
6971

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(

0 commit comments

Comments
 (0)