Metadata-Version: 2.4
Name: meterlog-sdk
Version: 1.2.1
Summary: Read live values, data logs and event logs from SATEC EM133/EM235 power meters over Modbus TCP
Author: SATEC Australia
License: Commercial
Keywords: satec,modbus,power-meter,em133,em235,energy,datalog
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Topic :: System :: Hardware
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"

# MeterLog SDK for Python

Read live values, data logs and event logs from SATEC EM133 and EM235 power
meters over Modbus TCP. Includes the `meterlog` command-line tool. Fully
typed (`py.typed`).

Requires Python 3.9+ on Windows x64, Linux x64, Linux arm64 or macOS Apple
Silicon. The meter must be reachable over Modbus TCP (port 502 by default).

## Install

```bash
pip install meterlog-sdk
```

Recent Linux distributions and macOS block system-wide pip installs. Use a
virtual environment:

```bash
python3 -m venv venv && source venv/bin/activate
pip install meterlog-sdk
```

## Quick Start

Command line — replace the address with your meter's IP:

```bash
meterlog info    --meter em133 --host 192.168.1.203
meterlog live    --meter em133 --host 192.168.1.203
meterlog datalog --meter em133 --host 192.168.1.203 --file-id 1 --out datalog1.csv
```

Python:

```python
from meterlog import Meter

meter = Meter("192.168.1.203", "em133")

info = meter.info()
print(info.model_name, info.serial, info.firmware)

live = meter.live()
print(live.timestamp, live["Total kW"].value, "kW", live["Frequency"].value, "Hz")

log = meter.datalog(1)
print(log.columns)                                 # (Column(name='V1', unit='V', decimals=1), ...)
for record in log:
    print(record.timestamp, record["V1"], record.trigger)

rows = meter.export_datalog(1, "datalog1.csv")
print(f"Exported {rows} rows")
```

## Examples, One Per Operation

Everything below is also in the repository's `examples/python/` folder as
runnable scripts, including a live matplotlib chart and a pandas notebook
starter.

```python
from meterlog import Meter, MeterLogCancelled

meter = Meter("192.168.1.203", "em133", port=502, unit_id=1, timeout=30)
```

**Identify the meter**

```python
info = meter.info()
info.model_name, info.serial, info.firmware        # 'EM133', 50004108, '12.28 build 50'
info.pt_ratio, info.high_resolution, info.energy_decimals
```

**See which logs exist**

```python
for log in meter.logs():
    print(log.file_id, log.type, log.records, [c.header for c in log.columns])
# 0 eventlog 1 []
# 1 datalog 1000 ['Energy REG1 (kWh)', 'Energy REG2 (kWh)', ...]
```

**Read all present values**

```python
live = meter.live()
live["Total kW"].value            # by name
live.get("I4")                    # None if the meter does not report it
live.as_dict()                    # {'V1': 230.1, 'V2': ..., 'Frequency': 50.02, ...}
live.when                         # datetime, UTC
```

**Read only the values you want** (any point, not just the live set)

```python
r = meter.read("V1", "I1", "Total kW", "Frequency")          # one Modbus round trip
r = meter.read("kW import SW demand", "Max kW import SW demand", "V1 angle", "Energy REG2")
r = meter.read(0x1100, "0x3709")                             # point IDs work too
for reading in r:
    print(hex(reading.pid), reading.name, reading.value, reading.unit)
```

**Find out what can be read** (no connection needed)

```python
for p in meter.points():
    print(hex(p.pid), p.name, p.unit, p.group)
# 0x1100 V1 V 1-second phase values
# 0x1609 kW import SW demand kW present demands
# 0x3709 Max kW import SW demand kW maximum demands
```

**Plot live values** (`pip install matplotlib`)

```python
import time, matplotlib.pyplot as plt
t, kw = [], []
for _ in range(60):
    live = meter.read("Total kW")
    t.append(live.when); kw.append(live["Total kW"].value)
    time.sleep(1)
plt.plot(t, kw); plt.ylabel("kW"); plt.show()
```

**Read a data log as records**

```python
log = meter.datalog(1)
log.columns                       # (Column(name='V1', unit='V', decimals=1), ...)
log.total, len(log), log.complete
for record in log:
    record.timestamp, record.trigger, record["V1"], record.values
```

**Into pandas** (`pip install pandas`)

```python
import pandas as pd
df = pd.DataFrame(meter.datalog(1).rows()).set_index("timestamp")
df["Total kW"].plot()
```

**Stream records and stop early**

```python
def on_record(record):
    print(record.timestamp, record["Total kW"])
    return record.timestamp < cutoff        # False stops; log.complete becomes False
log = meter.datalog(1, on_record)
```

**Read the event log**

```python
for e in meter.eventlog():
    print(e.timestamp, e.cause, e.point, e.effect, e.target, e.value_triggered)
```

**Export to CSV, with progress and cancellation**

```python
rows = meter.export_datalog(1, "datalog1.csv", on_progress=lambda done, total: print(done, "/", total))
meter.export_eventlog("eventlog.csv")
try:
    meter.export_datalog(1, "first50.csv", on_progress=lambda done, total: done < 50)
except MeterLogCancelled:
    pass
```

**Several meters at once**

```python
import asyncio
from meterlog import AsyncMeter

async def main():
    meters = [AsyncMeter(ip, "em133") for ip in ("192.168.1.203", "192.168.1.204")]
    for live in await asyncio.gather(*(m.read("Total kW", "Frequency") for m in meters)):
        print(live.host, live.as_dict())

asyncio.run(main())
```

**Handle errors and see diagnostics**

```python
import logging
from meterlog import MeterLogError
logging.getLogger("meterlog").setLevel(logging.INFO)   # connection and file-transfer messages
try:
    meter.live()
except MeterLogError as ex:
    print(ex)        # e.g. "Timed out connecting to 192.168.1.203:502"
```

## The `Meter` Object

`Meter(host, model="em133", *, port=502, unit_id=1, timeout=30.0)` binds the
connection once. `host` is an IPv4 address or host name; `model` is
`"em133"`, `"em235"` or a `MeterModel`. Every method opens its own
connection, so a `Meter` can be shared between threads.

| Method | Returns |
|--------|---------|
| `info()` | `DeviceInfo`: `model_name`, `model_id`, `serial`, `options`, `firmware` ("12.28 build 50"), `bootloader_version`/`_build`, `pt_ratio`, `energy_decimals`, `high_resolution` |
| `logs()` | `list[LogInfo]`: `file_id`, `type` (`"datalog"`/`"eventlog"`), `records`, `columns` |
| `live()` | `LiveReadings`: the standard set of present values (below) |
| `read(*points)` | `LiveReadings` with only the points you name, e.g. `read("V1", "I1", "kW import SW demand", 0x3709)` |
| `points()` | `list[Point]`: every point `read` accepts for this model (`pid`, `name`, `unit`, `group`). No connection needed |
| `datalog(file_id, on_record=None)` | `DataLog` |
| `eventlog(file_id=0, on_event=None)` | `EventLog` |
| `export_datalog(file_id, csv_path, on_progress=None)` | Rows written |
| `export_eventlog(csv_path, file_id=0, on_progress=None)` | Events written |

All methods raise `MeterLogError` with the meter's message on failure.

### Records

`DataLog` has `columns` (tuple of `Column(name, unit, decimals)`), `total`
(the count the meter reported, -1 if unknown), `records` and `complete`.
Iterate it, or call `rows()` for a list of dicts ready for `pandas.DataFrame`
or `csv.DictWriter`.

`DataLogRecord` has `no`, `seq`, `timestamp` (`datetime`, UTC), `trigger`
(the setpoint that logged it, e.g. `"SP1"`), `values` in column order,
`record["V1"]` / `record.get("V1")` by name and `as_dict()`.

`EventLogRecord` has `no`, `seq`, `timestamp`, `event_no`, `cause`, `point`,
`state`, `effect`, `target`, `source_id`, `effect_id`, `value_triggered`.

### Streaming, progress and cancellation

```python
def on_record(record):
    print(record.timestamp, record["Total kW"])
    return record.timestamp < cutoff        # False stops the read

log = meter.datalog(1, on_record)           # log.complete is False if stopped

meter.export_datalog(1, "dl.csv", on_progress=lambda done, total: print(done, "/", total))
```

A callback that returns `False` stops the operation: `datalog`/`eventlog`
return what was read so far, `export_*` raise `MeterLogCancelled`.
Exceptions raised inside a callback propagate unchanged.

### Async

`AsyncMeter` has the same methods as coroutines; each runs the blocking
native call in a worker thread.

```python
import asyncio
from meterlog import AsyncMeter

async def main():
    meters = [AsyncMeter(ip, "em133") for ip in ("192.168.1.203", "192.168.1.204")]
    for live in await asyncio.gather(*(m.live() for m in meters)):
        print(live.host, live["Total kW"].value)

asyncio.run(main())
```

### Diagnostics

The native library logs connection and file-transfer details to the
`meterlog` logger: `logging.getLogger("meterlog").setLevel(logging.DEBUG)`.

### Low-level client

`MeterLogClient(lib_path=None)` mirrors the C ABI one method per function,
with `host` and `meter` on every call; `Meter` is built on it. It also keeps
the 1.1.0 methods `export_em133_all(host, sync_rtc, out_dir, meter_password=0)`
and `export_em235_diagnostics(host, unit_id, start_reg, count, word_order, csv_path)`.
The bundled native library is used unless `lib_path` or the
`METERLOG_LIB_PATH` environment variable points elsewhere.

## Command Line

```bash
meterlog info              --meter em133 --host HOST [--json]
meterlog logs              --meter em133 --host HOST [--json]
meterlog live              --meter em133 --host HOST [--json] [--interval 5 [--count 12]] [--out live.csv]
meterlog read              --meter em133 --host HOST --points "V1, I1, Total kW, 0x1609" [--json] [--interval 5] [--out points.csv]
meterlog points            --meter em133 [--json]                                           # what `read` accepts
meterlog datalog           --meter em133 --host HOST --file-id 1  --out datalog1.csv [--progress]
meterlog datalog           --meter em133 --host HOST --file-id 1  --json          # one JSON record per line
meterlog eventlog          --meter em133 --host HOST --out eventlog.csv
meterlog em133-all         --host HOST --out-dir ./output [--sync-rtc] [--meter-password 0]
meterlog em235-diagnostics --host HOST --unit-id 1 --start-reg 44327 --count 2 --word-order HI_FIRST --out diag.csv
```

| Flag | Meaning |
|------|---------|
| `--meter` | `em133` or `em235` |
| `--host` | Meter IPv4 address or host name |
| `--port`, `--unit-id`, `--timeout` | Modbus TCP port (502), unit identifier (1), timeout in seconds (30) |
| `--file-id` | Log number. EM133 data logs `1`–`3`, EM235 data logs e.g. `13`/`14`, event log `0` |
| `--out`, `--out-dir` | Output CSV file, or output directory (must exist) |
| `--json` | Print JSON instead of a table; for `datalog`/`eventlog`, stream one document per record |
| `--progress` | Show records done / total on stderr during an export |
| `--points` | `read` only: comma-separated point names or IDs, as listed by `meterlog points` |
| `--interval`, `--count` | `live` and `read`: repeat every N seconds, optionally stopping after N readings. Ctrl+C stops |
| `--sync-rtc`, `--meter-password` | `em133-all` only: set the meter clock before the export (off by default) |
| `-v`, `-vv` | Show the native library's diagnostics |

`meterlog live --meter em133 --host HOST --interval 60 --out live.csv` is a
simple logger: one row per minute, timestamp first, one column per value with
its unit in the header.

### Live readings and points

`LiveReadings` has `meter`, `host`, `timestamp` (ISO 8601 UTC; `when` is the
same as a `datetime`) and `readings`, a list of `Reading(name, value, unit, pid)`
in the meter's order. `live["Total kW"]` and `live.get("Frequency")` look a
reading up by name; `live.as_dict()` gives `{name: value}`.

`live()` returns the names below. `read(...)` accepts those and every other
point `points()` lists: 1-cycle values, fundamental phasors (`V1 mag`,
`V1 angle`, ...), present demands (`kW import SW demand`, `I1 demand`, ...),
maximum demands (`Max kW import SW demand`, `I1 max demand`, ...) and the
billing summary registers (`Energy REG1`.., `Max demand REG1`..). Names are
case-insensitive; where a name exists in several groups the 1-second value
is used, so pass the point ID to pick another.

| Group | Names |
|-------|-------|
| Per phase | `V1`–`V3`, `V12`/`V23`/`V31`, `I1`–`I3`, `kW L1`–`L3`, `kvar L1`–`L3`, `kVA L1`–`L3`, `PF L1`–`L3`, `V1 THD`…, `I1 THD`…, `I1 K-Factor`…, `I1 TDD`… |
| Totals | `Total kW`, `Total kvar`, `Total kVA`, `Total PF`, `Total PF lag`, `Total PF lead`, `Total kW import`/`export`, `Total kvar import`/`export`, `V avg`, `V L-L avg`, `I avg` |
| Auxiliary | `In`, `Frequency`, `V unbalance`, `I unbalance`; EM235 also `I4`, `I leakage` |
| Energy | `kWh import`, `kWh export`, `kvarh import`, `kvarh export`, `kVAh total`; EM235 also `kWh net`, `kWh total`, `kvarh net`, `kvarh total` |

## Output

- Values are in engineering units (V, A, kW, kWh, Hz, …) with the decimal
  places defined by the meter's Modbus reference guide. Data log column
  names come from the parameters configured in each log. If the meter's
  scaling registers cannot be read, raw register values are returned instead.
- Timestamps are UTC.
- The whole log file is read from the oldest record; records are
  de-duplicated by sequence number.

## Troubleshooting

| Message | Fix |
|---------|-----|
| `No matching distribution found for meterlog-sdk` | Python is older than 3.9, or the platform is not one of the four supported |
| `externally-managed-environment` | Install inside a virtual environment (see Install) |
| `meterlog: command not found` | Activate the virtual environment the package was installed into |
| `Cannot connect to <host>:502` / `Timed out connecting` | Check the IP, `ping` the meter, and allow outbound TCP 502 through any firewall |
| `Cannot resolve host` | The host name is unknown to DNS; use the IP address |
| `Modbus exception 11` | Wrong `unit_id` for this meter |
| Export stops after ~30 s | The meter is offline or busy; raise `timeout` if the link is slow |

## Testing Without a Meter

`python -m pytest python/tests` in the repository runs the wrapper against a
stub native library built from `tests/stub/meterlog_stub.c` (needs a C
compiler). Set `METERLOG_LIB_PATH` to use another library.
