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, the standalone integration I built to fix it, and — the twist I didn’t see coming — how Span later closed the local API entirely in a firmware update. If you’re staring at a Gen3 panel in your basement wondering why the existing integrations won’t connect, you’re in the right place.
The short version
- The existing community Home Assistant integration (
SpanPanel/span) supports Gen2 / MAIN 32 panels via Span’s documented HTTP/JSON API — it can’t talk to Gen3 hardware at all. - 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 and packaged it as a standalone, Gen3-only Home Assistant integration —
Griswoldlabs/span-panel-ha(v1.0.0, Feb 2026), installable through HACS as a custom repository. Gen2 owners keep using the community integration; this one is for Gen3. - I shared it on the Home Assistant community forum for other Gen3 / MLO 48 owners to test — and a few weeks later a Span firmware update (7.2.0) closed the gRPC port. That’s the part of this story that turned out to matter most (it’s the last section).
If you just want the how, keep reading. But if you own a Gen3 panel today, skip to the last section first — a later Span firmware update changed what’s actually possible right now.
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:
- 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.
- 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.
- 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.
Structuring the integration
Home Assistant’s standard pattern for a polling integration is the DataUpdateCoordinator — HA calls an async_update_data method on an interval and fans the result out to entities. The Gen3 panel doesn’t poll, though; it pushes state at ~1 Hz over a server-streaming gRPC call. That doesn’t fit the coordinator pattern naturally.
I went back and forth on how to reconcile the two:
- Make the push look like polling. Keep a
DataUpdateCoordinator, but have the gRPC stream write each incoming payload to a buffer;async_update_datajust returns whatever’s currently in the buffer. Cheap, and it lets me reuse HA’s standard entity/coordinator plumbing. - Push state directly into entities. Skip the coordinator, update entity state straight from the stream handler. Slightly cleaner in theory, but you throw away all the coordinator machinery HA gives you for free — availability tracking, staggered updates, reload handling.
I went with option 1. A background task owns the gRPC stream and writes the latest panel snapshot into a buffer; the coordinator reads from it. It’s a mild abuse of the pattern, but it means the rest of the integration — entities, config flow, device registry — behaves like any other well-mannered HA component.
Because Gen3’s gRPC protocol shares essentially nothing with Gen2’s REST API, I built this as a separate, Gen3-only integration rather than trying to bolt it onto the community span_panel component. Gen2 / MAIN 32 owners are already well served there; carrying two entirely different transports in one repo would have been a maintenance burden for no real benefit. The layout is a plain HACS custom component (internal file names below are illustrative — see the repo for the current structure):
custom_components/span_panel/
├── __init__.py
├── config_flow.py # discovers the panel over gRPC on :50065
├── coordinator.py # buffers the streamed panel state
├── sensor.py # circuit + main-feed entities
└── proto/ # compiled protobuf stubs
The config flow connects to the gRPC service, confirms it’s answering as a Gen3 panel, and sets things up — there’s no REST fallback, because Gen3 hardware doesn’t answer on the old HTTP endpoints at all.
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.
Releasing it
I didn’t try to fold this into the community span_panel integration. Gen3’s gRPC transport shares almost nothing with Gen2’s REST API, so merging them into one codebase would have meant carrying two completely different clients for two generations of hardware, indefinitely. A clean, standalone Gen3-only integration was the honest packaging: Griswoldlabs/span-panel-ha, tagged v1.0.0 in February 2026 and installable through HACS as a custom repository — add the repo URL, then search “Span MAIN 40.” I posted it on the Home Assistant community forum so other Gen3 and MLO 48 owners could test it, since I only had my own MAIN 40 to develop against.
The parts I was least prepared for were the unglamorous ones: getting a typed gRPC stub clean under mypy and ruff, and making the config flow resilient to a panel that drops the stream and reconnects. Reverse-engineering the protocol was a weekend; turning it into a well-behaved HA integration that doesn’t spam the log on every reconnect took longer.
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
UpdateStateRPC that does the same thing, but I didn’t include it in the initial release — the read path is the more common use case and I wanted that in first. Relay control was 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
integrationplatform (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_powerentity. 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.
The twist: Span closed the door
A few weeks after I released it, Span shipped firmware 7.2.0 — and the local gRPC API I’d built on went dark.
I found out the way you’d expect: the integration stopped connecting. Port 50065 no longer answered; the service had moved, and every gRPC method now came back UNIMPLEMENTED. The local API I’d reverse-engineered was, functionally, revoked — remotely, without notice, on a panel I owned outright. Other Gen3 owners on the forum thread hit the same wall the same week.
That’s the part of this project I keep coming back to. The reverse-engineering was a fun weekend of mitmproxy and protoc. The part that actually mattered was the reminder that “local control” on cloud-managed hardware lasts exactly as long as the vendor allows it — a firmware update you don’t get to decline can quietly remove a capability the hardware physically still has.
So where does that leave a Gen3 owner today? If your panel is still on a firmware old enough that the gRPC service answers, Griswoldlabs/span-panel-ha will still work — add it to HACS as a custom repository and set it up through the HA UI. If you’re on 7.2.0 or later, that door is closed and there’s nothing any integration can do about it. The better news is that Span is productizing an official, authenticated, LAN-only local API — branded SPAN API, with a companion SPAN Home On-premise web app, built on the eBus / MQTT (Homie) framework — as a sanctioned, more-secure replacement for the old undocumented access. It’s live in public beta on the earlier MAIN 32 panels, but not yet on Gen3 (MAIN 40 / MLO 48); Span estimates the second half of 2026 for those. For most Gen3 owners the realistic move now is to wait for that official API rather than chase another undocumented protocol Span can revoke. I’ve left this integration up as a record of what was possible.
If you have a Gen2 / MAIN 32 panel, none of this drama applies — the community SpanPanel/span integration talks to your panel’s REST API and is the right choice.
Related guides: Tesla solar in HA without the cloud · Monitoring the smart home with Uptime Kuma · How I deploy HA config changes