Skip to content

Commit 43b0f45

Browse files
committed
Add REST reboot support and hosts endpoint fallback
1 parent fd98ecc commit 43b0f45

3 files changed

Lines changed: 222 additions & 34 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ The client supports two API variants:
135135
- `ApiMode.REST`: newer `/api/v1/*` API used by newer firmwares
136136
- `ApiMode.AUTO` (default): tries legacy first, then falls back to REST when the legacy endpoint is unavailable
137137

138-
When REST mode is active, high-level helpers like `get_device_info()` and `get_hosts()` are supported. XPath-based methods (`get_value_by_xpath`, `set_value_by_xpath`, `get_values_by_xpaths`) are legacy-only.
138+
When REST mode is active, high-level helpers like `get_device_info()`, `get_hosts()` and `reboot()` are supported. XPath-based methods (`get_value_by_xpath`, `set_value_by_xpath`, `get_values_by_xpaths`) are legacy-only.
139139

140140
### Determine the EncryptionMethod
141141

sagemcom_api/client.py

Lines changed: 128 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,116 @@ async def __rest_login(self):
415415
)
416416
return True
417417

418+
@staticmethod
419+
def __first_value(data: dict[str, Any], *keys: str) -> Any:
420+
"""Return the first non-None value from data for the given keys."""
421+
for key in keys:
422+
if key in data and data[key] is not None:
423+
return data[key]
424+
return None
425+
426+
@staticmethod
427+
def __to_bool(value: Any, default: bool = True) -> bool:
428+
"""Convert mixed payload boolean values to bool."""
429+
if value is None:
430+
return default
431+
if isinstance(value, bool):
432+
return value
433+
if isinstance(value, (int, float)):
434+
return bool(value)
435+
if isinstance(value, str):
436+
return value.strip().lower() in ("1", "true", "yes", "on", "up")
437+
return default
438+
439+
def __build_rest_device(
440+
self, entry: dict[str, Any], interface_type: str | None
441+
) -> Device:
442+
"""Map a REST host entry to Device."""
443+
detected_interface = self.__first_value(
444+
entry,
445+
"interface_type",
446+
"interfaceType",
447+
"interface",
448+
"connectionType",
449+
"connection_type",
450+
"type",
451+
)
452+
if interface_type is None and isinstance(detected_interface, str):
453+
normalized = detected_interface.lower()
454+
if "wifi" in normalized or "wireless" in normalized or "wlan" in normalized:
455+
interface_type = "wifi"
456+
elif "ethernet" in normalized or "eth" in normalized or "lan" in normalized:
457+
interface_type = "ethernet"
458+
else:
459+
interface_type = detected_interface
460+
461+
return Device(
462+
uid=self.__first_value(entry, "id", "uid"),
463+
phys_address=self.__first_value(
464+
entry, "macAddress", "mac_address", "phys_address"
465+
),
466+
ip_address=self.__first_value(entry, "ipAddress", "ip_address"),
467+
host_name=self.__first_value(entry, "hostname", "host_name", "name"),
468+
user_host_name=self.__first_value(
469+
entry, "friendlyname", "friendly_name", "user_host_name"
470+
),
471+
active=self.__to_bool(
472+
self.__first_value(entry, "active", "isActive"), True
473+
),
474+
interface_type=interface_type,
475+
detected_device_type=self.__first_value(
476+
entry, "devicetype", "deviceType", "detected_device_type"
477+
),
478+
)
479+
480+
def __extract_rest_home_hosts(self, data: Any) -> list[Device]:
481+
"""Parse /api/v1/home hosts payload."""
482+
if isinstance(data, list):
483+
if not data:
484+
return []
485+
home = data[0]
486+
elif isinstance(data, dict):
487+
home = data
488+
else:
489+
raise UnknownException("Invalid response from /api/v1/home")
490+
491+
if not isinstance(home, dict):
492+
raise UnknownException("Invalid response from /api/v1/home")
493+
494+
devices: list[Device] = []
495+
for entry in home.get("wirelessListDevice", []):
496+
if isinstance(entry, dict):
497+
devices.append(self.__build_rest_device(entry, "wifi"))
498+
499+
for entry in home.get("ethernetListDevice", []):
500+
if isinstance(entry, dict):
501+
devices.append(self.__build_rest_device(entry, "ethernet"))
502+
503+
return devices
504+
505+
def __extract_rest_hosts(self, data: Any) -> list[Device]:
506+
"""Parse /api/v1/hosts payload."""
507+
hosts: list[dict[str, Any]]
508+
if isinstance(data, list):
509+
hosts = [entry for entry in data if isinstance(entry, dict)]
510+
elif isinstance(data, dict):
511+
raw_hosts = self.__first_value(
512+
data,
513+
"hosts",
514+
"Hosts",
515+
"list",
516+
"listDevice",
517+
"list_device",
518+
"devices",
519+
)
520+
if not isinstance(raw_hosts, list):
521+
raise UnknownException("Invalid response from /api/v1/hosts")
522+
hosts = [entry for entry in raw_hosts if isinstance(entry, dict)]
523+
else:
524+
raise UnknownException("Invalid response from /api/v1/hosts")
525+
526+
return [self.__build_rest_device(entry, None) for entry in hosts]
527+
418528
def __should_fallback_to_rest(self, exception: Exception) -> bool:
419529
"""Return True when legacy API failure indicates a REST-only router."""
420530
if isinstance(exception, UnsupportedHostException):
@@ -663,40 +773,23 @@ async def get_device_info(self) -> DeviceInfo:
663773
async def get_hosts(self, only_active: bool | None = False) -> list[Device]:
664774
"""Retrieve hosts connected to Sagemcom F@st device."""
665775
if self._active_api_mode == ApiMode.REST:
666-
data = await self.__rest_request("GET", "/api/v1/home")
667-
if not data or not isinstance(data, list):
668-
raise UnknownException("Invalid response from /api/v1/home")
669-
670-
home = data[0]
776+
rest_errors: list[Exception] = []
671777
devices: list[Device] = []
672778

673-
for entry in home.get("wirelessListDevice", []):
674-
devices.append(
675-
Device(
676-
uid=entry.get("id"),
677-
phys_address=entry.get("macAddress"),
678-
ip_address=entry.get("ipAddress"),
679-
host_name=entry.get("hostname"),
680-
user_host_name=entry.get("friendlyname"),
681-
active=entry.get("active", True),
682-
interface_type="wifi",
683-
detected_device_type=entry.get("devicetype"),
684-
)
685-
)
686-
687-
for entry in home.get("ethernetListDevice", []):
688-
devices.append(
689-
Device(
690-
uid=entry.get("id"),
691-
phys_address=entry.get("macAddress"),
692-
ip_address=entry.get("ipAddress"),
693-
host_name=entry.get("hostname"),
694-
user_host_name=entry.get("friendlyname"),
695-
active=entry.get("active", True),
696-
interface_type="ethernet",
697-
detected_device_type=entry.get("devicetype"),
698-
)
699-
)
779+
for endpoint, parser in (
780+
("/api/v1/home", self.__extract_rest_home_hosts),
781+
("/api/v1/hosts", self.__extract_rest_hosts),
782+
):
783+
try:
784+
data = await self.__rest_request("GET", endpoint)
785+
devices = parser(data)
786+
break
787+
except (UnknownException, UnsupportedHostException) as exception:
788+
rest_errors.append(exception)
789+
else:
790+
if rest_errors:
791+
raise rest_errors[-1]
792+
raise UnknownException("Unable to retrieve hosts using REST endpoints")
700793

701794
if only_active:
702795
return [d for d in devices if d.active is True]
@@ -746,7 +839,9 @@ async def get_port_mappings(self) -> list[PortMapping]:
746839
)
747840
async def reboot(self):
748841
"""Reboot Sagemcom F@st device."""
749-
self.__ensure_legacy_api()
842+
if self._active_api_mode == ApiMode.REST:
843+
return await self.__rest_request("POST", "/api/v1/device/reboot")
844+
750845
action = {
751846
"id": 0,
752847
"method": "reboot",

tests/unit/test_client_basic.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,96 @@ async def test_get_hosts_rest_mode():
234234
assert devices[0].interface_type == "wifi"
235235
assert devices[1].host_name == "lan-device"
236236
assert devices[1].interface_type == "ethernet"
237+
238+
239+
@pytest.mark.asyncio
240+
async def test_get_hosts_rest_fallbacks_to_hosts_endpoint():
241+
"""/api/v1/hosts should be used when /api/v1/home response is invalid."""
242+
mock_session = MagicMock(spec=ClientSession)
243+
mock_session.close = AsyncMock()
244+
245+
login_response = AsyncMock()
246+
login_response.status = 204
247+
login_response.text = AsyncMock(return_value="")
248+
login_response.__aenter__ = AsyncMock(return_value=login_response)
249+
login_response.__aexit__ = AsyncMock(return_value=None)
250+
251+
home_response = AsyncMock()
252+
home_response.status = 200
253+
home_response.json = AsyncMock(return_value=[{"unexpected": "shape"}])
254+
home_response.__aenter__ = AsyncMock(return_value=home_response)
255+
home_response.__aexit__ = AsyncMock(return_value=None)
256+
257+
hosts_payload = [
258+
{
259+
"id": 7,
260+
"hostname": "tablet",
261+
"friendlyname": "tablet",
262+
"macAddress": "aa:aa:aa:aa:aa:aa",
263+
"ipAddress": "192.168.1.50",
264+
"active": "true",
265+
"interfaceType": "wireless",
266+
"devicetype": "TABLET",
267+
}
268+
]
269+
hosts_response = AsyncMock()
270+
hosts_response.status = 200
271+
hosts_response.json = AsyncMock(return_value=hosts_payload)
272+
hosts_response.__aenter__ = AsyncMock(return_value=hosts_response)
273+
hosts_response.__aexit__ = AsyncMock(return_value=None)
274+
275+
mock_session.request.side_effect = [login_response, home_response, hosts_response]
276+
277+
client = SagemcomClient(
278+
host="192.168.1.1",
279+
username="admin",
280+
password="admin",
281+
session=mock_session,
282+
api_mode=ApiMode.REST,
283+
)
284+
285+
await client.login()
286+
devices = await client.get_hosts(only_active=True)
287+
288+
assert len(devices) == 1
289+
assert devices[0].host_name == "tablet"
290+
assert devices[0].interface_type == "wifi"
291+
assert devices[0].active is True
292+
293+
294+
@pytest.mark.asyncio
295+
async def test_reboot_rest_mode():
296+
"""reboot should call REST endpoint on REST firmware."""
297+
mock_session = MagicMock(spec=ClientSession)
298+
mock_session.close = AsyncMock()
299+
300+
login_response = AsyncMock()
301+
login_response.status = 204
302+
login_response.text = AsyncMock(return_value="")
303+
login_response.__aenter__ = AsyncMock(return_value=login_response)
304+
login_response.__aexit__ = AsyncMock(return_value=None)
305+
306+
reboot_response = AsyncMock()
307+
reboot_response.status = 204
308+
reboot_response.text = AsyncMock(return_value="")
309+
reboot_response.__aenter__ = AsyncMock(return_value=reboot_response)
310+
reboot_response.__aexit__ = AsyncMock(return_value=None)
311+
312+
mock_session.request.side_effect = [login_response, reboot_response]
313+
314+
client = SagemcomClient(
315+
host="192.168.1.1",
316+
username="admin",
317+
password="admin",
318+
session=mock_session,
319+
api_mode=ApiMode.REST,
320+
)
321+
322+
await client.login()
323+
result = await client.reboot()
324+
325+
assert result is None
326+
assert mock_session.request.call_count == 2
327+
reboot_call = mock_session.request.call_args_list[1]
328+
assert reboot_call.args[0] == "POST"
329+
assert reboot_call.args[1].endswith("/api/v1/device/reboot")

0 commit comments

Comments
 (0)