Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,30 @@
All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).

## [2.1.0] - 2026-09-29

Structured logs: every record names its kind in an `event` field and carries its context, so logs can be filtered and
aggregated without parsing messages (e.g. `event:serial_error` in VictoriaLogs). The events and their fields are listed
in the [monitoring guide](https://j5lien.github.io/enocean/guides/monitoring/#logs).

### Added

- `event` field on the library's log records: `packet_received`, `packet_sent`, `teach_in`, `base_id_received`,
`base_id_timeout`, `communicator_started`/`stopped`, `client_connected`/`disconnected`, `serial_error`,
`processing_error`, `header_crc_error`, `data_crc_error`, `malformed_packet`, `unknown_profile`, `unknown_field`...
- Their context as fields: `base_id`; `transport`, `port`, `host` and `client` for the transports; `error` for serial
port errors (also in the message, it was lost); `length` of a corrupted packet; `data` and `optional` (hex) of a
malformed one, to replay it; `eep` and `command` of an unknown profile; `shortcut` of an unknown field; `outcome` of
a teach-in.
- Packet records also carry `destination`, `status`, `repeater_count` and `eep`.
- `SerialCommunicator.port`, the serial port it was opened on.

### Changed

- The `packet_type` and `rorg` fields of log records are enum names (`RADIO_ERP1`, `VLD`) instead of integers, as in
`packet.to_dict()`: log queries on their numeric values need updating.
- The communicators' started/stopped messages name the port, and the TCP client messages the client's address.

## [2.0.2] - 2026-09-29

### Fixed
Expand Down Expand Up @@ -148,6 +172,7 @@ First release of the [j5lien/enocean](https://github.com/j5lien/enocean) fork of

Last release of [kipe/enocean](https://github.com/kipe/enocean).

[2.1.0]: https://github.com/j5lien/enocean/compare/2.0.2...2.1.0
[2.0.2]: https://github.com/j5lien/enocean/compare/2.0.1...2.0.2
[2.0.1]: https://github.com/j5lien/enocean/compare/2.0.0...2.0.1
[2.0.0]: https://github.com/j5lien/enocean/compare/1.0.0...2.0.0
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,10 @@ The `enocean` logger has a `NullHandler`: the library is silent unless the appli
expected radio noise (header CRC errors while resynchronizing), INFO for lifecycle events (started/stopped, base ID,
teach-in answered), WARNING for dropped or unusable data (data CRC error, malformed packet, unknown profile), ERROR
for transport failures, `logger.exception` for errors raised while processing packets (e.g. in user callbacks). Pass
lazy `%s` args, never pre-formatted strings, and `extra=packet_log_fields(packet)` on packet-related records so
structured handlers get `packet_type`/`rorg`/`sender`/`dbm`.
lazy `%s` args, never pre-formatted strings. Every record gets an `event` field (snake_case, e.g. `base_id_received`)
plus its context as structured fields, through `extra=`; packet-related records use
`extra=packet_log_fields(packet, event)`, whose names and formats match `Packet.to_dict()`. The events are listed in
`docs/guides/monitoring.md`: update it with new ones.

## Documentation

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ This is a maintained fork of [kipe/enocean](https://github.com/kipe/enocean), wh
Requires Python 3.10 or later.

```bash
pip install git+https://github.com/j5lien/enocean.git@2.0.2
pip install git+https://github.com/j5lien/enocean.git@2.1.0
# or, in a uv-managed project
uv add git+https://github.com/j5lien/enocean.git --tag 2.0.2
uv add git+https://github.com/j5lien/enocean.git --tag 2.1.0
```

Wheels are also attached to each [GitHub release](https://github.com/j5lien/enocean/releases); see
Expand Down Expand Up @@ -132,8 +132,9 @@ More in [`examples/`](examples/). `TCPCommunicator` receives packets over TCP in
## Monitoring

**Logs.** The library is silent until you configure logging. `enocean.consolelogger.init_logging()` sends its logs to
stderr, or as one JSON object per line with `init_logging(json_format=True)`; packet-related records carry
`packet_type`, `rorg`, `sender` and `dbm` fields.
stderr, or as one JSON object per line with `init_logging(json_format=True)`; records carry an `event` field
(`packet_received`, `teach_in`, `serial_error`...) and structured fields such as `sender`, `rorg`, `eep` or `dbm`, named
as in `packet.to_dict()` (see the [monitoring guide](https://j5lien.github.io/enocean/guides/monitoring/#logs)).

**Statistics and health**, always available, no dependency:

Expand Down
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,4 @@ aujourd'hui) ; on **s'aligne sur les noms officiels** des champs (version 2.0).
- [x] Release 2.0.0 : profils officiels, API d'intégration, supervision, documentation
- [x] Release 2.0.1 : `status` et `repeater_count` des télégrammes VLD (lus dans le niveau de sécurité)
- [x] Release 2.0.2 : `dbm` à `None` au lieu de -255 pour les télégrammes à envoyer
- [x] Release 2.1.0 : logs structurés (champ `event` et contexte sur chaque log, champs de paquet alignés sur `to_dict()`)
6 changes: 3 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
## Installation

```bash
pip install git+https://github.com/j5lien/enocean.git@2.0.2
pip install git+https://github.com/j5lien/enocean.git@2.1.0
# or, in a uv-managed project
uv add git+https://github.com/j5lien/enocean.git --tag 2.0.2
uv add git+https://github.com/j5lien/enocean.git --tag 2.1.0
# with Prometheus metrics
pip install "enocean[prometheus] @ git+https://github.com/j5lien/enocean.git@2.0.2"
pip install "enocean[prometheus] @ git+https://github.com/j5lien/enocean.git@2.1.0"
```

## Finding the module's serial port
Expand Down
28 changes: 26 additions & 2 deletions docs/guides/monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,32 @@ init_logging(level=logging.INFO, json_format=True)
```

Levels: DEBUG for per-packet traffic and expected radio noise, INFO for lifecycle events (started, base ID, teach-in
answered), WARNING for dropped data (corrupted telegram, unknown profile), ERROR for transport failures. Packet-related
records carry `packet_type`, `rorg`, `sender` and `dbm` fields.
answered), WARNING for dropped data (corrupted telegram, unknown profile), ERROR for transport failures.

Records carry structured fields, emitted by the JSON format (and available on the `LogRecord` to any handler). Every
record (but a few DEBUG traces) has an `event` field naming its kind, to filter on it:

| `event` | Level | Other fields |
| --- | --- | --- |
| `packet_received`, `packet_sent` | DEBUG | the packet's fields (below) |
| `teach_in` | INFO | the packet's fields, `outcome` (`accepted`, `deleted`, `profile not supported`) |
| `base_id_received` | INFO | `base_id` (`FF:87:CA:00`) |
| `base_id_timeout` | WARNING | |
| `communicator_started`, `communicator_stopped` | INFO | `transport` (`serial`, `tcp`), `port`, `host` (TCP) |
| `client_connected`, `client_disconnected` | DEBUG | `transport`, `host`, `port`, `client` (`192.168.1.10:51234`) |
| `serial_error` | ERROR | `transport`, `port`, `error` |
| `processing_error` | ERROR | the traceback (e.g. of an exception raised by the callback) |
| `header_crc_error` | DEBUG | |
| `data_crc_error` | WARNING | `packet_type`, `length` |
| `malformed_packet` | WARNING | `packet_type`, `data`, `optional` (hex, to replay it) |
| `unknown_profile` | WARNING | `eep` (`D2-01-12`), `command` |
| `unknown_field` | WARNING | `shortcut` |
| `invalid_packet` | ERROR | (`send()` was given something else than a packet) |
| `profiles_not_loaded` | WARNING | (broken installation) |

The packet's fields use the names and formats of `packet.to_dict()`: `packet_type` and `rorg` (enum names, e.g.
`RADIO_ERP1`, `VLD`), and for radio telegrams `sender`, `destination`, `dbm` (None for telegrams to send), `status`,
`repeater_count` and `eep` (once decoded, e.g. with a device registry).

## Statistics and health

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ actuators…) through an EnOcean USB/serial module (e.g. USB 300) or a TCP bridg
It requires Python 3.10 or later, and has one dependency: `pyserial`.

```bash
pip install git+https://github.com/j5lien/enocean.git@2.0.2
pip install git+https://github.com/j5lien/enocean.git@2.1.0
```

```python
Expand Down
49 changes: 35 additions & 14 deletions enocean/communicators/communicator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,35 @@
from typing import TypeGuard

from enocean.devices import Device, DeviceRegistry
from enocean.protocol.constants import PACKET, PARSE_RESULT, RETURN_CODE
from enocean.protocol.packet import Packet, RadioPacket, ResponsePacket, UTETeachInPacket
from enocean.protocol.constants import PACKET, PARSE_RESULT, RETURN_CODE, RORG
from enocean.protocol.packet import Packet, RadioPacket, ResponsePacket, UTETeachInPacket, enum_name
from enocean.stats import CommunicatorStats, Health
from enocean.utils import to_hex_string


def packet_log_fields(packet: Packet) -> dict[str, object]:
"""Fields attached to log records about a packet, for structured log handlers."""
fields: dict[str, object] = {'packet_type': int(packet.packet_type)}
def packet_log_fields(packet: Packet, event: str | None = None) -> dict[str, object]:
"""Fields attached to log records about a packet, for structured log handlers.

The same names and formats as Packet.to_dict(): enum names, hex IDs.

Args:
packet: The packet the record is about.
event: The record's `event` field (e.g. 'packet_received'), to filter records by kind.
"""
fields: dict[str, object] = {'packet_type': enum_name(PACKET, packet.packet_type)}
if event is not None:
fields['event'] = event
if isinstance(packet, RadioPacket):
fields['rorg'] = int(packet.rorg)
fields['sender'] = packet.sender_hex
fields['dbm'] = packet.dbm
eep_id = packet.eep_id
fields.update(
rorg=enum_name(RORG, packet.rorg),
sender=packet.sender_hex,
destination=packet.destination_hex,
dbm=packet.dbm,
status=packet.status,
repeater_count=packet.repeater_count,
eep=str(eep_id) if eep_id else None,
)
return fields


Expand Down Expand Up @@ -80,7 +96,7 @@ def _get_from_send_queue(self) -> Packet | None:
"""Get message from send queue, if one exists."""
try:
packet = self.transmit.get(block=False)
self.logger.debug('Sending %s', packet, extra=packet_log_fields(packet))
self.logger.debug('Sending %s', packet, extra=packet_log_fields(packet, 'packet_sent'))
return packet
except queue.Empty:
pass
Expand All @@ -93,7 +109,7 @@ def send(self, packet: Packet) -> bool:
False if packet isn't a Packet.
"""
if not isinstance(packet, Packet):
self.logger.error('Object to send must be an instance of Packet')
self.logger.error('Object to send must be an instance of Packet', extra={'event': 'invalid_packet'})
return False
self.transmit.put(packet)
return True
Expand Down Expand Up @@ -128,7 +144,10 @@ def parse(self) -> PARSE_RESULT:

if self._base_id_requested and self._is_base_id_response(packet):
self._base_id = packet.response_data
self.logger.info('Base ID of the module: %s', to_hex_string(self._base_id))
base_id = to_hex_string(self._base_id)
self.logger.info(
'Base ID of the module: %s', base_id, extra={'event': 'base_id_received', 'base_id': base_id}
)
self._base_id_requested = False
self.stats.record_base_id_received(time.monotonic() - self._base_id_requested_at)
self._base_id_received.set()
Expand All @@ -139,7 +158,7 @@ def parse(self) -> PARSE_RESULT:
if self.base_id is not None:
self._answer_pending_teach_ins()

self.logger.debug('Received %s', packet, extra=packet_log_fields(packet))
self.logger.debug('Received %s', packet, extra=packet_log_fields(packet, 'packet_received'))
if self.__callback is None:
self.receive.put(packet)
else:
Expand Down Expand Up @@ -167,7 +186,7 @@ def _answer_pending_teach_ins(self) -> None:
packet.sender_hex,
packet.eep_id,
outcome,
extra=packet_log_fields(packet),
extra={**packet_log_fields(packet, 'teach_in'), 'outcome': outcome},
)
self.send(packet.create_response_packet(self._base_id, response))
self.stats.record_teach_in_response()
Expand Down Expand Up @@ -277,7 +296,9 @@ def base_id(self) -> list[int] | None:

self._request_base_id()
if threading.current_thread() is not self and not self._base_id_received.wait(1):
self.logger.warning('No response from the module to the Base ID request.')
self.logger.warning(
'No response from the module to the Base ID request.', extra={'event': 'base_id_timeout'}
)
self.stats.record_base_id_timeout()
self._base_id_requested = False
# Return the current Base ID (might be None).
Expand Down
26 changes: 19 additions & 7 deletions enocean/communicators/serialcommunicator.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,14 @@ def __init__(
serial.SerialException: The port can't be opened.
"""
super().__init__(callback, teach_in, devices)
self.port = port
# Initialize serial port
self.__ser = serial.Serial(port, 57600, timeout=0.1)
self._transport_ready = True

def run(self) -> None:
"""Thread body: write queued packets, read and parse bytes until stopped or the device disconnects."""
self.logger.info('SerialCommunicator started')
self.logger.info('SerialCommunicator started on %s', self.port, extra=self._log_fields('communicator_started'))
while not self._stop_flag.is_set():
# If there's messages in transmit queue
# send them
Expand All @@ -56,8 +57,12 @@ def run(self) -> None:
data = bytearray(packet.build())
try:
self.__ser.write(data)
except serial.SerialException:
self.logger.error('Serial port exception while writing! (device disconnected?)')
except serial.SerialException as error:
self.logger.error(
'Serial port exception while writing! (device disconnected?) %s',
error,
extra={**self._log_fields('serial_error'), 'error': str(error)},
)
self.stats.record_transport_error()
self._transport_ready = False
self.stop()
Expand All @@ -67,8 +72,12 @@ def run(self) -> None:
# Read chars from serial port as hex numbers
try:
self._feed(self.__ser.read(16))
except serial.SerialException:
self.logger.error('Serial port exception! (device disconnected or multiple access on port?)')
except serial.SerialException as error:
self.logger.error(
'Serial port exception! (device disconnected or multiple access on port?) %s',
error,
extra={**self._log_fields('serial_error'), 'error': str(error)},
)
self.stats.record_transport_error()
self._transport_ready = False
self.stop()
Expand All @@ -77,11 +86,14 @@ def run(self) -> None:
self.parse()
except Exception:
# Most likely raised by the user's callback: keep running, but with the full traceback
self.logger.exception('Error while processing received packets')
self.logger.exception('Error while processing received packets', extra={'event': 'processing_error'})
self.stats.record_processing_error()

time.sleep(0)

self.__ser.close()
self._transport_ready = False
self.logger.info('SerialCommunicator stopped')
self.logger.info('SerialCommunicator stopped on %s', self.port, extra=self._log_fields('communicator_stopped'))

def _log_fields(self, event: str) -> dict[str, object]:
return {'event': event, 'transport': 'serial', 'port': self.port}
28 changes: 23 additions & 5 deletions enocean/communicators/tcpcommunicator.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ def __init__(

def run(self) -> None:
"""Thread body: accept clients and parse the bytes they send until stopped."""
self.logger.info('TCPCommunicator started')
self.logger.info(
'TCPCommunicator started on %s:%s', self.host, self.port, extra=self._log_fields('communicator_started')
)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind((self.host, self.port))
sock.listen(5)
Expand All @@ -54,7 +56,12 @@ def run(self) -> None:
(client, addr) = sock.accept()
except TimeoutError:
continue
self.logger.debug('Client "%s" connected', addr)
client_address = '%s:%s' % addr[:2]
self.logger.debug(
'Client "%s" connected',
client_address,
extra={**self._log_fields('client_connected'), 'client': client_address},
)
client.settimeout(0.5)
while not self._stop_flag.is_set():
try:
Expand All @@ -68,10 +75,21 @@ def run(self) -> None:
self.parse()
except Exception:
# Most likely raised by the user's callback: keep serving, but with the full traceback
self.logger.exception('Error while processing received packets')
self.logger.exception(
'Error while processing received packets', extra={'event': 'processing_error'}
)
self.stats.record_processing_error()
client.close()
self.logger.debug('Client disconnected')
self.logger.debug(
'Client "%s" disconnected',
client_address,
extra={**self._log_fields('client_disconnected'), 'client': client_address},
)
sock.close()
self._transport_ready = False
self.logger.info('TCPCommunicator stopped')
self.logger.info(
'TCPCommunicator stopped on %s:%s', self.host, self.port, extra=self._log_fields('communicator_stopped')
)

def _log_fields(self, event: str) -> dict[str, object]:
return {'event': event, 'transport': 'tcp', 'host': self.host, 'port': self.port}
Loading
Loading