Metadata-Version: 2.4
Name: magnum-pi
Version: 2026.3.1.1
Summary: Private investigator for Magnum Energy inverter/charger RS-485 networks
Project-URL: Documentation, https://magnum-protocol.warehack.ing
Project-URL: Repository, https://git.supported.systems/warehack.ing/magnum-ms2811
Author-email: Ryan Malloy <ryan@supported.systems>
License-Expression: MIT
License-File: LICENSE
Keywords: asyncio,inverter,magnum,marine,off-grid,rs485,rv,solar
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Home Automation
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: pydantic>=2.0
Requires-Dist: pyserial-asyncio>=0.6
Requires-Dist: pyserial>=3.5
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# magnum-pi

[![PyPI](https://img.shields.io/pypi/v/magnum-pi)](https://pypi.org/project/magnum-pi/)
[![Python](https://img.shields.io/pypi/pyversions/magnum-pi)](https://pypi.org/project/magnum-pi/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

I know what you're thinking, and you're right.

Your Magnum Energy MS-Series inverter/charger has been broadcasting its secrets on a proprietary RS-485 bus since the day it was installed. Every 100 milliseconds, it transmits DC voltage, AC output, fault codes, battery temperature, charger state — the full dossier. The remote panel answers back with your configured setpoints. The AGS and BMK chime in from the back seat with generator status and battery state-of-charge. All of it flowing across two wires at 19200 baud, unencrypted, no authentication, just trust.

Someone needs to investigate.

```
╭──────────────────────────────────╮
│   ╭───────────────╮              │
│   ╰──╮         ╭──╯   MAGNUM    │
│      ╰─────────╯      P . I .   │
│                                  │
│  Private Investigator for        │
│  Magnum Energy RS-485 Networks   │
╰──────────────────────────────────╯
```

Async Python library for sniffing, decoding, and transmitting packets on the Magnum Network bus. Plug in any RS-485 adapter — USB dongle, Raspberry Pi HAT, FTDI cable — and start investigating. Built on `asyncio` with Pydantic models, typed enums, gap-based protocol framing, and a CLI that puts the data on your terminal in seconds.

## Install

```bash
pip install magnum-pi
```

Requires Python 3.11+. Dependencies (`pyserial`, `pyserial-asyncio`, `pydantic`) are installed automatically.

## Hardware

You need an RS-485 adapter connected to the **Network** RJ-11 port on the front of your Magnum inverter (or the daisy-chain port on your ME-RC remote). The bus uses two wires: pin 1 (Data+) and pin 4 (Data−), 19200 baud, 8N1.

Any adapter that presents a serial port to the OS will work:

| Adapter | Interface | Notes |
|---------|-----------|-------|
| USB-to-RS-485 dongle | `/dev/ttyUSB0` | Most common. CH340, FTDI, CP2102 chipsets all work. |
| Waveshare RS-485 CAN HAT | `/dev/ttyAMA0` | Raspberry Pi GPIO. Excellent for headless monitoring. |
| FTDI cable (TTL-RS485) | `/dev/ttyUSB0` | Industrial-grade, screw terminals. |

Auto-detection checks `/dev/ttyUSB*`, `/dev/ttyAMA*`, `/dev/ttyS0`, and `/dev/ttyACM*` in order. Pass `-d /dev/yourdevice` to any CLI command to skip auto-detection.

## CLI

### Sniff — raw hex packets

```bash
magnum-pi sniff
magnum-pi sniff -d /dev/ttyUSB0 -n 20   # Capture 20 packets
```

One line per packet, showing the identified type, length, and raw hex:

```
[INVERTER  ] (21 bytes) 400000F60016770001003D1133246B010005025800
[REMOTE    ] (21 bytes) 00002808640A2800009B840C14122014007300A0
[AGS_STATUS] ( 6 bytes) A102343A007F
[BMK       ] (18 bytes) 814C09F10074...
[RTR       ] ( 2 bytes) 9120
```

### Monitor — decoded live data

```bash
magnum-pi monitor --pretty
magnum-pi monitor                        # JSON lines (for piping)
magnum-pi monitor -n 5                   # Stop after 5 cycles
```

Pretty mode prints a dashboard updated every cycle (~100ms):

```
── Inverter ──────────────────────────────
  Status: INVERT           DC: 24.6V 22A
  AC Out: 119V 5A 60.0Hz   AC In: 0V 0A
  Temps: bat=17°C xfmr=51°C fet=36°C
  Model: MS4024PAE  Stack: PARALLEL_MASTER

── BMK Battery Monitor ───────────────────
  SOC: 76%  25.45V  11.6A (charging)
  Range: 20.16V min / 30.80V max

── AGS Generator ─────────────────────────
  Status: READY  12.7V  58°F  Runtime: 0.0h
```

JSON mode outputs one object per cycle — pipe it to `jq`, log it, or feed it to your monitoring stack.

### Send — transmit commands

```bash
magnum-pi send --inverter toggle         # Toggle inverter on/off
magnum-pi send --charger toggle          # Toggle charger on/off
magnum-pi send --voltage 24              # Manual system voltage override
```

The send command reads the current remote configuration from the bus first, applies your change, then transmits the modified packet. This read-modify-write approach preserves all your existing setpoints — shore amps, battery size, charger percentage, everything.

## Python API

### Read bus cycles

```python
import asyncio
from magnum_pi import MagnumBus

async def main():
    async with MagnumBus("/dev/ttyUSB0") as bus:
        cycle = await bus.read_cycle()

        if cycle.inverter:
            inv = cycle.inverter
            print(f"{inv.status.name} {inv.dc_volts}V {inv.dc_amps}A")
            print(f"AC out: {inv.ac_volts_out}V @ {inv.ac_freq_hz}Hz")
            print(f"Model: {inv.model.name}")

        if cycle.bmk:
            print(f"SOC: {cycle.bmk.soc_pct}% {cycle.bmk.dc_volts}V")

        if cycle.ags_status:
            print(f"AGS: {cycle.ags_status.status.name}")

asyncio.run(main())
```

### Continuous monitoring

```python
async with MagnumBus("/dev/ttyUSB0") as bus:
    async for cycle in bus.listen():
        data = cycle.to_dict()       # Flat dict, JSON-serializable
        send_to_influxdb(data)       # Your monitoring pipeline
```

### Send commands

```python
from magnum_pi import MagnumBus
from magnum_pi.models.remote import RemoteBase, RemotePacket

async with MagnumBus("/dev/ttyUSB0") as bus:
    # Read current config, modify, transmit
    cycle = await bus.read_cycle()
    updated = cycle.remote.base.model_copy(update={"inverter_toggle": True})
    await bus.send_remote_packet(RemotePacket(base=updated))
```

### Convenience properties

```python
async with MagnumBus("/dev/ttyUSB0") as bus:
    await bus.read_cycle()               # Populates internal state

    bus.inverter                         # Most recent InverterPacket
    bus.bmk                              # Most recent BMKPacket
    bus.remote                           # Most recent RemotePacket
    bus.voltage_multiplier               # Auto-detected: 1 (12V), 2 (24V), or 4 (48V)
```

### Mock transport for testing

```python
from magnum_pi import MagnumBus, MockTransport

transport = MockTransport(inter_packet_ms=5)
async with MagnumBus(transport=transport, gap_ms=15) as bus:
    transport.inject(my_inverter_bytes)
    transport.inject(my_remote_bytes)
    transport.inject(next_inverter_bytes)     # Triggers cycle boundary

    cycle = await bus.read_cycle()
    assert cycle.inverter is not None
```

## Devices and packets

The Magnum Network bus carries packets from up to five device types. Each packet is a Pydantic model with `from_bytes()` / `to_bytes()` round-trip serialization.

| Device | Packet class | Header | Size | Role |
|--------|-------------|--------|------|------|
| Inverter | `InverterPacket` | — (identified by length + revision byte) | 14-21 bytes | Bus master. Broadcasts status every ~100ms. |
| Remote | `RemotePacket` | — (identified by cycle position) | 21 bytes | Slave. Carries user setpoints + muxed footer. |
| AGS | `AGSStatusPacket` | `0xA1` | 6 bytes | Generator auto-start controller. |
| AGS Counts | `AGSCountsPacket` | `0xA2` | 6 bytes | Generator runtime counters. |
| BMK | `BMKPacket` | `0x81` | 18 bytes | Battery monitor (SOC, voltage, current). |
| RTR | `RTRPacket` | `0x91` | 2 bytes | Router/terminal (firmware version only). |

### Inverter fields

The inverter packet is the heartbeat of the bus. Core fields are always present (14+ bytes); extended fields require firmware revision 4.0+ (21 bytes).

| Field | Type | Example | Notes |
|-------|------|---------|-------|
| `status` | `InverterStatus` | `INVERT` | Operating mode (standby, charge, invert, search) |
| `fault` | `InverterFault` | `NONE` | Fault code (0x00 = no fault) |
| `dc_volts` | `float` | `24.6` | Battery voltage (0.1V resolution) |
| `dc_amps` | `int` | `22` | Battery current |
| `ac_volts_out` | `int` | `119` | AC output voltage |
| `ac_volts_in` | `int` | `0` | AC input voltage (0 = no shore power) |
| `inverter_led` | `bool` | `True` | Inverter operating indicator |
| `charger_led` | `bool` | `False` | Charger operating indicator |
| `revision` | `float` | `6.1` | Firmware version |
| `battery_temp_c` | `int` | `17` | Battery temperature (°C) |
| `transformer_temp_c` | `int` | `51` | Transformer temperature (°C) |
| `fet_temp_c` | `int` | `36` | FET temperature (°C) |
| `model` | `InverterModel` | `MS4024PAE` | Model ID (extended, `None` if < rev 4.0) |
| `stack_mode` | `StackMode` | `PARALLEL_MASTER` | Stacking config (extended) |
| `ac_amps_in` | `int` | `0` | AC input current (extended) |
| `ac_amps_out` | `int` | `5` | AC output current (extended) |
| `ac_freq_hz` | `float` | `60.0` | AC frequency (extended) |

### BMK fields

| Field | Type | Example | Notes |
|-------|------|---------|-------|
| `soc_pct` | `int` | `76` | State of charge (%, 255 = calculating) |
| `dc_volts` | `float` | `25.45` | Battery voltage (0.01V resolution) |
| `dc_amps` | `float` | `11.6` | Current (signed — negative = discharge) |
| `min_volts` | `float` | `20.16` | Lifetime minimum voltage |
| `max_volts` | `float` | `30.80` | Lifetime maximum voltage |
| `amp_hours` | `int` | | Cumulative amp-hours (signed) |
| `fault` | `BMKFault` | `NORMAL` | Status code |

### Remote base fields

Every remote packet carries a base configuration block (bytes 0-15) plus one of six possible footer types, rotated each cycle.

| Field | Type | Range | Notes |
|-------|------|-------|-------|
| `inverter_toggle` | `bool` | | Toggle inverter on/off |
| `charger_toggle` | `bool` | | Toggle charger on/off |
| `eq_toggle` | `bool` | | Enable equalization (auto-sets `charger_toggle`) |
| `search_watts` | `int` | 0-50 | Search mode threshold (watts) |
| `battery_size_ah` | `int` | 0-2550 | Battery capacity (step 10) |
| `charger_amps_pct` | `int` | 0-100 | Charger current limit (%) |
| `shore_amps` | `int` | -128 to 127 | Shore power limit (signed) |
| `float_v` | `float` | | Float voltage (scaled by system voltage) |
| `lbco_v` | `float` | | Low battery cutoff (scaled by system voltage) |
| `force_bulk` | `bool` | | Force bulk charge mode |
| `force_float` | `bool` | | Force float charge mode |
| `force_silent` | `bool` | | Force silent mode |

Footer types cycle through `BASE` (0x00), `AGS_LEGACY` (0xA0), `AGS_EXT_A`-`D` (0xA1-0xA4), and `BMK` (0x80) — carrying AGS scheduling, SOC thresholds, warm-up/cool-down timers, and BMK configuration in the trailing bytes.

## Supported models

The library recognizes all MS-Series models from the original Magnum protocol specification. The model ID (byte 14 of extended inverter packets) determines the voltage multiplier used to scale voltage fields throughout the protocol.

| Family | Models | System voltage |
|--------|--------|---------------|
| 12V | MM612, MM1212, MMS1012, ME1512, ME2012, ME2512, ME3112, MS2012, **MS2812**, MS2712E, and more | 12V (1x multiplier) |
| 24V | MM1324E, MM1524, RD1824, RD2624E, RD4024E, MS4124E, MS2024 | 24V (2x multiplier) |
| 48V | MS4024, MS4024AE, **MS4024PAE**, MS4448AE, MS4448PAE, MS4048, MS4348PE, and more | 48V (4x multiplier) |

## How it works

The Magnum RS-485 protocol has no delimiters, no length prefix, and no CRC. Packets are framed entirely by silence on the wire — a gap of ~2ms between the last byte of one packet and the first byte of the next. The `GapFramer` watches the inter-character timing and emits a complete packet when the gap exceeds the threshold.

The `CycleTracker` groups packets into ~100ms bus cycles. Each cycle starts with an inverter packet (the bus master), followed by the remote's response, then optional AGS, BMK, and RTR packets. Cycle boundaries are detected by recognizing when a new inverter packet arrives while the current cycle already has data.

Voltage multiplier auto-detection happens on the first extended inverter packet — the model byte reveals whether this is a 12V, 24V, or 48V system, and all subsequent voltage fields in remote and AGS packets are scaled accordingly.

For the full protocol specification — byte-level packet tables, timing diagrams, value scaling lookups, and known ambiguities — see **[magnum-protocol.warehack.ing](https://magnum-protocol.warehack.ing)**.

## Prior art

[pymagnum](https://github.com/CharlesGodwin/pymagnum) by Charles Godwin (BSD-3) — the original Python implementation, maintained since 2019. Read-only, synchronous, outputs JSON via the `magdump` CLI tool. The packet identification heuristics and scaling factors in magnum-pi build directly on Charles's work.

## License

[MIT](LICENSE)
