Implemented fritz box bridge
This commit is contained in:
parent
fd414c2cd1
commit
ff7b23b716
2 changed files with 212 additions and 0 deletions
28
src/fritz_test.py
Normal file
28
src/fritz_test.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from mash.bridges.fritzbox import FritzBoxBridge, FritzDeviceState
|
||||||
|
from time import sleep
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
bridge = FritzBoxBridge(id="fritzbox", ip="192.168.178.1", refresh_delay_sec=3)
|
||||||
|
|
||||||
|
|
||||||
|
def device_change(mac_address: str, device_state: FritzDeviceState) -> None:
|
||||||
|
print(
|
||||||
|
f"{device_state.host_name} - {'Connected' if device_state.active else 'Disconnected'}"
|
||||||
|
)
|
||||||
|
print(str(device_state))
|
||||||
|
|
||||||
|
|
||||||
|
bridge.subscribe_device(device_change, mac_address="FA:01:EC:90:50:49")
|
||||||
|
|
||||||
|
bridge.connect()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
sleep(3)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
break
|
||||||
|
|
||||||
|
bridge.disconnect()
|
|
@ -0,0 +1,184 @@
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
from typing import Coroutine, Optional
|
||||||
|
from mash.bridges.bridge import Bridge
|
||||||
|
from fritzconnection import FritzConnection
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
EXCEPTION_RECONNECT_TIMEOUT_SEC = 60
|
||||||
|
|
||||||
|
|
||||||
|
class FritzDeviceState:
|
||||||
|
def __init__(self, mac_address: str, raw_state: dict) -> None:
|
||||||
|
logging.debug(
|
||||||
|
f"Fritz raw device state to mac [{mac_address}]: {raw_state}",
|
||||||
|
extra=raw_state,
|
||||||
|
)
|
||||||
|
self.mac_address = mac_address
|
||||||
|
self.active: bool = raw_state["NewActive"]
|
||||||
|
self.ip_address: str = raw_state["NewIPAddress"]
|
||||||
|
self.address_source: str = raw_state["NewAddressSource"]
|
||||||
|
self.lease_time_remaining = raw_state["NewLeaseTimeRemaining"]
|
||||||
|
self.interface_type: str = raw_state["NewInterfaceType"]
|
||||||
|
self.host_name: str = raw_state["NewHostName"]
|
||||||
|
|
||||||
|
def __eq__(self, value: object) -> bool:
|
||||||
|
return (
|
||||||
|
type(value) is FritzDeviceState
|
||||||
|
and self.mac_address == value.mac_address
|
||||||
|
and self.ip_address == value.ip_address
|
||||||
|
and self.address_source == value.address_source
|
||||||
|
and self.lease_time_remaining == value.lease_time_remaining
|
||||||
|
and self.interface_type == value.interface_type
|
||||||
|
and self.host_name == value.host_name
|
||||||
|
and self.active == value.active
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"[{self.mac_address} | {self.host_name}] {'Active' if self.active else 'Inactive'} - {self.ip_address} - {self.address_source} - {self.interface_type} - {self.lease_time_remaining}"
|
||||||
|
|
||||||
|
|
||||||
|
class FritzBoxBridge(Bridge):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
id: str,
|
||||||
|
ip: str,
|
||||||
|
port: Optional[int] = None,
|
||||||
|
refresh_delay_sec: int = 60,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(id=id, type="fritzbox")
|
||||||
|
self._ip = ip
|
||||||
|
self._port = port
|
||||||
|
self._refresh_delay_sec = refresh_delay_sec
|
||||||
|
self._device_callbacks: dict[str, list[callable]] = {}
|
||||||
|
self._device_states: dict[str, FritzDeviceState] = {}
|
||||||
|
self._background_service: Optional[asyncio.Task] = None
|
||||||
|
self._fritz_api: FritzConnection = None
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
self.disconnect()
|
||||||
|
self._background_service = asyncio.run(self.__start_service_worker__())
|
||||||
|
|
||||||
|
async def __start_service_worker__(self) -> Coroutine:
|
||||||
|
return await self.__service_worker__()
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
if self._background_service is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._background_service.cancel()
|
||||||
|
|
||||||
|
async def __service_worker__(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
self.__establish_connection_if_required__()
|
||||||
|
self.__update_active_devices__()
|
||||||
|
|
||||||
|
await self.__sleep_until_next_refresh__(start_time)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
break
|
||||||
|
except Exception as ex:
|
||||||
|
await self.__handle_service_exception__(ex)
|
||||||
|
|
||||||
|
self._fritz_api = None
|
||||||
|
|
||||||
|
async def __sleep_until_next_refresh__(self, start_time: datetime):
|
||||||
|
refresh_duration_sec = (datetime.now() - start_time).total_seconds()
|
||||||
|
remaining_delay = self._refresh_delay_sec - refresh_duration_sec
|
||||||
|
|
||||||
|
if remaining_delay > 0:
|
||||||
|
logging.debug(
|
||||||
|
f"Fritz service worker sleeping for {remaining_delay} seconds."
|
||||||
|
)
|
||||||
|
await asyncio.sleep(remaining_delay)
|
||||||
|
|
||||||
|
def __update_active_devices__(self) -> None:
|
||||||
|
for mac_address in self._device_callbacks.keys():
|
||||||
|
raw_state = self.__get_specific_device__(mac_address)
|
||||||
|
|
||||||
|
new_device_state = FritzDeviceState(
|
||||||
|
mac_address=mac_address, raw_state=raw_state
|
||||||
|
)
|
||||||
|
if self._device_states[mac_address] == new_device_state:
|
||||||
|
continue # No state change
|
||||||
|
|
||||||
|
self._device_states[mac_address] = new_device_state
|
||||||
|
|
||||||
|
# Trigger every callback for device change
|
||||||
|
for cb in self._device_callbacks[mac_address]:
|
||||||
|
cb(mac_address, new_device_state)
|
||||||
|
|
||||||
|
async def __handle_service_exception__(self, exception: Exception) -> None:
|
||||||
|
logging.exception(
|
||||||
|
f"Exception in service worker of {self.type} caught: {exception}",
|
||||||
|
exc_info=exception,
|
||||||
|
)
|
||||||
|
logging.debug(
|
||||||
|
"Exception occurred, sleeping for {EXCEPTION_RECONNECT_TIMEOUT_SEC} seconds and reconnecting again."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._fritz_api = None # Trigger reconnect
|
||||||
|
|
||||||
|
await asyncio.sleep(EXCEPTION_RECONNECT_TIMEOUT_SEC)
|
||||||
|
|
||||||
|
def __establish_connection_if_required__(self) -> None:
|
||||||
|
if self._fritz_api:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._fritz_api = FritzConnection(address=self._ip, port=self._port)
|
||||||
|
logging.info("Connected")
|
||||||
|
|
||||||
|
def subscribe_device(self, callback, mac_address: str) -> None:
|
||||||
|
"""Register device and receive callbacks on state changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback (function): Function to call on state change.
|
||||||
|
mac_address (str): Mac address of device.
|
||||||
|
"""
|
||||||
|
self.register_device(mac_address)
|
||||||
|
self._device_callbacks[mac_address].append(callback)
|
||||||
|
|
||||||
|
def register_device(self, mac_address: str) -> None:
|
||||||
|
"""Make specified device known to bridge, to track device state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mac_address (str): Mac address of device.
|
||||||
|
"""
|
||||||
|
if mac_address not in self._device_callbacks.keys():
|
||||||
|
self._device_callbacks[mac_address] = []
|
||||||
|
self._device_states[mac_address] = None
|
||||||
|
|
||||||
|
def __get_known_devices__(self) -> list[dict]:
|
||||||
|
numberOfDevices = self._fritz_api.call_action(
|
||||||
|
"Hosts", "GetHostNumberOfEntries"
|
||||||
|
)["NewHostNumberOfEntries"]
|
||||||
|
devices = []
|
||||||
|
for i in range(numberOfDevices):
|
||||||
|
devices.append(
|
||||||
|
self._fritz_api.call_action("Hosts", "GetGenericHostEntry", NewIndex=i)
|
||||||
|
)
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def __get_specific_device__(self, mac_address: str) -> dict:
|
||||||
|
return self._fritz_api.call_action(
|
||||||
|
"Hosts", "GetSpecificHostEntry", NewMACAddress=mac_address
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_device_info(self, mac_address: str) -> FritzDeviceState | None:
|
||||||
|
"""Return latest device state or None if not registered.
|
||||||
|
Does not request new state, uses last known, cached state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mac_address (str): Mac address of device.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FritzDeviceState | None: Latest device state or None.
|
||||||
|
"""
|
||||||
|
return self._device_states[mac_address]
|
Loading…
Reference in a new issue