diff --git a/packages/api/src/api/controllers/devices.py b/packages/api/src/api/controllers/devices.py
index 3f2d31b..940eab7 100644
--- a/packages/api/src/api/controllers/devices.py
+++ b/packages/api/src/api/controllers/devices.py
@@ -86,6 +86,7 @@ async def scan_devices(
"device_name": device.device_name,
"firmware_version": device.firmware_version,
"available_firmware_version": device.available_firmware_version,
+ "available_firmware_channel": device.available_firmware_channel,
"response_time": device.response_time,
"error_message": device.error_message,
"last_seen": device.last_seen.isoformat() if device.last_seen else None,
diff --git a/packages/api/tests/unit/controllers/test_devices.py b/packages/api/tests/unit/controllers/test_devices.py
index c10d6e3..eb29fd0 100644
--- a/packages/api/tests/unit/controllers/test_devices.py
+++ b/packages/api/tests/unit/controllers/test_devices.py
@@ -43,6 +43,7 @@ async def execute(self, scan_request):
device_name="Test Device",
firmware_version="1.0.0",
available_firmware_version="1.2.0",
+ available_firmware_channel="stable",
response_time=0.5,
last_seen=datetime.now(),
)
@@ -64,6 +65,7 @@ async def execute(self, scan_request):
assert data[0]["ip"] == "192.168.1.100"
assert data[0]["status"] == "detected"
assert data[0]["available_firmware_version"] == "1.2.0"
+ assert data[0]["available_firmware_channel"] == "stable"
assert data[0]["device_type"] == "SHSW-PM"
assert data[0]["model_name"] == "Shelly 1PM"
diff --git a/packages/cli/README.md b/packages/cli/README.md
index ad696a5..0c2a9bd 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -83,6 +83,7 @@ shelly-manager scan --target 192.168.1.0/24 --export csv --export-file devices.c
- `--use-mdns`: Use mDNS service discovery
- `--timeout`: Timeout per device (default: 3.0s)
- `--workers`: Concurrent workers (default: 50)
+- `--include-beta`: Count beta-only firmware updates as available (hidden by default)
- `--export`: Export format (json, csv)
- `--export-file`: Output file path
@@ -94,12 +95,19 @@ shelly-manager scan --target 192.168.1.0/24 --export csv --export-file devices.c
# Check device status
shelly-manager device status 192.168.1.100
shelly-manager device status 192.168.1.100 192.168.1.101
+shelly-manager device status 192.168.1.100 --include-beta # Also surface beta-only updates
+
+# List known devices in a table
+shelly-manager device list 192.168.1.0/24
+shelly-manager device list 192.168.1.0/24 --include-beta
# Reboot devices
shelly-manager device reboot 192.168.1.100
shelly-manager device reboot 192.168.1.100 --force # Skip confirmation
```
+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`.
+
**Device Status Output:**
```
diff --git a/packages/cli/src/cli/commands/device_commands.py b/packages/cli/src/cli/commands/device_commands.py
index d937d6a..ab123dd 100644
--- a/packages/cli/src/cli/commands/device_commands.py
+++ b/packages/cli/src/cli/commands/device_commands.py
@@ -35,6 +35,11 @@ def device_commands() -> None:
@device_commands.command()
@click.argument("targets", nargs=-1)
@click.option("--use-mdns", is_flag=True, help="Use mDNS to discover devices")
+@click.option(
+ "--include-beta",
+ is_flag=True,
+ help="Count beta-only firmware updates as available (hidden by default)",
+)
@device_targeting_options
@common_options
@click.pass_context
@@ -44,6 +49,7 @@ async def scan(
targets: tuple[str, ...],
targets_opt: tuple[str, ...],
use_mdns: bool,
+ include_beta: bool,
timeout: int,
workers: int,
) -> None:
@@ -55,6 +61,7 @@ async def scan(
shelly-manager scan 192.168.1.0/24
shelly-manager scan -t 192.168.1.100 -t 192.168.1.101
shelly-manager scan --use-mdns
+ shelly-manager scan 192.168.1.0/24 --include-beta
"""
console = ctx.obj.console
container = ctx.obj.container
@@ -70,11 +77,16 @@ async def scan(
)
devices_found = await scan_use_case.execute(request)
- scan_use_case.display_results(devices_found)
+ scan_use_case.display_results(devices_found, include_beta=include_beta)
@device_commands.command("list")
@click.argument("targets", nargs=-1)
+@click.option(
+ "--include-beta",
+ is_flag=True,
+ help="Count beta-only firmware updates as available (hidden by default)",
+)
@device_targeting_options
@common_options
@click.pass_context
@@ -83,6 +95,7 @@ async def list_devices(
ctx: click.Context,
targets: tuple[str, ...],
targets_opt: tuple[str, ...],
+ include_beta: bool,
timeout: int,
workers: int,
) -> None:
@@ -90,8 +103,9 @@ async def list_devices(
Similar to scan but optimized for listing known devices with full details in a table format.
Examples:
- shelly-manager list 192.168.1.0/24
- shelly-manager list -t 192.168.1.100 -t 192.168.1.101
+ shelly-manager device list 192.168.1.0/24
+ shelly-manager device list -t 192.168.1.100 -t 192.168.1.101
+ shelly-manager device list 192.168.1.0/24 --include-beta
"""
console = ctx.obj.console
container = ctx.obj.container
@@ -108,13 +122,20 @@ async def list_devices(
devices_found = await scan_use_case.execute(request)
if devices_found:
- scan_use_case.display_results(devices_found, show_table=True)
+ scan_use_case.display_results(
+ devices_found, show_table=True, include_beta=include_beta
+ )
else:
console.print(f"\n{Messages.warning('No devices found')}")
@device_commands.command()
@click.argument("targets", nargs=-1, required=False)
+@click.option(
+ "--include-beta",
+ is_flag=True,
+ help="Include beta-only firmware updates in the update list (hidden by default)",
+)
@device_targeting_options
@common_options
@click.pass_context
@@ -123,6 +144,7 @@ async def status(
ctx: click.Context,
targets: tuple[str, ...],
targets_opt: tuple[str, ...],
+ include_beta: bool,
timeout: int,
workers: int,
) -> None:
@@ -133,6 +155,7 @@ async def status(
Examples:
shelly-manager status 192.168.1.100 192.168.1.101
shelly-manager status -t 192.168.1.0/24
+ shelly-manager status 192.168.1.100 --include-beta
"""
console = ctx.obj.console
container = ctx.obj.container
@@ -156,7 +179,7 @@ async def status(
"shelly-manager device status 192.168.1.0/24",
)
sys.exit(EXIT_VALIDATION)
- status_use_case.display_results(results)
+ status_use_case.display_results(results, include_beta=include_beta)
@click.group()
diff --git a/packages/cli/src/cli/use_cases/common/result_formatting.py b/packages/cli/src/cli/use_cases/common/result_formatting.py
index b879505..ea7bfbe 100644
--- a/packages/cli/src/cli/use_cases/common/result_formatting.py
+++ b/packages/cli/src/cli/use_cases/common/result_formatting.py
@@ -5,6 +5,7 @@
from typing import Any
from core.domain.entities import DeviceStatus, DiscoveredDevice
+from core.domain.enums.enums import Status
from rich.console import Console
from rich.table import Table
@@ -21,7 +22,10 @@ def __init__(self, console: Console):
self._console = console
def format_device_table(
- self, devices: list[Any], title: str = "Shelly Devices"
+ self,
+ devices: list[Any],
+ title: str = "Shelly Devices",
+ include_beta: bool = False,
) -> None:
"""
Format and display a table of devices.
@@ -29,19 +33,34 @@ def format_device_table(
Args:
devices: List of device objects or dictionaries
title: Table title
+ include_beta: Whether beta-only updates should be surfaced as
+ available, instead of reported as up to date
"""
if not devices:
return
if devices and isinstance(devices[0], DiscoveredDevice):
- self._format_discovered_devices_table(devices, title)
+ self._format_discovered_devices_table(devices, title, include_beta)
elif devices and isinstance(devices[0], DeviceStatus):
- self._format_device_status_table(devices)
+ self._format_device_status_table(devices, include_beta)
else:
self._format_legacy_device_table(devices, title)
+ def _effective_status(self, device: DiscoveredDevice, include_beta: bool) -> str:
+ """The status to display, downgrading a beta-only update to "no
+ update needed" when beta visibility is off — mirrors the same
+ allowed-channels rule the web UI applies."""
+ status = device.status
+ if (
+ not include_beta
+ and str(status) == Status.UPDATE_AVAILABLE.value
+ and getattr(device, "available_firmware_channel", None) == "beta"
+ ):
+ return Status.NO_UPDATE_NEEDED.value
+ return status
+
def _format_discovered_devices_table(
- self, devices: list[DiscoveredDevice], title: str
+ self, devices: list[DiscoveredDevice], title: str, include_beta: bool = False
) -> None:
"""Format table for DiscoveredDevice entities."""
table = Table(title=title)
@@ -61,17 +80,19 @@ def _format_discovered_devices_table(
device.model_name or device.device_type or "Unknown",
device.device_name or "Unknown",
device.firmware_version or "Unknown",
- format_device_status(device.status),
+ format_device_status(self._effective_status(device, include_beta)),
response_time,
)
self._console.print(table)
- def _format_device_status_table(self, devices: list[DeviceStatus]) -> None:
+ def _format_device_status_table(
+ self, devices: list[DeviceStatus], include_beta: bool = False
+ ) -> None:
"""Format table for DeviceStatus entities."""
for device_status in devices:
- self.format_detailed_device_status(device_status)
+ self.format_detailed_device_status(device_status, include_beta)
def _format_legacy_device_table(self, devices: list[Any], title: str) -> None:
"""Legacy format for backward compatibility."""
@@ -106,7 +127,9 @@ def _format_legacy_device_table(self, devices: list[Any], title: str) -> None:
self._console.print(table)
- def format_detailed_device_status(self, device_status: DeviceStatus) -> None:
+ def format_detailed_device_status(
+ self, device_status: DeviceStatus, include_beta: bool = False
+ ) -> None:
"""Format detailed component information for a single device."""
from rich.columns import Columns
from rich.panel import Panel
@@ -132,7 +155,16 @@ def format_detailed_device_status(self, device_status: DeviceStatus) -> None:
# Show available firmware updates with version information
if system_info.available_updates:
device_summary = device_status.get_device_summary()
- available_updates = device_summary.get("available_updates", {})
+ raw_available_updates = device_summary.get("available_updates", {})
+ available_updates = (
+ raw_available_updates
+ if include_beta
+ else {
+ channel: info
+ for channel, info in raw_available_updates.items()
+ if channel != "beta"
+ }
+ )
if available_updates:
system_content.append("[yellow]Updates Available:[/yellow]")
@@ -140,10 +172,16 @@ def format_detailed_device_status(self, device_status: DeviceStatus) -> None:
version = update_info.get("version", "Unknown")
name = update_info.get("name", update_type) or update_type
system_content.append(f" [cyan]• {name}:[/cyan] {version}")
- else:
+ elif not raw_available_updates:
+ # The raw component data has entries but none carried a
+ # usable version, so get_device_summary filtered
+ # everything out — unrelated to beta visibility.
system_content.append(
f"[yellow]Updates Available:[/yellow] {len(system_info.available_updates)}"
)
+ # else: only a beta release exists and beta visibility is
+ # off — show nothing, exactly like a device with no
+ # updates at all. No partial hint.
system_panel = Panel(
"\n".join(system_content),
diff --git a/packages/cli/src/cli/use_cases/device/device_status.py b/packages/cli/src/cli/use_cases/device/device_status.py
index deacb24..c69f2df 100644
--- a/packages/cli/src/cli/use_cases/device/device_status.py
+++ b/packages/cli/src/cli/use_cases/device/device_status.py
@@ -97,6 +97,6 @@ async def _check_device_status(
return results
- def display_results(self, results: list[Any]) -> None:
+ def display_results(self, results: list[Any], include_beta: bool = False) -> None:
"""Display status results to console."""
- self._result_formatter.format_device_table(results)
+ self._result_formatter.format_device_table(results, include_beta=include_beta)
diff --git a/packages/cli/src/cli/use_cases/device/scan_devices.py b/packages/cli/src/cli/use_cases/device/scan_devices.py
index 02305ab..711adca 100644
--- a/packages/cli/src/cli/use_cases/device/scan_devices.py
+++ b/packages/cli/src/cli/use_cases/device/scan_devices.py
@@ -60,7 +60,10 @@ async def execute(self, request: DeviceScanRequest) -> list[Any]:
return devices_found
def display_results(
- self, devices_found: list[Any], show_table: bool = True
+ self,
+ devices_found: list[Any],
+ show_table: bool = True,
+ include_beta: bool = False,
) -> None:
"""
Display scan results to console.
@@ -68,9 +71,12 @@ def display_results(
Args:
devices_found: List of discovered devices
show_table: Whether to show device table
+ include_beta: Whether beta-only updates count as available
"""
if show_table:
- self._result_formatter.format_device_table(devices_found)
+ self._result_formatter.format_device_table(
+ devices_found, include_beta=include_beta
+ )
if devices_found:
self._console.print(
diff --git a/packages/cli/tests/unit/commands/test_device_commands.py b/packages/cli/tests/unit/commands/test_device_commands.py
index 338730d..89f7c97 100644
--- a/packages/cli/tests/unit/commands/test_device_commands.py
+++ b/packages/cli/tests/unit/commands/test_device_commands.py
@@ -134,6 +134,28 @@ def test_scan_with_custom_timeout_and_workers(
assert call_args.timeout == 5.0
assert call_args.max_workers == 20
+ def test_scan_help_documents_include_beta(self, cli_context):
+ runner = CliRunner()
+ result = runner.invoke(device_commands, ["scan", "--help"], obj=cli_context)
+
+ assert result.exit_code == 0
+ assert "--include-beta" in result.output
+
+ def test_scan_accepts_include_beta_flag(
+ self, cli_context_with_scan, sample_devices, mock_scan_interactor
+ ):
+ mock_scan_interactor.execute.return_value = sample_devices
+
+ runner = CliRunner()
+ result = runner.invoke(
+ device_commands,
+ ["scan", "192.168.1.1-50", "--include-beta"],
+ obj=cli_context_with_scan,
+ )
+
+ assert result.exit_code == 0
+ mock_scan_interactor.execute.assert_called_once()
+
class TestListCommand:
@@ -178,6 +200,28 @@ def test_list_no_devices(self, cli_context_with_list, mock_scan_interactor):
assert result.exit_code == 0
mock_scan_interactor.execute.assert_called_once()
+ def test_list_help_documents_include_beta(self, cli_context):
+ runner = CliRunner()
+ result = runner.invoke(device_commands, ["list", "--help"], obj=cli_context)
+
+ assert result.exit_code == 0
+ assert "--include-beta" in result.output
+
+ def test_list_accepts_include_beta_flag(
+ self, cli_context_with_list, sample_devices, mock_scan_interactor
+ ):
+ mock_scan_interactor.execute.return_value = sample_devices
+
+ runner = CliRunner()
+ result = runner.invoke(
+ device_commands,
+ ["list", "10.0.0.1", "--include-beta"],
+ obj=cli_context_with_list,
+ )
+
+ assert result.exit_code == 0
+ mock_scan_interactor.execute.assert_called_once()
+
class TestStatusCommand:
@@ -296,6 +340,34 @@ def test_status_verbose_error_output(
assert result.exit_code == 0
mock_status_interactor.execute.assert_called_once()
+ def test_status_help_documents_include_beta(self, cli_context):
+ runner = CliRunner()
+ result = runner.invoke(device_commands, ["status", "--help"], obj=cli_context)
+
+ assert result.exit_code == 0
+ assert "--include-beta" in result.output
+
+ def test_status_accepts_include_beta_flag(
+ self,
+ cli_context_with_status,
+ mock_status_interactor,
+ mock_scan_interactor_for_status,
+ sample_devices,
+ sample_device,
+ ):
+ mock_scan_interactor_for_status.execute.return_value = sample_devices
+ mock_status_interactor.execute.return_value = sample_device
+
+ runner = CliRunner()
+ result = runner.invoke(
+ device_commands,
+ ["status", "192.168.1.100", "192.168.1.101", "--include-beta"],
+ obj=cli_context_with_status,
+ )
+
+ assert result.exit_code == 0
+ assert mock_status_interactor.execute.call_count == 2
+
class TestDeviceRebootCommand:
diff --git a/packages/cli/tests/unit/use_cases/common/__init__.py b/packages/cli/tests/unit/use_cases/common/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/cli/tests/unit/use_cases/common/test_result_formatting.py b/packages/cli/tests/unit/use_cases/common/test_result_formatting.py
new file mode 100644
index 0000000..9e919c7
--- /dev/null
+++ b/packages/cli/tests/unit/use_cases/common/test_result_formatting.py
@@ -0,0 +1,148 @@
+from unittest.mock import MagicMock
+
+import pytest
+from cli.use_cases.common.result_formatting import ResultFormatter
+from core.domain.entities.components.system import SystemComponent
+from core.domain.entities.device_status import DeviceStatus
+from core.domain.entities.discovered_device import DiscoveredDevice
+from core.domain.enums.enums import Status
+from rich.panel import Panel
+
+
+class TestEffectiveStatus:
+
+ @pytest.fixture
+ def formatter(self):
+ return ResultFormatter(MagicMock())
+
+ def _device(self, **overrides):
+ defaults = {
+ "ip": "192.168.1.100",
+ "status": Status.UPDATE_AVAILABLE,
+ "device_id": "test-device",
+ "device_type": "SHSW-1",
+ "firmware_version": "1.0.0",
+ "available_firmware_version": "1.1.0-beta1",
+ "available_firmware_channel": "beta",
+ }
+ return DiscoveredDevice(**{**defaults, **overrides})
+
+ def test_it_hides_a_beta_only_update_by_default(self, formatter):
+ device = self._device()
+
+ assert (
+ formatter._effective_status(device, include_beta=False)
+ == Status.NO_UPDATE_NEEDED.value
+ )
+
+ def test_it_surfaces_a_beta_only_update_when_included(self, formatter):
+ device = self._device()
+
+ assert (
+ formatter._effective_status(device, include_beta=True)
+ == Status.UPDATE_AVAILABLE.value
+ )
+
+ def test_it_leaves_a_stable_update_alone_either_way(self, formatter):
+ device = self._device(
+ available_firmware_version="1.1.0", available_firmware_channel="stable"
+ )
+
+ assert (
+ formatter._effective_status(device, include_beta=False)
+ == Status.UPDATE_AVAILABLE.value
+ )
+ assert (
+ formatter._effective_status(device, include_beta=True)
+ == Status.UPDATE_AVAILABLE.value
+ )
+
+ def test_it_leaves_a_device_without_updates_alone(self, formatter):
+ device = self._device(
+ status=Status.NO_UPDATE_NEEDED,
+ available_firmware_version=None,
+ available_firmware_channel=None,
+ )
+
+ assert (
+ formatter._effective_status(device, include_beta=False)
+ == Status.NO_UPDATE_NEEDED.value
+ )
+
+
+class TestFormatDetailedDeviceStatusBetaFiltering:
+
+ @pytest.fixture
+ def mock_console(self):
+ return MagicMock()
+
+ @pytest.fixture
+ def formatter(self, mock_console):
+ return ResultFormatter(mock_console)
+
+ def _device_status(self):
+ sys_component = SystemComponent(
+ key="sys",
+ component_type="sys",
+ device_name="Test Device",
+ firmware_version="1.0.0",
+ available_updates={
+ "stable": {"version": "1.1.0"},
+ "beta": {"version": "1.2.0-beta1", "name": "beta"},
+ },
+ )
+ return DeviceStatus(device_ip="192.168.1.100", components=[sys_component])
+
+ def _printed_text(self, mock_console) -> str:
+ chunks = []
+ for call in mock_console.print.call_args_list:
+ if not call.args:
+ continue
+ obj = call.args[0]
+ chunks.append(str(obj.renderable) if isinstance(obj, Panel) else str(obj))
+ return "\n".join(chunks)
+
+ def test_it_hides_the_beta_release_by_default(self, formatter, mock_console):
+ formatter.format_detailed_device_status(self._device_status())
+
+ output = self._printed_text(mock_console)
+ assert "1.1.0" in output
+ assert "1.2.0-beta1" not in output
+
+ def test_it_shows_the_beta_release_when_included(self, formatter, mock_console):
+ formatter.format_detailed_device_status(
+ self._device_status(), include_beta=True
+ )
+
+ output = self._printed_text(mock_console)
+ assert "1.1.0" in output
+ assert "1.2.0-beta1" in output
+
+ def _beta_only_device_status(self):
+ sys_component = SystemComponent(
+ key="sys",
+ component_type="sys",
+ device_name="Test Device",
+ firmware_version="1.0.0",
+ available_updates={"beta": {"version": "1.2.0-beta1", "name": "beta"}},
+ )
+ return DeviceStatus(device_ip="192.168.1.100", components=[sys_component])
+
+ def test_it_shows_nothing_when_only_a_hidden_beta_exists(
+ self, formatter, mock_console
+ ):
+ formatter.format_detailed_device_status(self._beta_only_device_status())
+
+ output = self._printed_text(mock_console)
+ assert "1.2.0-beta1" not in output
+ assert "Updates Available" not in output
+
+ def test_it_shows_the_beta_only_release_when_included(
+ self, formatter, mock_console
+ ):
+ formatter.format_detailed_device_status(
+ self._beta_only_device_status(), include_beta=True
+ )
+
+ output = self._printed_text(mock_console)
+ assert "1.2.0-beta1" in output
diff --git a/packages/core/src/core/domain/entities/discovered_device.py b/packages/core/src/core/domain/entities/discovered_device.py
index a0a7be2..3d7efe4 100644
--- a/packages/core/src/core/domain/entities/discovered_device.py
+++ b/packages/core/src/core/domain/entities/discovered_device.py
@@ -25,6 +25,10 @@ class DiscoveredDevice(BaseModel):
available_firmware_version: str | None = Field(
None, description="Version an available update would install"
)
+ available_firmware_channel: str | None = Field(
+ None,
+ description='Channel the available update was found on ("stable" or "beta")',
+ )
device_name: str | None = Field(None, description="User-defined device name")
auth_required: bool = Field(
False, description="Whether device requires authentication"
diff --git a/packages/core/src/core/gateways/device/legacy_device_gateway.py b/packages/core/src/core/gateways/device/legacy_device_gateway.py
index 0a1a384..c096b8c 100644
--- a/packages/core/src/core/gateways/device/legacy_device_gateway.py
+++ b/packages/core/src/core/gateways/device/legacy_device_gateway.py
@@ -157,16 +157,23 @@ async def discover_device(
)
has_update_flag = self._parse_update_flag(status_data)
- if has_update_flag is None:
+ update_version = self._parse_update_version(status_data)
+ available_version, available_channel = (
+ update_version if update_version is not None else (None, None)
+ )
+
+ if has_update_flag is None and available_version is None:
device_status = Status.DETECTED
has_update_value = False
else:
+ has_update_value = (
+ bool(has_update_flag) or available_version is not None
+ )
device_status = (
Status.UPDATE_AVAILABLE
- if has_update_flag
+ if has_update_value
else Status.NO_UPDATE_NEEDED
)
- has_update_value = has_update_flag
return DiscoveredDevice(
ip=ip,
@@ -175,6 +182,8 @@ async def discover_device(
device_type=device_info.get("model") or device_info.get("type"),
device_name=device_name,
firmware_version=firmware_version,
+ available_firmware_version=available_version,
+ available_firmware_channel=available_channel,
response_time=response_time,
last_seen=datetime.now(),
has_update=has_update_value,
@@ -532,3 +541,36 @@ def _parse_update_flag(self, status_data: dict[str, Any] | None) -> bool | None:
return new_version != old_version
return None
+
+ def _parse_update_version(
+ self, status_data: dict[str, Any] | None
+ ) -> tuple[str, str] | None:
+ """Parse the version and channel of an available update, if any.
+
+ Stable takes priority over beta, mirroring the RPC (Gen2+) gateway.
+ Returns ``None`` when no version-bearing update is reported (the
+ boolean-only ``has_update``/``update.has_update`` shorthand some
+ Gen1 firmwares report carries no version and isn't captured here).
+ """
+ if not isinstance(status_data, dict):
+ return None
+
+ update_block = status_data.get("update")
+ if not isinstance(update_block, dict):
+ return None
+
+ new_version = update_block.get("new_version")
+ old_version = update_block.get("old_version")
+ has_stable = bool(update_block.get("has_update")) or (
+ isinstance(new_version, str)
+ and isinstance(old_version, str)
+ and new_version != old_version
+ )
+ if has_stable and isinstance(new_version, str) and new_version:
+ return new_version, "stable"
+
+ beta_version = update_block.get("beta_version")
+ if isinstance(beta_version, str) and beta_version:
+ return beta_version, "beta"
+
+ return None
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 c9558c8..4ec0c91 100644
--- a/packages/core/src/core/gateways/device/shelly_device_gateway.py
+++ b/packages/core/src/core/gateways/device/shelly_device_gateway.py
@@ -107,11 +107,14 @@ async def discover_device(
stable_update = update_data.get("stable", {}) if update_data else {}
beta_update = update_data.get("beta", {}) if update_data else {}
- available_version = stable_update.get("version") or beta_update.get(
- "version"
- )
+ stable_version = stable_update.get("version")
+ beta_version = beta_update.get("version")
+ available_version = stable_version or beta_version
if available_version:
device.available_firmware_version = available_version
+ device.available_firmware_channel = (
+ "stable" if stable_version else "beta"
+ )
device.status = Status.UPDATE_AVAILABLE
else:
device.status = Status.NO_UPDATE_NEEDED
diff --git a/packages/core/src/core/use_cases/scan_devices.py b/packages/core/src/core/use_cases/scan_devices.py
index 649ecdf..fda66bc 100644
--- a/packages/core/src/core/use_cases/scan_devices.py
+++ b/packages/core/src/core/use_cases/scan_devices.py
@@ -111,6 +111,7 @@ async def _settle_update_status(self, devices: list[DiscoveredDevice]) -> None:
else:
device.status = Status.UPDATE_AVAILABLE
device.available_firmware_version = release.version
+ device.available_firmware_channel = "stable"
async def _lookup_release(
self, firmware_gateway: FirmwareGateway, app_name: str
diff --git a/packages/core/tests/unit/gateways/device/test_legacy_device_gateway.py b/packages/core/tests/unit/gateways/device/test_legacy_device_gateway.py
index 3d4af72..05d2ca4 100644
--- a/packages/core/tests/unit/gateways/device/test_legacy_device_gateway.py
+++ b/packages/core/tests/unit/gateways/device/test_legacy_device_gateway.py
@@ -73,6 +73,8 @@ async def test_it_discovers_device_successfully(
assert device.device_name == "Custom Name"
assert device.status == Status.NO_UPDATE_NEEDED
assert device.has_update is False
+ assert device.available_firmware_version is None
+ assert device.available_firmware_channel is None
async def test_it_handles_discovery_failure(self, gateway, mock_http_client):
mock_http_client.fetch_json.side_effect = Exception("Connection error")
@@ -94,6 +96,75 @@ async def test_it_detects_update_available(
assert device.status == Status.UPDATE_AVAILABLE
assert device.has_update is True
+ assert device.available_firmware_version is None
+ assert device.available_firmware_channel is None
+
+ async def test_it_captures_the_stable_version_from_the_update_block(
+ self, gateway, mock_http_client, sample_device_info
+ ):
+ mock_http_client.fetch_json.return_value = sample_device_info
+ mock_http_client.fetch_json_optional.side_effect = [
+ {
+ "update": {
+ "has_update": True,
+ "new_version": "20240101-000000/v1.14.1-g1234567",
+ "old_version": "20230913-112003/v1.14.0-gCB16476",
+ }
+ },
+ {},
+ ]
+
+ device = await gateway.discover_device("192.168.1.100")
+
+ assert device.status == Status.UPDATE_AVAILABLE
+ assert device.has_update is True
+ assert device.available_firmware_version == "20240101-000000/v1.14.1-g1234567"
+ assert device.available_firmware_channel == "stable"
+
+ async def test_it_captures_a_beta_only_update_from_the_update_block(
+ self, gateway, mock_http_client, sample_device_info
+ ):
+ mock_http_client.fetch_json.return_value = sample_device_info
+ mock_http_client.fetch_json_optional.side_effect = [
+ {
+ "update": {
+ "has_update": False,
+ "beta_version": "20231107-162940/v1.14.1-rc1-g0617c15",
+ }
+ },
+ {},
+ ]
+
+ device = await gateway.discover_device("192.168.1.100")
+
+ assert device.status == Status.UPDATE_AVAILABLE
+ assert device.has_update is True
+ assert (
+ device.available_firmware_version == "20231107-162940/v1.14.1-rc1-g0617c15"
+ )
+ assert device.available_firmware_channel == "beta"
+
+ async def test_it_prefers_stable_over_beta_when_both_are_reported(
+ self, gateway, mock_http_client, sample_device_info
+ ):
+ mock_http_client.fetch_json.return_value = sample_device_info
+ mock_http_client.fetch_json_optional.side_effect = [
+ {
+ "update": {
+ "has_update": True,
+ "new_version": "20240101-000000/v1.14.1-g1234567",
+ "old_version": "20230913-112003/v1.14.0-gCB16476",
+ "beta_version": "20231107-162940/v1.14.1-rc1-g0617c15",
+ }
+ },
+ {},
+ ]
+
+ device = await gateway.discover_device("192.168.1.100")
+
+ assert device.status == Status.UPDATE_AVAILABLE
+ assert device.available_firmware_version == "20240101-000000/v1.14.1-g1234567"
+ assert device.available_firmware_channel == "stable"
async def test_it_gets_device_status_successfully(
self,
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 80d4eb4..f3c1f49 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
@@ -822,6 +822,7 @@ async def test_it_handles_update_info_without_versions(
assert result is not None
assert result.status == Status.NO_UPDATE_NEEDED
assert result.available_firmware_version is None
+ assert result.available_firmware_channel is None
async def test_it_captures_the_available_update_version(
self, gateway, mock_rpc_client
@@ -842,6 +843,7 @@ async def test_it_captures_the_available_update_version(
assert result is not None
assert result.status == Status.UPDATE_AVAILABLE
assert result.available_firmware_version == "2.6.0"
+ assert result.available_firmware_channel == "stable"
async def test_it_captures_a_beta_only_update_version(
self, gateway, mock_rpc_client
@@ -859,6 +861,7 @@ async def test_it_captures_a_beta_only_update_version(
assert result is not None
assert result.status == Status.UPDATE_AVAILABLE
assert result.available_firmware_version == "2.7.0-beta1"
+ assert result.available_firmware_channel == "beta"
async def test_it_handles_null_update_info(self, gateway, mock_rpc_client):
device_info = {"id": "test-device", "model": "SHSW-1", "fw_id": "1.0.0"}
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 079a85c..146b2aa 100644
--- a/packages/core/tests/unit/use_cases/test_scan_devices.py
+++ b/packages/core/tests/unit/use_cases/test_scan_devices.py
@@ -286,6 +286,7 @@ async def test_it_marks_an_update_the_index_publishes(
assert result[0].status == Status.UPDATE_AVAILABLE
assert result[0].available_firmware_version == "1.8.0"
+ assert result[0].available_firmware_channel == "stable"
firmware_gateway.get_latest.assert_awaited_once_with("Plus2PM")
async def test_it_marks_a_device_already_on_the_published_build(
@@ -358,6 +359,7 @@ async def test_it_keeps_the_device_reported_available_version(
assert result[0].status == Status.UPDATE_AVAILABLE
assert result[0].available_firmware_version == "1.9.0"
+ assert result[0].available_firmware_channel is None
firmware_gateway.get_latest.assert_not_awaited()
async def test_it_skips_a_device_without_an_app_name(
diff --git a/packages/web/src/components/dashboard/device-table.tsx b/packages/web/src/components/dashboard/device-table.tsx
index e04e906..448df8a 100644
--- a/packages/web/src/components/dashboard/device-table.tsx
+++ b/packages/web/src/components/dashboard/device-table.tsx
@@ -82,8 +82,19 @@ export function DeviceTable({ devices, onBulkAction }: DeviceTableProps) {
initialSettings.tableDensity,
);
+ const getEffectiveStatus = (device: Device): string => {
+ if (
+ !initialSettings.showBetaUpdates &&
+ device.status === "update_available" &&
+ device.available_firmware_channel === "beta"
+ ) {
+ return "no_update_needed";
+ }
+ return device.status;
+ };
+
const getStatusBadge = (device: Device) => {
- const statusLower = device.status?.toLowerCase() || "";
+ const statusLower = getEffectiveStatus(device).toLowerCase();
let variant: "default" | "secondary" | "destructive" | "outline" =
"default";
@@ -104,13 +115,23 @@ export function DeviceTable({ devices, onBulkAction }: DeviceTableProps) {
variant = "outline";
}
- return