Skip to content

Commit 7f4f13c

Browse files
committed
Show friendly model names for discovered devices
Map Shelly model IDs to their marketing names (sourced from aioshelly's table) and surface them wherever the raw SKU was shown: the dashboard model column, the device detail header, and the CLI device tables. Unmapped models fall back to the raw model ID, so new hardware renders exactly as before.
1 parent d29997a commit 7f4f13c

17 files changed

Lines changed: 325 additions & 21 deletions

File tree

packages/api/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,8 @@ curl "http://localhost:8000/api/devices/scan?targets=192.168.1.1-10"
213213
{
214214
"ip": "192.168.1.100",
215215
"status": "online",
216-
"device_type": "shelly1pm",
216+
"device_type": "SHSW-PM",
217+
"model_name": "Shelly 1PM",
217218
"device_name": "Living Room Light",
218219
"firmware_version": "20230913-112003",
219220
"available_firmware_version": "1.2.0",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ async def scan_devices(
8282
"ip": device.ip,
8383
"status": device.status,
8484
"device_type": device.device_type,
85+
"model_name": device.model_name,
8586
"device_name": device.device_name,
8687
"firmware_version": device.firmware_version,
8788
"available_firmware_version": device.available_firmware_version,

packages/api/src/api/presentation/dto/responses.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class DeviceResponse(BaseModel):
2020
ip: str
2121
device_id: str | None = None
2222
device_type: str | None = None
23+
model_name: str | None = None
2324
device_name: str | None = None
2425
firmware_version: str | None = None
2526
status: str

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ async def execute(self, scan_request):
3939
device = DiscoveredDevice(
4040
ip="192.168.1.100",
4141
status=Status.DETECTED,
42-
device_type="Shelly 1",
42+
device_type="SHSW-PM",
4343
device_name="Test Device",
4444
firmware_version="1.0.0",
4545
available_firmware_version="1.2.0",
@@ -64,6 +64,8 @@ async def execute(self, scan_request):
6464
assert data[0]["ip"] == "192.168.1.100"
6565
assert data[0]["status"] == "detected"
6666
assert data[0]["available_firmware_version"] == "1.2.0"
67+
assert data[0]["device_type"] == "SHSW-PM"
68+
assert data[0]["model_name"] == "Shelly 1PM"
6769

6870
def test_scan_without_targets_returns_400(self):
6971
from core.domain.entities.exceptions import ValidationError
@@ -483,6 +485,7 @@ async def execute(self, request):
483485
device_ip=request.device_ip,
484486
components=[],
485487
total_components=0,
488+
device_type="SHSW-PM",
486489
)
487490

488491
with create_test_client(
@@ -501,6 +504,8 @@ async def execute(self, request):
501504
assert "components" in data
502505
assert "summary" in data
503506
assert "firmware" in data
507+
assert data["summary"]["device_type"] == "SHSW-PM"
508+
assert data["summary"]["model_name"] == "Shelly 1PM"
504509

505510
def test_bulk_operations_update_successfully(self):
506511
from core.use_cases.bulk_operations import BulkOperationsUseCase

packages/cli/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ shelly-manager device reboot 192.168.1.100 --force # Skip confirmation
104104

105105
```
106106
┌─────────────────┬──────────┬─────────────┬──────────────────┬─────────────┐
107-
│ IP Address │ Status │ Device Type │ Device Name │ Firmware │
107+
│ IP Address │ Status │ Model │ Device Name │ Firmware │
108108
├─────────────────┼──────────┼─────────────┼──────────────────┼─────────────┤
109-
│ 192.168.1.100 │ online │ shelly1pm │ Living Room │ 20230913... │
109+
│ 192.168.1.100 │ online │ Shelly 1PM │ Living Room │ 20230913... │
110110
│ 192.168.1.101 │ offline │ unknown │ - │ - │
111111
└─────────────────┴──────────┴─────────────┴──────────────────┴─────────────┘
112112
```

packages/cli/src/cli/commands/common.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,21 +127,27 @@ def format_device_table(devices: list[Any], console: Console) -> None:
127127

128128
table = Table(title="Shelly Devices")
129129
table.add_column("IP Address", style="cyan")
130-
table.add_column("Device Type", style="green")
130+
table.add_column("Model", style="green")
131131
table.add_column("Name", style="blue")
132132
table.add_column("Firmware", style="magenta")
133133
table.add_column("Status", style="yellow")
134134

135135
for device in devices:
136136
if hasattr(device, "ip"):
137137
ip = device.ip
138-
device_type = getattr(device, "device_type", "Unknown")
138+
device_type = (
139+
getattr(device, "model_name", None)
140+
or getattr(device, "device_type", None)
141+
or "Unknown"
142+
)
139143
name = getattr(device, "device_name", "Unknown")
140144
firmware = getattr(device, "firmware_version", "Unknown")
141145
status = getattr(device, "status", "Unknown")
142146
else:
143147
ip = device.get("ip", "Unknown")
144-
device_type = device.get("device_type", "Unknown")
148+
device_type = (
149+
device.get("model_name") or device.get("device_type") or "Unknown"
150+
)
145151
name = device.get("device_name", "Unknown")
146152
firmware = device.get("firmware_version", "Unknown")
147153
status = device.get("status", "Unknown")

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def _format_discovered_devices_table(
4646
"""Format table for DiscoveredDevice entities."""
4747
table = Table(title=title)
4848
table.add_column("IP Address", style="cyan")
49-
table.add_column("Device Type", style="green")
49+
table.add_column("Model", style="green")
5050
table.add_column("Name", style="blue")
5151
table.add_column("Firmware", style="magenta")
5252
table.add_column("Status", style="yellow")
@@ -58,7 +58,7 @@ def _format_discovered_devices_table(
5858
)
5959
table.add_row(
6060
device.ip,
61-
device.device_type or "Unknown",
61+
device.model_name or device.device_type or "Unknown",
6262
device.device_name or "Unknown",
6363
device.firmware_version or "Unknown",
6464
format_device_status(device.status),
@@ -77,21 +77,27 @@ def _format_legacy_device_table(self, devices: list[Any], title: str) -> None:
7777
"""Legacy format for backward compatibility."""
7878
table = Table(title=title)
7979
table.add_column("IP Address", style="cyan")
80-
table.add_column("Device Type", style="green")
80+
table.add_column("Model", style="green")
8181
table.add_column("Name", style="blue")
8282
table.add_column("Firmware", style="magenta")
8383
table.add_column("Status", style="yellow")
8484

8585
for device in devices:
8686
if hasattr(device, "ip"):
8787
ip = device.ip
88-
device_type = getattr(device, "device_type", "Unknown")
88+
device_type = (
89+
getattr(device, "model_name", None)
90+
or getattr(device, "device_type", None)
91+
or "Unknown"
92+
)
8993
name = getattr(device, "device_name", "Unknown")
9094
firmware = getattr(device, "firmware_version", "Unknown")
9195
status = getattr(device, "status", "Unknown")
9296
else:
9397
ip = device.get("ip", "Unknown")
94-
device_type = device.get("device_type", "Unknown")
98+
device_type = (
99+
device.get("model_name") or device.get("device_type") or "Unknown"
100+
)
95101
name = device.get("device_name", "Unknown")
96102
firmware = device.get("firmware_version", "Unknown")
97103
status = device.get("status", "Unknown")

packages/core/src/core/domain/entities/device_status.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from pydantic import BaseModel, Field
55

6+
from ..model_names import get_model_name
67
from .components import (
78
CloudComponent,
89
ComponentType,
@@ -186,6 +187,8 @@ def get_device_summary(self) -> dict[str, Any]:
186187
return {
187188
"device_name": self.device_name
188189
or (sys_info.device_name if sys_info else None),
190+
"device_type": self.device_type,
191+
"model_name": get_model_name(self.device_type),
189192
"mac_address": self.mac_address
190193
or (sys_info.mac_address if sys_info else None),
191194
"firmware_version": self.firmware_version

packages/core/src/core/domain/entities/discovered_device.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44

55
from datetime import datetime
66

7-
from pydantic import BaseModel, ConfigDict, Field, field_validator
7+
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
88

99
from ...utils.validation import validate_ip_address
1010
from ..enums.enums import Status
11+
from ..model_names import get_model_name
1112

1213

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

39+
@computed_field # type: ignore[prop-decorator]
40+
@property
41+
def model_name(self) -> str | None:
42+
"""Friendly marketing name for device_type, None when unmapped."""
43+
return get_model_name(self.device_type)
44+
3845
@field_validator("ip")
3946
@classmethod
4047
def validate_ip(cls, v: str) -> str:
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Friendly marketing names for Shelly model identifiers.
2+
3+
Data sourced from aioshelly (https://github.com/home-assistant-libs/aioshelly),
4+
the library behind Home Assistant's Shelly integration. Update by hand when a
5+
new model is reported unmapped.
6+
"""
7+
8+
MODEL_NAMES: dict[str, str] = {
9+
"S3BL-C010007AEU": "Shelly Multicolor Bulb Gen3",
10+
"S3BL-D010009AEU": "Shelly Duo Bulb Gen3",
11+
"S3DM-0010WW": "Shelly Dimmer 0/1-10V PM Gen3",
12+
"S3DM-0A101WWL": "Shelly Dimmer Gen3",
13+
"S3DM-0A1WW": "Shelly DALI Dimmer Gen3",
14+
"S3EM-002CXCEU": "Shelly EM Gen3",
15+
"S3EM-003CXCEU63": "Shelly 3EM-63 Gen3",
16+
"S3GW-1DBT001": "Shelly BLU Gateway Gen3",
17+
"S3MX-0A": "Shelly X MOD1",
18+
"S3PL-00112EU": "Shelly Plug S Gen3",
19+
"S3PL-10112EU": "Shelly AZ Plug",
20+
"S3PL-20112EU": "Shelly Outdoor Plug S Gen3",
21+
"S3PL-30110EU": "Shelly Plug M Gen3",
22+
"S3PL-30116EU": "Shelly Plug PM Gen3",
23+
"S3PM-001PCEU16": "Shelly PM Mini Gen3",
24+
"S3SH-0A2P4EU": "Shelly Shutter",
25+
"S3SN-0024X": "Shelly I4 Gen3",
26+
"S3SN-0U12A": "Shelly H&T Gen3",
27+
"S3SN-0U53X": "Shelly Pill",
28+
"S3SN-1U12A": "Shelly AZ H&T",
29+
"S3SW-001P16EU": "Shelly 1PM Gen3",
30+
"S3SW-001P8EU": "Shelly 1PM Mini Gen3",
31+
"S3SW-001X16EU": "Shelly 1 Gen3",
32+
"S3SW-001X8EU": "Shelly 1 Mini Gen3",
33+
"S3SW-002P16EU": "Shelly 2PM Gen3",
34+
"S3SW-0A1X1EUL": "Shelly 1L Gen3",
35+
"S3SW-0A2X4EUL": "Shelly 2L Gen3",
36+
"S4DM-0A101WWL": "Shelly Dimmer Gen4",
37+
"S4EM-001CXCEU63": "Shelly EM-63 Gen4",
38+
"S4EM-001PXCEU16": "Shelly EM Mini Gen4",
39+
"S4PB-00CU000002": "Shelly Cury",
40+
"S4PL-00116US": "Shelly Plug US Gen4",
41+
"S4PL-00415US": "Shelly Power Strip 4 US Gen4",
42+
"S4PL-00416EU": "Shelly Power Strip 4 Gen4",
43+
"S4PL-10416EU": "Shelly Power Strip 4 Gen4",
44+
"S4SN-0071A": "Shelly Flood Gen4",
45+
"S4SN-0071Z": "Shelly Flood S Gen4",
46+
"S4SN-0A24X": "Shelly I4 Gen4",
47+
"S4SN-0U61X": "Shelly Presence Gen4",
48+
"S4SW-001P16EU": "Shelly 1PM Gen4",
49+
"S4SW-001P8EU": "Shelly 1PM Mini Gen4",
50+
"S4SW-001X16EU": "Shelly 1 Gen4",
51+
"S4SW-001X8EU": "Shelly 1 Mini Gen4",
52+
"S4SW-002P16EU": "Shelly 2PM Gen4",
53+
"S4SW-0A1X1EUL": "Shelly 1L Gen4",
54+
"S4SW-0A2X4EUL": "Shelly 2L Gen4",
55+
"SAWD-0A1XX10EU1": "Shelly Wall Display",
56+
"SAWD-2A1XX10EU1": "Shelly Wall Display X2",
57+
"SAWD-3A1XE10EU2": "Shelly Wall Display XL",
58+
"SAWD-5A1XX10EU0": "Shelly Wall Display X2i",
59+
"SH2LED-1": "Shelly 2LED",
60+
"SHAIR-1": "Shelly Air",
61+
"SHBDUO-1": "Shelly DUO",
62+
"SHBLB-1": "Shelly Bulb",
63+
"SHBTN-1": "Shelly Button1",
64+
"SHBTN-2": "Shelly Button1",
65+
"SHBVIN-1": "Shelly Vintage",
66+
"SHCB-1": "Shelly Bulb RGBW",
67+
"SHCL-255": "Shelly Color",
68+
"SHDIMW-1": "Shelly Dimmer W1",
69+
"SHDM-1": "Shelly Dimmer",
70+
"SHDM-2": "Shelly Dimmer 2",
71+
"SHDW-1": "Shelly Door/Window",
72+
"SHDW-2": "Shelly Door/Window 2",
73+
"SHEM": "Shelly EM",
74+
"SHEM-3": "Shelly 3EM",
75+
"SHGS-1": "Shelly Gas",
76+
"SHHT-1": "Shelly H&T",
77+
"SHIX3-1": "Shelly i3",
78+
"SHMOS-01": "Shelly Motion",
79+
"SHMOS-02": "Shelly Motion 2",
80+
"SHPLG-1": "Shelly Plug",
81+
"SHPLG-S": "Shelly Plug S",
82+
"SHPLG-U1": "Shelly Plug US",
83+
"SHPLG2-1": "Shelly Plug E",
84+
"SHRGBW2": "Shelly RGBW2",
85+
"SHRGBWW-01": "Shelly RGBW",
86+
"SHSEN-1": "Shelly Sense",
87+
"SHSM-01": "Shelly Smoke",
88+
"SHSM-02": "Shelly Smoke 2",
89+
"SHSPOT-1": "Shelly Spot",
90+
"SHSPOT-2": "Shelly Spot 2",
91+
"SHSW-1": "Shelly 1",
92+
"SHSW-21": "Shelly 2",
93+
"SHSW-25": "Shelly 2.5",
94+
"SHSW-44": "Shelly 4Pro",
95+
"SHSW-L": "Shelly 1L",
96+
"SHSW-PM": "Shelly 1PM",
97+
"SHTRV-01": "Shelly Valve",
98+
"SHUNI-1": "Shelly UNI",
99+
"SHVIN-1": "Shelly Vintage",
100+
"SHWT-1": "Shelly Flood",
101+
"SNDC-0D4P10WW": "Shelly Plus RGBW PM",
102+
"SNDM-00100WW": "Shelly Plus 0-10V Dimmer",
103+
"SNDM-0013US": "Shelly Plus Wall Dimmer",
104+
"SNGW-0A11WW010": "Shelly Plus 10V",
105+
"SNGW-BT01": "Shelly BLU Gateway",
106+
"SNPL-00110IT": "Shelly Plus Plug IT",
107+
"SNPL-00112EU": "Shelly Plus Plug S",
108+
"SNPL-00112UK": "Shelly Plus Plug UK",
109+
"SNPL-00116US": "Shelly Plus Plug US",
110+
"SNPL-10112EU": "Shelly Plus Plug S",
111+
"SNPM-001PCEU16": "Shelly Plus PM Mini",
112+
"SNSN-0013A": "Shelly Plus H&T",
113+
"SNSN-0024X": "Shelly Plus I4",
114+
"SNSN-0031Z": "Shelly Plus Smoke",
115+
"SNSN-0043X": "Shelly Plus Uni",
116+
"SNSN-0D24X": "Shelly Plus I4DC",
117+
"SNSW-001P15UL": "Shelly Plus 1PM UL",
118+
"SNSW-001P16EU": "Shelly Plus 1PM",
119+
"SNSW-001P8EU": "Shelly Plus 1PM Mini",
120+
"SNSW-001X15UL": "Shelly Plus 1 UL",
121+
"SNSW-001X16EU": "Shelly Plus 1",
122+
"SNSW-001X8EU": "Shelly Plus 1 Mini",
123+
"SNSW-002P15UL": "Shelly Plus 2PM UL",
124+
"SNSW-002P16EU": "Shelly Plus 2PM",
125+
"SNSW-102P16EU": "Shelly Plus 2PM",
126+
"SPCB-01VENEU": "Shelly Pro 1CB",
127+
"SPCB-02VENEU": "Shelly Pro 2CB",
128+
"SPCB-03VENEU": "Shelly Pro 3CB",
129+
"SPCB-04VENEU": "Shelly Pro 4CB",
130+
"SPCC-001PE10EU": "Shelly Pro Dimmer 0/1-10V PM",
131+
"SPDC-0D5PE16EU": "Shelly Pro RGBWW PM",
132+
"SPDM-001PE01EU": "Shelly Pro Dimmer 1PM",
133+
"SPDM-002PE01EU": "Shelly Pro Dimmer 2PM",
134+
"SPEM-002CEBEU50": "Shelly Pro EM",
135+
"SPEM-003CEBEU": "Shelly Pro 3EM",
136+
"SPEM-003CEBEU120": "Shelly Pro 3EM",
137+
"SPEM-003CEBEU400": "Shelly Pro 3EM-400",
138+
"SPEM-003CEBEU63": "Shelly Pro 3EM 3CT63",
139+
"SPSH-002PE16EU": "Shelly Pro Dual Cover PM",
140+
"SPSW-001PE16EU": "Shelly Pro 1PM",
141+
"SPSW-001XE16EU": "Shelly Pro 1",
142+
"SPSW-002PE16EU": "Shelly Pro 2PM",
143+
"SPSW-002XE16EU": "Shelly Pro 2",
144+
"SPSW-003XE16EU": "Shelly Pro 3",
145+
"SPSW-004PE16EU": "Shelly Pro 4PM",
146+
"SPSW-101PE16EU": "Shelly Pro 1PM",
147+
"SPSW-101XE16EU": "Shelly Pro 1",
148+
"SPSW-102PE16EU": "Shelly Pro 2PM",
149+
"SPSW-102XE16EU": "Shelly Pro 2",
150+
"SPSW-104PE16EU": "Shelly Pro 4PM",
151+
"SPSW-201PE15UL": "Shelly Pro 1PM UL",
152+
"SPSW-201PE16EU": "Shelly Pro 1PM",
153+
"SPSW-201XE15UL": "Shelly Pro 1 UL",
154+
"SPSW-201XE16EU": "Shelly Pro 1",
155+
"SPSW-202PE16EU": "Shelly Pro 2PM",
156+
"SPSW-202XE12UL": "Shelly Pro 2 UL",
157+
"SPSW-202XE16EU": "Shelly Pro 2",
158+
"SPSW-204PE16EU": "Shelly Pro 4PM",
159+
}
160+
161+
162+
def get_model_name(model: str | None) -> str | None:
163+
"""Return the marketing name for a Shelly model ID, or None if unknown."""
164+
if not model:
165+
return None
166+
return MODEL_NAMES.get(model.strip())

0 commit comments

Comments
 (0)