diff --git a/docs/api/items/gateway.md b/docs/api/items/gateway.md new file mode 100644 index 0000000..174414c --- /dev/null +++ b/docs/api/items/gateway.md @@ -0,0 +1,19 @@ +--- +title: Gateway module +summary: Provides the main interface to the OpenWebNet gateway. +--- + +The Gateway module provides the main interface to the OpenWebNet gateway. + +::: pyown.items.gateway.gateway.WhatGateway + options: + show_inheritance_diagram: true + +::: pyown.items.gateway.gateway.GatewayModel + options: + show_inheritance_diagram: true + +::: pyown.items.gateway.gateway.Gateway + options: + show_inheritance_diagram: true + diff --git a/examples/automation_02/main.py b/examples/automation_02/main.py index a49a22c..89a4e67 100644 --- a/examples/automation_02/main.py +++ b/examples/automation_02/main.py @@ -1,9 +1,8 @@ import asyncio import logging -from pyown.client import Client +from pyown.client import Client, SessionType from pyown.items.automation import Automation, WhatAutomation -from pyown.protocol import SessionType log = logging.getLogger(__name__) diff --git a/examples/gateway_01/README.md b/examples/gateway_01/README.md new file mode 100644 index 0000000..7a1c1ed --- /dev/null +++ b/examples/gateway_01/README.md @@ -0,0 +1,3 @@ +# light_01 + +This example demonstrates how to create a simple light source and control it. \ No newline at end of file diff --git a/examples/gateway_01/main.py b/examples/gateway_01/main.py new file mode 100644 index 0000000..5129f36 --- /dev/null +++ b/examples/gateway_01/main.py @@ -0,0 +1,62 @@ +import asyncio +import logging + +from pyown.client import Client +from pyown.items import Gateway + + +async def run(host: str, port: int, password: str): + client = Client( + host=host, + port=port, + password=password, + ) + + await client.start() + + gateway = Gateway( + client=client + ) + + # get ip address of the gateway + ip = await gateway.get_ip() + print(ip) + + # get the model of the gateway + model = await gateway.get_model() + print(model.name) + + # get datetime of the gateway + datetime = await gateway.get_datetime() + print(datetime) + + # get the kernel version of the gateway + kernel = await gateway.get_kernel_version() + print(kernel) + + await client.close() + + +def main(host: str, port: int, password: str): + # Set the logging level to DEBUG + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # Run the asyncio event loop + asyncio.run(run(host, port, password)) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--host", type=str, help="The host to connect to", default="192.168.1.35") + parser.add_argument("--port", type=int, help="The port to connect to", default=20000) + parser.add_argument("--password", type=str, help="The password to authenticate with", default="12345") + + args = parser.parse_args() + + main(args.host, args.port, args.password) diff --git a/examples/gateway_02/README.md b/examples/gateway_02/README.md new file mode 100644 index 0000000..7a1c1ed --- /dev/null +++ b/examples/gateway_02/README.md @@ -0,0 +1,3 @@ +# light_01 + +This example demonstrates how to create a simple light source and control it. \ No newline at end of file diff --git a/examples/gateway_02/main.py b/examples/gateway_02/main.py new file mode 100644 index 0000000..d82554c --- /dev/null +++ b/examples/gateway_02/main.py @@ -0,0 +1,53 @@ +import asyncio +import logging + +from black import datetime + +from pyown.client import Client, SessionType +from pyown.items import Gateway, WhatGateway + + +async def on_time_change(gateway: Gateway, time: datetime.time): + print(f"Time of the gateway is now {time}") + + +async def run(host: str, port: int, password: str): + client = Client( + host=host, + port=port, + password=password, + session_type=SessionType.EventSession + ) + + Gateway.register_callback( + WhatGateway.TIME, + on_time_change + ) + + await client.start() + await client.loop() + + +def main(host: str, port: int, password: str): + # Set the logging level to DEBUG + logging.basicConfig( + level=logging.WARN, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # Run the asyncio event loop + asyncio.run(run(host, port, password)) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--host", type=str, help="The host to connect to", default="192.168.1.35") + parser.add_argument("--port", type=int, help="The port to connect to", default=20000) + parser.add_argument("--password", type=str, help="The password to authenticate with", default="12345") + + args = parser.parse_args() + + main(args.host, args.port, args.password) diff --git a/pyown/client/client.py b/pyown/client/client.py index 642ad56..ce4a257 100644 --- a/pyown/client/client.py +++ b/pyown/client/client.py @@ -139,7 +139,7 @@ async def loop(self, *, client: BaseClient | None = None): for item_obj in BaseItem.__subclasses__(): if message.who == item_obj.who: try: - tasks = item_obj.call_callbacks(item, message) + tasks = await item_obj.call_callbacks(item, message) except InvalidMessage as e: log.warning(f"Message not supported {e.message}") else: diff --git a/pyown/items/__init__.py b/pyown/items/__init__.py index 2bd9ac1..c8a0015 100644 --- a/pyown/items/__init__.py +++ b/pyown/items/__init__.py @@ -1 +1,3 @@ -from .lighting import Light, Dimmer +from .lighting import * +from .automation import * +from .gateway import * diff --git a/pyown/items/automation/automation.py b/pyown/items/automation/automation.py index 3e78912..5a397df 100644 --- a/pyown/items/automation/automation.py +++ b/pyown/items/automation/automation.py @@ -93,7 +93,7 @@ def on_status_change(cls, callback: Callable[[Self, WhatAutomation], Coroutine[N cls._event_callbacks.setdefault(AutomationEvents.ALL, []).append(callback) @classmethod - def call_callbacks(cls, item: Self, message: BaseMessage) -> list[Task]: + async def call_callbacks(cls, item: Self, message: BaseMessage) -> list[Task]: tasks: list[Task] = [] if isinstance(message, NormalMessage): diff --git a/pyown/items/base.py b/pyown/items/base.py index bf5fc8b..fd9052f 100644 --- a/pyown/items/base.py +++ b/pyown/items/base.py @@ -76,7 +76,7 @@ def _create_tasks(funcs: list[CoroutineCallback], *args: Any) -> list[Task]: @classmethod @abstractmethod - def call_callbacks(cls, item: Self, message: BaseMessage) -> list[Task]: + async def call_callbacks(cls, item: Self, message: BaseMessage) -> list[Task]: """ Calls the registered callbacks for the event. Used internally by the client to dispatch the events to the correct callbacks. diff --git a/pyown/items/gateway/__init__.py b/pyown/items/gateway/__init__.py index e69de29..503e969 100644 --- a/pyown/items/gateway/__init__.py +++ b/pyown/items/gateway/__init__.py @@ -0,0 +1 @@ +from .gateway import * diff --git a/pyown/items/gateway/gateway.py b/pyown/items/gateway/gateway.py new file mode 100644 index 0000000..d8728c5 --- /dev/null +++ b/pyown/items/gateway/gateway.py @@ -0,0 +1,544 @@ +import datetime +import ipaddress +from asyncio import Task +from enum import StrEnum +from typing import Self, Any + +from ..base import BaseItem, CoroutineCallback +from ...client import BaseClient +from ...exceptions import InvalidMessage +from ...messages import DimensionResponse, BaseMessage, DimensionWriting +from ...tags import Who, What, Value, Where + +__all__ = [ + "Gateway", + "WhatGateway", + "GatewayModel", +] + + +class GatewayModel(StrEnum): + """ + This enum is used to define the various models of gateways that are supported by the library. + + This is not a complete list of all the gateways because there are many different models of gateways that are not + listed in the official documentation. + So, if you have a gateway not listed here, you can send an issue on GitHub. + + Attributes: + MHServer: + MH200: + F452: + F452V: + MHServer2: + H4684: + """ + MHServer = "2" + MH200 = "4" + F452 = "6" + F452V = "7" + MHServer2 = "11" + H4684 = "12" + HL4684 = "23" + + + +class WhatGateway(What, StrEnum): + """ + This enum is used to define the various types of data that can be retrieved from a gateway. + + Attributes: + TIME: get or set the time of the gateway and bus. + DATE: get or set the date of the gateway and bus. + IP_ADDRESS: get the IP address of the gateway. + NET_MASK: get the net mask of the gateway. + MAC_ADDRESS: get the MAC address of the gateway. + DEVICE_TYPE: get the device type of the gateway. + FIRMWARE_VERSION: get the firmware version of the gateway. + UPTIME: get the uptime of the gateway. + DATE_TIME: get or set the date and time of the gateway. + KERNEL_VERSION: get the linux kernel version of the gateway. + DISTRIBUTION_VERSION: get the linux distribution version of the gateway. + """ + TIME: str = "0" + DATE: str = "1" + IP_ADDRESS: str = "10" + NET_MASK: str = "11" + MAC_ADDRESS: str = "12" + DEVICE_TYPE: str = "15" + FIRMWARE_VERSION: str = "16" + UPTIME: str = "19" + DATE_TIME: str = "22" + KERNEL_VERSION: str = "23" + DISTRIBUTION_VERSION: str = "24" + + +EventMessage = DimensionResponse | DimensionWriting | None + + +class Gateway(BaseItem): + _who = Who.GATEWAY + + _event_callbacks: dict[WhatGateway, list[CoroutineCallback]] = {} + + def __init__(self, client: BaseClient, where: Where | str = ""): + """ + Initializes the item. + Args: + client: The client to use to communicate with the server. + """ + super().__init__(client, Where("")) + + async def _single_dim_req(self, what: WhatGateway) -> DimensionResponse: + messages = [msg async for msg in self.send_dimension_request(what)] + + resp = messages[0] + if not isinstance(resp, DimensionResponse): + raise InvalidMessage("The message is not a DimensionResponse message.") + else: + return resp + + @staticmethod + def _parse_own_timezone(t: Value) -> datetime.timezone: + if t.string == "": + # return UTC if the timezone is not set + return datetime.timezone.utc + + sign = t.string[0] + hours = int(t.string[1:3]) + + return datetime.timezone( + datetime.timedelta(hours=hours) if sign == "0" else -datetime.timedelta(hours=hours) + ) + + @staticmethod + def _tz_to_own_tz(tzinfo: datetime.tzinfo | None) -> Value: + if tzinfo is None: + raise ValueError("The timezone must be set in the datetime object.") + + tz = tzinfo.utcoffset(None) + if tz is None: + raise ValueError("The timezone must be set in the datetime object.") + + sign = "0" if tz >= datetime.timedelta(0) else "1" # type: ignore[union-attr] + hours = abs(tz.seconds) // 3600 # type: ignore[union-attr] + + t = Value(f"{sign}{hours:03d}") + return t + + async def get_time(self, *, message: EventMessage = None) -> datetime.time: + """ + Requests the time of the gateway and bus. + + Args: + message: The message to parse the time from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + datetime.time: The time of the gateway and bus. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.TIME) + + h_v, m_v, s_v, t_v = resp.values + + h = int(h_v.string) + m = int(m_v.string) + s = int(s_v.string) + + # parse the time with the timezone + bus_time = datetime.time( + h, + m, + s, + tzinfo=self._parse_own_timezone(t_v) + ) + + return bus_time + + # noinspection DuplicatedCode + async def set_time(self, bus_time: datetime.time): + """ + Sets the time of the gateway and bus. + Args: + bus_time: the time to set with the timezone. + + Raises: + ValueError: if bus_time.tzinfo is None or bus_time.tzinfo.utcoffset(None) is None. + + Returns: + None + """ + t = self._tz_to_own_tz(bus_time.tzinfo) + h = Value(f"{bus_time.hour:02d}") + m = Value(f"{bus_time.minute:02d}") + s = Value(f"{bus_time.second:02d}") + + await self.send_dimension_writing(WhatGateway.TIME, h, m, s, t) + + async def get_date(self, *, message: EventMessage = None) -> datetime.date: + """ + Requests the date of the gateway and bus. + + Args: + message: The message to parse the date from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + datetime.date: The date of the gateway and bus. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.DATE) + + w, d, m, a = resp.values + # w is the day of the week, but we don't need it + + day = int(d.string) + month = int(m.string) + year = int(a.string) + + bus_date = datetime.date(year, month, day) + + return bus_date + + async def set_date(self, bus_date: datetime.date): + """ + Sets the date of the gateway and bus. + Args: + bus_date: the date to set. + + Returns: + None + """ + d = Value(f"{bus_date.day:02d}") + m = Value(f"{bus_date.month:02d}") + a = Value(f"{bus_date.year}") + # calculate the day of the week, 00 is Sunday + if bus_date.weekday() == 6: + w = Value("00") + else: + w = Value(f"{bus_date.weekday() + 1:02d}") + + await self.send_dimension_writing(WhatGateway.DATE, w, d, m, a) + + async def get_ip(self, *, message: EventMessage = None) -> ipaddress.IPv4Address: + """ + Requests the IP address of the gateway. + + Args: + message: The message to parse the IP address from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + ipaddress.IPv4Address: The IP address of the gateway. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.IP_ADDRESS) + + oct1, oct2, oct3, oct4 = resp.values + + ip = ipaddress.IPv4Address(f"{int(oct1.string)}.{int(oct2.string)}.{int(oct3.string)}.{int(oct4.string)}") + return ip + + async def get_netmask(self, *, message: EventMessage = None) -> str: + """ + Requests the net mask of the gateway. + + Args: + message: The message to parse the net mask from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + str: The net mask of the gateway. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.NET_MASK) + + oct1, oct2, oct3, oct4 = resp.values + + return f"{int(oct1.string)}.{int(oct2.string)}.{int(oct3.string)}.{int(oct4.string)}" + + async def get_macaddress(self, *, message: EventMessage = None) -> str: + """ + Requests the MAC address of the gateway. + + Args: + message: The message to parse the MAC address from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + str: The MAC address of the gateway. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.MAC_ADDRESS) + + oct1, oct2, oct3, oct4, oct5, oct6 = resp.values + + mac = f"{oct1.string}:{oct2.string}:{oct3.string}:{oct4.string}:{oct5.string}:{oct6.string}" + return mac + + async def get_netinfo(self) -> ipaddress.IPv4Network: + """ + Combines the net mask and the IP address to get the network info. + Returns: + ipaddress.IPv4Network: The network info. + """ + ip = await self.get_ip() + netmask = await self.get_netmask() + + return ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False) + + async def get_model(self, *, message: EventMessage = None) -> GatewayModel: + """ + Requests the device type of the gateway. + + Args: + message: The message to parse the device type from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + GatewayModel: The device type of the gateway. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.DEVICE_TYPE) + + return GatewayModel(resp.values[0].string) + + async def get_firmware(self, *, message: EventMessage = None) -> str: + """ + Requests the firmware version of the gateway. + + Args: + message: The message to parse the firmware version from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + str: The firmware version of the gateway. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.FIRMWARE_VERSION) + + v = resp.values[0].string + r = resp.values[1].string + b = resp.values[2].string + + return f"{v}.{r}.{b}" + + async def get_uptime(self, *, message: EventMessage = None) -> datetime.timedelta: + """ + Requests the uptime of the gateway. + + Args: + message: The message to parse the uptime from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + datetime.timedelta: The uptime of the gateway. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.UPTIME) + + d = int(resp.values[0].string) + h = int(resp.values[1].string) + m = int(resp.values[2].string) + s = int(resp.values[3].string) + + uptime = datetime.timedelta(days=d, hours=h, minutes=m, seconds=s) + return uptime + + async def get_datetime(self, *, message: EventMessage = None) -> datetime.datetime: + """ + Requests the date and time of the gateway. + + Args: + message: The message to parse the date and time from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + datetime.datetime: The date and time of the gateway. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.DATE_TIME) + + h = int(resp.values[0].string) + m = int(resp.values[1].string) + s = int(resp.values[2].string) + t = resp.values[3] + + # w = int(resp.values[4].string) + d = int(resp.values[5].string) + mo = int(resp.values[6].string) + y = int(resp.values[7].string) + + # parse the time with the timezone + bus_time = datetime.datetime( + y, mo, d, h, m, s, + tzinfo=self._parse_own_timezone(t) + ) + + return bus_time + + # noinspection DuplicatedCode + async def set_datetime(self, bus_time: datetime.datetime): + """ + Sets the date and time of the gateway. + + Args: + bus_time: the date and time to set with the timezone. + + Raises: + ValueError: if bus_time.tzinfo is None or bus_time.tzinfo.utcoffset(None) is None. + + Returns: + None + """ + t = self._tz_to_own_tz(bus_time.tzinfo) + h = Value(f"{bus_time.hour:02d}") + m = Value(f"{bus_time.minute:02d}") + s = Value(f"{bus_time.second:02d}") + + d = Value(f"{bus_time.day:02d}") + mo = Value(f"{bus_time.month:02d}") + y = Value(f"{bus_time.year}") + + await self.send_dimension_writing(WhatGateway.DATE_TIME, h, m, s, t, d, mo, y) + + async def get_kernel_version(self, *, message: EventMessage = None) -> str: + """ + Requests the linux kernel version of the gateway. + + Args: + message: The message to parse the kernel version from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + str: The linux kernel version used by the gateway. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.KERNEL_VERSION) + + v = resp.values[0].string + r = resp.values[1].string + b = resp.values[2].string + + return f"{v}.{r}.{b}" + + async def get_distribution_version(self, *, message: EventMessage = None) -> str: + """ + Requests the os distribution version of the gateway. + + Args: + message: The message to parse the distribution version from. If not provided, send a request to the gateway. + It's used by call_callbacks to parse the message. + + Returns: + str: The os distribution version used by the gateway. + """ + if message is not None: + resp = message + else: + resp = await self._single_dim_req(WhatGateway.DISTRIBUTION_VERSION) + + v = resp.values[0].string + r = resp.values[1].string + b = resp.values[2].string + + return f"{v}.{r}.{b}" + + # this does not follow the same pattern as the other item class because that would add too much complexity, + # and event messages for the gateway are very rarely sent + @classmethod + def register_callback(cls, what: WhatGateway, callback: CoroutineCallback): + """ + Register a callback for a specific event. + + Args: + what: The event to register the callback for. + callback: The callback to call when the event occurs. + + Returns: + None + """ + if what not in cls._event_callbacks: + cls._event_callbacks[what] = [] + + cls._event_callbacks[what].append(callback) + + @classmethod + async def call_callbacks(cls, item: Self, message: BaseMessage) -> list[Task]: + tasks = [] + + if isinstance(message, DimensionWriting): + # convert the DimensionWriting message to a DimensionResponse message + # noinspection PyTypeChecker + message = DimensionResponse( + ( + message.who, + message.where, + message.dimension, + *message.values + ) # type: ignore[arg-type] + ) + + if isinstance(message, DimensionResponse): + what = WhatGateway(message.dimension.string) + callbacks = cls._event_callbacks.get(what, []) + + # noinspection PyUnusedLocal + args: Any = None + match what: + case WhatGateway.TIME: + args = await item.get_time(message=message) + case WhatGateway.DATE: + args = await item.get_date(message=message) + case WhatGateway.IP_ADDRESS: + args = await item.get_ip(message=message) + case WhatGateway.NET_MASK: + args = await item.get_netmask(message=message) + case WhatGateway.MAC_ADDRESS: + args = await item.get_macaddress(message=message) + case WhatGateway.DEVICE_TYPE: + args = await item.get_model(message=message) + case WhatGateway.FIRMWARE_VERSION: + args = await item.get_firmware(message=message) + case WhatGateway.UPTIME: + args = await item.get_uptime(message=message) + case WhatGateway.DATE_TIME: + args = await item.get_datetime(message=message) + case WhatGateway.KERNEL_VERSION: + args = await item.get_kernel_version(message=message) + case WhatGateway.DISTRIBUTION_VERSION: + args = await item.get_distribution_version(message=message) + case _: + return [] + + tasks += cls._create_tasks( + callbacks, + item, + *args + ) + else: + raise InvalidMessage("The message is not a DimensionResponse message.") + + return tasks + diff --git a/pyown/items/lighting/base.py b/pyown/items/lighting/base.py index 44e16d9..cf58861 100644 --- a/pyown/items/lighting/base.py +++ b/pyown/items/lighting/base.py @@ -227,7 +227,7 @@ def on_temporization_change(cls, callback: Callable[[Self, int, int, int], Corou cls._event_callbacks.setdefault(LightEvents.LIGHT_TEMPORIZATION, []).append(callback) @classmethod - def call_callbacks(cls, item: BaseItem, message: BaseMessage) -> list[Task]: + async def call_callbacks(cls, item: BaseItem, message: BaseMessage) -> list[Task]: tasks: list[Task] = [] if isinstance(message, DimensionResponse): diff --git a/pyown/items/utils.py b/pyown/items/utils.py index 51bf1d6..cfe1dd3 100644 --- a/pyown/items/utils.py +++ b/pyown/items/utils.py @@ -2,6 +2,7 @@ from .automation import Automation from .base import BaseItem +from .gateway import Gateway from .lighting import Light from ..tags import Who @@ -13,5 +14,6 @@ ITEM_TYPES: Final[dict[Who, Type[BaseItem]]] = { Who.LIGHTING: Light, Who.AUTOMATION: Automation, + Who.GATEWAY: Gateway } """A dictionary that maps the Who tag to the corresponding item class."""