diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 89a81fe7..8f58ffe6 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -11,14 +11,20 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Fetch tags + run: | + git fetch --prune --unshallow + git fetch --depth=1 origin +refs/tags/*:refs/tags/* + - uses: actions/setup-python@v4 - name: Install dependencies run: | - pip install pyplumio sphinx sphinx-book-theme + pip install pyplumio build sphinx sphinx-book-theme - name: Sphinx build run: | + python -m build --sdist sphinx-build ./docs/source ./docs/build - name: Deploy diff --git a/README.md b/README.md index 1616f2e8..7aed0a97 100644 --- a/README.md +++ b/README.md @@ -19,17 +19,12 @@ Devices can be connected directly via RS-485 to USB adapter or through network b ![RS-485 converters](https://raw.githubusercontent.com/denpamusic/PyPlumIO/main/images/rs485.png) ## Table of contents -- [Usage](https://pyplumio.denpa.pro/usage.html) - - [Connecting](https://pyplumio.denpa.pro/usage.html#connecting) - - [Reading](https://pyplumio.denpa.pro/usage.html#reading) - - [Writing](https://pyplumio.denpa.pro/usage.html#writing) - - [Parameters](https://pyplumio.denpa.pro/usage.html#parameters) - - [Callbacks](https://pyplumio.denpa.pro/usage.html#callbacks) - - [Filters](https://pyplumio.denpa.pro/usage.html#filters) - - [Regulator Data](https://pyplumio.denpa.pro/usage.html#regulator-data) - - [Mixers/Thermostats](https://pyplumio.denpa.pro/usage.html#mixers-thermostats) - - [Schedules](https://pyplumio.denpa.pro/usage.html#schedules) - - [Network Information](https://pyplumio.denpa.pro/usage.html#network-information) +- [Connecting](https://pyplumio.denpa.pro/connecting.html) +- [Reading](https://pyplumio.denpa.pro/reading.html) +- [Writing](https://pyplumio.denpa.pro/writing.html) +- [Callbacks](https://pyplumio.denpa.pro/callbacks.html) +- [Mixers/Thermostats](https://pyplumio.denpa.pro/mixers_thermostats.html) +- [Schedules](https://pyplumio.denpa.pro/schedules.html) - [Protocol](https://pyplumio.denpa.pro/protocol.html) - [Frame Structure](https://pyplumio.denpa.pro/protocol.html#frame-structure) - [Requests and Responses](https://pyplumio.denpa.pro/protocol.html#requests-and-responses) diff --git a/docs/source/callbacks.rst b/docs/source/callbacks.rst new file mode 100644 index 00000000..bc0b7389 --- /dev/null +++ b/docs/source/callbacks.rst @@ -0,0 +1,223 @@ +Callbacks +========= + +Description +----------- + +PyPlumIO has an extensive event driven system where each device +property is an event that you can subscribe to. + +When you subscribe callback to the event, your callback will be +awaited with the property value each time the property is received +from the device by PyPlumIO. + +.. note:: + + Callbacks must be coroutines defined with **async def**. + +Subscribing to events +--------------------- + +.. autofunction:: pyplumio.devices.Device.subscribe + +.. autofunction:: pyplumio.devices.Device.subscribe_once + +To remove the previously registered callback, use the +``unsubcribe()`` method. + +.. autofunction:: pyplumio.devices.Device.unsubscribe + +Filters +------- + +PyPlumIO's callback system can be further improved upon by +using built-in filters. Filters allow you to specify when you want your +callback to be awaited. + +All built-in filters are described below. + +.. autofunction:: pyplumio.filters.aggregate + +This filter aggregates value for specified amount of seconds and +then calls the callback with the sum of values collected. + +.. code-block:: python + + from pyplumio.filters import aggregate + + # Await the callback with the fuel burned during 30 seconds. + ecomax.subscribe("fuel_burned", aggregate(my_callback, seconds=30)) + + +.. autofunction:: pyplumio.filters.on_change + +Normally callbacks are awaited each time the PyPlumIO receives data +from the device, regardless of whether value is changed or not. + +With this filter it's possible to only await the callback when +value is changed. + +.. code-block:: python + + from pyplumio.filter import on_change + + # Await the callback once heating_temp value is changed since + # last call. + ecomax.subscribe("heating_temp", on_change(my_callback)) + +.. autofunction:: pyplumio.filters.debounce + +This filter will only await the callback once value is settled across +multiple calls, specified in ``min_calls`` argument. + +.. code-block:: python + + from pyplumio.filter import debounce + + # Await the callback once outside_temp stays the same for three + # consecutive times it's received by PyPlumIO. + ecomax.subscribe("outside_temp", debounce(my_callback, min_calls=3)) + +.. autofunction:: pyplumio.filters.throttle + +This filter limits how often your callback will be awaited. + +.. code-block:: python + + from pyplumio.filter import throttle + + # Await the callback once per 5 seconds, regardless of + # how often outside_temp value is being processed by PyPlumIO. + ecomax.subscribe("outside_temp", throttle(my_callback, seconds=5)) + +.. autofunction:: pyplumio.filters.delta + +Instead of raw value, this filter awaits callback with value change. + +It can be used with numeric values, dictionaries, tuples or lists. + +.. code-block:: python + + from pyplumio.filter import delta + + # Await the callback with difference between values in current + # and last await. + ecomax.subscribe("outside_temp", delta(my_callback)) + +.. autofunction:: pyplumio.filters.custom + +This filter allows to specify filter function that will be called +every time the value is received from the controller. + +A callback is awaited once the filter function returns true. + +.. code-block:: python + + from pyplumio.filter import custom + + # Await the callback when temperature is higher that 10 degrees + # Celsius. + ecomax.subscribe("outside_temp", custom(my_callback, lambda x: x > 10)) + +Callbacks Examples +------------------ + +In this example we'll use filter chaining to achieve more complex event +processing. + +.. code-block:: python + + from pyplumio.filter import throttle, on_change + + + async def my_callback(value) -> None: + """Prints current heating temperature.""" + print(f"Heating Temperature: {value}") + + + async def main(): + """Subscribes callback to the current heating temperature.""" + async with pyplumio.open_tcp_connection("localhost", 8899) as conn: + + # Get the ecoMAX device. + ecomax = await conn.get("ecomax") + + # Await the callback on value change but no faster than + # once per 5 seconds. + ecomax.subscribe("heating_temp", throttle(on_change(my_callback), seconds=5)) + + # Wait until disconnected (forever) + await conn.wait_until_done() + + + asyncio.run(main()) + + +In the example below, ``my_callback`` with be called with +current heating temperature on every SensorDataMessage. + +.. code-block:: python + + import asyncio + + import pyplumio + + + async def my_callback(value) -> None: + """Prints current heating temperature.""" + print(f"Heating Temperature: {value}") + + + async def main(): + """Subscribes callback to the current heating temperature.""" + async with pyplumio.open_tcp_connection("localhost", 8899) as conn: + + # Get the ecoMAX device. + ecomax = await conn.get("ecomax") + + # Subscribe my_callback to heating_temp event. + ecomax.subscribe("heating_temp", my_callback) + + # Wait until disconnected (forever) + await conn.wait_until_done() + + + asyncio.run(main()) + +In the example below, ``my_callback`` with be called with +current target temperature once and ``my_callback2`` will be called +when heating temperature will change more the 0.1 degrees Celsius. + +.. code-block:: python + + import asyncio + + import pyplumio + from pyplumio.filters import on_change + + + async def my_callback(value) -> None: + """Prints heating target temperature.""" + print(f"Target Temperature: {value}") + + async def my_callback2(value) -> None: + """Prints current heating temperature.""" + print(f"Current Temperature: {value}") + + async def main(): + """Subscribes callback to the current heating temperature.""" + async with pyplumio.open_tcp_connection("localhost", 8899) as conn: + # Get the ecoMAX device. + ecomax = await conn.get("ecomax") + + # Subscribe my_callback to heating_target_temp event. + ecomax.subscribe_once("heating_target_temp", my_callback) + + # Subscribe my_callback2 to heating_temp changes. + ecomax.subscribe("heating_temp", on_change(my_callback2)) + + # Wait until disconnected (forever) + await conn.wait_until_done() + + + asyncio.run(main()) \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 3a2ca591..32cf457e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,4 +1,9 @@ from importlib.metadata import version as pkg_version +import os +import sys + +sys.path.insert(0, os.path.abspath("../../")) + # Configuration file for the Sphinx documentation builder. # @@ -21,6 +26,7 @@ "sphinx.ext.autodoc", "sphinx.ext.autosummary", "sphinx.ext.githubpages", + "sphinx.ext.autosectionlabel", ] autosummary_generate = True diff --git a/docs/source/connecting.rst b/docs/source/connecting.rst new file mode 100644 index 00000000..a1343c18 --- /dev/null +++ b/docs/source/connecting.rst @@ -0,0 +1,201 @@ +Connecting +========== + +Opening the connection +---------------------- + +.. autofunction:: pyplumio.open_tcp_connection + +With this you can connect to the controller remotely via RS-485 to +Ethernet/WiFi converters, which are readily available online or +can be custom built using wired connection and ser2net software. + +.. code-block:: python + + import pyplumio + + async with pyplumio.open_tcp_connection("localhost", port=8899) as conn: + ... + + +.. note:: + + Although **async with** syntax is preferred, you can initiate connection without it. + See following :ref:`examples` for more information. + +.. autofunction:: pyplumio.open_serial_connection + +You can connect to the ecoMAX controller via wired connection through +RS-485 to USB or RS-485 to TTL adapters, that are connected directly +to the device running PyPlumIO. + +You MUST not connect RS-485 lines directly to the UART outputs of your +PC or you'll risk damaging your PC/controller or the ecoMAX controller itself. + +.. code-block:: python + + import pyplumio + + async with pyplumio.open_serial_connection("/dev/ttyUSB0", baudrate=115200) as conn: + ... + +Protocols +--------- + +Connection helpers and classes allow to specify custom protocol to handle data, once +connection is established. + +All protocols should inherit following abstract base class: + +.. autoclass:: pyplumio.protocol.Protocol + +Most of documentation assumes that protocol is left as is, which +is by default AsyncProtocol. However, setting different protocol +allows for more fine-grained control over data processing. + +In following example we'll set protocol to DummyProtocol, request and +output alerts and close the connection without an additional overhead of +working with device classes and queues. + +.. code-block:: python + + import asyncio + import pyplumio + + from pyplumio.const import DeviceType + from pyplumio.protocol import DummyProtocol + from pyplumio.frames import requests, responses + + async def main(): + """Open a connection and request alerts.""" + async with pyplumio.open_tcp_connection( + host="localhost", + port=8899, + protocol=DummyProtocol + ) as connection: + await connection.write(request.AlertsRequest(recipient=DeviceType.ECOMAX)) + + while connection.connected: + if isinstance((frame := await connection.read()), responses.AlertsResponse): + print(frame.data) + break + + asyncio.run(main()) + +All built-in protocols are listed below. + +.. autoclass:: pyplumio.protocol.DummyProtocol +.. autoclass:: pyplumio.protocol.AsyncProtocol + +Network Information +------------------- +When opening the connection, you can send ethernet and wireless +network information to the ecoMAX controller using one or both +of data classes below. + +.. autoclass:: pyplumio.structures.network_info.EthernetParameters + :members: + +.. autoclass:: pyplumio.structures.network_info.WirelessParameters + :members: + + .. autoattribute:: status + +Once set, network information will be shown in information section +on the ecoMAX panel. + +In the example below, we'll set both ethernet and wireless parameters. + +.. code-block:: python + + import pyplumio + from pyplumio.const import EncryptionType + + + async def main(): + """Initialize a connection with network parameters.""" + async with pyplumio.open_tcp_connection( + host="localhost", + port=8899, + ethernet_parameters=pyplumio.EthernetParameters( + ip="10.10.1.100", + netmask="255.255.255.0", + gateway="10.10.1.1", + ), + wireless_parameters=pyplumio.WirelessParameters( + ip="10.10.2.100", + netmask="255.255.255.0", + gateway="10.10.2.1", + ssid="My SSID", + encryption=EncryptionType.WPA2, + signal_quality=100, + ), + ) as connection: + ... + + +Connection Examples +------------------- + +The following example illustrates opening a TCP connection using Python's +context manager. + +.. code-block:: python + + import asyncio + import logging + + import pyplumio + + + _LOGGER = logging.getLogger(__name__) + + + async def main(): + """Opens the connection and gets the ecoMAX device.""" + async with pyplumio.open_tcp_connection("localhost", port=8899) as conn: + try: + # Get the ecoMAX device within 10 seconds or timeout. + ecomax = await conn.get("ecomax", timeout=10) + except asyncio.TimeoutError: + # If device times out, log the error. + _LOGGER.error("Failed to get the device within 10 seconds") + + + # Run the coroutine in asyncio event loop. + asyncio.run(main()) + +The following example illustrates opening a TCP connection without +using Python's context manager. + +.. code-block:: python + + import asyncio + import logging + + import pyplumio + + + _LOGGER = logging.getLogger(__name__) + + + async def main(): + """Opens the connection and gets the ecoMAX device.""" + connection = pyplumio.open_tcp_connection("localhost", port=8899) + + # Connect to the device. + await connection.connect() + + try: + # Get the ecoMAX device within 10 seconds or timeout. + ecomax = await connection.get("ecomax", timeout=10) + except asyncio.TimeoutError: + # If device times out, log the error. + _LOGGER.error("Failed to get the device within 10 seconds") + + # Close the connection. + await connection.close() + + + # Run the coroutine in asyncio event loop. + asyncio.run(main()) diff --git a/docs/source/index.rst b/docs/source/index.rst index fcdb0f64..458a1aa1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -44,7 +44,12 @@ Documentation :maxdepth: 2 :caption: Contents: - usage + connecting + reading + writing + callbacks + mixers_thermostats + schedules protocol .. autosummary:: diff --git a/docs/source/mixers_thermostats.rst b/docs/source/mixers_thermostats.rst new file mode 100644 index 00000000..8d1559a4 --- /dev/null +++ b/docs/source/mixers_thermostats.rst @@ -0,0 +1,76 @@ +Mixers/Thermostats +================== + +About Mixers/Thermostats +------------------------ + +If your ecoMAX controller have connected devices such as mixers +and thermostats, you can access them through their +respective properties. + +.. code-block:: python + + ecomax = await conn.get("ecomax") + thermostats = await ecomax.get("thermostats") + mixers = await ecomax.get("mixers") + +Result of this call will be a dictionary of ``Mixer`` or ``Thermostat`` +object keyed by the device indexes. + +Both classes inherit the ``Device`` class and provide access to +getter/setter functions, callback support and access to the +``Device.data`` property. + +Both mixers and thermostats can also have editable parameters. + +Mixer Examples +-------------- + +In the following example, we'll get single mixer by it's index, +get it's current_temp property and set it's target temperature to +50 degrees Celsius. + +.. code-block:: python + + from pyplumio.devices import Mixer + + # Get the ecoMAX device. + ecomax = await conn.get("ecomax") + + # Get connected mixers. + mixers = await ecomax.get("mixers") + + # Get single mixer. + mixer: Mixer = mixers[1] + + # Get current mixer temperature. + mixer_current_temp = await mixer.get("current_temp") + + # Set mixer target temperature to 50 degrees Celsius. + await mixer.set("mixer_target_temp", 50) + +Thermosat Examples +------------------ + +In the following example, we'll get single thermostat by it's index, +get current room temperature and set daytime target temperature to 20 +degrees Celsius. + +.. code-block:: python + + from pyplumio.device import Thermostat + + # Get the ecoMAX device. + ecomax = await conn.get("ecomax") + + # Get connected thermostats. + thermostats = await ecomax.get("thermostats") + + # Get single thermostat. + thermostat: Thermostat = thermostats[1] + + # Get current room temperature. + thermostat_current_temp = await thermostat.get("current_temp") + + # Set day target temperature to 20 degrees Celsius. + await thermostat.set("day_target_temp", 20) diff --git a/docs/source/protocol.rst b/docs/source/protocol.rst index fcd73983..d557ff9b 100644 --- a/docs/source/protocol.rst +++ b/docs/source/protocol.rst @@ -1,5 +1,9 @@ Protocol ======== + +About Protocol +-------------- + Plum devices use RS-485 standard for communication. Each frame consists of header (7 bytes), message type (1 byte), diff --git a/docs/source/reading.rst b/docs/source/reading.rst new file mode 100644 index 00000000..546e1f03 --- /dev/null +++ b/docs/source/reading.rst @@ -0,0 +1,152 @@ +Reading +======= + +Dataset +------- + +.. autoattribute:: pyplumio.devices.Device.data + +You can use this property get a full device dataset. + +.. code-block:: python + + ecomax = await conn.get("ecomax") + print(ecomax.data) + +In a pinch, you can also use this attribute to directly access the data, +but this is not recommended, please use getters instead. + +Getters +------- + +.. autofunction:: pyplumio.devices.Device.get + +This method can be used to get a single item from the device dataset. + +The following example will print out current feed water temperature +and close the connection. + +.. code-block:: python + + import asyncio + + import pyplumio + + async def main(): + """Read the current heating temperature.""" + async with pyplumio.open_tcp_connection("localhost", 8899) as conn: + # Get the ecoMAX device. + ecomax = await conn.get("ecomax") + + # Get the heating temperature. + heating_temp = await ecomax.get("heating_temp") + print(heating_temp) + ... + + + asyncio.run(main()) + +.. autofunction:: pyplumio.devices.Device.get_nowait + +This method can be used to get single item from device dataset without +waiting until it becomes available. + +The following example will print out current feed water temperature if +it is available, otherwise it'll print ``60``. + +.. code-block:: python + + # Print the heating temperature or 60, if property is not available. + heating_temp = ecomax.get_nowait("heating_temp", default=60) + print(heating_temp) + +.. autofunction:: pyplumio.devices.Device.wait_for + +You can use this method to wait until the value is available and +then directly access the attribute under the device object. + +The following example will wait until current feed water temperature is +available and print it out. + +.. code-block:: python + + # Wait until the 'heating_temp' property becomes available. + await ecomax.wait_for("heating_temp") + + # Output the 'heating_temp' property. + print(ecomax.heating_temp) + +Regulator Data +-------------- +Regulator Data messages are broadcasted by the ecoMAX controller +once per second and allow access to some device-specific +information that isn't available elsewhere. + +.. note:: + + RegData is device-specific. Each ecoMAX controller has different + keys and their associated meanings. + +It's represented by a dictionary mapped with numerical keys. + +RegData can be accessed via the **regdata** property. + +.. code-block:: python + + from pyplumio.structures.regulator_data import RegulatorData + + ecomax = await conn.get("ecomax") + regdata: RegulatorData = await ecomax.get("regdata") + +The ``RegulatorData`` object supports all aforementioned methods of +getting the data. + +.. code-block:: python + + # Get regulator data with the 1280 key. + heating_target = regdata.get_nowait(1280) + +To see every value stored in the RegulatorData object, you can check +``RegulatorData.data`` property. + +Reading Examples +---------------- + +The following example make uses of all available methods to get +heating current and target temperatures and device state and outputs it +to the terminal. + +.. code-block:: python + + import asyncio + + import pyplumio + + + async def main(): + """Read current temp, target temp and device state from + the regdata. + """ + async with pyplumio.open_tcp_connection("localhost", 8899) as conn: + # Get the ecoMAX device. + ecomax = await conn.get("ecomax") + + # Get the heating temperature if it is available, or return + # default value (65). + heating_temp = ecomax.get_nowait("heating_temp", default=65) + + # Wait for the heating temperature and get it's value. + heating_target_temp = await ecomax.get("heating_target_temp") + + # Wait until regulator data is available, and grab key 1792. + await ecomax.wait_for("regdata") + status = ecomax.regdata.get(1792) + + print( + f"Current temp: {heating_temp}, " + f"target temp: {heating_target_temp}, " + f"status: {status}." + ) + + + asyncio.run(main()) diff --git a/docs/source/schedules.rst b/docs/source/schedules.rst new file mode 100644 index 00000000..319e66ee --- /dev/null +++ b/docs/source/schedules.rst @@ -0,0 +1,137 @@ + +Schedules +========= + +About Schedules +--------------- + +You can set different device schedules, enable or disable them and +change their associated parameters. + +Enabling/Disabling Schedule +--------------------------- + +To disable the schedule, set **schedule_{schedule_name}_switch** parameter +parameter to `off`, to enable it set the parameter to `on`. + +The following example illustrates how you can turn heating schedule +on or off. + +.. code-block:: python + + # Turn on the heating schedule. + await ecomax.set("schedule_heating_switch", "on") + + # Turn off the heating schedule. + await ecomax.set("schedule_heating_switch", "off") + +Changing Schedule Parameter +--------------------------- + +To change associated parameter value, change +**schedule_{schedule_name}_parameter** property. + +Use following example to lower night time heating +temperature by 10 degrees Celsius. + +.. code-block:: python + + # Lower heating temperature by 10 C at night time. + await ecomax.set("schedule_heating_parameter", 10) + +Setting Schedule +---------------- + +To set the schedule, you can use ``set_state(state)``, ``set_on()`` or +``set_off()`` methods and call ``commit()`` to send changes to the +device. + +This example sets nighttime mode for Monday from 00:00 to 07:00 and +switches back to daytime mode from 07:00 to 00:00. + +.. code-block:: python + + schedules = await ecomax.get("schedules") + heating_schedule = schedules["heating"] + heating_schedule.monday.set_off(start="00:00", end="07:00") + heating_schedule.monday.set_on(start="07:00", end="00:00") + heating_schedule.commit() + +For clarity sake, you might want to use ``STATE_NIGHT`` and +``STATE_DAY`` constants from ``pyplumio.helpers.schedule`` module. + +.. code-block:: python + + from pyplumio.helpers.schedule import STATE_NIGHT + + heating_schedule.monday.set_state(STATE_NIGHT, "00:00", "07:00") + +You may also omit one of the boundaries. +The other boundary is then set to the end or start of the day. + +.. code-block:: python + + heating_schedule.monday.set_on(start="07:00") + # is equivalent to + heating_schedule.monday.set_on(start="07:00", end="00:00") + +.. code-block:: python + + heating_schedule.monday.set_off(end="07:00") + # is equivalent to + heating_schedule.monday.set_off(start="00:00", end="07:00") + +This can be used to set state for a whole day with +``heating_schedule.monday.set_on()``. + +To set schedule for all days you can iterate through the +Schedule object: + +.. code-block:: python + + schedules = await ecomax.get("schedules") + heating_schedule = schedules["heating"] + + for weekday in heating_schedule: + # Set a nighttime mode from 00:00 to 07:00 + weekday.set_on("00:00", "07:00") + # Set a daytime mode from 07:00 to 00:00 + weekday.set_off("07:00", "00:00") + + # Commit changes to the device. + heating_schedule.commit() + +Schedule Examples +----------------- + +.. code-block:: python + + import pyplumio + from pyplumio.helpers.schedule import STATE_DAY, STATE_NIGHT + + + async def main(): + """Set a device schedule.""" + async with pyplumio.open_tcp_connection("localhost", 8899) as connection: + ecomax = await connection.get("ecomax") + schedules = await ecomax.get("schedules") + heating_schedule = schedules["heating"] + + # Turn the heating schedule on. + await ecomax.set("schedule_heating_switch", "on") + + # Drop the heating temperature by 10 degrees in the nighttime mode. + await ecomax.set("schedule_heating_parameter", 10) + + for weekday in heating_schedule: + weekday.set_state(STATE_DAY, "00:00", "00:30") + weekday.set_state(STATE_NIGHT, "00:30", "09:00") + weekday.set_state(STATE_DAY, "09:00", "00:00") + + # There will be no nighttime mode on sunday. + heating_schedule.sunday.set_state(STATE_DAY) + + heating_schedule.commit() + + + asyncio.run(main()) diff --git a/docs/source/usage.rst b/docs/source/usage.rst deleted file mode 100644 index a4562bdd..00000000 --- a/docs/source/usage.rst +++ /dev/null @@ -1,768 +0,0 @@ -Usage -===== - -Connecting ----------- - -TCP -^^^ - -.. autofunction:: pyplumio.open_tcp_connection - -You can connect to the controller remotely via RS-485 to Ethernet/WiFi converters, -which are readily available online or can be custom built using wired connection -and ser2net software. - -.. code-block:: python - - import pyplumio - - async with pyplumio.open_tcp_connection("localhost", port=8899) as conn: - ... - - -.. note:: - - Although **async with** syntax is preferred, you can initiate connection without it. - See following examples for more information. - -RS-485 -^^^^^^ - -.. autofunction:: pyplumio.open_serial_connection - -You can connect to the ecoMAX controller via wired connection through -RS-485 to USB or RS-485 to TTL adapters, that are connected directly -to the device running PyPlumIO. - -You MUST not connect RS-485 lines directly to the UART outputs of your -PC or you'll risk damaging your PC/controller or the ecoMAX controller itself. - -.. code-block:: python - - import pyplumio - - async with pyplumio.open_serial_connection("/dev/ttyUSB0", baudrate=115200) as conn: - ... - -Examples -^^^^^^^^ - -The following example illustrates opening the connection using Python's -context manager. - -.. code-block:: python - - import asyncio - import logging - - import pyplumio - - - _LOGGER = logging.getLogger(__name__) - - - async def main(): - """Opens the connection and gets the ecoMAX device.""" - async with pyplumio.open_tcp_connection("localhost", port=8899) as conn: - try: - # Get the ecoMAX device within 10 seconds or timeout. - ecomax = await conn.get("ecomax", timeout=10) - except asyncio.TimeoutError: - # If device times out, log the error. - _LOGGER.error("Failed to get the device within 10 seconds") - - - # Run the coroutine in asyncio event loop. - asyncio.run(main()) - -The following example illustrates opening the connection without -using Python's context manager. - -.. code-block:: python - - import asyncio - import logging - - import pyplumio - - - _LOGGER = logging.getLogger(__name__) - - - async def main(): - """Opens the connection and gets the ecoMAX device.""" - connection = pyplumio.open_tcp_connection("localhost", port=8899) - - # Connect to the device. - await connection.connect() - - try: - # Get the ecoMAX device within 10 seconds or timeout. - ecomax = await connection.get("ecomax", timeout=10) - except asyncio.TimeoutError: - # If device times out, log the error. - _LOGGER.error("Failed to get the device within 10 seconds") - - # Close the connection. - await connection.close() - - - # Run the coroutine in asyncio event loop. - asyncio.run(main()) - -Reading -------- -PyPlumIO stores collected data from the ecoMAX controller in the data -property of the device class. - -.. autoattribute:: pyplumio.devices.Device.data - -You can access this property to check what data is currently available. - -.. code-block:: python - - ecomax = await conn.get("ecomax") - print(ecomax.data) - -You can also use the data attribute to access the -device properties, but preferred way is through the use of the -getter method. - -.. autofunction:: pyplumio.devices.Device.get - -The following example will print out current feed water temperature -and close the connection. - -.. code-block:: python - - import asyncio - - import pyplumio - - async def main(): - """Opens the connection and reads heating temperature.""" - async with pyplumio.open_tcp_connection("localhost", 8899) as conn: - # Get the ecoMAX device. - ecomax = await conn.get("ecomax") - - # Get the heating temperature. - heating_temp = await ecomax.get("heating_temp") - ... - - - asyncio.run(main()) - -Non-blocking getter -^^^^^^^^^^^^^^^^^^^ - -If you don't want to wait for a value to become available, you can -use the non-blocking getter. In case the value is not available, default -value will be returned instead. - -.. autofunction:: pyplumio.devices.Device.get_nowait - -The following example will print out current feed water temperature if -it is available, otherwise it'll print ``60``. - -.. code-block:: python - - # Print the heating temperature or 60 it property is not available. - heating_temp = ecomax.get_nowait("heating_temp", default=60) - print(heating_temp) - -Waiting for a value -^^^^^^^^^^^^^^^^^^^ - -You can also wait until value is available and then directly access -the attribute under the device object. - -.. autofunction:: pyplumio.devices.Device.wait_for - -The following example will wait until current feed water temperature is -available and print it out. - -.. code-block:: python - - # Wait until the 'heating_temp' property becomes available. - await ecomax.wait_for("heating_temp") - - # Output the 'heating_temp' property. - print(ecomax.heating_temp) - -Writing -------- - -There's multiple ways to write data to the device. - -Blocking setter -^^^^^^^^^^^^^^^ - -.. autofunction:: pyplumio.devices.Device.set - -When using blocking setter, you will get the result -represented by the boolean value. **True** if write was successful, -**False** otherwise. - -.. code-block:: python - - result = await ecomax.set("heating_target_temp", 65) - if result: - print("Heating target temperature was successfully set.") - else: - print("Error while trying to set heating target temperature.") - -Non-blocking setter -^^^^^^^^^^^^^^^^^^^ - -You can't access result, when using non-blocking setter as task is done -in the background. You will, however, still get error message in the log -in case of failure. - -.. code-block:: python - - ecomax.set_nowait("heating_target_temp", 65) - -Parameters ----------- - -It's possible to get the Parameter object and then modify it using -it's own setter. When using the parameter object, you don't -need to pass the parameter name. - -.. autofunction:: pyplumio.helpers.parameter.Parameter.set -.. autofunction:: pyplumio.helpers.parameter.Parameter.set_nowait - -.. code-block:: python - - from pyplumio.helpers.parameter import Parameter - - - ecomax = await conn.get("ecomax") - heating_target: Parameter = ecomax.get("heating_target_temp") - result = heating_target.set(65) - -Parameter range -^^^^^^^^^^^^^^^ - -Each parameter has a range of acceptable values. -PyPlumIO will raise **ValueError** if value is not within -the acceptable range. - -You can check allowed range by reading **min_value** and **max_value** -attributes of parameter object. Both values are **inclusive**. - -.. code-block:: python - - ecomax = await connection.get("ecomax") - target_temp = await ecomax.get("heating_target_temp") - print(target_temp.min_value) # Minimum allowed target temperature. - print(target_temp.max_value) # Maximum allowed target temperature. - -Binary parameters -^^^^^^^^^^^^^^^^^ - -For binary parameters, you can also use boolean **True** or **False**, -string literals "on" or "off" or special **turn_on()** and -**turn_off()** methods. - -.. autofunction:: pyplumio.helpers.parameter.BinaryParameter.turn_on -.. autofunction:: pyplumio.helpers.parameter.BinaryParameter.turn_on_nowait -.. autofunction:: pyplumio.helpers.parameter.BinaryParameter.turn_off -.. autofunction:: pyplumio.helpers.parameter.BinaryParameter.turn_off_nowait - -One such parameter is "ecomax_control" that allows you to switch -the ecoMAX on or off. - -.. code-block:: python - - result = await ecomax.set("ecomax_control", "on") - -If you want to use **turn_on()** method, you must first get a parameter -object. - -.. code-block:: python - - ecomax_control = await ecomax.get("ecomax_control") - result = await ecomax_control.turn_on() - -ecoMAX control -^^^^^^^^^^^^^^ - -With the **ecomax_control** parameter there's also handy shortcut that -allows turning ecoMAX controller on or off by using the controller -object itself. - -.. autofunction:: pyplumio.devices.ecomax.EcoMAX.turn_on -.. autofunction:: pyplumio.devices.ecomax.EcoMAX.turn_on_nowait -.. autofunction:: pyplumio.devices.ecomax.EcoMAX.turn_off -.. autofunction:: pyplumio.devices.ecomax.EcoMAX.turn_off_nowait - -Examples -^^^^^^^^ -.. code-block:: python - - import asyncio - - import pyplumio - - async def main(): - """Opens the connection, enables the controller and - tries to set target temperature. - """ - async with pyplumio.open_tcp_connection("localhost", 8899) as conn: - # Get the ecoMAX device. - ecomax = await conn.get("ecomax") - - # Turn on controller without waiting for the result. - ecomax.turn_on_nowait() - - # Set heating temperature to 65 degrees. - result = await ecomax.set("heating_target_temp", 65) - if result: - print("Heating temperature is set to 65.") - else: - print("Couldn't set heating temperature.") - - - - asyncio.run(main()) - -Callbacks ---------- - -PyPlumIO has an extensive event driven system where each device -property is an event that you can subscribe to. - -When you subscribe callback to the event, your callback will be -awaited with the property value each time the property is received -from the device by PyPlumIO. - -.. note:: - - Callbacks must be coroutines defined with **async def**. - -.. autofunction:: pyplumio.devices.Device.subscribe -.. autofunction:: pyplumio.devices.Device.subscribe_once - -In example below, callback ``my_callback`` with be called with -current heating temperature on every SensorDataMessage. - -.. code-block:: python - - import asyncio - - import pyplumio - - async def my_callback(value) -> None: - """Prints current heating temperature.""" - print(f"Heating Temperature: {value}") - - - async def main(): - """Subscribes callback to the current heating temperature.""" - async with pyplumio.open_tcp_connection("localhost", 8899) as conn: - ecomax = await conn.get("ecomax") - ecomax.subscribe("heating_temp", my_callback) - - # Wait until disconnected (forever) - await conn.wait_until_done() - - - asyncio.run(main()) - - -In order to remove the previously registered callback, -use ``unsubscribe(name: str | int, callback)`` method. - -.. code-block:: python - - ecomax.unsubscribe("heating_temp", my_callback) - -To have you callback only awaited once and then be automatically unsubscribed, use -``subscribe_once(name: str | int, callback)`` method. - -.. code-block:: python - - ecomax.subscribe_once("heating_temp", my_callback) - -Filters -------- - -PyPlumIO's callback system can be further improved upon by -using built-in filters. Filters allow you to specify when you want your -callback to be awaited. Filters can also be chained. - -.. code-block:: python - - from pyplumio.filter import throttle, on_change - - # Await the callback on value change but no faster than - # once per 5 seconds. - ecomax.subscribe("heating_temp", throttle(on_change(my_callback), seconds=5)) - - -To use filter simply wrap your callback in filter like in example below. - -.. code-block:: python - - from pyplumio.filters import on_change - - ecomax.subscribe("heating_temp", on_change(my_callback)) - -There's total of five built-in filters described below. - -Aggregate -^^^^^^^^^ - -.. autofunction:: pyplumio.filters.aggregate - -This filter aggregates value for specified amount of seconds and -then calls the callback with the sum of values collected. - -.. note:: - - Aggregate filter can only be used with numeric values. - -.. code-block:: python - - from pyplumio.filters import aggregate - - # Await the callback with the fuel burned during 30 seconds. - ecomax.subscribe("fuel_burned", aggregate(my_callback, seconds=30)) - -On change -^^^^^^^^^ - -.. autofunction:: pyplumio.filters.on_change - -Normally callbacks are awaited each time the PyPlumIO receives data -from the device, regardless of whether value is changed or not. - -With this filter it's possible to only await the callback when -value is changed. - -.. code-block:: python - - from pyplumio.filter import on_change - - # Await the callback once heating_temp value is changed since - # last call. - ecomax.subscribe("heating_temp", on_change(my_callback)) - -Debounce -^^^^^^^^ - -.. autofunction:: pyplumio.filters.debounce - -This filter will only await the callback once value is settled across -multiple calls, specified in ``min_calls`` argument. - -.. code-block:: python - - from pyplumio.filter import debounce - - # Await the callback once outside_temp stays the same for three - # consecutive times it's received by PyPlumIO. - ecomax.subscribe("outside_temp", debounce(my_callback, min_calls=3)) - -Throttle -^^^^^^^^ - -.. autofunction:: pyplumio.filters.throttle - -This filter limits how often your callback will be awaited. - -.. code-block:: python - - from pyplumio.filter import throttle - - # Await the callback once per 5 seconds, regardless of - # how often outside_temp value is being processed by PyPlumIO. - ecomax.subscribe("outside_temp", throttle(my_callback, seconds=5)) - -Delta -^^^^^ - -.. autofunction:: pyplumio.filters.delta - -Instead of raw value, this filter awaits callback with value change. - -It can be used with numeric values, dictionaries, tuples or lists. - -.. code-block:: python - - from pyplumio.filter import delta - - # Await the callback with difference between values in current - # and last await. - ecomax.subscribe("outside_temp", delta(my_callback)) - -Custom -^^^^^^ - -.. autofunction:: pyplumio.filters.custom - -This filter allows to specify filter function that will be called -every time the value is received from the controller. - -A callback is awaited once this filter function returns true. - -.. code-block:: python - - from pyplumio.filter import custom - - # Await the callback when temperature is higher that 10 degrees - # Celsius. - ecomax.subscribe("outside_temp", custom(my_callback, lambda x: x > 10)) - -Regulator Data --------------- -Regulator Data or, as manufacturer calls it, RegData, messages are -broadcasted by the ecoMAX controller once per second and allow access to -some device-specific information that isn't available elsewhere. -It's represented by a dictionary mapped with numerical keys. - -RegData can be accessed via the **regdata** property. - -.. code-block:: python - - from pyplumio.structures.regulator_data import RegulatorData - - ecomax = await conn.get("ecomax") - regdata: RegulatorData = await ecomax.get("regdata") - -The **RegulatorData** object supports following getter methods, that -you can use to access the values. - -.. autofunction:: pyplumio.structures.regulator_data.RegulatorData.get -.. autofunction:: pyplumio.structures.regulator_data.RegulatorData.get_nowait -.. autofunction:: pyplumio.structures.regulator_data.RegulatorData.wait_for - -.. note:: - - RegData is device-specific. Each ecoMAX controller has different - keys and their associated meanings. - -.. code-block:: python - - # Get regulator data with the 1280 key. - heating_target = regdata.get_nowait(1280) - -To see every value stored in the RegulatorData object, you can check -it's **data** property. - -Mixers/Thermostats ------------------- - -If your ecoMAX controller have connected sub-devices such as mixers -and thermostats, you can access them through their -respective properties. - -.. code-block:: python - - ecomax = await conn.get("ecomax") - thermostats = await ecomax.get("thermostats") - mixers = await ecomax.get("mixers") - -Result of this call will be a dictionary of ``Mixer`` or ``Thermostat`` -object keyed by the device indexes. - -Both classes inherit the ``Device`` class and provide access to -getter/setter functions, callback support and access to the ``data`` -property. - -Both mixers and thermostats also can have editable parameters. - -Working with mixers -^^^^^^^^^^^^^^^^^^^ -In the following example, we'll get single mixer by it's index, -get it's current_temp property and set it's target temperature to -50 degrees Celsius. - -.. code-block:: python - - from pyplumio.devices import Mixer - - ecomax = await conn.get("ecomax") - mixers = await ecomax.get("mixers") - mixer: Mixer = mixers[1] - mixer_current_temp = await mixer.get("current_temp") - await mixer.set("mixer_target_temp", 50) - -Working with thermostats -^^^^^^^^^^^^^^^^^^^^^^^^ -In the following example, we'll get single thermostat by it's index, -get current room temperature and set daytime target temperature to 20 -degrees Celsius. - -.. code-block:: python - - from pyplumio.device import Thermostat - - ecomax = await conn.get("ecomax") - thermostats = await ecomax.get("thermostats") - thermostat: Thermostat = thermostats[1] - thermostat_current_temp = await thermostat.get("current_temp") - await thermostat.set("day_target_temp", 20) - -Schedules ---------- -You can set different device schedules, enable/disable them and -change their associated parameter. - -Turning schedule on or off -^^^^^^^^^^^^^^^^^^^^^^^^^^ -To disable the schedule, set "schedule_{schedule_name}_switch" parameter -parameter to "off", to enable it set the parameter to "on". - -The following example illustrates how you can turn heating schedule -on or off. - -.. code-block:: python - - await ecomax.set("schedule_heating_switch", "on") - await ecomax.set("schedule_heating_switch", "off") - -Changing schedule parameter -^^^^^^^^^^^^^^^^^^^^^^^^^^^ -To change associated parameter value, change -"schedule_{schedule_name}_parameter" property. - -Use following example to lower nighttime heating -temperature by 10 degrees. - -.. code-block:: python - - await ecomax.set("schedule_heating_parameter", 10) - -Setting the schedule -^^^^^^^^^^^^^^^^^^^^ -To set the schedule, you can use ``set_state(state)``, ``set_on()`` or -``set_off()`` methods and call ``commit()`` to send changes to the -device. - -This example sets nighttime mode for Monday from 00:00 to 07:00 and -switches back to daytime mode from 07:00 to 00:00. - -.. code-block:: python - - schedules = await ecomax.get("schedules") - heating_schedule = schedules["heating"] - heating_schedule.monday.set_off(start="00:00", end="07:00") - heating_schedule.monday.set_on(start="07:00", end="00:00") - heating_schedule.commit() - -For clarity sake, you might want to use ``STATE_NIGHT`` and -``STATE_DAY`` constants from ``pyplumio.helpers.schedule`` module. - -.. code-block:: python - - from pyplumio.helpers.schedule import STATE_NIGHT - - heating_schedule.monday.set_state(STATE_NIGHT, "00:00", "07:00") - -You may also omit one of the boundaries. -The other boundary is then set to the end or start of the day. - -.. code-block:: python - - heating_schedule.monday.set_on(start="07:00") - # is equivalent to - heating_schedule.monday.set_on(start="07:00", end="00:00") - -.. code-block:: python - - heating_schedule.monday.set_off(end="07:00") - # is equivalent to - heating_schedule.monday.set_off(start="00:00", end="07:00") - -This can be used to set state for a whole day with -``heating_schedule.monday.set_on()``. - -To set schedule for all days you can iterate through the -Schedule object: - -.. code-block:: python - - schedules = await ecomax.get("schedules") - heating_schedule = schedules["heating"] - - for weekday in heating_schedule: - # Set a nighttime mode from 00:00 to 07:00 - weekday.set_on("00:00", "07:00") - # Set a daytime mode from 07:00 to 00:00 - weekday.set_off("07:00", "00:00") - - # Commit changes to the device. - heating_schedule.commit() - -Examples -^^^^^^^^ - -.. code-block:: python - - import pyplumio - from pyplumio.helpers.schedule import STATE_DAY, STATE_NIGHT - - - async def main(): - """Set a device schedule.""" - async with pyplumio.open_tcp_connection("localhost", 8899) as connection: - ecomax = await connection.get("ecomax") - schedules = await ecomax.get("schedules") - heating_schedule = schedules["heating"] - - # Turn the heating schedule on. - await ecomax.set("schedule_heating_switch", "on") - - # Drop the heating temperature by 10 degrees in the nighttime mode. - await ecomax.set("schedule_heating_parameter", 10) - - for weekday in heating_schedule: - weekday.set_state(STATE_DAY, "00:00", "00:30") - weekday.set_state(STATE_NIGHT, "00:30", "09:00") - weekday.set_state(STATE_DAY, "09:00", "00:00") - - # There will be no nighttime mode on sunday. - heating_schedule.sunday.set_state(STATE_DAY) - - heating_schedule.commit() - - - asyncio.run(main()) - -Network information -------------------- -You can send ethernet and wireless network information to -the ecoMAX controller to show on it's LCD. - -It serves information purposes only and can be omitted. - -.. code-block:: python - - import pyplumio - from pyplumio.const import EncryptionType - - async def main(): - """Initialize a connection with network parameters.""" - ethernet = pyplumio.EthernetParameters( - ip="10.10.1.100", - netmask="255.255.255.0", - gateway="10.10.1.1", - ) - wireless = pyplumio.WirelessParameters( - ip="10.10.2.100", - netmask="255.255.255.0", - gateway="10.10.2.1", - ssid="My SSID", - encryption=EncryptionType.WPA2, - signal_quality=100, - ) - async with pyplumio.open_tcp_connection( - host="localhost", - port=8899, - ethernet_parameters=ethernet, - wireless_parameters=wireless, - ) as connection: - ... diff --git a/docs/source/writing.rst b/docs/source/writing.rst new file mode 100644 index 00000000..b19e0f94 --- /dev/null +++ b/docs/source/writing.rst @@ -0,0 +1,132 @@ +Writing +======= + +Setters +------- + +.. autofunction:: pyplumio.devices.Device.set + +When using blocking setter, you will get the result +represented by the boolean value. `True` if write was successful, +`False` otherwise. + +.. code-block:: python + + result = await ecomax.set("heating_target_temp", 65) + if result: + print("Heating target temperature was successfully set.") + else: + print("Error while trying to set heating target temperature.") + +.. autofunction:: pyplumio.devices.Device.set_nowait + +You can't access result, when using non-blocking setter as task is done +in the background. You will, however, still get error message in the log +in case of failure. + +.. code-block:: python + + ecomax.set_nowait("heating_target_temp", 65) + +Parameters +---------- + +It's possible to get the ``Parameter`` object and then modify it using +it's own setter methods. When using the parameter object, you don't +need to pass the parameter name. + +.. code-block:: python + + from pyplumio.helpers.parameter import Parameter + + + ecomax = await conn.get("ecomax") + heating_target: Parameter = ecomax.get("heating_target_temp") + result = heating_target.set(65) + +Each parameter has a range of allowed values. +PyPlumIO will raise ``ValueError`` if value is not within the +acceptable range. + +You can check allowed range by reading ``min_value`` and ``max_value`` +properties of the parameter object. Both values are **inclusive**. + +.. code-block:: python + + ecomax = await connection.get("ecomax") + target_temp = await ecomax.get("heating_target_temp") + print(target_temp.min_value) # Minimum allowed target temperature. + print(target_temp.max_value) # Maximum allowed target temperature. + +Binary Parameters +----------------- + +For binary parameters, you can also use boolean `True` or `False`, +string literals "on" or "off" or special ``turn_on()`` and +``turn_off()`` methods. + +.. autofunction:: pyplumio.helpers.parameter.BinaryParameter.turn_on +.. autofunction:: pyplumio.helpers.parameter.BinaryParameter.turn_on_nowait +.. autofunction:: pyplumio.helpers.parameter.BinaryParameter.turn_off +.. autofunction:: pyplumio.helpers.parameter.BinaryParameter.turn_off_nowait + +One such parameter is "ecomax_control" that allows you to switch +the ecoMAX on or off. + +.. code-block:: python + + # Set an ecomax_control parameter value to "on". + result = await ecomax.set("ecomax_control", "on") + +If you want to use **turn_on()** method, you must first get a parameter +object. + +.. code-block:: python + + # Get an ecomax_control parameter and turn it on. + ecomax_control = await ecomax.get("ecomax_control") + result = await ecomax_control.turn_on() + +If you simply want to turn on or off the ecoMAX controller there's, +handy shortcut built-in the controller object itself. + +.. code-block:: python + + # Turn on the controller. + await ecomax.turn_on() + + # Turn off the controller. + await ecomax.turn_off() + +Writing Examples +---------------- + +The following example opens the connection, enables the controller +without waiting for result and tries to set a target temperature, +outputting result to the terminal. + +.. code-block:: python + + import asyncio + + import pyplumio + + async def main(): + """Turn on the controller and set target temperature.""" + async with pyplumio.open_tcp_connection("localhost", 8899) as conn: + # Get the ecoMAX device. + ecomax = await conn.get("ecomax") + + # Turn on controller without waiting for the result. + ecomax.turn_on_nowait() + + # Set heating temperature to 65 degrees. + result = await ecomax.set("heating_target_temp", 65) + if result: + print("Heating temperature is set to 65.") + else: + print("Couldn't set heating temperature.") + + + + asyncio.run(main()) diff --git a/pyplumio/__init__.py b/pyplumio/__init__.py index 5ffbec2b..622d2944 100644 --- a/pyplumio/__init__.py +++ b/pyplumio/__init__.py @@ -17,7 +17,29 @@ def open_serial_connection( protocol: type[Protocol] = AsyncProtocol, **kwargs, ) -> SerialConnection: - """Create a serial connection.""" + r"""Create a serial connection. + + :param device: Serial port device name. e. g. /dev/ttyUSB0 + :type device: str + :param baudrate: Serial port baud rate, defaults to 115200 + :type baudrate: int, optional + :param ethernet_parameters: Ethernet parameters to send to an + ecoMAX controller + :type ethernet_parameters: EthernetParameters, optional + :param wireless_parameters: Wireless parameters to send to an + ecoMAX controller + :type wireless_parameters: WirelessParameters, optional + :param reconnect_on_failure: `True` if PyPlumIO should try + reconnecting on failure, otherwise `False`, default to `True` + :type reconnect_on_failure: bool, optional + :param protocol: Protocol that will be used for communication with + the ecoMAX controller, default to AsyncProtocol + :type protocol: Protocol, optional + :param \**kwargs: Additional keyword arguments to be passed to + serial_asyncio.open_serial_connection() + :return: An instance of serial connection + :rtype: SerialConnection + """ return SerialConnection( device, baudrate, @@ -39,7 +61,29 @@ def open_tcp_connection( protocol: type[Protocol] = AsyncProtocol, **kwargs, ) -> TcpConnection: - """Create a TCP connection.""" + r"""Create a TCP connection. + + :param host: IP address or host name of the remote RS-485 server + :type host: str + :param port: Port that remote RS-485 server is listening to + :type port: int + :param ethernet_parameters: Ethernet parameters to send to an + ecoMAX controller + :type ethernet_parameters: EthernetParameters, optional + :param wireless_parameters: Wireless parameters to send to an + ecoMAX controller + :type wireless_parameters: WirelessParameters, optional + :param reconnect_on_failure: `True` if PyPlumIO should try + reconnecting on failure, otherwise `False`, default to `True` + :type reconnect_on_failure: bool, optional + :param protocol: Protocol that will be used for communication with + the ecoMAX controller, default to AsyncProtocol + :type protocol: Protocol, optional + :param \**kwargs: Additional keyword arguments to be passed to + asyncio.open_connection() + :return: An instance of TCP connection + :rtype: TcpConnection + """ return TcpConnection( host, port, diff --git a/pyplumio/devices/__init__.py b/pyplumio/devices/__init__.py index 6561d53e..d8ef741b 100644 --- a/pyplumio/devices/__init__.py +++ b/pyplumio/devices/__init__.py @@ -58,8 +58,23 @@ async def set( ) -> bool: """Set a parameter value. - Name should point to a valid parameter object, otherwise - raise ParameterNotFoundError. + :param name: Name of the parameter + :type name: str + :param value: New value for the parameter + :type value: int | float | bool | Literal["on"] | Literal["off"] + :param timeout: Wait this amount of seconds for confirmation, + defaults to `None` + :type timeout: float, optional + :param retries: Try setting parameter for this amount of + times, defaults to 5 + :type retries: int, optional + :return: `True` if parameter was successfully set, `False` + otherwise. + :rtype: bool + :raise pyplumio.exceptions.ParameterNotFoundError: when + parameter with the specified name is not found + :raise asyncio.TimeoutError: when waiting past specified timeout + :raise ValueError: when a new value is outside of allowed range """ parameter = await self.get(name, timeout=timeout) if not isinstance(parameter, Parameter): @@ -74,7 +89,24 @@ def set_nowait( timeout: float | None = None, retries: int = SET_RETRIES, ) -> None: - """Set a parameter value without waiting for the result.""" + """Set a parameter value without waiting for the result. + + :param name: Name of the parameter + :type name: str + :param value: New value for the parameter + :type value: int | float | bool | Literal["on"] | Literal["off"] + :param timeout: Wait this amount of seconds for confirmation. + As this method operates in the background without waiting, + this value is used to determine failure when + retrying and doesn't block, defaults to `None` + :type timeout: float, optional + :param retries: Try setting parameter for this amount of + times, defaults to 5 + :type retries: int, optional + :return: `True` if parameter was successfully set, `False` + otherwise. + :rtype: bool + """ self.create_task(self.set(name, value, timeout, retries)) diff --git a/pyplumio/filters.py b/pyplumio/filters.py index 9f28cdc0..325d9fd8 100644 --- a/pyplumio/filters.py +++ b/pyplumio/filters.py @@ -111,6 +111,11 @@ def on_change(callback: EventCallbackType) -> _OnChange: A callback function will only be called if value is changed from the previous call. + + :param callback: A callback function to be awaited on value change + :type callback: Callable[[Any], Awaitable[Any]] + :return: A instance of callable filter + :rtype: _OnChange """ return _OnChange(callback) @@ -127,7 +132,7 @@ class _Debounce(Filter): _calls: int _min_calls: int - def __init__(self, callback: EventCallbackType, min_calls: int = 3): + def __init__(self, callback: EventCallbackType, min_calls: int): """Initialize a new debounce filter.""" super().__init__(callback) self._calls = 0 @@ -151,6 +156,14 @@ def debounce(callback: EventCallbackType, min_calls) -> _Debounce: A callback function will only called once value is stabilized across multiple filter calls. + + :param callback: A callback function to be awaited on value change + :type callback: Callable[[Any], Awaitable[Any]] + :param min_calls: Value shouldn't change for this amount of + filter calls + :type min_calls: int + :return: A instance of callable filter + :rtype: _Debounce """ return _Debounce(callback, min_calls) @@ -189,6 +202,15 @@ def throttle(callback: EventCallbackType, seconds: float) -> _Throttle: A callback function will only be called once a certain amount of seconds passed since the last call. + + :param callback: A callback function that will be awaited once + filter conditions are fulfilled + :type callback: Callable[[Any], Awaitable[Any]] + :param seconds: A callback will be awaited at most once per + this amount of seconds + :type seconds: float + :return: A instance of callable filter + :rtype: _Throttle """ return _Throttle(callback, seconds) @@ -218,6 +240,12 @@ def delta(callback: EventCallbackType) -> _Delta: A callback function will be called with a difference between two subsequent value. + + :param callback: A callback function that will be awaited with + difference between values in two subsequent calls + :type callback: Callable[[Any], Awaitable[Any]] + :return: A instance of callable filter + :rtype: _Delta """ return _Delta(callback) @@ -263,7 +291,16 @@ def aggregate(callback: EventCallbackType, seconds: float) -> _Aggregate: """An aggregate filter. A callback function will be called with a sum of values collected - over a specified time period. + over a specified time period. Can only be used with numeric values. + + :param callback: A callback function to be awaited once filter + conditions are fulfilled + :type callback: Callable[[Any], Awaitable[Any]] + :param seconds: A callback will be awaited with a sum of values + aggregated over this amount of seconds. + :type seconds: float + :return: A instance of callable filter + :rtype: _Aggregate """ return _Aggregate(callback, seconds) @@ -297,5 +334,14 @@ def custom(callback: EventCallbackType, filter_fn: Callable[[Any], bool]) -> _Cu A callback function will be called when user-defined filter function, that's being called with the value as an argument, returns true. + + :param callback: A callback function to be awaited when + filter function return true + :type callback: Callable[[Any], Awaitable[Any]] + :param filter_fn: Filter function, that will be called with a + value and should return `True` to await filter's callback + :type filter_fn: Callable[[Any], bool] + :return: A instance of callable filter + :rtype: _Custom """ return _Custom(callback, filter_fn) diff --git a/pyplumio/helpers/event_manager.py b/pyplumio/helpers/event_manager.py index ad719817..e703f823 100644 --- a/pyplumio/helpers/event_manager.py +++ b/pyplumio/helpers/event_manager.py @@ -29,31 +29,74 @@ def __getattr__(self, name: str): raise AttributeError from e async def wait_for(self, name: str | int, timeout: float | None = None) -> None: - """Wait for the value to become available.""" + """Wait for the value to become available. + + :param name: Event name or ID + :type name: str | int + :param timeout: Wait this amount of seconds for a data to + become available, defaults to `None` + :type timeout: float, optional + :raise asyncio.TimeoutError: when waiting past specified timeout + """ if name not in self.data: await asyncio.wait_for(self.create_event(name).wait(), timeout=timeout) async def get(self, name: str | int, timeout: float | None = None): - """Get the value.""" + """Get the value by name. + + :param name: Event name or ID + :type name: str | int + :param timeout: Wait this amount of seconds for a data to + become available, defaults to `None` + :type timeout: float, optional + :return: An event data + :raise asyncio.TimeoutError: when waiting past specified timeout + """ await self.wait_for(name, timeout=timeout) return self.data[name] def get_nowait(self, name: str | int, default=None): - """Get the value without waiting.""" + """Get the value by name without waiting. + + If value is not available, default value will be + returned instead. + + :param name: Event name or ID + :type name: str | int + :param default: default value to return if data is unavailable, + defaults to `None` + :type default: Any, optional + :return: An event data + """ try: return self.data[name] except KeyError: return default def subscribe(self, name: str | int, callback: EventCallbackType) -> None: - """Subscribe a callback to the event.""" + """Subscribe a callback to the event. + + :param name: Event name or ID + :type name: str + :param callback: A coroutine callback function, that will be + awaited on the with the event data as an argument. + :type callback: Callable[[Any], Awaitable[Any]] + """ if name not in self._callbacks: self._callbacks[name] = [] self._callbacks[name].append(callback) def subscribe_once(self, name: str | int, callback: EventCallbackType) -> None: - """Subscribe a callback to the single event.""" + """Subscribe a callback to the event once. Callback will be + unsubscribed after single event. + + :param name: Event name or ID + :type name: str + :param callback: A coroutine callback function, that will be + awaited on the with the event data as an argument. + :type callback: Callable[[Any], Awaitable[Any]] + """ async def _callback(value): """Unsubscribe callback from the event and calls it.""" @@ -63,7 +106,15 @@ async def _callback(value): self.subscribe(name, _callback) def unsubscribe(self, name: str | int, callback: EventCallbackType) -> None: - """Usubscribe a callback from the event.""" + """Usubscribe a callback from the event. + + :param name: Event name or ID + :type name: str + :param callback: A coroutine callback function, previously + subscribed to an event using ``subscribe()`` or + ``subscribe_once()`` methods. + :type callback: Callable[[Any], Awaitable[Any]] + """ if name in self._callbacks and callback in self._callbacks[name]: self._callbacks[name].remove(callback) diff --git a/pyplumio/helpers/parameter.py b/pyplumio/helpers/parameter.py index 71af223f..04bc4d42 100644 --- a/pyplumio/helpers/parameter.py +++ b/pyplumio/helpers/parameter.py @@ -235,19 +235,29 @@ class BinaryParameter(Parameter): """Represents binary device parameter.""" async def turn_on(self) -> bool: - """Set a parameter value to 'on'.""" + """Set a parameter value to 'on'. + + :return: `True` if parameter was successfully turned on, `False` + otherwise. + :rtype: bool + """ return await self.set(STATE_ON) async def turn_off(self) -> bool: - """Set a parameter value to 'off'.""" + """Set a parameter value to 'off'. + + :return: `True` if parameter was successfully turned off, `False` + otherwise. + :rtype: bool + """ return await self.set(STATE_OFF) def turn_on_nowait(self) -> None: - """Set a parameter state to 'on' without waiting.""" + """Set a parameter value to 'on' without waiting.""" self.set_nowait(STATE_ON) def turn_off_nowait(self) -> None: - """Set a parameter state to 'off' without waiting.""" + """Set a parameter value to 'off' without waiting.""" self.set_nowait(STATE_OFF) @property diff --git a/pyplumio/protocol.py b/pyplumio/protocol.py index 71699b16..5d6419b7 100644 --- a/pyplumio/protocol.py +++ b/pyplumio/protocol.py @@ -89,7 +89,11 @@ async def shutdown(self): class DummyProtocol(Protocol): - """Represents a dummy protocol.""" + """Represents a dummy protocol. + + This protocol sets frame reader and writer as attributes, then + sets connected event and does nothing. + """ def connection_established( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter @@ -115,7 +119,20 @@ async def shutdown(self): class AsyncProtocol(Protocol, EventManager): - """Represents an async protocol.""" + """Represents an async protocol. + + This protocol implements producer-consumers pattern using + asyncio queues. + + The frame producer tries to read frames from write queue, if any + available, and sends them to the device via frame writer. + + It reads stream via frame reader, creates device handler entry + and puts received frame and handler into the read queue. + + Frame consumers reads handler-frame tuples from the read queue and + sends frame to respective handler for further processing. + """ _queues: tuple[asyncio.Queue, asyncio.Queue] diff --git a/pyplumio/structures/network_info.py b/pyplumio/structures/network_info.py index 9575c46a..332ba635 100644 --- a/pyplumio/structures/network_info.py +++ b/pyplumio/structures/network_info.py @@ -22,9 +22,16 @@ class EthernetParameters: """Represents an ethernet parameters.""" + #: IP address. ip: str = DEFAULT_IP + + #: IP subnet mask. netmask: str = DEFAULT_NETMASK + + #: Gateway IP address. gateway: str = DEFAULT_IP + + #: Connection status. Parameters will be ignored if set to False. status: bool = True @@ -32,8 +39,13 @@ class EthernetParameters: class WirelessParameters(EthernetParameters): """Represents a wireless network parameters.""" + #: Wireless Service Set IDentifier. ssid: str = "" + + #: Wireless encryption standard. encryption: EncryptionType = EncryptionType.NONE + + #: Wireless signal strength in percentage. signal_quality: int = 100 diff --git a/pyproject.toml b/pyproject.toml index 766b8c07..86f93034 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,14 @@ force_sort_within_sections = true combine_as_imports = true [tool.flake8] -exclude = ".git,.tox,.mypy_cache,build,docs,_version.py" +exclude = [ + ".git", + ".tox", + ".mypy_cache", + "build", + "docs/source/conf.py", + "_version.py" +] max-complexity = 25 ignore = [ "E501",