Metadata-Version: 2.4
Name: ondosense-connect
Version: 0.19.4
Summary: Python library to use the OndoSense RS485 sensor family in your Python code.
Author-email: Ruben Rögels <rroegels@ondosense.com>, Andreas Inhofer <ainhofer@ondosense.com>, Manuel Rometsch <mrometsch@ondosense.com>
Maintainer-email: Ruben Rögels <rroegels@ondosense.com>, Manuel Rometsch <mrometsch@ondosense.com>
License-Expression: MIT
Project-URL: Homepage, https://www.ondosense.com
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Natural Language :: English
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: pyserial==3.5.*
Requires-Dist: numpy==2.4.*
Requires-Dist: ondosense-connect-contracts>=0.8.4
Requires-Dist: ondosense-eventhandling>=0.1.2
Provides-Extra: test
Requires-Dist: pytest==8.3.*; extra == "test"
Requires-Dist: ondosense-connect-api-public>=0.39; extra == "test"
Provides-Extra: usability
Requires-Dist: ondosense-connect-api-public>=0.39; extra == "usability"
Requires-Dist: ondosense-connect-tools; extra == "usability"

# ondosense-connect

`ondosense-connect` provides an easy and simple way to use the OndoSense RS485 RADAR sensor family in Python.

## TLDR; Quick Start

**Note:** For the best Developer Experience, please install the `ondosense-connect-api` package,
which provides classes for all Commands and Parameters you can use with the RS485 sensor family.

The following examples cover some common use cases like

- (1) taking a simple distance measurement,
- (2) configuring the sensor and persisting the configuration,
- (3) saving the data of a burst measurement,
- (4) selecting additional result data selectors
- (5) and resetting the sensor to factory settings.

### (1) Take a single measurement

This is the most basic use case, assuming you have a sensor with factory settings:

~~~python
from ondosense.connect.rs485.sensor import SerialSensor
import ondosense.connect.parameters as p

# Adapt the serial device name according to your setup.
port = 'COM6'

with (SerialSensor(port) as sensor):
    # Let the sensor find the best amplifier gain values for the current target
    # Please make sure, your sensor is properly aligned to the target you want to measure.
    sensor.autoset_amplifier_gains()

    # Get one measurement
    measurement = sensor.get_measurement()

    # Print the measured distance in "m"
    print(f"Distance      : {measurement.distance.distance_in_m} m")
~~~

If you run this code you should get an output like that:

~~~text
Protocol startup
Driver connected
1.701446
Remaining bytes in read buffer: 0
26 bytes written to sensor.
10 bytes read from sensor.
Driver disconnected
Protocol shutdown
~~~

### (2) Configuring the sensor and persisting the configuration

Int his example, we'll configure the sensor and make these changes persistent
so the next time the sensor starts up, you don't need to configure it again.

**Please note:** The baud rate won't be persisted when saving parameters.
The sensor will always start up with the default baud rate of 19200 baud.

~~~python
from ondosense.connect.rs485.sensor import SerialSensor
import ondosense.connect.parameters as p

# Adapt the serial device name according to your setup.
port = 'COM6'

with (SerialSensor(port) as sensor):
    (
        sensor
        # Increase baudrate to speed up the communication with the sensor
        .set_baudrate(921600, True)
        # Set the minimum distance you expect your target in
        .set_parameter(p.MinDistanceInMm(500))
        # Set the maximum distance you expect your target in
        .set_parameter(p.MaxDistanceInMm(5000))
        # Let the sensor find the best amplifier gain values for the current target
        # Please make sure, your sensor is properly aligned to the target you want to measure. 
        .autoset_amplifier_gains()
        # Persist the configuration changes
        .save_parameters()
    )
~~~

### (3) Saving the measurement data of a burst measurement into a JSON file

In this example, we will configure the sensor, perform a burst measurement and
then save the measurement data into a JSON file.


~~~python
from ondosense.connect.rs485.sensor import SerialSensor
import ondosense.connect.parameters as p

# Adapt the serial device name according to your setup.
port = 'COM6'

with (SerialSensor(port) as sensor):
    (
        sensor
        # Increase baudrate to speed up the communication with the sensor
        .set_baudrate(921600, True)
        # Set the minimum distance you expect your target in
        .set_parameter(p.MinDistanceInMm(500))
        # Set the maximum distance you expect your target in
        .set_parameter(p.MaxDistanceInMm(5000))
        # Let the sensor find the best amplifier gain values for the current target
        # Please make sure, your sensor is properly aligned to the target you want to measure. 
        .autoset_amplifier_gains()
    )

    # Get a burst of 100 measurements
    measurements = sensor.get_burst_measurement(100)

    with open('result_data.json', 'w', encoding='utf8') as file:
        file.write(measurements.to_json())
~~~

`SerialSensor.get_measurement()` and `SerialSensor.get_burst_measurement()` return a `Measurement` respectively
a `MeasurementIterator` object which both provide a `.to_json()` method.

The `.to_json()` method returns one or multiple measurements as JSON string which can easily be saved
into a file.

**Note:** `.to_json()` accepts the `indent` argument. Per default, `.to_json()` will return a compact
JSON string. To make its output human-readable, you can specify a value greater than zero, e.g.
`measurement.to_json(indent = 2)`.

### (4) Selecting additional Result Data Selectors

~~~Python
from ondosense.connect.rs485.sensor import SerialSensor
import ondosense.connect.parameters as p
import ondosense.connect.result_data_selectors as rds

# Adapt the serial device name according to your setup.
port = 'COM6'

with (SerialSensor(port) as sensor):
    # Configure the sensor
    (
        sensor
        # Increase baudrate to speed up the communication with the sensor
        .set_baudrate(921600, True)
        # Set the minimum distance you expect your target in
        .set_parameter(p.MinDistanceInMm(500))
        # Set the maximum distance you expect your target in
        .set_parameter(p.MaxDistanceInMm(5000))

        # Set desired measurement type
        .set_result_data_selector(
            (
                rds.DistanceSelector()
                + rds.PeakSelector()
            ).value
        )

        .autoset_amplifier_gains()  # Let the sensor find the best amplifier gain values for the current target
    )

    measurement = sensor.get_measurement()

    print(f"Distance      : {measurement.distance.distance_in_m} m")
    print(f"Peak Amplitude: {measurement.peak.amplitude_in_ou}")
~~~

### (5) resetting the Sensor to Factory Settings

~~~Python
from ondosense.connect.rs485.sensor import SerialSensor

# Adapt the serial device name according to your setup.
port = 'COM6'

with (SerialSensor(port) as sensor):
    sensor.execute_factory_reset()
~~~

**Note:** After a factory reset, all parameter values are reset to their factory default.
The sensor's baud rate is reset to 19200.

## Advanced library features

### SerialSensor initializer

`SerialSensor`'s initializer accepts at least the serial device name (argument `port`) and several optional
arguments:

~~~
SerialSensor(port: str, baudrate: int = 19200, timeout: float = 1, plugins: list | None = None,
    logger: logging.Logger | None = None, power_on_grace_time: float = 0.0,
    power_off_grace_time: float = 0.0)
~~~

* `port`: The serial device to use to communicate with the sensor.
* `baudrate`: Per default 19200. This is the sensor's default baud rate after powering on.
* `timeout`: The serial timeout in seconds, after the library assumes the sensor has not responded. Do not change.
* `plugins`: A list of plugins to be added to the SerialSensor instance. [See chapter "Plugins".](#Plugins)
* `logger`: You can specify a `logging.Logger` instance to be used by `SerialSensor`. [See chapter "Logging".](#Logging)
* `power_on_grace_time`: Delay after sensor has been switched [See chapter "Software controlled power supply"](#Software-controlled-power-supply)
* `power_off_grace_time`: Delay before sensor will bee switched off [See chapter "Software controlled power supply"](#Software-controlled-power-supply)


### Context Manager vs. manual serial connection control

In the examples above, we used Python's context manager to create and handle the `SerialSensor` instance.
This is the recommended usage, as it ensures the serial connection is properly established and released.

~~~Python
from ondosense.connect.rs485.sensor import SerialSensor
from ondosense.connect.parameters import *
from ondosense.connect.result_data_selectors import *

my_serial_device = 'COM6'

with (SerialSensor(my_serial_device) as sensor):
    # Configure the sensor
    (
        sensor
        .set_baudrate(921600, pray_it_works=True) # Increase communication baud rate 
        .set_parameter(MinDistanceInMm(500))  # Set minimum distance range
        .set_parameter(MaxDistanceInMm(5000))  # Set maximum distance range
        .set_result_data_selector(DistanceSelector().value)  # Set desired measurement type
        .autoset_amplifier_gains()  # Let the sensor find the best amplifier gain values for the current target
    )

    # Get a row measurement
    measurements = sensor.get_burst_measurement(100)

    with open('my_measurements.json', 'w', encoding='utf8') as file:
        file.write(measurements.to_json())
~~~

However, the library also allows to manually control the serial connection, by using the `SerialSensor.connect()`
and `SerialSensor.disconnect()` methods:

~~~Python
from ondosense.connect.rs485.sensor import SerialSensor

sensor = SerialSensor('COM6')
sensor.connect()

measurement = sensor.get_measurement()
print(measurement.distance.distance_in_m)

sensor.disconnect()
~~~

While this allows for manual connection establishment and release in cases the use of the context manager is undesirable,
it poses the risk of forgetting to properly release the serial connection.


### Working with Parameters

Parameters can be read and written. To do so, `SerialSensor` provides the `set_parameter()` and `get_parameter()`
methods. Both methods can be used with the named Parameter classes from the `ondosense-connect-api` package
or with numeric values.


#### Using numeric parameters

~~~Python
from ondosense.connect.rs485.sensor import SerialSensor

with SerialSensor('COM6') as sensor:
    # Parameter "0x44" is "MinDistanceInMm"
    sensor.set_parameter(0x44, 300)  
    print(sensor.get_parameter(0x44))
~~~

will print

~~~Text
300
~~~


#### Using Parameter objects

*Note:* Parameter and Command classes are provided by the `ondosense-connect-api` package.

~~~Python
from ondosense.connect.parameters import MinDistanceInMm
from ondosense.connect.rs485.sensor import SerialSensor

with SerialSensor('COM6') as sensor:
    sensor.set_parameter(MinDistanceInMm(300))
    print(sensor.get_parameter(MinDistanceInMm))
~~~

will print

~~~Text
<class 'ondosense.connect.parameters.MinDistance'>(id=0x44, name=MIN_DISTANCE, status=1, value=300)
~~~

Please note, the return value of `SerialSensor.get_parameter()` depends on the type of its input argument.
If it has been a numeric value, the method will return a numeric value. If it has been a Parameter
object / class, it will return a Parameter object.

Please also note, that `SerialSensor.set_parameter()` accepts a Parameter object (class instance) and also returns a
Parameter object, while `SerialSensor.get_parameter()` accepts a Parameter class and returns a Parameter object!

There is a special Parameter class `GenericParameter`. Its purpose is to enable the developer to use parameter objects
without the need to actually know the named Parameter classes: 

~~~Python
from ondosense.connect.contracts import GenericParameter
from ondosense.connect.rs485.sensor import SerialSensor

with SerialSensor('COM6') as sensor:
    sensor.set_parameter(GenericParameter(0x44, 500))
    sensor.get_parameter(GenericParameter(0x44))
~~~

This generic implementation can later be replaced with the named parameters if needed by a simple search & replace. 


#### Working with Commands

For Command execution `SerialSensor.execute_command()` must be used.

Similar to Parameters, Commands can be executed by specifying their numeric value or by using a named Command class.

Commands may have a body or not. When using numeric values, please refer to the RS485 API description. When using Command
objects, they will provide the structure of the command request and response data.


#### Using numeric commands

Command without body.

~~~python
from ondosense.connect.rs485.sensor import SerialSensor

with SerialSensor('COM6') as sensor:
    sensor.execute_command(0x07)  # Set auto amplifier gains
~~~

If the Command has a body, the body data must be provided as `bytes()`:

~~~python
from ondosense.connect.rs485.sensor import SerialSensor

with SerialSensor('COM6') as sensor:
    sensor.execute_command(0xff, (353350403412).to_bytes(5, byteorder="big", signed=False))  # Factory reset
~~~


#### Using Command objects

*Note:* Parameter and Command classes are provided by the `ondosense-connect-api` package.

Using Command objects is much easier:

~~~python
from ondosense.connect.rs485.sensor import SerialSensor
from ondosense.connect.commands import *

with SerialSensor('COM6') as sensor:
    cmd = GetRadarProfiles()
    sensor.execute_command(cmd)

    print(cmd.response_data)
~~~

will print

~~~
...
{'count': 2, 'profiles': [3, 16]}
... 
~~~

A Command object has the properties `serializiation_hints` and `deserialization_hints` which can be used to
determine the structure of the command request and response body:

~~~python
from ondosense.connect.commands import GetRadarProfiles
from pprint import pprint

pprint(GetRadarProfiles.deserialization_hint)
~~~

prints

~~~
{'count': {'data_type': {'C': 'uint32_t',
                         'CS': 'UInt32',
                         '_type': 'data_type',
                         'python': 'int'},
           'length': 4,
           'signed': False},
 'profiles': {'count': 'count',
              'data_type': {'C': 'uint32_t',
                            'CS': 'UInt32',
                            '_type': 'data_type',
                            'python': 'int'},
              'length': 4,
              'signed': False},
 'status': {'data_type': {'C': 'int8_t',
                          'CS': 'SByte',
                          '_type': 'data_type',
                          'python': 'int'},
            'length': 1,
            'signed': True}}
~~~

### Restoring saved measurements

In section (3) of the Quick Start you learned about saving measurements to a file.
Now we have a look at loading these measurements into the `MeasurementIterator` object
so you can use them as if you had a sensor.

~~~python
import json
from ondosense.connect.rs485.measurement import SerialMeasurementIterator

with open('result_data.json', 'r', encoding='utf8') as file:
    data = json.load(file)
    measurements = SerialMeasurementIterator().hydrate(data)

for meas in measurements:
    print(meas.distance.distance_in_m)
~~~

The essential part is using the `hydrate()` method on the `SerialMeasurementIterator`. From this step onwards,
the `SerialMeasurementItzerator` will behave exactly like real time sensor measurements.

## Logging

`SerialSensor`'s initializer accepts an optional `logger` argument, which must be a `logging.Logger` instance.

If the `logger` argument is not provided, `SerialSensor` will automatically create an internal logger
for outputting status information during runtime. This default logger logs all events above and including `logging.INFO`
and uses `logging.StreamHandler(sys.stdout)` to output to the system console.

The used `Logger` can be retrieved using `SerialSensor.get_logger()`.

Internally, `SerialSensor` creates several Loggers in this hierarchy: 

- root (default: `serial_sensor`)
  - `protocol`
    - `driver`
    - `serializer`

Whether a logger is explicitly passed or the default logger is used, `SerialSensor`
takes care of creating this logger hierarchy.


### Using the default Logger

~~~Python
from ondosense.connect.rs485.sensor import SerialSensor

import logging


# No logger is provided via the initializer
with SerialSensor('COM6') as sensor:
    logger = sensor.get_logger()  # get the logger
    logger.setLevel(logging.DEBUG)  # reconfigure the loglevel
~~~


### Using an external logger

~~~Python
from ondosense.connect.rs485.sensor import SerialSensor

import sys
import logging

# Creating and configuring a Logger instance
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter('%(asctime)s: %(message)s')
handler.setFormatter(formatter)

logger = logging.getLogger('my_logger')
logger.addHandler(handler)
logger.setLevel(logging.INFO)

# a Logger instance is passed via the logger argument
with SerialSensor('COM6', logger=logger) as sensor:
    print(sensor.get_parameter(0x44))
~~~

prints

~~~
2025-09-29 11:58:36,243: Protocol startup
2025-09-29 11:58:36,273: Driver connected
100
2025-09-29 11:58:36,294: Remaining bytes in read buffer: 0
2025-09-29 11:58:36,411: 2 bytes written to sensor.
2025-09-29 11:58:36,412: 5 bytes read from sensor.
2025-09-29 11:58:36,412: Driver disconnected
2025-09-29 11:58:36,412: Protocol shutdown
~~~


### Select log level of logging

`SerialSensor`'s default logger (when none has been passed to the initializer) will log all messages including and above
level `loggin.INFO`. To change this behavior during runtime, you can can do something like that:

~~~Python
from ondosense.connect.rs485.sensor import SerialSensor
import logging

with SerialSensor('COM6') as sensor:
    # from now on, only warnings will be logged
    sensor.get_logger().setLevel(logging.WARNING)
~~~


### Disabling logging altogether

When logging is not desired at all, you can provide a Logger which uses the `NullHandler`.
This effectively disables logging to the console.

~~~Python
from ondosense.connect.rs485.sensor import SerialSensor

import logging

logger = logging.getLogger('my_logger')
logger.addHandler(logging.NullHandler())

with SerialSensor('COM6', logger=logger) as sensor:
    print(sensor.get_parameter(0x44))
~~~

prints

~~~
100
~~~


## Software controlled power supply

`SerialSensor` supports switching a sensor on and off. To use this feature, you must use the `USB/RS485 Connector Board`
starting from Revision 03.

If you do not use this hardware, you can skip this chapter.

**Note:** To enable the support of software controlled power supply, you must enable this feature on the PCB with a jumper.
Please refer to the `USB/RS485 Connector Board` documentation for details.

To switching the sensor on and off, you can provide two additional arguments to `SerialSensor`'s
initializer: `power_on_grace_time` and `power_off_grace_time`:

~~~Python
from ondosense.connect.rs485.sensor import SerialSensor
from ondosense.connect.parameters_intern import *

# This switches the sensor on, waits 5 seconds, reads the MinDistanceInMm parameter
# and then shuts the sensor off and waits another second before continuing.
with SerialSensor('COM6', power_on_grace_time=5.0, power_off_grace_time=1.0) as sensor:
    print(sensor.get_parameter(MinDistanceInMm).value)

print('Sensor has been shut off.')
~~~

If `power_on_grace_time`'s value is greater than zero, `SerialSensor` will automatically try to switch on the sensor
and wait the specified time in seconds before trying to communicate with the sensor.
This ensures, the sensor has fully started up and is ready to communicate.

If `power_off_grace_time`'s value is greater than zeo, `SerialSensor` will wait the specified time in seconds
before powering off the sensor. This ensures the sensor can finish pending actions before beeing shut down.


## Plugins

`SerialSensor` offers a plugin interface to interact with the sensor communication on serial level.

The abstract base class for these plugins is defined by `DriverPlugin`.
You can find it in the module `ondosense.connect.generic.driver`.

All plugins must inherit from this class and implement the methods `on_write()` and `on_read()`.

Furthermore, it is highly recommended to also implement the methods `enable() -> Self` und `disable() -> Self`.
These can be used to enabled or disable a plugin during runtime.

You can find an example implementation (`PrintDriverCommunicationPlugin`) in the module `ondosense.connect.generic.driver`:

~~~python
from ondosense.connect.generic.driver import DriverPlugin

from typing import Self


class PrintDriverCommunicationPlugin(DriverPlugin):
    def __init__(self, read_prefix: str = "R: ", write_prefix: str = "W: "):
        super().__init__()

        self._enabled: bool = True
        self._read_prefix: str = read_prefix
        self._write_prefix: str = write_prefix

    def enable(self) -> Self:
        self._enabled = True
        return self

    def disable(self) -> Self:
        self._enabled = False
        return self

    def on_read(self, data: bytes) -> bytes:
        if self._enabled:
            hex_string = ' '.join(format(byte, '02X') for byte in data)
            print(f'{self._read_prefix}{hex_string}')

        return data

    def on_write(self, data: bytes) -> bytes:
        if self._enabled:
            hex_string = ' '.join(format(byte, '02X') for byte in data)
            print(f'{self._write_prefix}{hex_string}')

        return data
~~~

### Working with plugins

Plugins are passed in the `plugins` argument of `SerialSensor`'s initializer: 

~~~python
from ondosense.connect.rs485.sensor import SerialSensor
from ondosense.connect.parameters import SensorType, ExactSensorType
from ondosense.connect.plugin.driver import PrintDriverCommunicationPlugin

print_plugin = PrintDriverCommunicationPlugin()
driver_plugins = [print_plugin]

with SerialSensor('COM6', plugins=driver_plugins) as sensor:
    sensor_type = sensor.get_parameter(SensorType)
    print(f'Type........: {str(sensor_type)}')

    # Disable the plugin. From now on, the PrintDriverCommunicationPlugin does not print anything anymore.
    print_plugin.disable()

    exact_sensor_type = sensor.get_parameter(ExactSensorType)
    print(f'Exact Type..: {str(sensor_type)}')
~~~

prints

~~~
Protocol startup
Driver connected
W: 01
W: 52
R: 01
R: 00 00 00 11
Type........: apex
Exact Type..: apex
Remaining bytes in read buffer: 0
4 bytes written to sensor.
10 bytes read from sensor.
Driver disconnected
Protocol shutdown
~~~

### Available Plugins

#### `PrintDriverCommunicationPlugin`

This is the example plugin to demonstrate the implementation of a plugin.
For real world use cases, the `LoggingDriverCommunicationPlugin` should be preferred.

#### `LoggingDriverCommunicationPlugin`

This plugin uses a `logging.Logger` instance to log the sensor's serial communication.

The plugin can be enabled and disabled during runtime, using `enable()` und `disable()`.

The default log level of `LoggingDriverCommunicationPlugin` is `logging.DEBUG`.

for details regarding logging in general, please refer to [the official logging component documentation.](https://docs.python.org/3/library/logging.html)

**Using an external Logger**

~~~python
from ondosense.connect.rs485.sensor import SerialSensor
from ondosense.connect.plugin.driver import LoggingDriverCommunicationPlugin

import logging

logger = logging.getLogger('my_logger')

stream_handler = logging.StreamHandler()
logger.addHandler(stream_handler)
logger.setLevel(logging.INFO)

plugin = LoggingDriverCommunicationPlugin(logger, loglevel=logging.INFO)

with SerialSensor('COM6', plugins=[plugin]) as sensor:
   ...
~~~

**Using the default logger**

~~~python
from ondosense.connect.rs485.sensor import SerialSensor
from ondosense.connect.plugin.driver import LoggingDriverCommunicationPlugin

import logging

plugin = LoggingDriverCommunicationPlugin()

with SerialSensor('COM4', 19200, plugins=[plugin]) as sensor:
    sensor.get_logger().setLevel(logging.DEBUG)
    ...
~~~

**Note:** This works, because `LoggingDriverCommunicationPlugin` extends the `LoggerAwareDriverPlugin`.
Plugins extending this base class will receive a `logging.Logger` instance when passed to `SerialSensor`
as initializer argument.

## For developers

### High level architecture

There are four components, playing together:

- A `Sensor` class working as facade and central entry point for every interaction with the sensor
- A `Protocol` class, flanked by the `Serializer` class, which takes care of data transformation from and to the sensor
- A `Driver` class handling the actual communication over the given medium

Currently, `ondosense-connect` implements the serial/RS485 interface.

### Tests

Tests are implemented using `pytest`.
Requirements for the test suite are defined in `requirements.txt`.

All test cases can be found in the `/test` directory.

To specify the serial device name to run the tests against, please use the CLI argument `--serial-device=<device>`.

#### Test Groups

The test cases are organized in groups, using custom pytest *marks*.
There are tests which require sensor communication. These are marked with the marker `com`.
Other tests do not require sensor communication and are marked with `nocom`. 

**Invocation examples:**

~~~shell
pytest --m "nocom" # Run all tests marked with "nocom"
pytest --m "com" # Run all tests marked with "com"
~~~

### Error Handling

The `ondosense-connect` library will raise different error types depending on cause and underlying component.

All custom error classes extend `ondosense.connect.generic.error.OndosenseConnectError`.
This enabled the use of a `try-except` matching `OndosenseConnectError` to detect errors
during communication with the sensor.

**Warning:** There are exceptions to this rule.
Internal errors (e.g. invalid `(de)serialization-hints`) raise `ValueError` or `KeyErrors`.
However, as these errors are not expected to occur, using these untyped "low level" errors helps during debugging.

Generic base error classes:

- `ondosense.connect.generic.driver`
  - `DriverError` Generic base class indicating errors on the low-level communication layer
- `ondosense.connect.generic.protocol`
  - `ProtocolError` Generic base class indicating errors on the low-level protocol layer
- `ondosense.connect.generic.sensor`
  - `SensorError` Generic base class indicating usage errors
- `ondosense.connect.generic.serializer`
  - `SerializationError` Generic base class indicating errors during (de)serialization of data

The RS485/Serial implementation derives some more fine-grained exceptions:

- `ondosense.connect.rs485.driver`
  - `SerialError` Generic base class for errors in the serial communication layer
  - `ResponseTooShortError`
  - `NoResponseError`
- `ondosense.connect.rs485.protocol`
  - `SerialProtocolError` Generic base class for errors on the protocol layer
  - `ParameterError` Base class indicating an error when dealing with parameters
  - `ParameterWriteError`
  - `ParameterReadError`
  - `ExecuteCommandError`

### Event Handling

As the sensor may provide responses (e.g. a `Measurement`) with dynamic lengths, the deserialization component must
be able to control the number of bytes to be read from the serial interface.

To enable the serialization component to do this without (totally) breaking the separation of concerns,
the driver component listens to `ReadEvent` and `WriteEvent` events which can be emitted by the `SerialSerializer`.
The request and response data is then transported via the events, without the need to inject the `SerialDriver` instance
into the `SerialSerializer`.

The event handling component lives in the `ondosense.eventhandling` package. This component provides base
implementations for `Event` classes, an `EventSubscriber` and ean `EventDispatcher`.

The concrete implementation can be seen in the `ondosense.connect.rs485.driver` component.

### Known Issues / Limitations

- This library is not suited for timing critical applications (down to the ms) as it cannot ensure timings at OS
  and hardware level. However, for general purpose applications it is stable and well tested.
