Metadata-Version: 2.4
Name: sblcp
Version: 0.1.0
Summary: Coordinator library and simulator for the Smart Breaker Local Communication Protocol (SBLCP), an Eaton EMLCP-family local UDP protocol
Author: Clark Communications Corporation
License-Expression: MIT
Project-URL: Homepage, https://github.com/electrification-bus/sblcp
Project-URL: Source, https://github.com/electrification-bus/sblcp
Project-URL: Changelog, https://github.com/electrification-bus/sblcp/blob/main/CHANGELOG.md
Keywords: sblcp,emlcp,eaton,smart-breaker,udp,energy
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Home Automation
Classifier: Topic :: System :: Networking
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: crypto
Requires-Dist: cryptography>=41; extra == "crypto"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: cryptography>=41; extra == "dev"
Dynamic: license-file

# sblcp

[![PyPI](https://img.shields.io/pypi/v/sblcp.svg)](https://pypi.org/project/sblcp/)
[![CI](https://github.com/electrification-bus/sblcp/actions/workflows/test.yml/badge.svg)](https://github.com/electrification-bus/sblcp/actions/workflows/test.yml)
[![Lint](https://github.com/electrification-bus/sblcp/actions/workflows/lint.yml/badge.svg)](https://github.com/electrification-bus/sblcp/actions/workflows/lint.yml)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)

A Python **coordinator** library and **breaker simulator** for the **Smart Breaker Local Communication Protocol (SBLCP)**, the local UDP protocol spoken by Eaton AbleEdge Smart Breaker 2.0 devices on the LAN. SBLCP is the AbleEdge instantiation of Eaton's EMLCP family (the same lineage as the first-generation EMCB and Eaton's [`emcb-udp-coordinator`](https://github.com/EatonEM/emcb-udp-coordinator)).

> Status: **early, working.** The wire codec (framing, HMAC-SHA256 signing, sequence-number handling), the coordinator client, and a breaker simulator are implemented and tested against spec-derived vectors, including a coordinator↔simulator integration test over UDP loopback. Payload codecs cover **every fixed message code**: sequence sync, meter telemetry, device status, primary/remote handle read + write, trip log, energy reset, factory reset, production details, time sync, debug log + level, WiFi RSSI, identify-me, the Local-OTA HTTP server, **UDP key management** (TTL query + rotation), and **`WHO_ARE_YOU`** broadcast discovery (hash-based IP↔breaker identification via `Coordinator.who_are_you`).

Rotating a key requires encrypting it under the device's secondary unicast key (AES-256-GCM); that lives in `sblcp.crypto` behind an optional dependency — `pip install sblcp[crypto]`. The core library is standard-library-only.

This library is deliberately **standalone**: it implements the SBLCP wire protocol and nothing more. It has no runtime dependencies beyond the Python standard library and no coupling to any particular application that consumes it, so it can back a coordinator daemon, a test harness, or a higher-level integration equally well.

## What SBLCP is

- Transport: **UDP** on **port 32866** over a 2.4 GHz WiFi LAN, IPv4 unicast and subnet broadcast.
- Model: **coordinator → node.** The coordinator (this library) always initiates; the breaker (node) validates and responds.
- Security: every message carries a 32-byte **HMAC-SHA256** signature computed with a shared **UDP key**, plus a **sequence number** to defeat replays. Keys are 32 bytes, come in unicast and broadcast sets (each with a primary and secondary), and expire after 7 days.
- Framing: a `ETNM` (coordinator) / `ETNS` (node) start marker, a little-endian `u32` sequence number, a big-endian `u16` message code, a little-endian message body, and the 32-byte hash footer. Minimum 42 bytes.

## Scope

- **In scope:** the SBLCP fixed message codes (device status, meter telemetry, remote handle position, trip log, energy reset, time sync, RSSI, UDP-key management, sequence-number sync, ...), the coordinator transport, key management, node discovery (both the signed `WHO_ARE_YOU` kind and the keyless ARP kind, see [`sblcp-arp`](#finding-breakers-before-they-have-keys-sblcp-arp)), and a software simulator for hardware-free development and CI.
- **Out of scope (intentionally):** a vendor extension envelope that carries a separate, non-public message model. This library implements the fixed message codes only; a raw payload for such a code can still be sent over the same transport via the low-level `request()` API.

## Interface

See [`doc/INTERFACE.md`](doc/INTERFACE.md) for the full Python API: the `Coordinator` client and its operations, the `KeySet` key model, the typed payloads and enums, the low-level `request()` escape hatch, and the `BreakerNode` simulator.

## Install

```bash
pip install -e ".[dev]"
```

## Run the simulator

```bash
sblcp-sim            # binds a fake breaker node on :32866
```

## Finding breakers before they have keys (`sblcp-arp`)

SBLCP's own discovery signs every datagram, so `Coordinator.discover()` and `who_are_you()` are useless until a device has been provisioned. That is a real gap: a factory-fresh breaker is on your network and answering ARP, but nothing at the application layer.

`sblcp-arp` closes it by working purely from the host's ARP cache, which needs no keys and no root:

```bash
sblcp-arp                                  # scan the local /24, list everything
sblcp-arp 192.168.1.0/24 --oui 2c:bc:bb    # only devices from one vendor
sblcp-arp --no-populate --mac-suffix ab:cd:ef
```

```text
nudging 192.168.1.0/24 ...
192.168.1.200    2c:bc:bb:ab:cd:ef  on en0
```

Two filters, for two different situations:

| Filter | Use it when |
| --- | --- |
| `--mac-suffix ab:cd:ef` | You know part of one device's MAC and want its IP and full address. Reliable |
| `--oui 2c:bc:bb` | You want a rough sweep for one vendor prefix. Incomplete by nature, see below |

**Prefer the suffix.** A device that advertises over Bluetooth gives you only its low three octets, and a suffix match turns that into a full MAC and an IP without needing to know the vendor prefix at all.

An OUI filter finds only devices using the prefix you name, and a vendor may well use several. Two AbleEdge breakers observed on one network reported `2C:BC:BB` and `F0:24:F9`: enumerating by either prefix alone would have found one and silently missed the other. Treat `--oui` as a starting point for exploration, never as a complete inventory.

### How the scan works

By default the target network is nudged first: one empty UDP datagram is sent to every address, on the discard port. The kernel has to resolve each address to send, which populates the ARP cache as a side effect. Hosts ignore the datagram, as they should, but their network stack still answers the ARP.

That approach is deliberate. It needs no elevated privileges, crafts no raw frames, and shells out to nothing, so the package stays standard-library-only and works the same on macOS, BSD and Linux. Pass `--no-populate` to read the cache as it stands, which is faster and completely silent on the wire.

Reading the cache tries `arp -an` first and falls back to `ip neigh`, so hosts without net-tools are covered. Unresolved neighbours are skipped: an incomplete entry means something was asked about, not that anything answered.

### From Python

```python
from sblcp import find_hosts

# Resolve one device from the low-order octets in its BLE advertising name.
for entry in find_hosts("192.168.1.0/24", mac_suffix="ab:cd:ef"):
    print(entry.ip, entry.mac, entry.interface)
```

`find_hosts()` returns `ArpEntry` records sorted numerically by address. Also exported: `read_arp_table()`, `populate_arp_cache()`, and `parse_arp_output()` if you already have output to parse.

No OUI is built into this package. It implements a protocol, not a vendor's product, so pass the prefix you care about.

## Releasing

The version has a single source of truth: `__version__` in [`src/sblcp/__init__.py`](src/sblcp/__init__.py). `pyproject.toml` reads it dynamically (`dynamic = ["version"]` plus `[tool.setuptools.dynamic]`), so there is exactly one line to bump. This repo is modern-build only: there is no `setup.py` shim and no second copy to keep in sync.

To cut a release:

1. Bump `__version__` in `src/sblcp/__init__.py` (the only place).
2. Move the `CHANGELOG.md` `[Unreleased]` entries into a new version section.
3. Commit (`git commit -am "release X.Y.Z"`).
4. Build the artifacts: `python -m build` produces `dist/sblcp-X.Y.Z*` carrying the version from `__init__.py`.

There is no automated PyPI publish workflow yet (the package is not on PyPI). When one is added (`on: push: tags: v*`), it should also carry the "verify the tag equals `v$__version__`" guard step from the [version-single-source convention](https://github.com/electrification-bus/specification/blob/main/conventions/version-single-source.md) so a tag can never disagree with the packaged version.

## License

[MIT](LICENSE) © Clark Communications Corporation
