Skip to content

Add Gen3 (MAIN 40 / MLO 48) gRPC support#169

Open
Griswoldlabs wants to merge 8 commits intoSpanPanel:mainfrom
Griswoldlabs:gen3-grpc-support
Open

Add Gen3 (MAIN 40 / MLO 48) gRPC support#169
Griswoldlabs wants to merge 8 commits intoSpanPanel:mainfrom
Griswoldlabs:gen3-grpc-support

Conversation

@Griswoldlabs
Copy link

Gen3 (MAIN 40 / MLO 48) gRPC Support

Adds local gRPC-based support for Gen3 Span panels alongside existing Gen2 REST support. Gen2 code is completely untouched — Gen3 activates only via auto-detection in the config flow.

What this does

  • Auto-detects Gen2 vs Gen3 during config flow (REST → gRPC fallback)
  • Gen3 panels communicate via gRPC on port 50065 (no auth required)
  • Real-time push-based streaming power/voltage/current/frequency data
  • Breaker state monitoring via voltage threshold detection
  • Gen2 code is completely untouched — zero risk to existing users

Architecture

  • New gen3/ subdirectory with isolated Gen3 code path
  • SpanGrpcClient: raw protobuf parsing on port 50065 (no generated stubs needed)
  • SpanGen3Coordinator: wraps push-based streaming in HA's DataUpdateCoordinator pattern
  • Pluggable design — Gen3 gRPC client can be extracted to a span-panel-grpc package later

Config Flow Detection Logic

User enters host IP
  → Try REST: GET /api/v1/status (existing validate_host)
    → Success → Gen2 panel, continue existing auth flow
    → Failure →
      → Try gRPC: connect to port 50065, send GetInstances (5s timeout)
        → Success → Gen3 panel, skip auth, create entry with panel_gen="gen3"
        → Failure → "Cannot connect to panel"

What's included

  • Per-circuit: power, voltage, current sensors + breaker binary sensor
  • Main feed: power, voltage, current, frequency sensors
  • Config flow auto-detection (REST → gRPC fallback)
  • Proto descriptor set file (shipped for reference/future use)

What's NOT included (future PRs)

  • Circuit relay control via gRPC UpdateState RPC
  • Energy accumulation (Gen3 gRPC doesn't provide this yet)
  • Solar sensor combining
  • MLO 48 testing (may work — same gRPC protocol suspected)

Modified files (minimal, surgical changes)

File Lines added Change
const.py +3 Add CONF_PANEL_GEN constant
manifest.json +2 Add grpcio>=1.60.0 dependency, bump to v1.4.0
config_flow.py +30 gRPC fallback in user step + _test_gen3_connection() helper
__init__.py +43 Gen3 setup/unload routing (early return branches)
sensor.py +10 Gen3 sensor factory early return
binary_sensor.py +9 Gen3 binary sensor factory early return

New files (all under gen3/)

File Lines Purpose
__init__.py 1 Package marker
const.py 30 Gen3 constants (trait IDs, gRPC port, voltage threshold)
span_grpc_client.py ~750 gRPC client with raw protobuf parsing
coordinator.py ~90 DataUpdateCoordinator wrapper for push-based streaming
sensors.py ~260 Main feed + per-circuit sensor entities
binary_sensors.py ~90 Breaker ON/OFF binary sensors
span.protoset binary Proto descriptor set (documentation/future use)

Testing

  • Tested on MAIN 40 with 25 circuits (104 entities created)
  • Gen2 path is completely unchanged — zero risk to existing users
  • All Gen3 entities appear with correct values matching the Span app
  • gRPC streaming updates entities in real-time

Related

Background

We (GriswoldLabs) reverse-engineered the Gen3 gRPC protocol and built a standalone integration that's been running successfully. Per Discussion #168, @cayossarian offered org admin access for a PR with a pluggable architecture. This PR implements that as an additive, non-breaking change with Gen3 code fully isolated in its own subdirectory.

🤖 Generated with Claude Code

Adds local gRPC-based support for Gen3 Span panels alongside existing
Gen2 REST support. Gen2 code is completely untouched — Gen3 activates
only via auto-detection in the config flow.

Architecture:
- New gen3/ subdirectory with isolated Gen3 code path
- SpanGrpcClient: connects to port 50065, raw protobuf parsing
- SpanGen3Coordinator: wraps push-based streaming in DataUpdateCoordinator
- Config flow auto-detects Gen2 vs Gen3 (REST → gRPC fallback)
- Gen3 panels require no authentication

Entities:
- Main feed: power, voltage, current, frequency sensors
- Per-circuit: power, voltage, current sensors + breaker binary sensor
- Device hierarchy: panel → circuit sub-devices

Not included (future PRs):
- Circuit relay control via gRPC UpdateState RPC
- Energy accumulation (Gen3 gRPC doesn't provide this yet)
- Solar sensor combining

Closes SpanPanel#96
Relates to SpanPanel#98
The METRIC_IID_OFFSET was hardcoded to 27, which only worked for panels
where trait 26 (metrics) IIDs start at 28. On panels with different
numbering, this caused names to pair with wrong power readings.

Now dynamically discovers both trait 16 (name) and trait 26 (metric)
instance IDs during setup and pairs them by sorted position, making the
mapping work regardless of the panel's IID numbering scheme.
@Griswoldlabs
Copy link
Author

Thanks for testing this @cecilkootz! You're right — there was a bug in the circuit mapping.

Root cause: The mapping between circuit names (trait 16) and power metrics (trait 26) used a hardcoded offset of 27 between the two instance ID spaces. This was reverse-engineered from one specific MAIN 40 panel where trait 16 IIDs are 1-25 and trait 26 IIDs are 28-52. On your panel the numbering is likely different, so names were getting paired with the wrong power readings.

Fix (just pushed): Instead of assuming a fixed offset, the integration now discovers both trait 16 and trait 26 instance IDs during setup, sorts them, and pairs them by position. This should work correctly regardless of how your panel numbers its instances.

Could you try the updated branch and let me know if the mapping is correct now? If you're still seeing mismatches, enabling debug logging for custom_components.span_panel.gen3 would help — the discovery phase now logs the IID mapping for each circuit.

@cecilkootz
Copy link

cecilkootz commented Feb 17, 2026

Thanks for testing this @cecilkootz! You're right — there was a bug in the circuit mapping.

Root cause: The mapping between circuit names (trait 16) and power metrics (trait 26) used a hardcoded offset of 27 between the two instance ID spaces. This was reverse-engineered from one specific MAIN 40 panel where trait 16 IIDs are 1-25 and trait 26 IIDs are 28-52. On your panel the numbering is likely different, so names were getting paired with the wrong power readings.

Fix (just pushed): Instead of assuming a fixed offset, the integration now discovers both trait 16 and trait 26 instance IDs during setup, sorts them, and pairs them by position. This should work correctly regardless of how your panel numbers its instances.

Could you try the updated branch and let me know if the mapping is correct now? If you're still seeing mismatches, enabling debug logging for custom_components.span_panel.gen3 would help — the discovery phase now logs the IID mapping for each circuit.

Yeah. Let me get the update pushed and try again. Sorry for the original comment removal. I have a MLO48 and wanted to debug further before raising an issue.

2026-02-17 15:56:37.584 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Discovered 31 name instances (trait 16) and 36 metric instances (trait 26, excl main feed). Name IIDs: [1, 2, 3, 4, 5], Metric IIDs: [2, 35, 35, 36, 37]
2026-02-17 15:56:37.584 WARNING (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Trait 16 has 31 instances but trait 26 has 36 — pairing by position (some circuits may be unnamed)
2026-02-17 15:56:37.595 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 1 (name_iid=1, metric_iid=2): Bedroom #2 / Hall Lighting
2026-02-17 15:56:37.605 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 2 (name_iid=2, metric_iid=35): Bedroom #4 / Smoke Detectors
2026-02-17 15:56:37.617 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 3 (name_iid=3, metric_iid=35): Garage Door Opener / Garage Lighting
2026-02-17 15:56:37.627 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 4 (name_iid=4, metric_iid=36): Master Bedroom / Laundry Lights
2026-02-17 15:56:37.639 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 5 (name_iid=5, metric_iid=37): Microwave
2026-02-17 15:56:37.650 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 6 (name_iid=6, metric_iid=38): Dishwasher / Disposal
2026-02-17 15:56:37.661 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 7 (name_iid=7, metric_iid=39): Kitchen G.F.I. / Refrigerator
2026-02-17 15:56:37.671 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 8 (name_iid=8, metric_iid=40): Wine Cooler
2026-02-17 15:56:37.719 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 9 (name_iid=9, metric_iid=41): Kitchen Island G.F.I. Recepticles
2026-02-17 15:56:37.730 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 10 (name_iid=10, metric_iid=42): Laundry Room / Washer
2026-02-17 15:56:37.740 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 11 (name_iid=11, metric_iid=43): Bathroom G.F.I. Recepticles
2026-02-17 15:56:37.751 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 12 (name_iid=12, metric_iid=44): Kitchen G.F.I. Recepticles
2026-02-17 15:56:37.764 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 13 (name_iid=13, metric_iid=45): Master Bathroom / Hall Bathroom
2026-02-17 15:56:37.775 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 14 (name_iid=14, metric_iid=46): Garage G.F.I. Recepticles
2026-02-17 15:56:37.787 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 15 (name_iid=15, metric_iid=47): Foyer / Front Entry / Dining Room Lights
2026-02-17 15:56:37.797 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 16 (name_iid=16, metric_iid=48): Pocket Office / Kitchen Lighting
2026-02-17 15:56:37.807 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 17 (name_iid=17, metric_iid=49): Family Room / Back Porch
2026-02-17 15:56:37.818 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 18 (name_iid=18, metric_iid=50): Bedroom #3 / Servers
2026-02-17 15:56:37.830 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 19 (name_iid=19, metric_iid=51): Bonus Room / Stairway Lights
2026-02-17 15:56:37.841 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 20 (name_iid=21, metric_iid=52): Downstairs Central Air
2026-02-17 15:56:37.852 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 21 (name_iid=23, metric_iid=53): Cooktop
2026-02-17 15:56:37.863 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 22 (name_iid=24, metric_iid=55): Water Heater
2026-02-17 15:56:37.874 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 23 (name_iid=25, metric_iid=57): Downstairs Air Conditioning
2026-02-17 15:56:37.886 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 24 (name_iid=26, metric_iid=58): Laundry Dryer
2026-02-17 15:56:37.896 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 25 (name_iid=27, metric_iid=59): Upstairs Air Conditioning
2026-02-17 15:56:37.906 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 26 (name_iid=28, metric_iid=60): Upstairs Central Air
2026-02-17 15:56:37.916 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 27 (name_iid=29, metric_iid=61): Big Garage Air Conditioning
2026-02-17 15:56:37.927 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 28 (name_iid=30, metric_iid=62): EV Charger
2026-02-17 15:56:37.938 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 29 (name_iid=31, metric_iid=63): Venthood
2026-02-17 15:56:37.947 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 30 (name_iid=32, metric_iid=64): Dining Room Recepticles
2026-02-17 15:56:37.957 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 31 (name_iid=34, metric_iid=65): Double Oven
2026-02-17 15:56:37.967 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 32 (name_iid=32, metric_iid=68): Dining Room Recepticles
2026-02-17 15:56:37.987 DEBUG (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Circuit 34 (name_iid=34, metric_iid=74): Double Oven
2026-02-17 15:56:38.010 INFO (MainThread) [custom_components.span_panel.gen3.span_grpc_client] Connected to Gen3 panel at 192.168.1.21:50065 — 36 circuits discovered

@haggerty23
Copy link

This PR worked for me except I have two Span panels. But after I used AI coding tools, I was able to create a workaround. When you get a chance, it'd be great to add support for more than one gen3 panel.

@cayossarian
Copy link
Member

@Griswoldlabs I have taken the liberty of refactoring to accommodate your grpc support both in the span-panel-api and the span repo. I can only confirm the gen2 is not broken but hopefully your PR is faithfully represented as well. See the handoff and let me know how I can facilitate any further integration. There are planning docs in docs/dev for each repo that inform as to what changed and why.

Once you are able to test and satisfied with your panel, we can publish a beta and invite others to use it. An org invite will be forthcoming. The credit is all yours.

I have yet to update the readme's but the change logs are updated. Simulation mode only works for gen2 at present, but no big deal.

Thanks on behalf of the community.

Support for reading and exposing the physical breaker slot position from Gen3 panels. It introduces a new "Panel Position" sensor that displays the breaker position (1-48) for each circuit, and refactors the circuit discovery logic to properly resolve breaker group information.
@Griswoldlabs
Copy link
Author

This is incredible — thank you for taking this on so quickly. The architecture looks exactly right: gRPC transport in the library, capabilities-based entity loading, push vs poll auto-selection.

I'll pull both branches and test against my MAIN 40 this week. The main thing I want to verify is that the refactored decoders produce the same readings I validated against the Span app.

The big open item is the name/metric IID count mismatch on @cecilkootz's MLO 48 (31 names vs 36 metrics). My best theory is that 240V dual-phase circuits report two metric IIDs (one per breaker position) but share a single trait 16 name. Trait 15 (Breaker Groups) likely holds the mapping between physical breaker positions and named circuits — I'll look into using that to properly correlate them. Unfortunately I can only test single-phase dedup logic against my MAIN 40, so @cecilkootz's help will be essential for validating the MLO 48 fix.

Looking forward to the org invite. Happy to own the gRPC side going forward.

@Griswoldlabs
Copy link
Author

Glad it's working! Multi-panel support should be doable — the config flow already creates a separate config entry per host, so in theory each panel gets its own coordinator and entity set. The issue is likely unique_id collisions or the config flow not allowing a second Gen3 entry. Can you share what specifically broke with two panels? (e.g., did the second panel fail to add, or did entities from both panels merge together?)

@cecilkootz
Copy link

cecilkootz commented Feb 18, 2026

This is incredible — thank you for taking this on so quickly. The architecture looks exactly right: gRPC transport in the library, capabilities-based entity loading, push vs poll auto-selection.

I'll pull both branches and test against my MAIN 40 this week. The main thing I want to verify is that the refactored decoders produce the same readings I validated against the Span app.

The big open item is the name/metric IID count mismatch on @cecilkootz's MLO 48 (31 names vs 36 metrics). My best theory is that 240V dual-phase circuits report two metric IIDs (one per breaker position) but share a single trait 16 name. Trait 15 (Breaker Groups) likely holds the mapping between physical breaker positions and named circuits — I'll look into using that to properly correlate them. Unfortunately I can only test single-phase dedup logic against my MAIN 40, so @cecilkootz's help will be essential for validating the MLO 48 fix.

Looking forward to the org invite. Happy to own the gRPC side going forward.

I pushed up a PR to your branch with a working version. I did some inspection and think the discovery works (at least on MLO48). Jumped a bit with the other changes going on so may need more work. Thank you for working through this and getting a working solution for Gen3.

==============================================================================================================
  SPAN PANEL - MAIN FEED
==============================================================================================================
  Panel ID:    d457b54377c59cb9
  Power:           3380.2 W
  Voltage:          240.6 V  (Leg A: 120.3V  Leg B: 120.3V)
  Current:          14.05 A
  Frequency:        60.04 Hz

  Breakers:    31 configured, 31 ON, 0 OFF
  Slots:       31 occupied / 48 total

==============================================================================================================
  ACTIVE BREAKERS
==============================================================================================================
  Slot  Name                           State  Phase   Power (W)  Voltage (V)  Current (A)     PF
--------------------------------------------------------------------------------------------------------------
     1  Bedroom #2 / Hall Lighting        ON   120V       150.3        120.7         1.74      -
     3  Bedroom #4 / Smoke Detectors      ON   120V       239.8        108.2         2.62      -
     4  Microwave                         ON   120V         0.9        120.2         0.03      -
     5  Garage Door Opener / Garage Lighting    ON   120V        11.4        120.7         0.18      -
     6  Dishwasher / Disposal             ON   120V         1.0        120.6         0.03      -
     7  Master Bedroom / Laundry Lights    ON   120V        65.9        120.2         0.74      -
     8  Kitchen G.F.I. / Refrigerator     ON   120V      1443.9        120.2        12.07      -
     9  Master Bathroom / Hall Bathroom    ON   120V         0.9        120.6         0.03      -
    10  Wine Cooler                       ON   120V        92.0        120.7         1.06      -
    11  Garage G.F.I. Recepticles         ON   120V        44.2        120.2         0.66      -
    12  Kitchen Island G.F.I. Recepticles    ON   120V         0.2        120.2         0.03      -
    13  Foyer / Front Entry / Dining Room Lights    ON   120V         6.1        120.6         0.10      -
    14  Laundry Room / Washer             ON   120V       202.2        120.6         1.71      -
    15  Pocket Office / Kitchen Lighting    ON   120V        82.5        120.1         0.86      -
    16  Bathroom G.F.I. Recepticles       ON   120V         5.2        120.1         0.16      -
    17  Family Room / Back Porch          ON   120V        76.4        120.7         1.00      -
    18  Kitchen G.F.I. Recepticles        ON   120V        13.1        120.7         0.20      -
    19  Bedroom #3 / Servers              ON   120V       251.4        120.2         2.49      -
    20  Venthood                          ON   120V         0.8        120.3         0.03      -
    21  Bonus Room / Stairway Lights      ON   120V        49.2        120.7         0.92      -
    22  Dining Room Recepticles           ON   120V         3.3        108.5         0.13      -
    23  Double Oven                       ON   240V         1.7        240.9         0.06   0.88
          Leg A:  120.2V    0.04A    |  Leg B:  120.7V    0.03A
    26  Downstairs Air Conditioning       ON   240V         0.0        240.9         0.05   0.01
          Leg A:  120.7V    0.03A    |  Leg B:  120.2V    0.03A
    27  Downstairs Central Air            ON   240V        13.5        240.9         0.19   0.61
          Leg A:  120.2V    0.10A    |  Leg B:  120.6V    0.10A
    30  Laundry Dryer                     ON   240V         0.7        240.8         0.05   0.95
          Leg A:  120.6V    0.03A    |  Leg B:  120.2V    0.02A
    31  Cooktop                           ON   240V         0.1        240.9         0.05   0.07
          Leg A:  120.3V    0.03A    |  Leg B:  120.6V    0.03A
    34  Upstairs Air Conditioning         ON   240V         0.0        241.0         0.05   0.09
          Leg A:  120.7V    0.03A    |  Leg B:  120.3V    0.03A
    35  Water Heater                      ON   240V         0.0        240.8         0.05   0.08
          Leg A:  120.2V    0.03A    |  Leg B:  120.6V    0.03A
    38  Upstairs Central Air              ON   240V        13.3        240.8         0.14   0.90
          Leg A:  120.7V    0.07A    |  Leg B:  120.1V    0.07A
    39  EV Charger                        ON   240V         0.0        240.9         0.05   0.23
          Leg A:  120.2V    0.02A    |  Leg B:  108.6V    0.03A
    42  Big Garage Air Conditioning       ON   240V       464.2        240.8         4.02   0.99
          Leg A:  120.7V    2.01A    |  Leg B:  120.2V    2.01A
--------------------------------------------------------------------------------------------------------------
        TOTAL                                            3234.3

==============================================================================================================
  POWER QUALITY DETAIL
==============================================================================================================
  Slot  Name                              Real (W)  Apparent (VA)  Reactive (VAR)     PF
--------------------------------------------------------------------------------------------------------------
     1  Bedroom #2 / Hall Lighting           150.3           87.4            89.2      -
     3  Bedroom #4 / Smoke Detectors         239.8          151.5           185.5      -
     4  Microwave                              0.9            1.1             2.0      -
     5  Garage Door Opener / Garage Lighting        11.4            8.2            11.7      -
     6  Dishwasher / Disposal                  1.0            0.6             0.7      -
     7  Master Bedroom / Laundry Lights        65.9           36.9            33.3      -
     8  Kitchen G.F.I. / Refrigerator       1443.9          721.9             2.6      -
     9  Master Bathroom / Hall Bathroom         0.9            0.7             1.0      -
    10  Wine Cooler                           92.0           62.7            85.3      -
    11  Garage G.F.I. Recepticles             44.2           33.6            50.6      -
    12  Kitchen Island G.F.I. Recepticles         0.2            0.3             0.5      -
    13  Foyer / Front Entry / Dining Room Lights         6.1            4.4             6.3      -
    14  Laundry Room / Washer                202.2          102.5            34.1      -
    15  Pocket Office / Kitchen Lighting        82.5           48.2            49.9      -
    16  Bathroom G.F.I. Recepticles            5.2            8.8            16.8      -
    17  Family Room / Back Porch              76.4           51.0            67.6      -
    18  Kitchen G.F.I. Recepticles            13.1            8.9            12.1      -
    19  Bedroom #3 / Servers                 251.4          141.9           132.0      -
    20  Venthood                               0.8            1.1             2.0      -
    21  Bonus Room / Stairway Lights          49.2           45.7            77.1      -
    22  Dining Room Recepticles                3.3            3.2             5.3      -
    23  Double Oven                            1.7            1.0             0.9   0.88
    26  Downstairs Air Conditioning            0.0            0.1             0.1   0.01
    27  Downstairs Central Air                13.5           11.0            17.4   0.61
    30  Laundry Dryer                          0.7            0.4             0.0   0.95
    31  Cooktop                                0.1            1.0             2.1   0.07
    34  Upstairs Air Conditioning              0.0            0.2             0.1   0.09
    35  Water Heater                           0.0            0.1             0.1   0.08
    38  Upstairs Central Air                  13.3            7.4             6.4   0.90
    39  EV Charger                             0.0            0.2             0.0   0.23
    42  Big Garage Air Conditioning          464.2          234.9            71.8   0.99

==============================================================================================================
  BREAKER SLOT MAP (48 slots)
==============================================================================================================
            Left (Odd)                       Right (Even)
  ------------------------------    ------------------------------
   1 [ON ] Bedroom #2 / Hall      2  ---
   3 [ON ] Bedroom #4 / Smoke     4 [ON ] Microwave
   5 [ON ] Garage Door Opener     6 [ON ] Dishwasher / Dispo
   7 [ON ] Master Bedroom / L     8 [ON ] Kitchen G.F.I. / R
   9 [ON ] Master Bathroom /     10 [ON ] Wine Cooler
  11 [ON ] Garage G.F.I. Rece    12 [ON ] Kitchen Island G.F
  13 [ON ] Foyer / Front Entr    14 [ON ] Laundry Room / Was
  15 [ON ] Pocket Office / Ki    16 [ON ] Bathroom G.F.I. Re
  17 [ON ] Family Room / Back    18 [ON ] Kitchen G.F.I. Rec
  19 [ON ] Bedroom #3 / Serve    20 [ON ] Venthood
  21 [ON ] Bonus Room / Stair    22 [ON ] Dining Room Recept
  23 [ON ] Double Oven           24  ---
  25  ---                        26 [ON ] Downstairs Air Con
  27 [ON ] Downstairs Central    28  ---
  29  ---                        30 [ON ] Laundry Dryer
  31 [ON ] Cooktop               32  ---
  33  ---                        34 [ON ] Upstairs Air Condi
  35 [ON ] Water Heater          36  ---
  37  ---                        38 [ON ] Upstairs Central A
  39 [ON ] EV Charger            40  ---
  41  ---                        42 [ON ] Big Garage Air Con
  43  ---                        44  ---
  45  ---                        46  ---
  47  ---                        48  ---
==============================================================================================================

The _parse_breaker_group method already distinguishes field 11 (single-pole)
from field 13 (dual-pole) but wasn't propagating that info. Now returns
is_dual_phase and sets it on CircuitInfo.

Tested on MAIN 40: correctly identifies Furnace, Electric dryer, Water heater,
and Electric range as 240V dual-phase circuits.
@Griswoldlabs
Copy link
Author

Breaker Group Mapping — Validated on Both MAIN 40 and MLO 48 ✓

Great work @cecilkootz! Merged your PR and pushed one small follow-up commit to also propagate is_dual_phase from the BG field type detection.

What Changed

The original positional pairing approach (sorting trait 16 name IIDs and trait 26 metric IIDs, pairing by index) was fundamentally wrong:

  • MAIN 40: 25 names vs 28 metrics — phantom IID 2 shifted ALL 25 names by 1 position
  • MLO 48: 31 names vs 36 metrics — even worse mismatch

Fix: Use Trait 15 (Breaker Groups) as the authoritative mapping source. Each BG instance:

  • Has the same IID as its corresponding metric IID
  • Contains an explicit reference to its trait 16 name IID (no guessing)
  • Identifies single-pole (field 11, 120V) vs dual-pole (field 13, 240V)
  • Contains a BreakerConfig reference with the physical slot number (1-48)

This eliminates the hardcoded offset AND the positional pairing — both were fragile.

Results

MAIN 40 (my panel): 25 circuits, 4 dual-phase, all names and positions correct.
MLO 48 (@cecilkootz): 31 circuits, 10 dual-phase, all names and positions correct.

cecilkootz also added a Panel Position sensor showing the physical breaker slot number for each circuit, which is great for verification and labeling.

For @cayossarian

I've synced the same BG-based mapping fix into the span-panel-api library branch (your grpc_addition). The library version also extracts breaker_position and is_dual_phase. The code structure closely mirrors what cecilkootz did here but adapted to the library's _query_breaker_group() / _parse_breaker_group() pattern.

Happy to push that to span-panel-api once the org invite comes through, or I can PR it against your branch.

@cayossarian
Copy link
Member

@Griswoldlabs the span-panel-api branch is for all intents yours so you can submit the PR from it. I'll do another test and once we are comfortable that the span-panel-api is working as desired we can publish a span version (which also pushes to pypi). At that point we can publish a beta off the span branch, no need to merge that first since there might be feedback that we want to get into the branch before merging to main which would update docs prematurely. Anybody at that point can install the beta from HACs directly.

The org invite is nearly secondary as membership simply allows you to approve other folks PR's and merge. The repo rules are set up so a contributor approval is required prior to merge. This rule is necessary now more than ever to test multiple panel versions.

@Griswoldlabs
Copy link
Author

Library Update: BG mapping fix pushed to span-panel-api

Just pushed the Breaker Group mapping fix to the grpc_addition branch on span-panel-api — commit d8f918f.

What changed (library side)

  • client.py (191 insertions, 27 deletions):

    • _fetch_breaker_groups() — uses trait 15 as the authoritative source for metric→name IID mapping (each BG IID == its metric IID, contains explicit name_iid reference)
    • _query_breaker_group() — parses single-phase (field 11) and dual-phase (field 13) BG instances
    • _extract_trait_ref_iid() — helper for cleaner protobuf trait reference extraction
    • _metric_iid_to_circuit reverse map for O(1) lookup during streaming
    • Orphan metric IIDs (e.g. 2, 401, 402 on MAIN40) filtered automatically
    • Falls back to positional pairing if no BG instances found
  • models.py (+1 line):

    • Added breaker_position: int = 0 to CircuitInfo (physical slot 1–48)

Integration side

The same fix was already merged into the integration fork via @cecilkootz's PR + dual-phase follow-up commit (3e0fbf2). Both integration and library are now in sync.

Deployed & validated

Deployed to a live MAIN 40 panel running Home Assistant — 25 circuits discovered, 4 dual-phase correctly identified, all names and power readings match the Span app. Panel position sensors showing physical slot numbers.

Also validated by @cecilkootz on MLO 48 (31 circuits, 10 dual-phase) — all correct.

@cecilkootz
Copy link

cecilkootz commented Feb 18, 2026

During some more testing tonight I got an alert that HA disk/cpu had an abnormal increase over a sustained period of time. Thinking it perhaps was the unary stream from the channel updating 1/s, I re-used the scan_interval from Gen2 to "throttle" updates. Now pushes to HA are configured with the scan_interval to avoid excessive DB writes. I don't believe data loss will occur as the gRPC client always holds the latest readings. Data loss may occur on disconnect or restart but it's limited to whatever was received in the last interval period. In my case i left the default of 15s.

After doing this I noticed CPU dropped back to normal as did disk IO. This may be a paranoid over reaction. If this was useful, any thoughts on if it should be a new config workflow for Gen3?

As a side, the spikes I was randomly seeing have all but disappeared. Going to let this run over night and see if anything strange shows up.

The spikes cleared up for me with the interval change. 🤷‍♂️
Screenshot 2026-02-18 at 10 15 54

@Griswoldlabs
Copy link
Author

@cecilkootz Good catch on the CPU/disk spike from the 1/s push stream. That's definitely something we need to address before beta.

The gRPC Subscribe stream pushes every second by design (it's how the panel broadcasts), but HA's recorder doesn't need that granularity — 15s is plenty for energy monitoring. Your scan_interval throttle is the right approach.

I think this should be a config flow option for Gen3 with a sensible default (15s to match Gen2). The coordinator can buffer the latest readings from the stream and only push to HA on the interval tick. That way the gRPC client always has fresh data internally (for instant response to manual polls) but HA's recorder isn't overwhelmed.

I'll work this into the library-side coordinator when I test @cayossarian's refactored branches this week.

@haggerty23 Re: multi-panel — the config flow already creates a separate config entry per host, so each panel should get its own coordinator and entity set. The issue is likely unique_id collisions or the config flow blocking a second Gen3 entry. Can you share what specifically broke? (Did the second panel fail during setup, or did entities from both panels merge/conflict?)

@Griswoldlabs
Copy link
Author

Push throttle fix committedfda5aa0 on gen3-grpc-integration

This addresses @cecilkootz's finding that 1/s gRPC push notifications were overwhelming HA's recorder with state writes.

What changed in coordinator.py:

  • Removed the callback-driven push approach (_register_push_callback, _on_push_data, _async_push_update)
  • Gen3 now uses the same polling timer as Gen2 (default 15s via scan_interval)
  • The gRPC stream still runs in the background keeping the client buffer fresh every ~1s
  • The coordinator just reads the latest snapshot at each scan interval — no more 1/s state writes

Net effect: ~60 lines removed, much simpler code path. Gen3 and Gen2 coordinators now behave identically from HA's perspective. The scan_interval config option controls update frequency for both.

@Griswoldlabs
Copy link
Author

Library PR createdSpanPanel/span-panel-api#101 (grpc_additionmain)

@cayossarian — this is ready for your Gen2 regression testing whenever you have time. Once you're satisfied it doesn't break Gen2, you can merge + publish to PyPI and then the gen3-grpc-integration branch on this repo will be ready for beta.

Summary of what's on that branch:

  • Full Gen3 gRPC transport layer (grpc/ subpackage)
  • Protocol abstraction + PanelCapability flags
  • Unified snapshot model for both transports
  • create_span_client() factory with auto-detection
  • Trait 15 BreakerGroup authoritative mapping (fixes MLO 48)
  • Dual-phase detection
  • grpcio as optional dependency (span-panel-api[grpc])

Also just pushed the push throttle fix (fda5aa0) to gen3-grpc-integration on this repo — Gen3 now uses the same polling timer as Gen2 instead of 1/s push callbacks.

@haggerty23
Copy link

Glad it's working! Multi-panel support should be doable — the config flow already creates a separate config entry per host, so in theory each panel gets its own coordinator and entity set. The issue is likely unique_id collisions or the config flow not allowing a second Gen3 entry. Can you share what specifically broke with two panels? (e.g., did the second panel fail to add, or did entities from both panels merge together?)

I got the error "Span Panel already configured. Only a single configuration is possible."

I used ChatGPT to create a patch for me.... it worked but may not be the best way to fix it.

diff --git a/custom_components/span_panel/gen3/span_grpc_client.py b/custom_components/span_panel/gen3/span_grpc_client.py
@@
async def test_connection(self) -> bool:
"""Test if we can connect to the panel (static method-like)."""
try:
channel = grpc.aio.insecure_channel(
f"{self._host}:{self._port}",
options=[("grpc.initial_reconnect_backoff_ms", 1000)],
)
try:
response = await asyncio.wait_for(
channel.unary_unary(
_GET_INSTANCES,
request_serializer=lambda x: x,
response_deserializer=lambda x: x,
)(b""),
timeout=5.0,
)
return len(response) > 0
finally:
await channel.close()
except Exception:
return False
+

  • async def get_unique_id(self) -> str | None:
  •    """Return a stable unique identifier for the panel.
    
  •    Uses panel_resource_id discovered via GetInstances.
    
  •    Falls back to None if it cannot be determined.
    
  •    """
    
  •    try:
    
  •        channel = grpc.aio.insecure_channel(
    
  •            f"{self._host}:{self._port}",
    
  •            options=[("grpc.initial_reconnect_backoff_ms", 1000)],
    
  •        )
    
  •        try:
    
  •            response = await asyncio.wait_for(
    
  •                channel.unary_unary(
    
  •                    _GET_INSTANCES,
    
  •                    request_serializer=lambda x: x,
    
  •                    response_deserializer=lambda x: x,
    
  •                )(b""),
    
  •                timeout=5.0,
    
  •            )
    
  •            # Populate panel_resource_id
    
  •            self._parse_instances(response)
    
  •            if self._data.panel_resource_id:
    
  •                return self._data.panel_resource_id
    
  •            return None
    
  •        finally:
    
  •            await channel.close()
    
  •    except Exception:
    
  •        _LOGGER.debug(
    
  •            "Failed to obtain Gen3 unique_id from %s",
    
  •            self._host,
    
  •            exc_info=True,
    
  •        )
    
  •        return None
    

diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py
@@
if not await validate_host(self.hass, host, use_ssl=use_ssl):
# REST failed — try Gen3 gRPC as fallback
if await self._test_gen3_connection(host):
_LOGGER.info(
"Gen3 panel detected at %s (REST unavailable, gRPC OK)", host
)

  •            await self.async_set_unique_id(host)
    
  •            self._abort_if_unique_id_configured()
    
  •            from .gen3.span_grpc_client import SpanGrpcClient
    
  •            grpc_client = SpanGrpcClient(host)
    
  •            unique_id = await grpc_client.get_unique_id()
    
  •            # Use stable panel_resource_id when available
    
  •            await self.async_set_unique_id(unique_id or host)
    
  •            self._abort_if_unique_id_configured(updates={CONF_HOST: host})
    
  •            return self.async_create_entry(
                   title=f"SPAN Panel ({host})",
                   data={
                       CONF_HOST: host,
                       CONF_PANEL_GEN: "gen3",
                   },
               )
    

@Griswoldlabs
Copy link
Author

Serial Number Fix for Gen3

Found and fixed a critical bug in the gRPC client: serial_number was never populated for Gen3 panels.

Root Cause

The PanelData.serial field defaults to "" and no gRPC trait populates it. Gen2 gets the serial from the REST /api/v1/status endpoint, but Gen3 has no equivalent trait.

Impact

Without a serial number, SpanSensorBase.__init__() skips setting _attr_unique_id (the if span_panel.status.serial_number and description.key: guard evaluates to False). Entities without unique IDs cannot be registered in HA's entity registry, so zero sensor entities appear despite the integration loading successfully.

Fix

Using panel_resource_id (the hex identifier captured from GetInstances during circuit discovery) as a serial number fallback. This value is unique per panel and stable across sessions. Commit 5f8277f on grpc_addition.

Result

Tested on a physical MAIN 40: 191 entities created successfully (180 sensors + 10 binary_sensors + 1 update). All 25 circuits reporting live data (power, voltage, current, apparent power, reactive power, frequency, power factor).

@cayossarian — this fix is on the grpc_addition branch in span-panel-api. We should investigate whether there's a gRPC trait that exposes the actual serial number and firmware version in the future.

@Griswoldlabs
Copy link
Author

@haggerty23 Good news — the multi-panel issue should be fixed on the gen3-grpc-integration branch.

Root cause: The original gen3-grpc-support branch used host (IP address) as the unique_id, but there was actually a deeper bug: serial_number was never populated for Gen3 panels, which caused entity registration failures. The fix (commit 5f8277f on span-panel-api) now uses panel_resource_id — a unique hex identifier per panel — as the serial number fallback.

How it works now:

  1. Config flow calls get_snapshot() which returns each panel's unique panel_resource_id as serial_number
  2. ensure_not_already_configured() sets unique_id = serial_number (unique per panel)
  3. Two panels → two different unique_ids → both can be added without collision

Your ChatGPT patch was directionally correct — the fix just landed in a different layer (the library populates serial_number automatically now, so the config flow didn't need changes).

To test: Switch from the gen3-grpc-support branch to gen3-grpc-integration on SpanPanel/span + install span-panel-api from the grpc_addition branch. This is the branch heading toward beta release.

Also fixed ruff formatting on both branches — CI should pass now.

@cecilkootz
Copy link

I believe testing branch gen3-grpc-integration is blocked until SpanPanel/span-panel-api#101 is merged and pushed. I did some hacks to get around but you'll get the error

homeassistant.requirements.RequirementsNotFound: Requirements for span_panel not found: ['span-panel-api[grpc]~=1.1.15'].

@Griswoldlabs
Copy link
Author

⚠️ Important: Firmware 7.2.0 breaks local gRPC access

A firmware update (7.2.0) appears to be rolling out that disables the local gRPC interface we're using. Confirmed on my MAIN 40 after a power cycle today, and independently reported by @fwump38 on #96.

Changes in 7.2.0:

  • gRPC moved from port 50065 → 50058
  • Reflection API disabled
  • All RPC methods (GetInstances, GetState, etc.) return UNIMPLEMENTED
  • REST API still 502 (unchanged)
  • Cloud/app access unaffected

This means the integration in this PR will not work on panels running 7.2.0+. The gRPC server is still listening but actively rejecting all calls.

I'll keep this PR open since panels on older firmware still benefit from it, and there's always the possibility Span reverts this or provides an alternative. In the meantime, I'd encourage anyone with a Gen3 panel to contact Span support requesting local API access.

See #96 for the full discussion.

Claude added 2 commits February 19, 2026 20:09
- Replace relative parent imports with absolute imports (TID252)
- Remove unused imports: DOMAIN in coordinator, METRIC_IID_OFFSET in client (F401)
- Add missing docstrings to sensor __init__ and native_value methods (D107/D102)
- Use contextlib.suppress instead of try/except/pass (SIM105)
- Fix docstring to imperative mood (D401)
- Auto-fix import sorting (I001)
target-version is py311 which doesn't support PEP 695 type parameter
syntax. Ignore UP046 until target-version is bumped to py312+.
- Fix pylint C0415 (import-outside-toplevel): move disable comment to
  the `from ... import (` line in __init__.py, sensor.py, binary_sensor.py,
  and config_flow.py so pylint recognizes the suppression correctly

- Fix 48 mypy errors across gen3/sensors.py, gen3/span_grpc_client.py,
  gen3/binary_sensors.py, sensor_definitions.py, binary_sensor.py,
  sensor.py, and sensors/circuit.py:
  - Add type annotations to all untyped __init__ methods in gen3/sensors.py
  - Use `SensorDeviceClass | None` + `# type: ignore[mutable-override]`
    to resolve HA entity hierarchy type contradiction
  - Add `assert self._channel is not None` guards for union-attr errors
  - Fix _parse_protobuf_fields and _get_field return types with Any
  - Fix no-any-return errors with explicit typed intermediate variables
  - Change value_fn type to `Callable[[SpanPanelCircuit], float | None]`
  - Fix no-redef errors by renaming coordinator → gen3_coordinator

- Apply PEP 695 generic class syntax to SpanPanelBinarySensor,
  SpanSensorBase, and SpanEnergySensorBase (ruff UP046)

- Update pyproject.toml python constraint to `>=3.13.2,<3.15` to allow
  Python 3.14.x; update ruff target-version to py313
@cayossarian
Copy link
Member

Wow, that was short lived, they must be monitoring this thread and saw you were making progress!

Anyway I just updated the branch on top of the refactored code so be sure you pull it before making other changes and update your python to 3.14.2 which is the HA dev version (may need to replace your venv).

I also published span-panel-api but I guess that's moot now too. I'll hold off on publising a span beta unless you just want it out there.

@cecilkootz
Copy link

Wow, that was short lived, they must be monitoring this thread and saw you were making progress!

Anyway I just updated the branch on top of the refactored code so be sure you pull it before making other changes and update your python to 3.14.2 which is the HA dev version (may need to replace your venv).

I also published span-panel-api but I guess that's moot now too. I'll hold off on publising a span beta unless you just want it out there.

Is there a discord or place to talk about this change while on 7.2.0?

@cayossarian
Copy link
Member

Wow, that was short lived, they must be monitoring this thread and saw you were making progress!
Anyway I just updated the branch on top of the refactored code so be sure you pull it before making other changes and update your python to 3.14.2 which is the HA dev version (may need to replace your venv).
I also published span-panel-api but I guess that's moot now too. I'll hold off on publising a span beta unless you just want it out there.

Is there a discord or place to talk about this change while on 7.2.0?

#170

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MLO 48 & MAIN 40 support

4 participants

Comments