Energy Monitoring

Getting a Span Gen3 Panel Working in Home Assistant (And Getting the Patch Merged Upstream)

Independent technologist · 200+ HA devices · GriswoldLabs
10 min read

I bought a Span Gen3 (MAIN 40) for the load center because I wanted per-circuit visibility in Home Assistant without sending every watt-hour to a cloud I don’t control. What I didn’t realize when I clicked buy was that Span had quietly broken local API access between Gen2 and Gen3, and the existing community integration — the one I’d been planning to use — only spoke to Gen2 panels.

This is the story of how I figured that out, what I built to fix it, and how the patch ended up merged into the upstream SpanPanel/span integration. If you’re staring at a Gen3 panel in your basement wondering why the HACS integration won’t connect, you’re in the right place.

The short version

  • The community Home Assistant integration (span_panel, ~3000 GitHub stars combined across the ecosystem) had Gen2-only support via Span’s documented HTTP/JSON API.
  • Gen3 hardware (MAIN 40, MLO 48) replaced that with a gRPC service on port 50065 that Span never publicly documented.
  • I reverse-engineered enough of the gRPC schema to read panel state, wrote a Gen3 client into the existing span-panel-api Python library, wired it through a separate code path in the HA integration, and submitted the patch.
  • It merged in February 2026 and shipped as span-panel-api 1.1.15 on PyPI. The integration itself rolled the Gen3 path into a 1.4.x release behind auto-detection — Gen2 panels keep using the original REST path untouched.

If you just want it working: update span_panel to a release that includes Gen3 support, add the integration in HA, and let the config flow auto-detect. The rest of this post is for people who want to know how it actually works, what I’d do differently, and what’s still missing.

Why local control mattered enough to do this

A whole-home energy monitor that talks to a vendor’s cloud has three failure modes I care about:

  1. The vendor goes down or rate-limits you. Span has had outages. If your automations depend on circuit-level data and the cloud is gone, your “smart” home stops being smart at exactly the moment a 30-amp dryer kicks on during a brownout and you wanted to know about it.
  2. The vendor changes pricing or features. A “free” cloud API in 2026 is a $5/month subscription in 2028. I didn’t want my dashboard to evaporate.
  3. Latency. Cloud round-trips are 200–800ms. Local polling on the LAN is consistently under 50ms in my setup. That matters when you’re using circuit power as a trigger condition for, say, “front porch motion + dryer running = playback alert in the laundry room.”

I’m not anti-cloud — I run plenty of cloud-integrated stuff — but for the load center, local was the right call.

What was actually broken

The community span_panel integration is a HACS custom component that talks to a panel’s HTTP/JSON API. On Gen2 panels (the older hardware), you can hit http://<panel-ip>/api/v1/... and get a JSON dump of every circuit’s state. The integration polls that endpoint, parses it, and exposes circuits as Home Assistant entities. Simple, stable, well-supported.

When I plugged in my Gen3 and ran the existing integration, it failed at the config flow. The HTTP endpoint returned a 404 and the config flow timed out. A bit of nmap later confirmed what I suspected: the Gen3 wasn’t running the same web service. It was running gRPC on port 50065.

$ nmap -p 1-65535 <panel-ip>
PORT      STATE SERVICE
22/tcp    open  ssh
80/tcp    open  http     # mostly a status page, not the API
50065/tcp open  unknown  # this is the gRPC port

Span hadn’t published a .proto file for Gen3, hadn’t documented the gRPC service publicly anywhere I could find, and the integration’s GitHub issues had at least three threads from other Gen3 owners asking the same question. The threads had stalled because nobody wanted to volunteer to do the reverse-engineering.

Reverse-engineering the gRPC service

gRPC traffic is HTTP/2 with protobuf payloads. If you don’t have the .proto file, you have two options: ask the vendor (I asked; got nowhere), or capture traffic from a known client and figure out the schema empirically. I went with option two.

The Span mobile app talks to the Gen3 panel locally over the same gRPC service when you’re on the home WiFi. I set up mitmproxy in transparent mode on a Pi between my phone and the panel, captured a few minutes of traffic, and started decoding the binary protobuf payloads with protoc --decode_raw.

A few hours of pattern matching later I had:

  • Service name: roughly span.panel.v1.PanelService
  • Streaming RPC: the panel pushes state at ~1 Hz over a server-streaming method (the client sends one request, the server keeps responding forever)
  • Message shape: each push includes a list of circuits, each with position, name, power_w, voltage_v, current_a, plus a main-feed block with frequency and bidirectional power
  • Authentication: none on the LAN. Gen3 trusts the local network, full stop. (This is the right call for an appliance, but it means you need to think about VLAN segregation if you have IoT devices you don’t fully trust.)

I wrote enough of a .proto file to compile a Python stub, then built SpanGrpcClient as a thin async wrapper around the streaming call. The whole client is about 300 lines.

Slotting Gen3 into the existing integration

The HA integration was already structured around a DataUpdateCoordinator — Home Assistant’s standard pattern for periodic polling. The Gen2 path polls REST every few seconds and updates entity state. The push-streaming Gen3 path doesn’t fit that pattern naturally.

I went back and forth on how to shoehorn it in. The two reasonable options were:

  1. Make Gen3 look like polling. Wrap the gRPC stream in a coordinator that exposes the latest pushed value when HA asks. Cheap to implement, but adds an awkward layer where the coordinator’s “update” call returns instantly with whatever was already buffered.
  2. Push state directly into entity state. Bypass the coordinator entirely on the Gen3 path. Cleaner, but it forks the integration into two architectural styles and makes future maintenance harder.

I went with option 1. The push handler writes the latest payload to a buffer, and the DataUpdateCoordinator.async_update_data call just reads from the buffer. It’s a slight abuse of the pattern but it keeps both code paths looking similar, which matters more for upstream maintainability than for elegance.

The other architectural decision was where to put the Gen3 code. I put it in a separate gen3/ subdirectory with its own client, coordinator, and entity definitions. The Gen2 code was completely untouched. The config flow was the only file that needed to know about both paths — it tries the REST endpoint first, falls back to gRPC on connection failure, and tags the discovered panel with its generation so the rest of the integration knows which client to instantiate.

custom_components/span_panel/
├── __init__.py
├── config_flow.py          # auto-detects Gen2 vs Gen3
├── coordinator.py          # Gen2 (REST polling)
├── sensor.py               # shared entity definitions
├── span_panel_api/         # Gen2 client (existing)
└── gen3/
    ├── client.py           # SpanGrpcClient — gRPC streaming
    ├── coordinator.py      # SpanGen3Coordinator
    └── proto/              # compiled protobuf stubs

A handful of shared sensor classes work against either coordinator type because the data shape is similar enough — once you’ve extracted power_w for a circuit, the entity doesn’t care whether it came over REST or gRPC.

What the YAML side looks like

There’s no manual YAML for span_panel — it’s a config-flow integration, you add it through the Home Assistant UI. But once it’s set up, you can use the entities like any other power sensor. Here’s a chunk from my actual setup that combines a Span circuit reading with my Tesla solar production:

template:
  - sensor:
      - name: "House Net Power"
        unique_id: house_net_power
        unit_of_measurement: "W"
        device_class: power
        state_class: measurement
        state: >
          {% set load = states('sensor.span_main_feed_power') | float(0) %}
          {% set solar = states('sensor.my_home_solar_power') | float(0) %}
          {{ (load - solar) | round(0) }}
        availability: >
          {{ states('sensor.span_main_feed_power') | is_number and
             states('sensor.my_home_solar_power') | is_number }}

When that number goes positive, I’m pulling from the grid. When it goes negative, I’m exporting. It’s a one-line template once both integrations work, but it’s the kind of dashboard tile that’s useless if the underlying data is flaky — which is why local-first matters.

Submitting the patch

The PR landed in two places:

  • The Python library (span-panel-api on PyPI) got the gRPC client added, shipped as version 1.1.15.
  • The HA integration (SpanPanel/span on GitHub) consumed the new library version and wired up the Gen3 code path, plus auto-detection in the config flow.

Review took a couple of weeks. The maintainers wanted the Gen2 code path to stay completely untouched — which I’d already done — and asked for a few changes:

  • Move the auto-detection logic out of __init__.py and into the config flow proper, so reauth flows hit it too.
  • Add a gen field to the config entry so we don’t re-detect on every restart.
  • Add tests for the Gen3 coordinator using the captured protobuf payloads as fixtures.

All reasonable. The mypy and ruff CI rounds were the part I was least prepared for — getting a typed gRPC stub clean under both is its own little adventure. Two follow-up commits (fix: resolve all mypy and pylint CI failures for Gen3 gRPC integration and style: fix all ruff lint errors in gen3 module) finished that off.

What’s still missing

The Gen3 path doesn’t yet support:

  • Circuit relay control. Gen2 lets you toggle individual breakers via REST. The Gen3 gRPC service has an UpdateState RPC that does the same thing, but I didn’t include it in the initial PR — the read path is the more common use case and I wanted that in first. Relay control is queued for a follow-up.
  • Energy accumulation. The Gen3 push stream gives you instantaneous power but not lifetime energy counters. I’m computing my own using HA’s integration platform (the same pattern I use for solar). It works fine, but you lose data on HA restarts unless you have your recorder set up to persist it.
  • Solar circuit combining. If your solar circuits are wired into the panel rather than directly inverter-tied, the Gen2 integration has logic to combine them into a single solar_power entity. The Gen3 path doesn’t do this yet because circuit naming on Gen3 panels is more flexible and the heuristics need rewriting.

These are all “I’ll get to it” items rather than blockers. If you need them, watch the issue tracker — or send a PR.

What I’d do differently

Two things, if I were starting from scratch:

Capture more app traffic before writing the client. I had enough protobuf samples to model the steady-state push, but I missed an edge case where a freshly-restarted panel sends a slightly different initial message. That cost me a debugging session at 11pm trying to figure out why the integration would work after a few minutes but fail on first connection. More mitmproxy time up front would have caught it.

Start with the gRPC reflection API. Modern gRPC servers often expose a reflection service that lets you query the schema at runtime. I should have checked for that before doing the manual reverse-engineering. Span’s panel doesn’t expose it (at least, mine doesn’t on this firmware), but it took me a day to confirm that, which is a day I won’t get back.

If you have a Gen3 panel

The integration is there. Update to a recent span_panel release, add it through the Home Assistant UI, and the config flow will auto-detect Gen3 and use the gRPC path. If anything misbehaves, the integration’s GitHub issues are the right place — and the Gen3 code path has my fingerprints on it, so it’s likely my fault.

If you have a Gen2 panel, nothing has changed for you. The original REST path is untouched, no migration is needed, and the auto-detection means an installation on a Gen2 panel won’t even try the gRPC port.

Tags: #home-assistant #span-panel #grpc #energy-monitoring #open-source
Share: X / Twitter Facebook

Related Articles