ekm-proxy 0.1.0__py3-none-any.whl

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.
ekm_proxy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ekm-proxy: publish EKM Metering devices onto the Electrification Bus (Homie 5)."""
2
+
3
+ __version__ = "0.1.0"
ekm_proxy/cli.py ADDED
@@ -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()
ekm_proxy/config.py ADDED
@@ -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)
ekm_proxy/emitter.py ADDED
@@ -0,0 +1,197 @@
1
+ """Homie 5 emitter/adapter: publish EKM meter-points onto eBus via ebus-sdk.
2
+
3
+ The proxy is an eBus proxier of type `energy.ebus.device.bridge`: a single root
4
+ "bridge" device anchors the Homie tree and owns the MQTT connection, and each
5
+ proxied EKM meter is published as a child `energy.ebus.device.circuit` bearing
6
+ the `meter` capability (behind-the-meter sub-metering is a capability on a host
7
+ circuit, not a standalone device type; see the spec's data-models/circuit.md),
8
+ named `{proxier-id}-{serial}` per the proxy convention. The bridge publishes only
9
+ an `info` capability identifying the proxy publisher; the metering data lives on
10
+ the child circuits.
11
+
12
+ Each meter's fields are resolved with `ebus_sdk.resolve` (the EKM domain mapper
13
+ `EKM_MAP` first, HA-discovery `ha_fallback` for the rest), and the result is
14
+ built into an observable model + Homie tree in one call via
15
+ `ebus_sdk.build_from_declarations` (see doc/building-a-proxy.md). Sources only
16
+ update the model; publishing is a reactive side-effect.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import time
23
+ from typing import Optional
24
+
25
+ from ebus_sdk import (
26
+ Device,
27
+ DeviceState,
28
+ GroupedPropertyDict,
29
+ PropertyDatatype,
30
+ PropertySpec,
31
+ build_from_declarations,
32
+ ebus_cfg_add_auth,
33
+ resolve,
34
+ sanitize_homie_id,
35
+ specs_and_values,
36
+ )
37
+ from ebus_sdk.ha import HA_ECOSYSTEM, imported_extension, imported_from_attribute
38
+
39
+ from .mapping import CIRCUIT_TYPE, EKM_MAP, ha_fallback, normalize_fields
40
+
41
+ ROOT_TYPE = "energy.ebus.device.bridge"
42
+ INFO_CAPABILITY = "energy.ebus.capability.info"
43
+
44
+ _CAP_NODE_NAMES = {
45
+ "info": "Meter identity",
46
+ "meter": "Electrical measurements",
47
+ "demand": "Demand",
48
+ "status": "Meter status",
49
+ }
50
+
51
+
52
+ def _node_name(capability: str) -> str:
53
+ return _CAP_NODE_NAMES.get(capability, capability)
54
+
55
+
56
+ class EbusEmitter:
57
+ def __init__(self, cfg: dict):
58
+ self.proxier_id = sanitize_homie_id(cfg.get("proxier_id") or "ekm-proxy")
59
+ self.vendor_name = cfg.get("vendor_name", "ekm-proxy")
60
+ self.root_name = cfg.get("name", "EKM Proxy")
61
+ self._mqtt_cfg = self._build_mqtt_cfg(cfg)
62
+ self._root: Optional[Device] = None
63
+ self._models: dict[str, GroupedPropertyDict] = {} # serial -> observable model
64
+ self._devices: dict[str, Device] = {} # serial -> homie child device
65
+ self._available: dict[str, Optional[bool]] = {} # serial -> last published availability
66
+
67
+ @staticmethod
68
+ def _build_mqtt_cfg(cfg: dict) -> dict:
69
+ mqtt_cfg = {
70
+ "host": os.path.expandvars(cfg.get("host", "localhost")),
71
+ "port": int(cfg.get("port", 1883)),
72
+ }
73
+ user = os.path.expandvars(cfg.get("username", "")) or None
74
+ if user:
75
+ ebus_cfg_add_auth(mqtt_cfg, user, os.environ.get("EBUS_MQTT_PASS"))
76
+ return mqtt_cfg
77
+
78
+ @staticmethod
79
+ def _wait_connected(device: Device, timeout: float = 5.0) -> None:
80
+ deadline = time.monotonic() + timeout
81
+ while time.monotonic() < deadline:
82
+ if device.mqttc is not None and device.mqttc.is_connected():
83
+ return
84
+ time.sleep(0.05)
85
+
86
+ def device_id(self, serial: str) -> str:
87
+ # {proxier-id}-{proxied-id} per proxy.md; sanitize each segment independently.
88
+ return f"{self.proxier_id}-{sanitize_homie_id(serial)}"
89
+
90
+ # ── root bridge ──
91
+
92
+ def _ensure_root(self) -> Device:
93
+ if self._root is not None:
94
+ return self._root
95
+ root = Device(self.proxier_id, name=self.root_name, type=ROOT_TYPE, mqtt_cfg=self._mqtt_cfg)
96
+ root.start_mqtt_client()
97
+ # Publish only once connected, so the first retained $description/$state
98
+ # the broker keeps is the correct one (not a pre-connect empty snapshot).
99
+ self._wait_connected(root)
100
+ with root.state_transition():
101
+ info = root.add_node_from_dict({"id": "info", "name": "Proxy identity", "type": INFO_CAPABILITY})
102
+ info.add_property_from_dict(
103
+ {"id": "vendor-name", "datatype": PropertyDatatype.STRING, "value": self.vendor_name}
104
+ )
105
+ self._root = root
106
+ return root
107
+
108
+ # ── per-meter circuit ──
109
+
110
+ def publish(self, reading) -> None:
111
+ """Create the circuit model+device on first sight, else update its values."""
112
+ self._ensure_root()
113
+ serial = reading.device_id
114
+ if serial not in self._models:
115
+ self._create_circuit(reading)
116
+ else:
117
+ self._apply_values(self._models[serial], reading)
118
+ self._update_state(reading)
119
+
120
+ def _resolve(self, reading):
121
+ field_names = reading.schema_fields or list(reading.fields.keys())
122
+ fields = normalize_fields(reading.fields)
123
+ return resolve(field_names, fields, EKM_MAP, fallback=ha_fallback(reading.components_by_field))
124
+
125
+ def _create_circuit(self, reading) -> None:
126
+ serial = reading.device_id
127
+ resolved = self._resolve(reading)
128
+
129
+ # info capability: device metadata (reading.info) plus any mapped info fields.
130
+ # On a circuit realized by a distinct instrument (a proxied EKM meter-point),
131
+ # the nameplate identity properties (vendor-name/serial-number/model/
132
+ # firmware-version) record the measuring instrument, alongside info/name for
133
+ # the conductor (capabilities/info.md §Nameplate versus conductor identity).
134
+ info_values = dict(reading.info or {})
135
+ for r in resolved:
136
+ if r.spec.capability == "info" and r.value is not None:
137
+ info_values.setdefault(r.spec.prop_id, r.value)
138
+ # Mirror the EKM serial into external-ids as a cross-system reference,
139
+ # complementary to the nameplate serial-number (circuit.md allows this).
140
+ info_values.setdefault("external-ids", f"ekm:{serial}")
141
+
142
+ info_specs = [PropertySpec("info", pid, PropertyDatatype.STRING) for pid in info_values]
143
+ non_info_specs, non_info_values = specs_and_values(r for r in resolved if r.spec.capability != "info")
144
+ specs = info_specs + non_info_specs
145
+ values = {("info", pid): val for pid, val in info_values.items()}
146
+ values.update(non_info_values)
147
+
148
+ # Each circuit is created from Home Assistant MQTT discovery, so mark it
149
+ # imported (Guard B): the `energy.ebus.imported` extension plus an
150
+ # `imported-from: ha` $description attribute. A co-resident eBus->HA
151
+ # bridge (ebus_sdk.ha.HaDiscoveryBridge, skip_reimport default) then skips
152
+ # these rather than echoing them back to Home Assistant.
153
+ device = Device(
154
+ self.device_id(serial),
155
+ name=reading.name or f"EKM {serial}",
156
+ type=CIRCUIT_TYPE,
157
+ parent=self._root,
158
+ extensions=[imported_extension()],
159
+ description_extras=imported_from_attribute(HA_ECOSYSTEM),
160
+ )
161
+ model = GroupedPropertyDict()
162
+ build_from_declarations(device, model, specs, node_name=_node_name, values=values)
163
+ self._models[serial] = model
164
+ self._devices[serial] = device
165
+
166
+ def _apply_values(self, model: GroupedPropertyDict, reading) -> None:
167
+ for r in self._resolve(reading):
168
+ if r.value is not None:
169
+ model.set_value(r.spec.capability, r.spec.prop_id, r.value)
170
+
171
+ def stop(self) -> None:
172
+ """Cleanly tear down the published tree on shutdown.
173
+
174
+ `Device.stop()` is a tree-level teardown: it publishes a final
175
+ `$state=disconnected` on the root (covering every child per the Homie 5
176
+ effective-state rule) and performs a clean MQTT disconnect that
177
+ suppresses the LWT, so consumers see a tidy exit rather than a stale
178
+ `ready` or a badly-disconnected `lost`. It is bounded even if the broker
179
+ is unreachable, so it never stalls a shutting-down process.
180
+ """
181
+ if self._root is not None:
182
+ self._root.stop()
183
+ self._root = None
184
+
185
+ def _update_state(self, reading) -> None:
186
+ if reading.available is None:
187
+ return
188
+ serial = reading.device_id
189
+ if self._available.get(serial) == reading.available:
190
+ return
191
+ device = self._devices.get(serial)
192
+ if device is None:
193
+ return
194
+ state = DeviceState.READY if reading.available else DeviceState.LOST
195
+ device.set_state(state)
196
+ device.publish_state(state)
197
+ self._available[serial] = reading.available
ekm_proxy/mapping.py ADDED
@@ -0,0 +1,122 @@
1
+ """EKM readMeter field -> eBus property mapping (the EKM domain-specific mapper).
2
+
3
+ Every source normalizes to a readMeter-style dict ({ekm_field: value, ...}).
4
+ `EKM_MAP` is the hand-authored, authoritative `{ekm_field: PropertySpec}` table
5
+ for the `meter` (and `demand`) capability an EKM meter-point publishes as an
6
+ `energy.ebus.device.circuit` (the eBus capability-on-host model): it fixes the
7
+ eBus capability, property name, canonical unit, and any unit `scale` (EKM reports
8
+ energy in kWh; eBus is Wh, so energy carries scale=1000; per-phase fields get
9
+ -a/-b/-c suffixes, per the canonical catalog in the spec's capabilities/meter.md).
10
+
11
+ The emitter passes `EKM_MAP` to `ebus_sdk.resolve` as the authoritative domain
12
+ `mapping`, and `ha_fallback(...)` as the general-HA `fallback`, so a field with
13
+ no EKM entry is still covered from its Home Assistant discovery metadata. Fields
14
+ with neither an eBus home nor an HA hint are HELD (see ORPHANS), documented
15
+ rather than silently dropped.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Callable, Optional
21
+
22
+ from ebus_sdk import PropertyDatatype, PropertySpec, Unit
23
+ from ebus_sdk.ha import derive_spec
24
+
25
+ # The eBus device type each proxied EKM meter-point is published as: a circuit
26
+ # bearing the `meter` capability (behind-the-meter sub-metering is a capability on
27
+ # a host device, not a standalone device type; see the spec's data-models/circuit.md
28
+ # and capabilities/meter.md). Per-conductor legs are -a/-b/-c property suffixes on
29
+ # the one device, not separate devices.
30
+ CIRCUIT_TYPE = "energy.ebus.device.circuit"
31
+
32
+
33
+ def _meas(capability: str, prop: str, unit: Unit, scale: float = 1.0) -> PropertySpec:
34
+ """A float physical-measurement property."""
35
+ return PropertySpec(capability, prop, PropertyDatatype.FLOAT, unit, scale)
36
+
37
+
38
+ # ekm_field -> PropertySpec. Ln_1/2/3 -> phase suffix -a/-b/-c. EKM energy is
39
+ # reported in kWh; eBus canonical energy is Wh, hence scale=1000 on energy.
40
+ METER_MAP: dict[str, PropertySpec] = {
41
+ "kWh_Tot": _meas("meter", "imported-energy", Unit.WATT_HOUR, 1000.0),
42
+ "Rev_kWh_Tot": _meas("meter", "exported-energy", Unit.WATT_HOUR, 1000.0),
43
+ "RMS_Watts_Tot": _meas("meter", "active-power", Unit.WATT),
44
+ "Reactive_Pwr_Tot": _meas("meter", "reactive-power", Unit.VOLT_AMPERE_REACTIVE),
45
+ "Line_Freq": _meas("meter", "frequency", Unit.HERTZ),
46
+ "RMS_Volts_Ln_1": _meas("meter", "voltage-a", Unit.VOLTS),
47
+ "RMS_Volts_Ln_2": _meas("meter", "voltage-b", Unit.VOLTS),
48
+ "RMS_Volts_Ln_3": _meas("meter", "voltage-c", Unit.VOLTS),
49
+ "Amps_Ln_1": _meas("meter", "current-a", Unit.AMPERE),
50
+ "Amps_Ln_2": _meas("meter", "current-b", Unit.AMPERE),
51
+ "Amps_Ln_3": _meas("meter", "current-c", Unit.AMPERE),
52
+ "RMS_Watts_Ln_1": _meas("meter", "active-power-a", Unit.WATT),
53
+ "RMS_Watts_Ln_2": _meas("meter", "active-power-b", Unit.WATT),
54
+ "RMS_Watts_Ln_3": _meas("meter", "active-power-c", Unit.WATT),
55
+ "Reactive_Pwr_Ln_1": _meas("meter", "reactive-power-a", Unit.VOLT_AMPERE_REACTIVE),
56
+ "Reactive_Pwr_Ln_2": _meas("meter", "reactive-power-b", Unit.VOLT_AMPERE_REACTIVE),
57
+ "Reactive_Pwr_Ln_3": _meas("meter", "reactive-power-c", Unit.VOLT_AMPERE_REACTIVE),
58
+ }
59
+
60
+ DEMAND_MAP: dict[str, PropertySpec] = {
61
+ "RMS_Watts_Max_Demand": _meas("demand", "peak-demand-this-period", Unit.WATT),
62
+ # eBus `demand/integration-window` is an integer number of SECONDS. EKM reports
63
+ # it as a period-select CODE, translated to seconds by `normalize_fields` before
64
+ # resolve (a lookup, not the linear `scale` resolve applies).
65
+ "Max_Demand_Period": PropertySpec("demand", "integration-window", PropertyDatatype.INTEGER, Unit.SECONDS),
66
+ }
67
+
68
+ # EKM `Max_Demand_Period` is a period-select code, not a duration: 1 -> 15 min,
69
+ # 2 -> 30 min, 3 -> 60 min (ekmmeters MaxDemandPeriod). eBus wants seconds.
70
+ _MAX_DEMAND_PERIOD_SECONDS: dict[int, int] = {1: 900, 2: 1800, 3: 3600}
71
+
72
+ INFO_MAP: dict[str, PropertySpec] = {
73
+ "Model": PropertySpec("info", "model", PropertyDatatype.STRING),
74
+ "Firmware": PropertySpec("info", "firmware-version", PropertyDatatype.STRING),
75
+ }
76
+
77
+ # No eBus home yet -> HELD. Documented so the gap is explicit, not a silent drop.
78
+ ORPHANS: dict[str, list[str]] = {
79
+ "tou": [f"kWh_Tariff_{i}" for i in range(1, 5)] + [f"Rev_kWh_Tariff_{i}" for i in range(1, 5)],
80
+ "pulse": [f"Pulse_Cnt_{i}" for i in range(1, 4)] + [f"Pulse_Ratio_{i}" for i in range(1, 4)],
81
+ "dio": ["State_Inputs", "State_Out", "State_Watts_Dir"],
82
+ }
83
+
84
+ # The EKM domain-specific mapper, passed to ebus_sdk.resolve() as `mapping`.
85
+ EKM_MAP: dict[str, PropertySpec] = {**METER_MAP, **DEMAND_MAP, **INFO_MAP}
86
+
87
+
88
+ def normalize_fields(fields: dict) -> dict:
89
+ """Translate EKM code-valued fields to the canonical units `EKM_MAP` expects.
90
+
91
+ `ebus_sdk.resolve` only applies a linear `scale`; a field whose EKM encoding is
92
+ a lookup rather than a multiply is converted here first. Currently only
93
+ `Max_Demand_Period` (period-select code -> `integration-window` seconds). Returns
94
+ `fields` unchanged when there is nothing to normalize and never mutates the input;
95
+ an unrecognized code resolves to None (published as absent/unknown).
96
+ """
97
+ code = fields.get("Max_Demand_Period")
98
+ if code is None:
99
+ return fields
100
+ out = dict(fields)
101
+ try:
102
+ out["Max_Demand_Period"] = _MAX_DEMAND_PERIOD_SECONDS.get(int(code))
103
+ except (TypeError, ValueError):
104
+ out["Max_Demand_Period"] = None
105
+ return out
106
+
107
+
108
+ def ha_fallback(components_by_field: Optional[dict]) -> Callable[[str], Optional[PropertySpec]]:
109
+ """A `resolve()` fallback that derives a PropertySpec from an HA discovery component.
110
+
111
+ Used as the general-HA `fallback` for fields not in `EKM_MAP`: it infers a
112
+ `PropertySpec` from the field's HA `device_class` / `unit_of_measurement`.
113
+ """
114
+ components_by_field = components_by_field or {}
115
+
116
+ def _fallback(field_name: str) -> Optional[PropertySpec]:
117
+ comp = components_by_field.get(field_name)
118
+ if comp is None:
119
+ return None
120
+ return derive_spec(comp.device_class, comp.unit_of_measurement, comp.value_field or field_name)
121
+
122
+ return _fallback
@@ -0,0 +1,45 @@
1
+ """Pluggable EKM data sources. Each yields normalized readMeter-style Readings.
2
+
3
+ A Source is any object with a `stream()` generator yielding Reading. Variants
4
+ (cloud MQTT, cloud/local readMeter API, RS-485, on-device) differ ONLY here;
5
+ the core mapping + emitter are shared.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from dataclasses import dataclass, field
10
+ from typing import Iterator, Optional, Protocol
11
+
12
+
13
+ @dataclass
14
+ class Reading:
15
+ device_id: str # EKM serial, e.g. "000300004127"
16
+ kind: str # "meter" | "iostack"
17
+ fields: dict # {ekm_field: value} current values (may be partial)
18
+ name: str | None = None # human name if the source knows it
19
+ # Optional richer structure a source may supply (e.g. from HA discovery),
20
+ # used by the emitter to build a typed device and its info capability.
21
+ info: dict = field(default_factory=dict) # eBus info props (vendor-name, model, ...)
22
+ schema_fields: Optional[list] = None # declared field names; default = fields.keys()
23
+ components_by_field: Optional[dict] = None # ekm_field -> HA component (fallback semantics)
24
+ available: Optional[bool] = None # source-reported availability, drives $state
25
+
26
+
27
+ class Source(Protocol):
28
+ def stream(self) -> Iterator[Reading]: ...
29
+
30
+
31
+ def build_source(cfg: dict) -> "Source":
32
+ kind = cfg["source"]["kind"]
33
+ if kind == "cloud_mqtt":
34
+ from .cloud_mqtt import CloudMqttSource
35
+
36
+ return CloudMqttSource(cfg["source"]["cloud_mqtt"])
37
+ if kind in ("cloud_api", "local_api"):
38
+ from .cloud_api import ReadMeterSource # same poller, different base_url
39
+
40
+ return ReadMeterSource(cfg["source"][kind])
41
+ if kind == "rs485":
42
+ from .rs485 import Rs485Source
43
+
44
+ return Rs485Source(cfg["source"]["rs485"])
45
+ raise ValueError(f"unknown source kind: {kind!r}")
@@ -0,0 +1,40 @@
1
+ """readMeter API source (full 71-field). Serves both the cloud variant
2
+ (base_url https://api.ekmpush.com) and the local variant (http://<push3-ip>).
3
+
4
+ TODO: verify the exact serial field on each ReadSet element against a real
5
+ captured response.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import os
10
+ import time
11
+ from typing import Iterator
12
+
13
+ import requests
14
+
15
+ from . import Reading
16
+
17
+
18
+ class ReadMeterSource:
19
+ def __init__(self, cfg: dict):
20
+ self.base_url = os.path.expandvars(cfg["base_url"]).rstrip("/")
21
+ self.key = os.path.expandvars(cfg.get("key", "")) or os.environ.get("EKM_PUSH_KEY", "")
22
+ self.meters = cfg.get("meters", [])
23
+ self.poll = int(cfg.get("poll_seconds", 60))
24
+
25
+ def _read_once(self) -> Iterator[Reading]:
26
+ params = {"key": self.key, "format": "json", "cnt": 1}
27
+ if self.meters:
28
+ params["devices"] = ",".join(self.meters)
29
+ r = requests.get(f"{self.base_url}/readMeter", params=params, timeout=15)
30
+ r.raise_for_status()
31
+ data = r.json()
32
+ for rs in data.get("readMeter", {}).get("ReadSet", []):
33
+ serial = str(rs.get("Meter", rs.get("Meter_Address", ""))).zfill(12)
34
+ for rd in rs.get("ReadData", []):
35
+ yield Reading(serial, "meter", rd, rs.get("Meter_Name"))
36
+
37
+ def stream(self) -> Iterator[Reading]:
38
+ while True:
39
+ yield from self._read_once()
40
+ time.sleep(self.poll)
@@ -0,0 +1,145 @@
1
+ """MVP source: subscribe the EKM Push3 Home Assistant MQTT discovery stream.
2
+
3
+ Topics:
4
+ homeassistant/device/<id>/config (retained) -> device structure + metadata
5
+ ekmdata/<id>/state -> field values (curated subset)
6
+ ekmdata/<id>/availability -> online/offline
7
+
8
+ The device structure is parsed with the vendor-neutral `ha_discovery` parser;
9
+ this source only adds the EKM-specific topic/id conventions and turns each
10
+ parsed device into an enriched `Reading` (values + declared schema + component
11
+ semantics + info metadata + availability) for the shared emitter.
12
+
13
+ NB: state payloads carry only the fields the operator exposed as HA entities
14
+ (a curated subset, not the full readMeter field set). Use the readMeter source
15
+ when full fidelity is needed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import queue
23
+ from typing import Iterator
24
+
25
+ import paho.mqtt.client as mqtt
26
+
27
+ from ebus_sdk.ha import HADevice, is_ebus_sdk_origin, parse_device_config
28
+
29
+ from . import Reading
30
+
31
+
32
+ def _kind_of(object_id: str) -> str:
33
+ return "iostack" if "iostack" in object_id else "meter"
34
+
35
+
36
+ def _info_from_device(dev: HADevice) -> dict:
37
+ """eBus info-capability properties from the HA device block (skip placeholders)."""
38
+ info = {}
39
+ if dev.manufacturer:
40
+ info["vendor-name"] = dev.manufacturer
41
+ if dev.model and dev.model.lower() != "n/a":
42
+ info["model"] = dev.model
43
+ if dev.serial_number:
44
+ info["serial-number"] = dev.serial_number
45
+ if dev.sw_version and dev.sw_version.lower() != "n/a":
46
+ info["firmware-version"] = dev.sw_version
47
+ return info
48
+
49
+
50
+ class _DeviceStructure:
51
+ """Cached, parsed structure for one discovered device."""
52
+
53
+ __slots__ = ("serial", "kind", "name", "info", "schema_fields", "components_by_field")
54
+
55
+ def __init__(self, dev: HADevice, object_id: str):
56
+ self.serial = dev.serial_number or dev.primary_id or object_id
57
+ self.kind = _kind_of(object_id)
58
+ self.name = dev.name
59
+ self.info = _info_from_device(dev)
60
+ self.schema_fields: list[str] = []
61
+ self.components_by_field: dict = {}
62
+ for comp in dev.components.values():
63
+ if comp.removed or not comp.value_field:
64
+ continue
65
+ self.schema_fields.append(comp.value_field)
66
+ self.components_by_field[comp.value_field] = comp
67
+
68
+
69
+ class CloudMqttSource:
70
+ def __init__(self, cfg: dict):
71
+ self.host = os.path.expandvars(cfg.get("host", "localhost"))
72
+ self.port = int(cfg.get("port", 1883))
73
+ self.user = os.path.expandvars(cfg.get("username", "")) or None
74
+ self.password = os.environ.get("EKM_MQTT_PASS")
75
+ self.data_prefix = cfg.get("data_prefix", "ekmdata")
76
+ self._structs: dict[str, _DeviceStructure] = {} # object_id -> structure
77
+ self._values: dict[str, dict] = {} # object_id -> last state payload
78
+ self._avail: dict[str, bool] = {} # object_id -> availability
79
+ self._q: "queue.Queue[Reading]" = queue.Queue()
80
+
81
+ def _on_connect(self, client, *_):
82
+ client.subscribe("homeassistant/device/+/config")
83
+ client.subscribe(f"{self.data_prefix}/+/state")
84
+ client.subscribe(f"{self.data_prefix}/+/availability")
85
+
86
+ def _reading_for(self, object_id: str) -> Reading | None:
87
+ struct = self._structs.get(object_id)
88
+ if struct is None:
89
+ return None # values/availability seen before config; wait for structure
90
+ return Reading(
91
+ device_id=struct.serial,
92
+ kind=struct.kind,
93
+ fields=dict(self._values.get(object_id, {})),
94
+ name=struct.name,
95
+ info=struct.info,
96
+ schema_fields=struct.schema_fields,
97
+ components_by_field=struct.components_by_field,
98
+ available=self._avail.get(object_id),
99
+ )
100
+
101
+ def _emit(self, object_id: str) -> None:
102
+ reading = self._reading_for(object_id)
103
+ if reading is not None:
104
+ self._q.put(reading)
105
+
106
+ def _on_message(self, client, userdata, msg):
107
+ parts = msg.topic.split("/")
108
+ if msg.topic.startswith("homeassistant/device/") and parts[-1] == "config":
109
+ object_id = parts[2]
110
+ result = parse_device_config(msg.payload, object_id=object_id)
111
+ if isinstance(result, HADevice):
112
+ # Guard A: ignore discovery the eBus SDK itself published (a
113
+ # co-resident eBus->HA bridge's own output), so we never
114
+ # re-import our own echo back onto eBus.
115
+ if is_ebus_sdk_origin(result):
116
+ return
117
+ self._structs[object_id] = _DeviceStructure(result, object_id)
118
+ self._emit(object_id)
119
+ return
120
+ if len(parts) >= 3 and parts[0] == self.data_prefix:
121
+ object_id = parts[1]
122
+ leaf = parts[-1]
123
+ if leaf == "state":
124
+ try:
125
+ self._values[object_id] = json.loads(msg.payload.decode())
126
+ except (ValueError, UnicodeDecodeError):
127
+ return
128
+ self._emit(object_id)
129
+ elif leaf == "availability":
130
+ self._avail[object_id] = msg.payload.decode().strip().lower() == "online"
131
+ self._emit(object_id)
132
+
133
+ def stream(self) -> Iterator[Reading]:
134
+ c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
135
+ if self.user:
136
+ c.username_pw_set(self.user, self.password)
137
+ c.on_connect = self._on_connect
138
+ c.on_message = self._on_message
139
+ c.connect(self.host, self.port, 60)
140
+ c.loop_start()
141
+ try:
142
+ while True:
143
+ yield self._q.get()
144
+ finally:
145
+ c.loop_stop()
@@ -0,0 +1,8 @@
1
+ """push3-native variant (PLACEHOLDER, no implementation here).
2
+
3
+ Running the proxy ON the Push3 gateway is a distinct deployment whose runtime
4
+ (OS, language, resources) depends on the Push3 platform and is TBD. It is very
5
+ likely NOT this Python package. This file documents the variant and keeps it in
6
+ the architecture. The reusable piece across all variants is `ekm_proxy.mapping`;
7
+ only acquisition + eBus transport would be reimplemented for the device.
8
+ """
@@ -0,0 +1,100 @@
1
+ """RS-485 source: read EKM Omnimeters directly over serial (no Push3).
2
+
3
+ Wraps EKM's `ekmmeters` library. Because `ekmmeters`' field names are identical
4
+ to the mapping table's keys, a read becomes the same `Reading` shape the other
5
+ sources produce, carrying the meter's FULL field set (all phases, reactive
6
+ power, per-line energy), and flows through the shared resolver + emitter with no
7
+ translation.
8
+
9
+ The serial/meter I/O lives in `stream()`; the read-buffer -> `Reading` transform
10
+ is factored into `reading_from_fields()` so it can be unit-tested without
11
+ hardware. `ekmmeters` is imported lazily so this module imports without it
12
+ installed. To run the source, install `ekmmeters` (the local clone is
13
+ recommended for Python 3): `pip install -e ../ekmmeters`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import time
20
+ from typing import Iterator, Optional
21
+
22
+ from . import Reading
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # EKM read fields describing the meter itself -> eBus info-capability properties.
27
+ _INFO_FIELDS = {"Model": "model", "Firmware": "firmware-version"}
28
+ _VENDOR_NAME = "EKM Metering Inc."
29
+
30
+
31
+ def reading_from_fields(address: str, fields: dict) -> Reading:
32
+ """Turn a flat {ekm_field: value} read into a Reading. Pure, hardware-free.
33
+
34
+ Model/Firmware become info-capability properties and the meter address is the
35
+ serial. Every other field is passed through untouched: the shared resolver
36
+ maps the ones it knows and holds the rest, so unmapped/reserved fields (meter
37
+ time, CRC, status, and so on) are harmless.
38
+ """
39
+ address = str(address)
40
+ info = {"vendor-name": _VENDOR_NAME, "serial-number": address}
41
+ meter_fields = {}
42
+ for name, value in fields.items():
43
+ if value is None:
44
+ continue
45
+ if name in _INFO_FIELDS:
46
+ info[_INFO_FIELDS[name]] = str(value)
47
+ else:
48
+ meter_fields[name] = value
49
+ return Reading(device_id=address, kind="meter", fields=meter_fields, info=info, available=True)
50
+
51
+
52
+ class Rs485Source:
53
+ def __init__(self, cfg: dict):
54
+ self.port = cfg.get("port", "/dev/ttyUSB0")
55
+ self.baud = int(cfg.get("baud", 9600))
56
+ self.poll_seconds = float(cfg.get("poll_seconds", 10))
57
+ # Each entry: {"address": "000300001463", "version": "v4"|"v3"}.
58
+ self.meters = cfg.get("meters", [])
59
+
60
+ def _build_meters(self):
61
+ from ekmmeters import SerialPort, V3Meter, V4Meter # lazy: needs ekmmeters
62
+
63
+ port = SerialPort(self.port, self.baud)
64
+ if not port.initPort():
65
+ raise RuntimeError(f"rs485: could not open serial port {self.port!r}")
66
+ meters = []
67
+ for spec in self.meters:
68
+ address = spec["address"] if isinstance(spec, dict) else spec
69
+ version = (spec.get("version", "v4") if isinstance(spec, dict) else "v4").lower()
70
+ meter = V3Meter(address) if version == "v3" else V4Meter(address)
71
+ meter.attachPort(port)
72
+ meters.append((str(address), meter))
73
+ return port, meters
74
+
75
+ def _read(self, meter) -> Optional[dict]:
76
+ from ekmmeters import MeterData # lazy
77
+
78
+ if not meter.request():
79
+ return None
80
+ buf = meter.getReadBuffer()
81
+ # Use the typed NativeValue, not getField()'s pre-formatted string.
82
+ return {name: buf[name][MeterData.NativeValue] for name in buf}
83
+
84
+ def stream(self) -> Iterator[Reading]:
85
+ port, meters = self._build_meters()
86
+ try:
87
+ while True:
88
+ for address, meter in meters:
89
+ try:
90
+ fields = self._read(meter)
91
+ except Exception:
92
+ logger.exception("rs485: read failed for meter %s", address)
93
+ continue
94
+ if fields:
95
+ yield reading_from_fields(address, fields)
96
+ else:
97
+ logger.warning("rs485: no/invalid response from meter %s", address)
98
+ time.sleep(self.poll_seconds)
99
+ finally:
100
+ port.closePort()
@@ -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,16 @@
1
+ ekm_proxy/__init__.py,sha256=lfafLKNpHVQrM4qEm08h8IM4nougKwJqJudrFLbs83Y,109
2
+ ekm_proxy/cli.py,sha256=MVtIBSZRw4W816XhiG3zf1euDOfy3RX3OlHk2W-hADk,1187
3
+ ekm_proxy/config.py,sha256=37LK7B_1kuG4IzqirhhELIzpHyl7rm6iwzzafo6rBsk,257
4
+ ekm_proxy/emitter.py,sha256=O3vpxoS_wtO6B57h1Sc-6-bTepOtU2bf-nkR5jZuoww,8452
5
+ ekm_proxy/mapping.py,sha256=X1teLkvq5kQbu4lXhhdaW9BMPSubenCB603blfUIiqg,6130
6
+ ekm_proxy/sources/__init__.py,sha256=9IOR4-UR_ASU49fgcU24lqKJqyDySDzYiguCoNX_JwM,1808
7
+ ekm_proxy/sources/cloud_api.py,sha256=tPJ1mA9u9jHqCjGqToKhLKace80lvnGQCpldEOXmGpA,1426
8
+ ekm_proxy/sources/cloud_mqtt.py,sha256=V5i_XQLMRPNfGDWBzGA4C2EW46CNTUIDfWSjq75n_0Y,5720
9
+ ekm_proxy/sources/push3_native.py,sha256=fcT_yvPtbRix31sji8-8GvRMoCkr_Kkin5H4hjLiQCk,459
10
+ ekm_proxy/sources/rs485.py,sha256=jhEGSA0T0T7CHhsBXTRWgma04hjXAQVSJa5cHIzA58A,4065
11
+ ekm_proxy-0.1.0.dist-info/licenses/LICENSE,sha256=SNfEtQtKuaLySMOhEdErGT_hoKN-6L0BJlPJpUXZoDI,1089
12
+ ekm_proxy-0.1.0.dist-info/METADATA,sha256=ewab5ZDBvH5-_DacD3vPotmCVW0g15fzm1YgvYa6vSI,11921
13
+ ekm_proxy-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ ekm_proxy-0.1.0.dist-info/entry_points.txt,sha256=N1cNtCsIdVT3ogsVXERvX5x0FOuYkWuDKXw8QwPgs4M,49
15
+ ekm_proxy-0.1.0.dist-info/top_level.txt,sha256=L6ojEG5sf1BnjUlQpXpv7YzEdAQ7GRnNUqdBB072FuQ,10
16
+ ekm_proxy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ekm-proxy = ekm_proxy.cli:main
@@ -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 @@
1
+ ekm_proxy