Skip to content

powermeter: add support for SML over serial#229

Open
mvo5 wants to merge 1 commit intotomquist:mainfrom
mvo5:add-sml
Open

powermeter: add support for SML over serial#229
mvo5 wants to merge 1 commit intotomquist:mainfrom
mvo5:add-sml

Conversation

@mvo5
Copy link

@mvo5 mvo5 commented Feb 8, 2026

This commit adds support to read the power reading directly via the serial port and the SML protocol from energy meters that support outputing via the IR port.

(I'm using this since a couple of weeks and it seems to be stable for me)

Summary by CodeRabbit

  • New Features

    • Added SML powermeter support for reading power data from devices connected via USB serial ports.
    • Introduced NETMASK-based routing for multiple powermeters, allowing configuration to route power requests based on client IP addresses.
  • Documentation

    • Added SML device configuration guide with serial port setup examples.
    • Enhanced documentation for configuring multiple powermeters with default fallback settings.

This commit adds support to read the power reading directly
via the serial port and the SML protocol from energy meters
that support outputing via the IR port.
@coderabbitai
Copy link

coderabbitai bot commented Feb 8, 2026

Walkthrough

This PR introduces SML (Smart Meter Language) powermeter support by adding dependencies, implementing a serial-based power meter reader, extending the configuration system to recognize and instantiate SML powermeters, and documenting the feature with examples.

Changes

Cohort / File(s) Summary
Dependencies
Pipfile
Added smllib and pyserial packages for SML frame parsing and serial device communication.
Documentation & Examples
README.md, config.ini.example
Added SML configuration section with serial device settings and documented NETMASK-based powermeter selection logic for multiple powermeters.
Configuration Loader
config/config_loader.py
Extended powermeter factory with SML_SECTION constant and create_sml_powermeter function to instantiate SML powermeters from configuration.
Powermeter Module
powermeter/__init__.py
Exported new Sml class to make it available as part of the powermeter package public API.
SML Powermeter Implementation
powermeter/sml.py
New Sml class implementing serial-based power meter reading with EnergyStats dataclass for parsing SML frames, OBIS field extraction, thread-safe serial access, and CRC error handling.

Sequence Diagram(s)

sequenceDiagram
    participant Config as Config Loader
    participant Sml as Sml Instance
    participant Serial as Serial Device
    participant Parser as smllib
    participant Stats as EnergyStats

    Config->>Sml: create_sml_powermeter(config)
    Sml->>Sml: __init__(serial_device)

    Note over Sml: Later: Power Reading Request
    Sml->>Sml: get_powermeter_watts()
    Sml->>Sml: read_serial()
    Sml->>Serial: acquire lock & open serial
    Serial-->>Sml: serial data stream
    Sml->>Parser: SmlStreamReader.feed(data)
    Parser-->>Sml: parsed SmlFrame
    Sml->>Stats: from_sml_frame(frame)
    Stats-->>Sml: EnergyStats with power values
    Sml->>Sml: return [current_power_watts]
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'powermeter: add support for SML over serial' accurately and concisely describes the main change—adding SML protocol support for serial port communication in the powermeter module.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@config/config_loader.py`:
- Around line 170-174: create_sml_powermeter currently passes an empty string as
fallback so Sml(config.get(..., fallback="")) overrides Sml's default
"/dev/ttyUSB0" and can cause serial-open failures; change create_sml_powermeter
to read the SERIAL value (e.g. via config.get with fallback=None or similar) and
if the retrieved value is falsy/empty call Sml() without arguments (or
explicitly pass "/dev/ttyUSB0"), otherwise pass the retrieved serial string to
Sml; update the logic inside create_sml_powermeter (reference function name
create_sml_powermeter and class Sml) so the Sml constructor default remains
reachable.

In `@powermeter/sml.py`:
- Around line 75-91: In read_serial, the logger.debug call in function
read_serial (and its message string) contains a typo "attemps"; update the log
message in the logger.debug invocation inside read_serial (where it logs "got
sml frame: %s after %s attemps") to use the correct word "attempts" so the
message reads "... after %s attempts"; ensure only the string is changed and
formatting/arguments (self._current, i) remain unchanged.
- Around line 28-41: The from_sml_frame method in EnergyStats uses assert for
unit checks which can be disabled; replace those asserts with explicit runtime
validation that raises a clear exception (e.g., ValueError) when UNITS[ov.unit]
is not the expected unit for the given OBIS code (_OBIS_POWER_CURRENT -> "W",
_OBIS_POWER_IN_TOTAL/_OBIS_POWER_OUT_TOTAL -> "Wh"); validate each ov from
sml_frame.get_obis() before assigning stats.current_power, stats.total_power_in,
and stats.total_power_out and include the actual ov.unit and ov.value in the
error message to aid debugging.
🧹 Nitpick comments (2)
powermeter/sml.py (2)

58-73: _try_read_frame returns None even after a successful stream.add — consider documenting or restructuring.

When sml_frame is None and ser.read returns data, the method adds data to the stream but still returns None. The frame will only be retrieved on the next call to _try_read_frame. This works because of the retry loop in read_serial, but the method name _try_read_frame is slightly misleading since it may add data without attempting to parse again.

Also, the bare except Exception (line 64) is flagged by Ruff (BLE001). Consider narrowing to smllib.errors.SmlError or whatever the library's base exception is, if one exists, to avoid masking unrelated bugs.


75-91: Silent fallback to stale/zero data when all read attempts fail.

If all 10 iterations exhaust without a valid frame, read_serial logs an error but get_powermeter_watts still returns self._current.current_power, which is either the previous reading or the default 0. The caller has no indication the reading is stale or failed.

This may be acceptable for resilience (the author mentions stable behavior over weeks), but worth noting that downstream consumers will silently receive outdated values.

Comment on lines +170 to +174
def create_sml_powermeter(
section: str, config: configparser.ConfigParser
) -> Powermeter:
return Sml(config.get(section, "SERIAL", fallback=""))

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fallback "" overrides the Sml constructor's default of "/dev/ttyUSB0".

If a user adds an [SML] section without specifying SERIAL, the fallback="" here will pass an empty string to Sml(), which will fail when opening the serial port. The Sml.__init__ default of "/dev/ttyUSB0" is effectively unreachable. Consider aligning the fallback:

Proposed fix
-    return Sml(config.get(section, "SERIAL", fallback=""))
+    return Sml(config.get(section, "SERIAL", fallback="/dev/ttyUSB0"))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def create_sml_powermeter(
section: str, config: configparser.ConfigParser
) -> Powermeter:
return Sml(config.get(section, "SERIAL", fallback=""))
def create_sml_powermeter(
section: str, config: configparser.ConfigParser
) -> Powermeter:
return Sml(config.get(section, "SERIAL", fallback="/dev/ttyUSB0"))
🤖 Prompt for AI Agents
In `@config/config_loader.py` around lines 170 - 174, create_sml_powermeter
currently passes an empty string as fallback so Sml(config.get(...,
fallback="")) overrides Sml's default "/dev/ttyUSB0" and can cause serial-open
failures; change create_sml_powermeter to read the SERIAL value (e.g. via
config.get with fallback=None or similar) and if the retrieved value is
falsy/empty call Sml() without arguments (or explicitly pass "/dev/ttyUSB0"),
otherwise pass the retrieved serial string to Sml; update the logic inside
create_sml_powermeter (reference function name create_sml_powermeter and class
Sml) so the Sml constructor default remains reachable.

Comment on lines +28 to +41
def from_sml_frame(cls, sml_frame: SmlFrame) -> "EnergyStats":
stats = cls()
for ov in sml_frame.get_obis():
if ov.obis == _OBIS_POWER_CURRENT:
stats.current_power = ov.value
assert UNITS[ov.unit] == "W"
elif ov.obis == _OBIS_POWER_IN_TOTAL:
stats.total_power_in = ov.value
assert UNITS[ov.unit] == "Wh"
elif ov.obis == _OBIS_POWER_OUT_TOTAL:
stats.total_power_out = ov.value
assert UNITS[ov.unit] == "Wh"
stats.when = datetime.datetime.now()
return stats
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace assert with explicit validation — assertions are stripped under python -O.

Using assert for runtime validation (lines 33, 36, 39) is unsafe in production: running Python with -O (optimize) disables all assertions silently, allowing incorrect unit data to be accepted without any error.

🛡️ Proposed fix
             if ov.obis == _OBIS_POWER_CURRENT:
                 stats.current_power = ov.value
-                assert UNITS[ov.unit] == "W"
+                if UNITS.get(ov.unit) != "W":
+                    raise ValueError(f"Unexpected unit for current power: {UNITS.get(ov.unit)}")
             elif ov.obis == _OBIS_POWER_IN_TOTAL:
                 stats.total_power_in = ov.value
-                assert UNITS[ov.unit] == "Wh"
+                if UNITS.get(ov.unit) != "Wh":
+                    raise ValueError(f"Unexpected unit for total power in: {UNITS.get(ov.unit)}")
             elif ov.obis == _OBIS_POWER_OUT_TOTAL:
                 stats.total_power_out = ov.value
-                assert UNITS[ov.unit] == "Wh"
+                if UNITS.get(ov.unit) != "Wh":
+                    raise ValueError(f"Unexpected unit for total power out: {UNITS.get(ov.unit)}")
🤖 Prompt for AI Agents
In `@powermeter/sml.py` around lines 28 - 41, The from_sml_frame method in
EnergyStats uses assert for unit checks which can be disabled; replace those
asserts with explicit runtime validation that raises a clear exception (e.g.,
ValueError) when UNITS[ov.unit] is not the expected unit for the given OBIS code
(_OBIS_POWER_CURRENT -> "W", _OBIS_POWER_IN_TOTAL/_OBIS_POWER_OUT_TOTAL ->
"Wh"); validate each ov from sml_frame.get_obis() before assigning
stats.current_power, stats.total_power_in, and stats.total_power_out and include
the actual ov.unit and ov.value in the error message to aid debugging.

Comment on lines +75 to +91
def read_serial(self) -> None:
if not self._lock.acquire(blocking=False):
return
try:
stream = SmlStreamReader()
with serial.Serial(self._serial_device, 9600, timeout=10) as ser:
data = ser.read(512)
stream.add(data)
for i in range(10):
sml_frame = self._try_read_frame(ser, stream)
if sml_frame is not None:
self._current = EnergyStats.from_sml_frame(sml_frame)
logger.debug("got sml frame: %s after %s attemps", self._current, i)
return
logger.error("failed to read SML frame after 10 attempts")
finally:
self._lock.release()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Typo: "attemps" → "attempts" on line 87.

📝 Fix
-                        logger.debug("got sml frame: %s after %s attemps", self._current, i)
+                        logger.debug("got sml frame: %s after %s attempts", self._current, i)
🤖 Prompt for AI Agents
In `@powermeter/sml.py` around lines 75 - 91, In read_serial, the logger.debug
call in function read_serial (and its message string) contains a typo "attemps";
update the log message in the logger.debug invocation inside read_serial (where
it logs "got sml frame: %s after %s attemps") to use the correct word "attempts"
so the message reads "... after %s attempts"; ensure only the string is changed
and formatting/arguments (self._current, i) remain unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant