Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ curl "http://localhost:8000/api/devices/scan?targets=192.168.1.1-10"
{
"ip": "192.168.1.100",
"status": "online",
"device_type": "shelly1pm",
"device_type": "SHSW-PM",
"model_name": "Shelly 1PM",
"device_name": "Living Room Light",
"firmware_version": "20230913-112003",
"available_firmware_version": "1.2.0",
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/api/controllers/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ async def scan_devices(
"ip": device.ip,
"status": device.status,
"device_type": device.device_type,
"model_name": device.model_name,
"device_name": device.device_name,
"firmware_version": device.firmware_version,
"available_firmware_version": device.available_firmware_version,
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/api/presentation/dto/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class DeviceResponse(BaseModel):
ip: str
device_id: str | None = None
device_type: str | None = None
model_name: str | None = None
device_name: str | None = None
firmware_version: str | None = None
status: str
Expand Down
7 changes: 6 additions & 1 deletion packages/api/tests/unit/controllers/test_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def execute(self, scan_request):
device = DiscoveredDevice(
ip="192.168.1.100",
status=Status.DETECTED,
device_type="Shelly 1",
device_type="SHSW-PM",
device_name="Test Device",
firmware_version="1.0.0",
available_firmware_version="1.2.0",
Expand All @@ -64,6 +64,8 @@ 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]["device_type"] == "SHSW-PM"
assert data[0]["model_name"] == "Shelly 1PM"

def test_scan_without_targets_returns_400(self):
from core.domain.entities.exceptions import ValidationError
Expand Down Expand Up @@ -483,6 +485,7 @@ async def execute(self, request):
device_ip=request.device_ip,
components=[],
total_components=0,
device_type="SHSW-PM",
)

with create_test_client(
Expand All @@ -501,6 +504,8 @@ async def execute(self, request):
assert "components" in data
assert "summary" in data
assert "firmware" in data
assert data["summary"]["device_type"] == "SHSW-PM"
assert data["summary"]["model_name"] == "Shelly 1PM"

def test_bulk_operations_update_successfully(self):
from core.use_cases.bulk_operations import BulkOperationsUseCase
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ shelly-manager device reboot 192.168.1.100 --force # Skip confirmation

```
┌─────────────────┬──────────┬─────────────┬──────────────────┬─────────────┐
│ IP Address │ Status │ Device Type │ Device Name │ Firmware │
│ IP Address │ Status │ Model │ Device Name │ Firmware │
├─────────────────┼──────────┼─────────────┼──────────────────┼─────────────┤
│ 192.168.1.100 │ online │ shelly1pm │ Living Room │ 20230913... │
│ 192.168.1.100 │ online │ Shelly 1PM │ Living Room │ 20230913... │
│ 192.168.1.101 │ offline │ unknown │ - │ - │
└─────────────────┴──────────┴─────────────┴──────────────────┴─────────────┘
```
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/cli/commands/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,21 +127,27 @@ def format_device_table(devices: list[Any], console: Console) -> None:

table = Table(title="Shelly Devices")
table.add_column("IP Address", style="cyan")
table.add_column("Device Type", style="green")
table.add_column("Model", style="green")
table.add_column("Name", style="blue")
table.add_column("Firmware", style="magenta")
table.add_column("Status", style="yellow")

for device in devices:
if hasattr(device, "ip"):
ip = device.ip
device_type = getattr(device, "device_type", "Unknown")
device_type = (
getattr(device, "model_name", None)
or getattr(device, "device_type", None)
or "Unknown"
)
name = getattr(device, "device_name", "Unknown")
firmware = getattr(device, "firmware_version", "Unknown")
status = getattr(device, "status", "Unknown")
else:
ip = device.get("ip", "Unknown")
device_type = device.get("device_type", "Unknown")
device_type = (
device.get("model_name") or device.get("device_type") or "Unknown"
)
name = device.get("device_name", "Unknown")
firmware = device.get("firmware_version", "Unknown")
status = device.get("status", "Unknown")
Expand Down
16 changes: 11 additions & 5 deletions packages/cli/src/cli/use_cases/common/result_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def _format_discovered_devices_table(
"""Format table for DiscoveredDevice entities."""
table = Table(title=title)
table.add_column("IP Address", style="cyan")
table.add_column("Device Type", style="green")
table.add_column("Model", style="green")
table.add_column("Name", style="blue")
table.add_column("Firmware", style="magenta")
table.add_column("Status", style="yellow")
Expand All @@ -58,7 +58,7 @@ def _format_discovered_devices_table(
)
table.add_row(
device.ip,
device.device_type or "Unknown",
device.model_name or device.device_type or "Unknown",
device.device_name or "Unknown",
device.firmware_version or "Unknown",
format_device_status(device.status),
Expand All @@ -77,21 +77,27 @@ def _format_legacy_device_table(self, devices: list[Any], title: str) -> None:
"""Legacy format for backward compatibility."""
table = Table(title=title)
table.add_column("IP Address", style="cyan")
table.add_column("Device Type", style="green")
table.add_column("Model", style="green")
table.add_column("Name", style="blue")
table.add_column("Firmware", style="magenta")
table.add_column("Status", style="yellow")

for device in devices:
if hasattr(device, "ip"):
ip = device.ip
device_type = getattr(device, "device_type", "Unknown")
device_type = (
getattr(device, "model_name", None)
or getattr(device, "device_type", None)
or "Unknown"
)
name = getattr(device, "device_name", "Unknown")
firmware = getattr(device, "firmware_version", "Unknown")
status = getattr(device, "status", "Unknown")
else:
ip = device.get("ip", "Unknown")
device_type = device.get("device_type", "Unknown")
device_type = (
device.get("model_name") or device.get("device_type") or "Unknown"
)
name = device.get("device_name", "Unknown")
firmware = device.get("firmware_version", "Unknown")
status = device.get("status", "Unknown")
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/core/domain/entities/device_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from pydantic import BaseModel, Field

from ..model_names import get_model_name
from .components import (
CloudComponent,
ComponentType,
Expand Down Expand Up @@ -186,6 +187,8 @@ def get_device_summary(self) -> dict[str, Any]:
return {
"device_name": self.device_name
or (sys_info.device_name if sys_info else None),
"device_type": self.device_type,
"model_name": get_model_name(self.device_type),
"mac_address": self.mac_address
or (sys_info.mac_address if sys_info else None),
"firmware_version": self.firmware_version
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/core/domain/entities/discovered_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

from datetime import datetime

from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator

from ...utils.validation import validate_ip_address
from ..enums.enums import Status
from ..model_names import get_model_name


class DiscoveredDevice(BaseModel):
Expand Down Expand Up @@ -35,6 +36,12 @@ class DiscoveredDevice(BaseModel):
error_message: str | None = Field(None, description="Last error message if any")
has_update: bool = Field(False, description="Whether firmware update is available")

@computed_field # type: ignore[prop-decorator]
@property
def model_name(self) -> str | None:
"""Friendly marketing name for device_type, None when unmapped."""
return get_model_name(self.device_type)

@field_validator("ip")
@classmethod
def validate_ip(cls, v: str) -> str:
Expand Down
166 changes: 166 additions & 0 deletions packages/core/src/core/domain/model_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Friendly marketing names for Shelly model identifiers.

Data sourced from aioshelly (https://github.com/home-assistant-libs/aioshelly),
the library behind Home Assistant's Shelly integration. Update by hand when a
new model is reported unmapped.
"""

MODEL_NAMES: dict[str, str] = {
"S3BL-C010007AEU": "Shelly Multicolor Bulb Gen3",
"S3BL-D010009AEU": "Shelly Duo Bulb Gen3",
"S3DM-0010WW": "Shelly Dimmer 0/1-10V PM Gen3",
"S3DM-0A101WWL": "Shelly Dimmer Gen3",
"S3DM-0A1WW": "Shelly DALI Dimmer Gen3",
"S3EM-002CXCEU": "Shelly EM Gen3",
"S3EM-003CXCEU63": "Shelly 3EM-63 Gen3",
"S3GW-1DBT001": "Shelly BLU Gateway Gen3",
"S3MX-0A": "Shelly X MOD1",
"S3PL-00112EU": "Shelly Plug S Gen3",
"S3PL-10112EU": "Shelly AZ Plug",
"S3PL-20112EU": "Shelly Outdoor Plug S Gen3",
"S3PL-30110EU": "Shelly Plug M Gen3",
"S3PL-30116EU": "Shelly Plug PM Gen3",
"S3PM-001PCEU16": "Shelly PM Mini Gen3",
"S3SH-0A2P4EU": "Shelly Shutter",
"S3SN-0024X": "Shelly I4 Gen3",
"S3SN-0U12A": "Shelly H&T Gen3",
"S3SN-0U53X": "Shelly Pill",
"S3SN-1U12A": "Shelly AZ H&T",
"S3SW-001P16EU": "Shelly 1PM Gen3",
"S3SW-001P8EU": "Shelly 1PM Mini Gen3",
"S3SW-001X16EU": "Shelly 1 Gen3",
"S3SW-001X8EU": "Shelly 1 Mini Gen3",
"S3SW-002P16EU": "Shelly 2PM Gen3",
"S3SW-0A1X1EUL": "Shelly 1L Gen3",
"S3SW-0A2X4EUL": "Shelly 2L Gen3",
"S4DM-0A101WWL": "Shelly Dimmer Gen4",
"S4EM-001CXCEU63": "Shelly EM-63 Gen4",
"S4EM-001PXCEU16": "Shelly EM Mini Gen4",
"S4PB-00CU000002": "Shelly Cury",
"S4PL-00116US": "Shelly Plug US Gen4",
"S4PL-00415US": "Shelly Power Strip 4 US Gen4",
"S4PL-00416EU": "Shelly Power Strip 4 Gen4",
"S4PL-10416EU": "Shelly Power Strip 4 Gen4",
"S4SN-0071A": "Shelly Flood Gen4",
"S4SN-0071Z": "Shelly Flood S Gen4",
"S4SN-0A24X": "Shelly I4 Gen4",
"S4SN-0U61X": "Shelly Presence Gen4",
"S4SW-001P16EU": "Shelly 1PM Gen4",
"S4SW-001P8EU": "Shelly 1PM Mini Gen4",
"S4SW-001X16EU": "Shelly 1 Gen4",
"S4SW-001X8EU": "Shelly 1 Mini Gen4",
"S4SW-002P16EU": "Shelly 2PM Gen4",
"S4SW-0A1X1EUL": "Shelly 1L Gen4",
"S4SW-0A2X4EUL": "Shelly 2L Gen4",
"SAWD-0A1XX10EU1": "Shelly Wall Display",
"SAWD-2A1XX10EU1": "Shelly Wall Display X2",
"SAWD-3A1XE10EU2": "Shelly Wall Display XL",
"SAWD-5A1XX10EU0": "Shelly Wall Display X2i",
"SH2LED-1": "Shelly 2LED",
"SHAIR-1": "Shelly Air",
"SHBDUO-1": "Shelly DUO",
"SHBLB-1": "Shelly Bulb",
"SHBTN-1": "Shelly Button1",
"SHBTN-2": "Shelly Button1",
"SHBVIN-1": "Shelly Vintage",
"SHCB-1": "Shelly Bulb RGBW",
"SHCL-255": "Shelly Color",
"SHDIMW-1": "Shelly Dimmer W1",
"SHDM-1": "Shelly Dimmer",
"SHDM-2": "Shelly Dimmer 2",
"SHDW-1": "Shelly Door/Window",
"SHDW-2": "Shelly Door/Window 2",
"SHEM": "Shelly EM",
"SHEM-3": "Shelly 3EM",
"SHGS-1": "Shelly Gas",
"SHHT-1": "Shelly H&T",
"SHIX3-1": "Shelly i3",
"SHMOS-01": "Shelly Motion",
"SHMOS-02": "Shelly Motion 2",
"SHPLG-1": "Shelly Plug",
"SHPLG-S": "Shelly Plug S",
"SHPLG-U1": "Shelly Plug US",
"SHPLG2-1": "Shelly Plug E",
"SHRGBW2": "Shelly RGBW2",
"SHRGBWW-01": "Shelly RGBW",
"SHSEN-1": "Shelly Sense",
"SHSM-01": "Shelly Smoke",
"SHSM-02": "Shelly Smoke 2",
"SHSPOT-1": "Shelly Spot",
"SHSPOT-2": "Shelly Spot 2",
"SHSW-1": "Shelly 1",
"SHSW-21": "Shelly 2",
"SHSW-25": "Shelly 2.5",
"SHSW-44": "Shelly 4Pro",
"SHSW-L": "Shelly 1L",
"SHSW-PM": "Shelly 1PM",
"SHTRV-01": "Shelly Valve",
"SHUNI-1": "Shelly UNI",
"SHVIN-1": "Shelly Vintage",
"SHWT-1": "Shelly Flood",
"SNDC-0D4P10WW": "Shelly Plus RGBW PM",
"SNDM-00100WW": "Shelly Plus 0-10V Dimmer",
"SNDM-0013US": "Shelly Plus Wall Dimmer",
"SNGW-0A11WW010": "Shelly Plus 10V",
"SNGW-BT01": "Shelly BLU Gateway",
"SNPL-00110IT": "Shelly Plus Plug IT",
"SNPL-00112EU": "Shelly Plus Plug S",
"SNPL-00112UK": "Shelly Plus Plug UK",
"SNPL-00116US": "Shelly Plus Plug US",
"SNPL-10112EU": "Shelly Plus Plug S",
"SNPM-001PCEU16": "Shelly Plus PM Mini",
"SNSN-0013A": "Shelly Plus H&T",
"SNSN-0024X": "Shelly Plus I4",
"SNSN-0031Z": "Shelly Plus Smoke",
"SNSN-0043X": "Shelly Plus Uni",
"SNSN-0D24X": "Shelly Plus I4DC",
"SNSW-001P15UL": "Shelly Plus 1PM UL",
"SNSW-001P16EU": "Shelly Plus 1PM",
"SNSW-001P8EU": "Shelly Plus 1PM Mini",
"SNSW-001X15UL": "Shelly Plus 1 UL",
"SNSW-001X16EU": "Shelly Plus 1",
"SNSW-001X8EU": "Shelly Plus 1 Mini",
"SNSW-002P15UL": "Shelly Plus 2PM UL",
"SNSW-002P16EU": "Shelly Plus 2PM",
"SNSW-102P16EU": "Shelly Plus 2PM",
"SPCB-01VENEU": "Shelly Pro 1CB",
"SPCB-02VENEU": "Shelly Pro 2CB",
"SPCB-03VENEU": "Shelly Pro 3CB",
"SPCB-04VENEU": "Shelly Pro 4CB",
"SPCC-001PE10EU": "Shelly Pro Dimmer 0/1-10V PM",
"SPDC-0D5PE16EU": "Shelly Pro RGBWW PM",
"SPDM-001PE01EU": "Shelly Pro Dimmer 1PM",
"SPDM-002PE01EU": "Shelly Pro Dimmer 2PM",
"SPEM-002CEBEU50": "Shelly Pro EM",
"SPEM-003CEBEU": "Shelly Pro 3EM",
"SPEM-003CEBEU120": "Shelly Pro 3EM",
"SPEM-003CEBEU400": "Shelly Pro 3EM-400",
"SPEM-003CEBEU63": "Shelly Pro 3EM 3CT63",
"SPSH-002PE16EU": "Shelly Pro Dual Cover PM",
"SPSW-001PE16EU": "Shelly Pro 1PM",
"SPSW-001XE16EU": "Shelly Pro 1",
"SPSW-002PE16EU": "Shelly Pro 2PM",
"SPSW-002XE16EU": "Shelly Pro 2",
"SPSW-003XE16EU": "Shelly Pro 3",
"SPSW-004PE16EU": "Shelly Pro 4PM",
"SPSW-101PE16EU": "Shelly Pro 1PM",
"SPSW-101XE16EU": "Shelly Pro 1",
"SPSW-102PE16EU": "Shelly Pro 2PM",
"SPSW-102XE16EU": "Shelly Pro 2",
"SPSW-104PE16EU": "Shelly Pro 4PM",
"SPSW-201PE15UL": "Shelly Pro 1PM UL",
"SPSW-201PE16EU": "Shelly Pro 1PM",
"SPSW-201XE15UL": "Shelly Pro 1 UL",
"SPSW-201XE16EU": "Shelly Pro 1",
"SPSW-202PE16EU": "Shelly Pro 2PM",
"SPSW-202XE12UL": "Shelly Pro 2 UL",
"SPSW-202XE16EU": "Shelly Pro 2",
"SPSW-204PE16EU": "Shelly Pro 4PM",
}


def get_model_name(model: str | None) -> str | None:
"""Return the marketing name for a Shelly model ID, or None if unknown."""
if not model:
return None
return MODEL_NAMES.get(model.strip())
18 changes: 18 additions & 0 deletions packages/core/tests/unit/domain/entities/test_device_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,24 @@ def test_it_includes_zigbee_info_in_device_summary(self):
assert summary["zigbee_connected"] is True
assert summary["zigbee_network_state"] == "joined"

def test_it_includes_model_name_in_device_summary(self):
device_status = DeviceStatus(
device_ip="192.168.1.100", components=[], device_type="SNSW-102P16EU"
)

summary = device_status.get_device_summary()

assert summary["device_type"] == "SNSW-102P16EU"
assert summary["model_name"] == "Shelly Plus 2PM"

def test_it_returns_no_model_name_for_unknown_device_type(self):
device_status = DeviceStatus(device_ip="192.168.1.100", components=[])

summary = device_status.get_device_summary()

assert summary["device_type"] is None
assert summary["model_name"] is None

def test_it_handles_device_summary_no_zigbee(self):
device_status = DeviceStatus(device_ip="192.168.1.100", components=[])

Expand Down
Loading
Loading