Skip to main content

ebus-panel-sim

PyPI CI Python versions Ruff eBus spec License: MIT

A fully-loaded, spec-conformant distribution-enclosure simulator and producer-side Homie 5 publisher for the eBus convention. It publishes a complete eBus Homie device tree (the enclosure plus a device for every circuit, lugs pair, and integrated DER: BESS, PV, EVSE, and MID) so external developers can build and test their consumers against a realistic SPAN-like panel without beta firmware, a live panel, or the commissioned add-ons (SPAN Drive/EVSE, BESS, PV, MID) a real installation would have.

It serves two roles:

  • Simulator / test fixture. Drive it from a small YAML definition and it publishes a spec-conformant, fully-commissioned enclosure to any MQTT broker. Consumers (Home Assistant integrations, dashboards, SDK code) validate against it before shipping to the field.
  • Producer library. The canonical eBus Homie publisher. A producer (a simulator, a real panel gateway, an LLM-driven model) hands the emitter a small per-tick driving signal (signed power per circuit, current time, grid-online flag) via TickInputs; the emitter derives all telemetry and publishes Homie-conformant retained MQTT with diff-only updates. The split is identity = manifest (once at startup), telemetry = derived from TickInputs (per tick).

For the internals (the per-tick pipeline, the native BESS/load-shed devices, /set handling, the wire model) see DESIGN.md; for the dev setup see DEVELOPER.md.

Requirements

  • Python >= 3.11
  • uv
  • An MQTT broker reachable at localhost:1883 (plaintext). The companion broker-quickstart bundle brings one up in one command; any mosquitto works too.

Install

pip install ebus-panel-sim    # or: uv add ebus-panel-sim

The import package is ebus_panel_sim. During local development, pin a path instead:

ebus-panel-sim = { path = "../distribution-enclosure-simulator", editable = true }

It depends on ebus-sdk.

Before 0.3.0 this package was named panel-sim, importing as panel_sim, and was installable only from git. Update both the dependency and your imports.

Run

The repo ships a runnable example: it builds an emitter from a YAML definition, publishes a couple of ticks to an MQTT broker, then reads the retained tree back through an ebus-sdk Controller and prints it. It expects a plaintext broker on localhost:1883.

The quickest broker is the companion broker-quickstart in its open profile (plaintext, anonymous, port 1883):

# in a broker-quickstart checkout — a plaintext :1883 broker (anon read + write)
python -m laptop.run --profile open

Then, in this repo, publish to it and print the retained tree:

uv sync --group dev
uv run python examples/run_forty_tab_minimal.py                        # print the retained tree
uv run python examples/run_forty_tab_minimal.py --broker 127.0.0.1:1883 --ticks 2 > tree.txt

Any broker that accepts anonymous connections on localhost:1883 works; --broker host:port points the example elsewhere.

The definition is examples/forty_tab_minimal.yaml: a fully-commissioned enclosure with circuits, upstream/downstream lugs, a BESS (plus its MID), PV, and SPAN Drive EVSEs. Each node is its own Homie device: the enclosure at ebus/5/<enclosure-id>/… and each circuit, lugs pair, and DER at its own topic root, for example ebus/5/<circuit-id>/switch/relay, ebus/5/<lugs-id>/meter/current-a, ebus/5/<bess-id>-mid/grid/islanding-state.

Configure

The simulator is driven by a config that says which enclosure, which add-ons, and which circuits. There are two entry points.

1. Example YAML

examples/forty_tab_minimal.yaml is the quickest path. Top-level sections:

  • panel_config — enclosure identity plus total_tabs, main_size, postal_code, time_zone, and islandable. A grid-forming BESS in an islandable enclosure automatically exposes an integrated MID (the islanding authority), mirroring a real SPAN panel.
  • circuit_templates and circuits — per-circuit tabs, breaker rating, priority, relay behavior, and an optional device_type (evse or pv) to land a DER on a circuit.
  • bess — nameplate capacity, charge mode, charge/discharge limits.
  • ticks — the per-tick driving signal: signed watts per circuit and the grid-online flag.

2. DeviceManifest (programmatic)

A producer can build DeviceInstances directly instead of using the YAML loader. Each device class's identity and static attributes live in the instance's metadata, validated once at startup by ManifestPhysicsView (missing required keys or malformed values raise ManifestValidationError naming the offending instance). The metadata keys per device class:

entity_class required keys optional keys
panel vendor-name, serial-number, firmware-version (or software-version), hardware-version, panel-size, main-breaker-rating-a, panel-model, postal-code, time-zone service-voltage-v (240), line-voltage-v (120), islandable (false), schema-topology (flat | parent-child)
lugs direction (upstream | downstream)
circuit tab-numbers (CSV ints), breaker-rating-a, default-priority, relay-behavior, placement (upstream-of-lugs | downstream-of-lugs) always-on, dipole (defaults to len(tab-numbers) > 1), pcs-priority (0), initial-consumed-wh (0), initial-produced-wh (0)
bess vendor-name, nameplate-capacity-kwh model, part-number, serial-number, firmware-version/software-version, relative-position (UPSTREAM), feed, initial-soe-kwh
pv vendor-name, nominal-power-w, inverter-type (hybrid | ac-coupled) model, serial-number, firmware-version/software-version, relative-position (IN_PANEL), feed
evse vendor-name, model, part-number, serial-number, firmware-version (or software-version), max-current-a feed
mid (none) vendor-name, serial-number, model, firmware-version/software-version, hardware-version

Usage (as a producer library)

import time

from ebus_panel_sim import (
    BESSConfig, DeviceInstance, DeviceManifest, Emitter,
    LoadSheddingConfig, SetterRegistry, TickInputs,
)


def main() -> None:
    manifest = DeviceManifest(instances=(
        DeviceInstance("panel", "abc-123", "Span Panel", metadata={
            "vendor-name": "Span", "serial-number": "abc-123",
            "firmware-version": "sim/v0.1.0", "hardware-version": "rev2",
            "panel-size": "40", "main-breaker-rating-a": "200",
            "panel-model": "MAIN_40", "postal-code": "94103",
            "time-zone": "America/Los_Angeles", "islandable": "true",
        }),
        DeviceInstance("lugs", "abc-123-lugs-up", "Upstream lugs", {"direction": "upstream"}),
        DeviceInstance("circuit", "kitchen", "Kitchen", metadata={
            "tab-numbers": "1", "breaker-rating-a": "20",
            "default-priority": "NICE_TO_HAVE", "relay-behavior": "controllable",
            "placement": "downstream-of-lugs",
        }),
        DeviceInstance("bess", "abc-123-bess", "Battery", metadata={
            "vendor-name": "Span", "nameplate-capacity-kwh": "13.5",
        }),
    ))
    bess_cfg = BESSConfig(instance_id="abc-123-bess", nameplate_capacity_kwh=13.5,
                          max_charge_w=3500.0, max_discharge_w=3500.0)

    # With mqtt_cfg the emitter owns the MQTT connection: ebus-sdk builds the
    # client and sets the enclosure's LWT. (Injecting your own client instead
    # moves both of those to you — see "Bring your own transport" below.)
    # Empty SetterRegistry -> the emitter installs internal default /set
    # handlers; register your own before construction to override them.
    emitter = Emitter(
        manifest, SetterRegistry(),
        mqtt_cfg={"host": "127.0.0.1", "port": 1883},
        bess_configs=(bess_cfg,),
        load_shedding_config=LoadSheddingConfig(soc_threshold_pct=20.0),
    )
    emitter.start()
    try:
        while True:
            emitter.publish_tick(TickInputs(
                current_time=time.time(),
                grid_online=True,
                circuits=collect_powers_from_your_model(),  # instance_id -> signed watts
            ))
            time.sleep(1.0)
    finally:
        emitter.stop()


main()

Read the most recently published state back through emitter.last_snapshot. mqtt_cfg is handed straight to ebus-sdk: beyond host/port it takes the ebus-mqtt-client TLS and authentication keys for secured brokers (e.g. broker-quickstart's mTLS discovery/strict profiles).

Bring your own transport

A host that already owns an MQTT connection can publish through it instead of having a second one opened underneath: pass Emitter(..., mqttc=client) in place of mqtt_cfg=. The two are mutually exclusive. This mirrors ebus-sdk's own Device(mqttc=...), and the case it exists for is a host like a Home Assistant add-on, whose MQTT integration is single_config_entry and which forbids background threads (ebus-mqtt-client 0.4.0's asyncio_driver() pumps paho's loop on yours).

The emitter never starts or stops a client it did not build. Two things it consequently cannot do for you — register the Last Will, and re-announce the tree on reconnect — are automatic on the mqtt_cfg path and yours here. They are steps 1 and 4 below, and the order is forced rather than stylistic:

from ebus_panel_sim import Emitter, SetterRegistry
from ebus_mqtt_client import MqttClient

# 1. The Last Will must exist before the client connects — it rides the CONNECT
#    packet, so it cannot be attached afterwards. This is why it is a
#    staticmethod: there is no Emitter yet, and cannot be.
lwt = Emitter.lwt_settings(manifest)

# 2. Build your client with it, still unconnected.
client = MqttClient.from_config({"host": "127.0.0.1", "port": 1883}, client_id="my-host", lwt=lwt)

# 3. Now the emitter, publishing through it.
emitter = Emitter(manifest, SetterRegistry(), mqttc=client)

# 4. Re-announce the whole tree on every (re)connect. Assigned after construction
#    rather than passed to from_config, because the callback needs the emitter and
#    the emitter needs the client. Invoked with no arguments.
client.on_connect_callback = emitter.republish_tree

# 5. You connect, not the emitter — it never starts a client it did not build.
client.start()
emitter.start()           # returns immediately; it has no connection to wait for

To pump paho on your own event loop instead of its background thread — the case a Home Assistant add-on needs — replace step 5's client.start() with the driver, which is async and mutually exclusive with start():

driver = client.asyncio_driver()   # must be called from inside a running loop
await driver.start()
emitter.start()

What each buys, and one obligation that is about timing rather than wiring. All three are silent when they bite:

  • No will means no liveness signal. Skip step 1 and the tree has no LWT at all: a host that dies leaves every consumer reading a stale retained ready, indefinitely. stop(graceful=False) publishes $state=lost itself, but that only covers an orderly teardown — the case where the process didn't die.
  • No re-announce means the tree does not come back. Skip step 4 and a broker that loses its retained store never sees the tree again; what returns is whatever later ticks happen to republish. Measured after wiping a real broker's retained store: 5 topics of 56, every $description missing.
  • Let your loop turn before you close the client. stop(graceful=False) queues the lost on your loop rather than flushing it — flushing would block the very thread that has to run loop_write. Closing the client in the same synchronous breath drops the message and leaves the retained tree on ready.

Layout

  • src/ebus_panel_sim/ — the package (emitter.py, manifest.py, wire/ profiles + publishing, native_devices/); see DESIGN.md.
  • examples/ — the runnable example and its YAML definition.
  • tests/ — the pytest suite.

Tests

uv run pytest
uv run mypy --strict src/ebus_panel_sim tests
uv run ruff check src tests

Contributing

Contributions are welcome. See CONTRIBUTING.md for how to file issues, start a discussion, and open pull requests, plus the local quality gates (ruff, mypy --strict, pytest).

Credits

A fork of, and building on, the original simulator created by Bill Flood (@cayossarian); since updated to track the latest eBus specification. See AUTHORS.

License

See LICENSE.

Release files for ebus-panel-sim 0.6.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ebus-panel-sim 0.6.0
File Size Uploaded
ebus_panel_sim-0.6.0.tar.gz 167.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ebus-panel-sim 0.6.0
File Interpreter ABI Platform
ebus_panel_sim-0.6.0-py3-none-any.whl Python 3 none any Details

Total release size: 259.9 kB

Release files / ebus_panel_sim-0.6.0.tar.gz

Download URL ebus_panel_sim-0.6.0.tar.gz
Size 167.0 kB
Tags Source
SHA-256 checksum
How to use checksums
5a13abfb7d99680efae55584a3ae1ec8d552db201b80f88c1f5d478cbdf73ead
BLAKE2b-256 checksum
How to use checksums
d616c97a27929626d35cf98aaa95d7b48933f5d7904c4fd442c9bf694fceac07
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 20, 2026.

Transparency log

Release files / ebus_panel_sim-0.6.0-py3-none-any.whl

Download URL ebus_panel_sim-0.6.0-py3-none-any.whl
Size 92.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
33ac2715a1984a6a21621ec9db48a038493a22c3d4187296a776e6940b1b7e28
BLAKE2b-256 checksum
How to use checksums
018e9d1083e4692fe1cb1a57116472971279ea491d2ebe897677e9647dbcdee8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 20, 2026.

Transparency log

Release history Release notifications | RSS feed

0.8.0

2 release files

0.7.0

2 release files

0.6.1

2 release files

This release

0.6.0 This release

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page