ekm-proxy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clark Communications Corporation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: ekm-proxy
3
+ Version: 0.1.0
4
+ Summary: Proxy EKM Metering devices onto the Electrification Bus (eBus / Homie 5)
5
+ Author: Clark Communications Corporation
6
+ License-Expression: MIT
7
+ Keywords: ekm,omnimeter,iostack,metering,sub-metering,ebus,homie,proxy
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: ebus-sdk>=0.11.0
12
+ Requires-Dist: paho-mqtt>=2.0
13
+ Requires-Dist: requests>=2.28
14
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
15
+ Provides-Extra: rs485
16
+ Requires-Dist: pyserial>=3.5; extra == "rs485"
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # ekm-proxy
22
+
23
+ [![PyPI](https://img.shields.io/pypi/v/ekm-proxy.svg)](https://pypi.org/project/ekm-proxy/)
24
+ [![Test](https://github.com/electrification-bus/ekm-proxy/actions/workflows/test.yml/badge.svg)](https://github.com/electrification-bus/ekm-proxy/actions/workflows/test.yml)
25
+ [![Lint](https://github.com/electrification-bus/ekm-proxy/actions/workflows/lint.yml/badge.svg)](https://github.com/electrification-bus/ekm-proxy/actions/workflows/lint.yml)
26
+ [![spec drift](https://github.com/electrification-bus/ekm-proxy/actions/workflows/spec-drift.yml/badge.svg)](https://github.com/electrification-bus/ekm-proxy/actions/workflows/spec-drift.yml)
27
+ [![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)
28
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
29
+
30
+ Publishes **EKM Metering** devices (Omnimeter meters, and ioStack IO/sensor modules) onto the **Electrification Bus (eBus)** as Homie 5 devices.
31
+
32
+ It is an eBus **proxier** (see the eBus [`proxy.md`](https://github.com/electrification-bus/specification/blob/main/data-models/proxy.md) pattern): EKM meters do not speak eBus natively, so this service reads their data and republishes each discrete device onto eBus/Homie 5. It is not an "adapter" in the vague sense; it is a proxy with a specific, first-class role in the eBus model.
33
+
34
+ > Status: **scaffold / MVP.** The cloud-MQTT source is the first target; other sources are stubbed with a common interface (see Variants).
35
+
36
+ ## What it emits
37
+
38
+ - Each **Omnimeter** → one `energy.ebus.device.circuit` device bearing the eBus `meter` (and `demand`) capability plus `info`. Behind-the-meter sub-metering is modeled as a capability on a host circuit, not a standalone device type; per-conductor legs are `-a`/`-b`/`-c` property suffixes on the one device.
39
+ - Each **ioStack** → an IO/sensor device. Its non-electrical sensors (e.g. 1-Wire temperature) are **deferred** pending the eBus `homie/5` domain-partitioning decision (spec issue SPEC-611) and are not emitted yet.
40
+ - Device IDs follow the proxier convention `{proxier-id}-{proxied-id}` (the meter/ioStack serial is the proxied id).
41
+
42
+ Full context, the field-mapping rationale, and the "why MVP-first is safe" spec-dependency analysis are maintained in the eBus specification project's design notes.
43
+
44
+ ## Architecture: shared core + pluggable sources
45
+
46
+ All variants share the same **core** (EKM→eBus field mapping + Homie 5 emitter) and differ only in the **source** (how EKM data is acquired). Ports-and-adapters:
47
+
48
+ ```
49
+ source (acquire EKM data) -> core.mapping (EKM fields -> eBus props) -> core.emitter (Homie 5 -> eBus MQTT)
50
+ ```
51
+
52
+ - `ekm_proxy/mapping.py` — the EKM-field → eBus-property mapping (source-agnostic).
53
+ - `ekm_proxy/emitter.py` — Homie 5 device/description + value publisher to the eBus broker.
54
+ - `ekm_proxy/sources/` — pluggable data sources; each yields normalized `readMeter`-style readings.
55
+
56
+ ## Variants (sources)
57
+
58
+ Recall there will be several deployment variants of this proxy; they are all just different `sources/` behind the same core:
59
+
60
+ | Source | Path | Cloud? | Status | Notes |
61
+ |---|---|---|---|---|
62
+ | **cloud-mqtt** (MVP) | `sources/cloud_mqtt.py` | yes | done (MVP) | Subscribe the EKM Push3 Home Assistant MQTT discovery stream (`homeassistant/device/+/config` + `ekmdata/+/state`). Curated field subset (only what the operator exposed as HA entities). |
63
+ | **rs485** | `sources/rs485.py` | no | planned (full-detail path) | Talk RS-485 directly to Omnimeters (via EKM's `ekmmeters.py`). Full field detail, fully offline, and no Push3 required (just a low-cost USB-RS485 adapter). |
64
+ | **cloud-api** | `sources/cloud_api.py` | yes | TBD if needed | Poll the EKM cloud `readMeter` API (full field set). See the note below on why this is deferred. |
65
+ | **local-api** | `sources/local_api.py` | no | TBD if needed | Poll a Push3's onboard LAN `readMeter` API (`http://<push3-ip>/readMeter?...`), fully offline. Requires a Push3. See the note below. |
66
+ | **push3-native** | (separate) | no | future | Run *on* the Push3 itself. Platform/language TBD; likely not this Python package. Placeholder documented in `sources/push3_native.py`. |
67
+
68
+ `cloud-mqtt` and the `readMeter`-based sources (`cloud-api`, `local-api`) share this Python package. The two `readMeter` variants are **TBD if needed**: their advantages over `cloud-mqtt` are full field fidelity and offline (LAN) operation, but `rs485` already delivers both without a Push3 (just a low-cost USB-RS485 adapter), so it is the preferred path to full detail. The `readMeter` sources earn their place mainly where a Push3 is already deployed and you want to reuse its aggregation without wiring to the RS-485 bus. `push3-native` is a separate consideration because it depends on the Push3's on-device runtime.
69
+
70
+ ## eBus spec status
71
+
72
+ This proxy composes existing eBus capabilities (`info` / `meter` / `demand`) under the `proxy.md` pattern. It emits `energy.ebus.device.circuit` bearing the `meter` capability, conforming to the eBus specification's capability-on-host model for behind-the-meter sub-metering (`data-models/circuit.md`, `capabilities/meter.md`). Instrument nameplate (`vendor-name` / `serial-number` / `model` / `firmware-version`) rides on the circuit's `info` per `capabilities/info.md` §"Nameplate versus conductor identity", with the EKM serial mirrored into `info/external-ids` as `ekm:<serial>`.
73
+
74
+ ## How to run
75
+
76
+ Step-by-step for the MVP `cloud_mqtt` variant. Anyone with MQTT broker credentials can run it; no EKM hardware is required. It subscribes to the Home Assistant MQTT discovery stream an EKM Push3 publishes, translates each meter, and republishes it as an eBus (Homie 5) device.
77
+
78
+ ### 1. What you need
79
+
80
+ - Python 3.10 or newer, with `pip` (a virtual environment is recommended).
81
+ - The Mosquitto client tools (`mosquitto_sub` / `mosquitto_pub`) for watching the output. Optional, but handy for step 5.
82
+ - Access to two MQTT brokers, which may be the **same** broker:
83
+ - a **source** broker carrying an EKM Push3's Home Assistant discovery topics (`homeassistant/device/+/config`, `ekmdata/+/state`, `ekmdata/+/availability`). This is any broker an EKM Push3 publishes to (your own Push3, or a shared/demo broker). You supply the MQTT credentials.
84
+ - a **target** eBus broker to publish the translated Homie 5 devices to. Can be the same broker, or a separate/local one. To stand up a local eBus broker on your own machine, see [broker-quickstart](https://github.com/electrification-bus/broker-quickstart).
85
+
86
+ ### 2. Install
87
+
88
+ ekm-proxy is published on [PyPI](https://pypi.org/project/ekm-proxy/), so the quickest install is:
89
+
90
+ ```bash
91
+ python3 -m venv .venv && source .venv/bin/activate
92
+ pip install ekm-proxy
93
+ ```
94
+
95
+ That installs the `ekm-proxy` command plus its dependencies: the eBus library `ebus-sdk` and `paho-mqtt`. For the RS-485 source, install the extra with `pip install "ekm-proxy[rs485]"`.
96
+
97
+ To hack on the proxy itself, install from a source checkout instead:
98
+
99
+ ```bash
100
+ git clone https://github.com/electrification-bus/ekm-proxy.git
101
+ cd ekm-proxy
102
+ python3 -m venv .venv && source .venv/bin/activate
103
+ pip install -e ".[dev]"
104
+ ```
105
+
106
+ If your Python package index does not carry `ebus-sdk`, install it from the `electrification-bus` GitHub organization.
107
+
108
+ ### 3. Configure
109
+
110
+ ```bash
111
+ cp config/config.example.toml config/config.toml
112
+ ```
113
+
114
+ Edit `config/config.toml`. The two sections you set are the **source** (`[source.cloud_mqtt]`, the broker with the EKM discovery stream) and the **target** (`[ebus]`, where translated devices are published):
115
+
116
+ ```toml
117
+ [source]
118
+ kind = "cloud_mqtt"
119
+
120
+ [source.cloud_mqtt]
121
+ host = "${EKM_MQTT_HOST}" # broker carrying the Push3 HA discovery stream
122
+ port = 1883
123
+ username = "${EKM_MQTT_USER}" # password comes from EKM_MQTT_PASS
124
+
125
+ [ebus]
126
+ host = "${EBUS_MQTT_HOST}" # where translated eBus devices are published
127
+ port = 1883
128
+ username = "${EBUS_MQTT_USER}" # password comes from EBUS_MQTT_PASS
129
+ proxier_id = "ekm-proxy-1" # your bridge id; devices are {proxier-id}-{serial}
130
+ vendor_name = "ekm-proxy"
131
+ name = "EKM Proxy"
132
+ ```
133
+
134
+ Provide the credentials via environment variables (never commit secrets):
135
+
136
+ ```bash
137
+ # source broker (the EKM Push3 / demo broker):
138
+ export EKM_MQTT_HOST=<source-broker-host>
139
+ export EKM_MQTT_USER=<source-username>
140
+ export EKM_MQTT_PASS=<source-password>
141
+
142
+ # target eBus broker (may be the same host):
143
+ export EBUS_MQTT_HOST=<target-broker-host>
144
+ export EBUS_MQTT_USER=<target-username>
145
+ export EBUS_MQTT_PASS=<target-password>
146
+ ```
147
+
148
+ To publish back to the same broker you read from (the simplest setup), point the `EBUS_MQTT_*` values at the same host and credentials as the `EKM_MQTT_*` ones.
149
+
150
+ ### 4. Run
151
+
152
+ ```bash
153
+ ekm-proxy --config config/config.toml
154
+ ```
155
+
156
+ It connects, discovers each EKM meter from the retained HA discovery messages, and publishes one eBus device per meter. Leave it running; it updates property values as new readings arrive.
157
+
158
+ ### 5. Verify
159
+
160
+ Watch the eBus tree on the **target** broker:
161
+
162
+ ```bash
163
+ mosquitto_sub -h <target-broker-host> -p 1883 -u <user> -P <pass> -t 'ebus/5/#' -v
164
+ ```
165
+
166
+ You should see:
167
+
168
+ - `ebus/5/<proxier-id>/...` : the bridge (`energy.ebus.device.bridge`) root device.
169
+ - `ebus/5/<proxier-id>-<serial>/...` : one circuit (`energy.ebus.device.circuit`) per EKM meter, each with a typed `$description`, a `$state`, an `info` capability (nameplate: vendor, serial, plus `external-ids = ekm:<serial>`), and a `meter` capability (`imported-energy`, `active-power`, `current-a`, ...) in eBus-canonical units (energy in Wh, power in W, current in A).
170
+
171
+ ## Contributing
172
+
173
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for how to file issues, propose changes, and the lint/test expectations (`ruff` + `pytest`).
174
+
175
+ ## Releasing
176
+
177
+ Published to [PyPI](https://pypi.org/project/ekm-proxy/) via Trusted Publishing (GitHub Actions OIDC, no stored token): the [`publish.yml`](.github/workflows/publish.yml) workflow builds and uploads on any `v*` tag. To cut a release:
178
+
179
+ 1. Bump the version in **both** `pyproject.toml` (`[project].version`) and `src/ekm_proxy/__init__.py` (`__version__`); the `setup.py` shim reads the latter for the Yocto/kirkstone build path, so they must agree. Update the `CHANGELOG.md` `[Unreleased]` section.
180
+ 2. Commit, then tag and push:
181
+ ```bash
182
+ git tag v0.1.0 && git push origin v0.1.0
183
+ ```
184
+ 3. The workflow runs the tests, builds the sdist + wheel, and publishes. Confirm the new version on PyPI and that the badge updates.
185
+
186
+ The `setup.py` shim exists so the older setuptools pinned in Yocto kirkstone builds a correct wheel (all modules plus the `ekm-proxy` entry point) from the sdist; the modern build path uses the PEP 621 metadata in `pyproject.toml`.
187
+
188
+ ## License
189
+
190
+ [MIT](LICENSE) (c) 2026 Clark Communications Corporation.
191
+
192
+ ## References (EKM public docs)
193
+
194
+ - EKM Push Open API / MQTT / RS-485: <https://documents.ekmmetering.com/api-docs/>
195
+ - Push3 LAN UI + local `readMeter` API: <https://help.ekmmetering.com/support/solutions/articles/6000236439>
196
+ - Omnimeter Pulse v.4, ioStack, Push3: <https://www.ekmmetering.com/>
197
+
198
+ Captured copies of these docs and live data captures are kept private (EKM's docs are copyrighted and are not redistributed here).
@@ -0,0 +1,178 @@
1
+ # ekm-proxy
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ekm-proxy.svg)](https://pypi.org/project/ekm-proxy/)
4
+ [![Test](https://github.com/electrification-bus/ekm-proxy/actions/workflows/test.yml/badge.svg)](https://github.com/electrification-bus/ekm-proxy/actions/workflows/test.yml)
5
+ [![Lint](https://github.com/electrification-bus/ekm-proxy/actions/workflows/lint.yml/badge.svg)](https://github.com/electrification-bus/ekm-proxy/actions/workflows/lint.yml)
6
+ [![spec drift](https://github.com/electrification-bus/ekm-proxy/actions/workflows/spec-drift.yml/badge.svg)](https://github.com/electrification-bus/ekm-proxy/actions/workflows/spec-drift.yml)
7
+ [![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)
8
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
9
+
10
+ Publishes **EKM Metering** devices (Omnimeter meters, and ioStack IO/sensor modules) onto the **Electrification Bus (eBus)** as Homie 5 devices.
11
+
12
+ It is an eBus **proxier** (see the eBus [`proxy.md`](https://github.com/electrification-bus/specification/blob/main/data-models/proxy.md) pattern): EKM meters do not speak eBus natively, so this service reads their data and republishes each discrete device onto eBus/Homie 5. It is not an "adapter" in the vague sense; it is a proxy with a specific, first-class role in the eBus model.
13
+
14
+ > Status: **scaffold / MVP.** The cloud-MQTT source is the first target; other sources are stubbed with a common interface (see Variants).
15
+
16
+ ## What it emits
17
+
18
+ - Each **Omnimeter** → one `energy.ebus.device.circuit` device bearing the eBus `meter` (and `demand`) capability plus `info`. Behind-the-meter sub-metering is modeled as a capability on a host circuit, not a standalone device type; per-conductor legs are `-a`/`-b`/`-c` property suffixes on the one device.
19
+ - Each **ioStack** → an IO/sensor device. Its non-electrical sensors (e.g. 1-Wire temperature) are **deferred** pending the eBus `homie/5` domain-partitioning decision (spec issue SPEC-611) and are not emitted yet.
20
+ - Device IDs follow the proxier convention `{proxier-id}-{proxied-id}` (the meter/ioStack serial is the proxied id).
21
+
22
+ Full context, the field-mapping rationale, and the "why MVP-first is safe" spec-dependency analysis are maintained in the eBus specification project's design notes.
23
+
24
+ ## Architecture: shared core + pluggable sources
25
+
26
+ All variants share the same **core** (EKM→eBus field mapping + Homie 5 emitter) and differ only in the **source** (how EKM data is acquired). Ports-and-adapters:
27
+
28
+ ```
29
+ source (acquire EKM data) -> core.mapping (EKM fields -> eBus props) -> core.emitter (Homie 5 -> eBus MQTT)
30
+ ```
31
+
32
+ - `ekm_proxy/mapping.py` — the EKM-field → eBus-property mapping (source-agnostic).
33
+ - `ekm_proxy/emitter.py` — Homie 5 device/description + value publisher to the eBus broker.
34
+ - `ekm_proxy/sources/` — pluggable data sources; each yields normalized `readMeter`-style readings.
35
+
36
+ ## Variants (sources)
37
+
38
+ Recall there will be several deployment variants of this proxy; they are all just different `sources/` behind the same core:
39
+
40
+ | Source | Path | Cloud? | Status | Notes |
41
+ |---|---|---|---|---|
42
+ | **cloud-mqtt** (MVP) | `sources/cloud_mqtt.py` | yes | done (MVP) | Subscribe the EKM Push3 Home Assistant MQTT discovery stream (`homeassistant/device/+/config` + `ekmdata/+/state`). Curated field subset (only what the operator exposed as HA entities). |
43
+ | **rs485** | `sources/rs485.py` | no | planned (full-detail path) | Talk RS-485 directly to Omnimeters (via EKM's `ekmmeters.py`). Full field detail, fully offline, and no Push3 required (just a low-cost USB-RS485 adapter). |
44
+ | **cloud-api** | `sources/cloud_api.py` | yes | TBD if needed | Poll the EKM cloud `readMeter` API (full field set). See the note below on why this is deferred. |
45
+ | **local-api** | `sources/local_api.py` | no | TBD if needed | Poll a Push3's onboard LAN `readMeter` API (`http://<push3-ip>/readMeter?...`), fully offline. Requires a Push3. See the note below. |
46
+ | **push3-native** | (separate) | no | future | Run *on* the Push3 itself. Platform/language TBD; likely not this Python package. Placeholder documented in `sources/push3_native.py`. |
47
+
48
+ `cloud-mqtt` and the `readMeter`-based sources (`cloud-api`, `local-api`) share this Python package. The two `readMeter` variants are **TBD if needed**: their advantages over `cloud-mqtt` are full field fidelity and offline (LAN) operation, but `rs485` already delivers both without a Push3 (just a low-cost USB-RS485 adapter), so it is the preferred path to full detail. The `readMeter` sources earn their place mainly where a Push3 is already deployed and you want to reuse its aggregation without wiring to the RS-485 bus. `push3-native` is a separate consideration because it depends on the Push3's on-device runtime.
49
+
50
+ ## eBus spec status
51
+
52
+ This proxy composes existing eBus capabilities (`info` / `meter` / `demand`) under the `proxy.md` pattern. It emits `energy.ebus.device.circuit` bearing the `meter` capability, conforming to the eBus specification's capability-on-host model for behind-the-meter sub-metering (`data-models/circuit.md`, `capabilities/meter.md`). Instrument nameplate (`vendor-name` / `serial-number` / `model` / `firmware-version`) rides on the circuit's `info` per `capabilities/info.md` §"Nameplate versus conductor identity", with the EKM serial mirrored into `info/external-ids` as `ekm:<serial>`.
53
+
54
+ ## How to run
55
+
56
+ Step-by-step for the MVP `cloud_mqtt` variant. Anyone with MQTT broker credentials can run it; no EKM hardware is required. It subscribes to the Home Assistant MQTT discovery stream an EKM Push3 publishes, translates each meter, and republishes it as an eBus (Homie 5) device.
57
+
58
+ ### 1. What you need
59
+
60
+ - Python 3.10 or newer, with `pip` (a virtual environment is recommended).
61
+ - The Mosquitto client tools (`mosquitto_sub` / `mosquitto_pub`) for watching the output. Optional, but handy for step 5.
62
+ - Access to two MQTT brokers, which may be the **same** broker:
63
+ - a **source** broker carrying an EKM Push3's Home Assistant discovery topics (`homeassistant/device/+/config`, `ekmdata/+/state`, `ekmdata/+/availability`). This is any broker an EKM Push3 publishes to (your own Push3, or a shared/demo broker). You supply the MQTT credentials.
64
+ - a **target** eBus broker to publish the translated Homie 5 devices to. Can be the same broker, or a separate/local one. To stand up a local eBus broker on your own machine, see [broker-quickstart](https://github.com/electrification-bus/broker-quickstart).
65
+
66
+ ### 2. Install
67
+
68
+ ekm-proxy is published on [PyPI](https://pypi.org/project/ekm-proxy/), so the quickest install is:
69
+
70
+ ```bash
71
+ python3 -m venv .venv && source .venv/bin/activate
72
+ pip install ekm-proxy
73
+ ```
74
+
75
+ That installs the `ekm-proxy` command plus its dependencies: the eBus library `ebus-sdk` and `paho-mqtt`. For the RS-485 source, install the extra with `pip install "ekm-proxy[rs485]"`.
76
+
77
+ To hack on the proxy itself, install from a source checkout instead:
78
+
79
+ ```bash
80
+ git clone https://github.com/electrification-bus/ekm-proxy.git
81
+ cd ekm-proxy
82
+ python3 -m venv .venv && source .venv/bin/activate
83
+ pip install -e ".[dev]"
84
+ ```
85
+
86
+ If your Python package index does not carry `ebus-sdk`, install it from the `electrification-bus` GitHub organization.
87
+
88
+ ### 3. Configure
89
+
90
+ ```bash
91
+ cp config/config.example.toml config/config.toml
92
+ ```
93
+
94
+ Edit `config/config.toml`. The two sections you set are the **source** (`[source.cloud_mqtt]`, the broker with the EKM discovery stream) and the **target** (`[ebus]`, where translated devices are published):
95
+
96
+ ```toml
97
+ [source]
98
+ kind = "cloud_mqtt"
99
+
100
+ [source.cloud_mqtt]
101
+ host = "${EKM_MQTT_HOST}" # broker carrying the Push3 HA discovery stream
102
+ port = 1883
103
+ username = "${EKM_MQTT_USER}" # password comes from EKM_MQTT_PASS
104
+
105
+ [ebus]
106
+ host = "${EBUS_MQTT_HOST}" # where translated eBus devices are published
107
+ port = 1883
108
+ username = "${EBUS_MQTT_USER}" # password comes from EBUS_MQTT_PASS
109
+ proxier_id = "ekm-proxy-1" # your bridge id; devices are {proxier-id}-{serial}
110
+ vendor_name = "ekm-proxy"
111
+ name = "EKM Proxy"
112
+ ```
113
+
114
+ Provide the credentials via environment variables (never commit secrets):
115
+
116
+ ```bash
117
+ # source broker (the EKM Push3 / demo broker):
118
+ export EKM_MQTT_HOST=<source-broker-host>
119
+ export EKM_MQTT_USER=<source-username>
120
+ export EKM_MQTT_PASS=<source-password>
121
+
122
+ # target eBus broker (may be the same host):
123
+ export EBUS_MQTT_HOST=<target-broker-host>
124
+ export EBUS_MQTT_USER=<target-username>
125
+ export EBUS_MQTT_PASS=<target-password>
126
+ ```
127
+
128
+ To publish back to the same broker you read from (the simplest setup), point the `EBUS_MQTT_*` values at the same host and credentials as the `EKM_MQTT_*` ones.
129
+
130
+ ### 4. Run
131
+
132
+ ```bash
133
+ ekm-proxy --config config/config.toml
134
+ ```
135
+
136
+ It connects, discovers each EKM meter from the retained HA discovery messages, and publishes one eBus device per meter. Leave it running; it updates property values as new readings arrive.
137
+
138
+ ### 5. Verify
139
+
140
+ Watch the eBus tree on the **target** broker:
141
+
142
+ ```bash
143
+ mosquitto_sub -h <target-broker-host> -p 1883 -u <user> -P <pass> -t 'ebus/5/#' -v
144
+ ```
145
+
146
+ You should see:
147
+
148
+ - `ebus/5/<proxier-id>/...` : the bridge (`energy.ebus.device.bridge`) root device.
149
+ - `ebus/5/<proxier-id>-<serial>/...` : one circuit (`energy.ebus.device.circuit`) per EKM meter, each with a typed `$description`, a `$state`, an `info` capability (nameplate: vendor, serial, plus `external-ids = ekm:<serial>`), and a `meter` capability (`imported-energy`, `active-power`, `current-a`, ...) in eBus-canonical units (energy in Wh, power in W, current in A).
150
+
151
+ ## Contributing
152
+
153
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for how to file issues, propose changes, and the lint/test expectations (`ruff` + `pytest`).
154
+
155
+ ## Releasing
156
+
157
+ Published to [PyPI](https://pypi.org/project/ekm-proxy/) via Trusted Publishing (GitHub Actions OIDC, no stored token): the [`publish.yml`](.github/workflows/publish.yml) workflow builds and uploads on any `v*` tag. To cut a release:
158
+
159
+ 1. Bump the version in **both** `pyproject.toml` (`[project].version`) and `src/ekm_proxy/__init__.py` (`__version__`); the `setup.py` shim reads the latter for the Yocto/kirkstone build path, so they must agree. Update the `CHANGELOG.md` `[Unreleased]` section.
160
+ 2. Commit, then tag and push:
161
+ ```bash
162
+ git tag v0.1.0 && git push origin v0.1.0
163
+ ```
164
+ 3. The workflow runs the tests, builds the sdist + wheel, and publishes. Confirm the new version on PyPI and that the badge updates.
165
+
166
+ The `setup.py` shim exists so the older setuptools pinned in Yocto kirkstone builds a correct wheel (all modules plus the `ekm-proxy` entry point) from the sdist; the modern build path uses the PEP 621 metadata in `pyproject.toml`.
167
+
168
+ ## License
169
+
170
+ [MIT](LICENSE) (c) 2026 Clark Communications Corporation.
171
+
172
+ ## References (EKM public docs)
173
+
174
+ - EKM Push Open API / MQTT / RS-485: <https://documents.ekmmetering.com/api-docs/>
175
+ - Push3 LAN UI + local `readMeter` API: <https://help.ekmmetering.com/support/solutions/articles/6000236439>
176
+ - Omnimeter Pulse v.4, ioStack, Push3: <https://www.ekmmetering.com/>
177
+
178
+ Captured copies of these docs and live data captures are kept private (EKM's docs are copyrighted and are not redistributed here).
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "ekm-proxy"
3
+ version = "0.1.0"
4
+ description = "Proxy EKM Metering devices onto the Electrification Bus (eBus / Homie 5)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [
9
+ {name = "Clark Communications Corporation"}
10
+ ]
11
+ keywords = ["ekm", "omnimeter", "iostack", "metering", "sub-metering", "ebus", "homie", "proxy"]
12
+ dependencies = [
13
+ "ebus-sdk>=0.11.0",
14
+ "paho-mqtt>=2.0",
15
+ "requests>=2.28",
16
+ "tomli>=2.0; python_version < '3.11'",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ # rs485 also requires EKM's `ekmmeters`; install it separately (a local clone is
21
+ # recommended for Python 3): pip install -e ../ekmmeters
22
+ rs485 = ["pyserial>=3.5"]
23
+ dev = ["pytest>=7"]
24
+
25
+ [project.scripts]
26
+ ekm-proxy = "ekm_proxy.cli:main"
27
+
28
+ [build-system]
29
+ requires = ["setuptools>=68"]
30
+ build-backend = "setuptools.build_meta"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.ruff]
36
+ line-length = 120
37
+
38
+ [tool.ruff.lint]
39
+ # E402: module-level import not at top-of-file. The house pattern is a module
40
+ # docstring, then `from __future__ import annotations`, then imports; ignored
41
+ # for consistency with the sibling eBus repos (python-sdk, ebus-mqtt-client).
42
+ ignore = ["E402"]
43
+
44
+ [tool.ruff.format]
45
+ quote-style = "double"
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ """Legacy setup.py shim for setuptools < 61 (no PEP 621 support).
2
+
3
+ Modern setuptools (>=61) reads all package metadata from pyproject.toml's
4
+ [project] table and ignores the args passed here. This shim exists only so that
5
+ older setuptools (notably the 59.5.0 pinned in Yocto kirkstone) can build a
6
+ wheel with correct name, version, packages, and console script from the sdist.
7
+ Without it, the legacy build reads nothing from [tool.setuptools.packages.find]
8
+ or [project.scripts] and emits a wheel with the right name/version but zero
9
+ modules and no ekm-proxy entry point (see the python-cta2045 / python-sdk
10
+ kirkstone history for the full saga).
11
+
12
+ find_packages is used so every subpackage (ekm_proxy.sources, ...) is included;
13
+ a hand-listed packages=["ekm_proxy"] would silently drop them. The version is
14
+ read from the package so it cannot drift; bump it in
15
+ src/ekm_proxy/__init__.py (and keep pyproject.toml's [project].version in sync
16
+ for the modern build path).
17
+ """
18
+
19
+ import re
20
+ from pathlib import Path
21
+
22
+ from setuptools import find_packages, setup
23
+
24
+ version = re.search(
25
+ r'^__version__ = "([^"]+)"',
26
+ Path("src/ekm_proxy/__init__.py").read_text(encoding="utf-8"),
27
+ re.M,
28
+ ).group(1)
29
+
30
+ setup(
31
+ name="ekm-proxy",
32
+ version=version,
33
+ package_dir={"": "src"},
34
+ packages=find_packages(where="src"),
35
+ entry_points={
36
+ "console_scripts": [
37
+ "ekm-proxy = ekm_proxy.cli:main",
38
+ ],
39
+ },
40
+ )
@@ -0,0 +1,3 @@
1
+ """ekm-proxy: publish EKM Metering devices onto the Electrification Bus (Homie 5)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import signal
4
+ import sys
5
+
6
+ from .config import load
7
+ from .sources import build_source
8
+ from .emitter import EbusEmitter
9
+
10
+
11
+ def _install_sigterm(handler):
12
+ # SIGINT already surfaces as KeyboardInterrupt; translate SIGTERM (systemd
13
+ # stop / `kill`) into the same graceful path so the `finally` teardown runs.
14
+ signal.signal(signal.SIGTERM, handler)
15
+
16
+
17
+ def main(argv=None):
18
+ ap = argparse.ArgumentParser(prog="ekm-proxy")
19
+ ap.add_argument("--config", required=True)
20
+ args = ap.parse_args(argv)
21
+
22
+ cfg = load(args.config)
23
+ source = build_source(cfg)
24
+ emitter = EbusEmitter(cfg["ebus"])
25
+ print(f"ekm-proxy: source={cfg['source']['kind']} -> ebus/5", file=sys.stderr)
26
+
27
+ _install_sigterm(lambda *_: sys.exit(0))
28
+
29
+ try:
30
+ for reading in source.stream():
31
+ if reading.kind != "meter":
32
+ continue # ioStack sensors deferred (homie/5); see README
33
+ emitter.publish(reading)
34
+ except KeyboardInterrupt:
35
+ pass
36
+ finally:
37
+ # Bounded clean shutdown: publish $state=disconnected, suppress the LWT.
38
+ emitter.stop()
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ try:
4
+ import tomllib # Python 3.11+
5
+ except ModuleNotFoundError: # Python 3.10
6
+ import tomli as tomllib # type: ignore
7
+
8
+
9
+ def load(path: str) -> dict:
10
+ with open(path, "rb") as f:
11
+ return tomllib.load(f)