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 {getStatusLabel(device)}; + return ( +
+ {getStatusLabel(device)} + {initialSettings.showBetaUpdates && + device.available_firmware_channel === "beta" && ( + + {t("bulkActions.beta")} + + )} +
+ ); }; const getStatusLabel = (device: Device) => { - const statusLower = device.status?.toLowerCase() || ""; - const label = t(`status.${statusLower}`, device.status); - return statusLower === "update_available" && + const effectiveStatus = getEffectiveStatus(device).toLowerCase(); + const label = t(`status.${effectiveStatus}`, device.status); + return effectiveStatus === "update_available" && device.available_firmware_version ? `${label} (${device.available_firmware_version})` : label; diff --git a/packages/web/src/components/device-detail/device-actions.tsx b/packages/web/src/components/device-detail/device-actions.tsx index 54d8741..51ed951 100644 --- a/packages/web/src/components/device-detail/device-actions.tsx +++ b/packages/web/src/components/device-detail/device-actions.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -41,6 +42,7 @@ import { hasAnyRelease, type UpdateChannel, } from "@/lib/firmware-updates"; +import { loadAppSettings } from "@/lib/settings"; import type { DeviceStatus, UpdateSource } from "@/types/api"; interface DeviceActionsProps { @@ -122,9 +124,27 @@ export function DeviceActions({ }, }); + const { showBetaUpdates } = loadAppSettings(); const availableUpdates = deviceStatus?.firmware.available_updates; const selectedRelease = getChannelRelease(availableUpdates, updateChannel); - const hasUpdates = hasAnyRelease(availableUpdates); + const hasUpdates = hasAnyRelease( + availableUpdates, + showBetaUpdates ? undefined : ["stable"], + ); + + // Preselect whichever channel actually explains the "Update Firmware" + // button's state: stable first, falling back to beta only when beta + // visibility is on — mirrors the same allowed-channels rule as hasUpdates + // so the dialog never opens showing "no update" for a device the button + // just said has one. + const preferredChannel: UpdateChannel = getChannelRelease( + availableUpdates, + "stable", + ) + ? "stable" + : showBetaUpdates && getChannelRelease(availableUpdates, "beta") + ? "beta" + : "stable"; // The device's own available_updates answer for the internet source; an // offline device reports none, so the local source asks the manager what @@ -169,14 +189,32 @@ export function DeviceActions({ {/* Update Firmware */} - + { + setUpdateDialogOpen(open); + if (open) { + setUpdateChannel(preferredChannel); + } + }} + > @@ -241,12 +279,15 @@ export function DeviceActions({ onValueChange={(value: UpdateChannel) => setUpdateChannel(value) } + disabled={!showBetaUpdates} > - {UPDATE_CHANNELS.map((channel) => { + {UPDATE_CHANNELS.filter( + (channel) => channel !== "beta" || showBetaUpdates, + ).map((channel) => { const release = updateSource === "local" ? localReleases?.[channel] @@ -260,6 +301,13 @@ export function DeviceActions({ })} + {!showBetaUpdates && ( +

+ {t( + "deviceDetail.dialogs.updateFirmware.onlyStableChannel", + )} +

+ )} {updateSource === "internet" && selectedRelease && ( diff --git a/packages/web/src/components/device-detail/device-header.tsx b/packages/web/src/components/device-detail/device-header.tsx index 8e45269..1b7634f 100644 --- a/packages/web/src/components/device-detail/device-header.tsx +++ b/packages/web/src/components/device-detail/device-header.tsx @@ -21,6 +21,7 @@ import { CardTitle, } from "@/components/ui/card"; import { getChannelReleases } from "@/lib/firmware-updates"; +import { loadAppSettings } from "@/lib/settings"; import type { DeviceStatus } from "@/types/api"; interface DeviceHeaderProps { @@ -59,8 +60,10 @@ export function DeviceHeader({ deviceStatus, isLoading }: DeviceHeaderProps) { } const { summary, ip } = deviceStatus; + const { showBetaUpdates } = loadAppSettings(); const channelReleases = getChannelReleases( deviceStatus.firmware.available_updates, + showBetaUpdates ? undefined : ["stable"], ); const getStatusBadge = () => { @@ -145,24 +148,27 @@ export function DeviceHeader({ deviceStatus, isLoading }: DeviceHeaderProps) {
{channelReleases.map(({ channel, release }) => ( -
+
- {channel} + {t(`bulkActions.${channel}`)} - - {release.version} - - {release.name && ( - - ({release.name}) +
+ + {release.version} - )} + {release.name && ( + + ({release.name}) + + )} +
))}
@@ -292,8 +298,10 @@ export function DeviceHeader({ deviceStatus, isLoading }: DeviceHeaderProps) {
🔄
{t("deviceDetail.deviceInfo.updates")} - - {summary.has_updates + 0 ? "secondary" : "outline"} + > + {channelReleases.length > 0 ? t("deviceDetail.deviceInfo.available") : t("deviceDetail.deviceInfo.upToDate")} diff --git a/packages/web/src/i18n/en.json b/packages/web/src/i18n/en.json index 479feab..e31f93b 100644 --- a/packages/web/src/i18n/en.json +++ b/packages/web/src/i18n/en.json @@ -413,7 +413,8 @@ "startingUpdate": "Starting Update...", "startUpdate": "Start Update", "noUpdateOnChannel": "No update available on this channel.", - "noUpdatesAvailable": "This device reports no firmware updates available." + "noUpdatesAvailable": "This device reports no firmware updates available.", + "onlyStableChannel": "Only the stable channel is available. Enable beta updates in Settings to choose a beta release." }, "rebootDevice": { "title": "Reboot Device", @@ -473,6 +474,13 @@ "comfortable": "Comfortable", "savePreferences": "Save Table Preferences" }, + "updates": { + "title": "Firmware Updates", + "description": "Control how beta firmware releases are surfaced across the app", + "showBetaUpdates": "Show beta updates", + "showBetaUpdatesDescription": "When off, beta-only releases are hidden from update badges and status indicators. You can still manually select the beta channel when updating a device.", + "save": "Save Update Preferences" + }, "footer": { "title": "Shelly Manager", "description": "A modern web interface for locally managing Shelly IoT devices" diff --git a/packages/web/src/lib/firmware-updates.ts b/packages/web/src/lib/firmware-updates.ts index e223bec..084e0ea 100644 --- a/packages/web/src/lib/firmware-updates.ts +++ b/packages/web/src/lib/firmware-updates.ts @@ -18,16 +18,25 @@ export function getChannelRelease( export function getChannelReleases( availableUpdates: AvailableUpdates | undefined, + allowedChannels?: readonly string[], ): { channel: string; release: FirmwareRelease }[] { return Object.entries(availableUpdates ?? {}).flatMap(([channel, release]) => - isRelease(release) ? [{ channel, release }] : [], + isRelease(release) && + (!allowedChannels || allowedChannels.includes(channel)) + ? [{ channel, release }] + : [], ); } export function hasAnyRelease( availableUpdates: AvailableUpdates | undefined, + allowedChannels?: readonly string[], ): boolean { - return Object.values(availableUpdates ?? {}).some(isRelease); + return Object.entries(availableUpdates ?? {}).some( + ([channel, release]) => + isRelease(release) && + (!allowedChannels || allowedChannels.includes(channel)), + ); } function isRelease( diff --git a/packages/web/src/lib/settings.ts b/packages/web/src/lib/settings.ts index c2de249..3c41604 100644 --- a/packages/web/src/lib/settings.ts +++ b/packages/web/src/lib/settings.ts @@ -2,12 +2,14 @@ export interface AppSettings { tablePageSize: number; tableDensity: "compact" | "normal" | "comfortable"; scanRequestTimeout: number; // in milliseconds + showBetaUpdates: boolean; } export const DEFAULT_SETTINGS: AppSettings = { tablePageSize: 10, tableDensity: "normal", scanRequestTimeout: 600000, // 10 minutes default + showBetaUpdates: false, }; const STORAGE_KEY = "shelly-manager-settings"; diff --git a/packages/web/src/pages/settings.tsx b/packages/web/src/pages/settings.tsx index bc460da..c8b8f19 100644 --- a/packages/web/src/pages/settings.tsx +++ b/packages/web/src/pages/settings.tsx @@ -15,6 +15,7 @@ import { X, Loader2, Clock, + Download, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -25,6 +26,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -364,6 +366,41 @@ export function Settings() { + + {/* Firmware Updates */} + + + + + {t("settings.updates.title")} + + + {t("settings.updates.description")} + + + +
+ + updateSetting("showBetaUpdates", checked === true) + } + /> +
+ +

+ {t("settings.updates.showBetaUpdatesDescription")} +

+
+
+ +
+
diff --git a/packages/web/src/types/api.ts b/packages/web/src/types/api.ts index dc94acb..57df2f9 100644 --- a/packages/web/src/types/api.ts +++ b/packages/web/src/types/api.ts @@ -6,6 +6,7 @@ export interface Device { device_name: string; firmware_version: string; available_firmware_version?: string | null; + available_firmware_channel?: string | null; response_time: number | null; error_message?: string | null; last_seen?: string | null;