feat: Add MIT-5000-8T (MIT_8CH) inverter support + RF capture mode#2962
feat: Add MIT-5000-8T (MIT_8CH) inverter support + RF capture mode#2962Geoffn-Hub wants to merge 11 commits intotbnobody:masterfrom
Conversation
|
Thank you! |
|
I'm going to submit a dedicated PR for the changes to the receive loop - I figured you might want to have the parsing logic for the MIT inverter packaged seperately from code that alters the core code of the project in more fundamental ways |
|
FWIW - I did take a look at the AhoyDTU implementation which is in the form of a state machine, but don't think it works in this case either |
Add passive RF capture mode to the CMT2300A radio for protocol reverse-engineering. When enabled via the web API, the radio hops across all legal EU channels (50ms dwell) and streams decoded packets over a WebSocket endpoint. Key changes: - CMT radio starts in RX mode on init for passive listening - Channel hopping across legal frequency range in capture mode - FIFO drain loop processes all queued packets per iteration - WebSocket endpoint for real-time packet streaming - Web API endpoints to enable/disable capture mode - Documentation in docs/CaptureMode.md Also fixes FIFO burst reception: the hardware FIFO drain now runs unconditionally and uses break (not continue) on buffer full, ensuring back-to-back MIT response fragments are not lost. Increases MAX_RETRANSMIT_COUNT to 20 for MIT inverters which respond with 6 rapid fragments per poll.
Add initial support for the Hoymiles MIT-5000-8T microinverter. The MIT-5000-8T presents as a 4-channel device at the RF level (each MPPT has dual panel inputs aggregated internally). - New MIT_8CH class extending HMT_Abstract - 4-channel AC/DC data parsing from validated RF captures - Auto-detection by serial number prefix in Hoymiles.cpp
MIT-5000-8T sends SystemConfigPara across 3 fragments (vs 2 for HM/HMT), requiring a larger buffer. Increase expected size to 48 bytes and add validation for the MIT response format.
2744569 to
016c9af
Compare
| if (memcmp(&f.fragment[5], &dtuId.b[1], 4) == 0) { | ||
| // The CMT RF module does not filter foreign packages by itself. | ||
| // Has to be done manually here. | ||
| if (memcmp(&f.fragment[5], &dtuId.b[1], 4) == 0) { |
There was a problem hiding this comment.
So here we could consider to run the Capture mode in promiscuous mode if we simply log received data regardless of the DTU ID.
There was a problem hiding this comment.
Good idea — capture mode already logs all valid CRC-checked frames regardless of source/destination serial, so it's effectively promiscuous already. The DTU serial filtering happens later in the normal processing path (the dumpFragment/command matching), which capture mode bypasses by logging before that stage. We could make this more explicit by skipping the _inverters lookup entirely when in capture mode, but functionally it already captures everything on the current channel.
The MIT-5000-8T sends response fragments at ~835ms intervals instead of the ~50ms typical for HMS/HMT inverters. The default 500ms RX timeout causes premature retransmit requests after receiving only 1-2 of 6 fragments, resulting in 13-15x retransmit ratios and ~55% success. Discovery: capture mode showed all 6 MIT fragments arriving correctly at ~835ms spacing. The DTU-Pro (which works) likely uses longer timeouts. Changes: - RealTimeRunDataCommand: 6000ms timeout for 0x1520 (MIT) serials - AlarmDataCommand: 12000ms timeout for 0x1520 serials - SingleDataCommand: 2000ms retransmit timeout for 0x1520 serials - Clean up debug logging from HoymilesRadio_CMT Note: OTA updates via /api/firmware/update appear to silently fail (device reports old git hash). Flash via USB/serial to test.
The CMT2300A radio automatically exits RX mode (goes to STBY) after receiving a packet. The read() function was reading the FIFO and clearing interrupts, but never putting the radio back into RX mode. This caused subsequent fragments in a burst to be missed — the radio was in STBY when they arrived. This explains why only 2 of 6 MIT-5000-8T fragments were received: frag 1 arrives, radio exits RX, re-enters RX only after the drain loop completes, missing frags 2-3. Then catches frag 4, misses 5-6. The HMS-4CH (Shed) worked because its fragments arrive at ~50ms intervals which is fast enough that the drain loop + next poll caught them. The MIT's ~835ms (capture mode) / ~50ms (normal mode) timing hit a window where the radio was in STBY. Fix: after reading each packet, immediately re-enter RX mode by clearing the FIFO and calling GoRx(). This keeps the radio continuously listening during multi-fragment bursts.
…ch read The previous fix (GoRx after ReadFifo) improved reception from 2/6 to 3/6 fragments, but the GoRx state transition takes too long — fragments arriving during the STBY→RFS→RX transition are still missed. Better approach: set the RX_AUTO_EXIT_DIS bit (0x20) in MODE_CTL when entering RX mode. This tells the CMT2300A to stay in RX after receiving a packet, eliminating the state transition gap entirely. The radio remains continuously listening throughout the entire fragment burst.
The previous commit set RX_AUTO_EXIT_DIS in startListening() before calling GoRx(), but GoRx() writes the bare GO_RX command (0x08) to MODE_CTL, overwriting the 0x20 bit we just set. Fix: OR the RX_AUTO_EXIT_DIS bit into the GoRx command itself within CMT2300A_AutoSwitchStatus(), so both bits are written atomically. Also fix the TX/RX status check comparisons to mask out the new bit.
…instead RX_AUTO_EXIT_DIS caused zero packets to be received — the radio likely needs the RX→STBY transition to properly latch FIFO data. New approach: after reading each packet, write GO_RX directly to MODE_CTL with a single register write (no polling, no FIFO clear). This is the fastest possible RX re-entry — just one SPI transaction instead of the full startListening() sequence.
Instead of draining only what's immediately available and returning, keep polling for 80ms after the last received packet. This catches burst fragments arriving at ~50ms intervals — the radio re-enters RX after each read (fast GoRx write), and the polling loop checks for the next fragment before the loop() cycle completes. Previously, the drain loop would exit after reading 1 packet, set _packetReceived=false, and not check again until the next loop() iteration — by which time the radio had been in STBY and missed the next fragment.
The bare GoRx write (single register write to MODE_CTL) corrupted FIFO reads — 19 'Frame kaputt' CRC failures per cycle. The radio needs the full GoStby → EnableReadFifo → ClearRxFifo → GoRx sequence to properly reset its internal state between packets. Combined with the 80ms polling drain loop, this should catch burst fragments: read packet → full RX re-entry → poll for next packet within 80ms window.
Every approach to re-enter RX after reading a packet has failed: - Bare GoRx write: corrupts FIFO reads (Frame kaputt flood) - Full startListening (GoStby+EnableReadFifo+ClearRxFifo+GoRx): also corrupts because ClearRxFifo destroys in-flight packet data - RX_AUTO_EXIT_DIS bit: radio receives nothing at all The CMT2300A's single-packet FIFO design means it can only hold one packet at a time. After receiving a packet, the radio must exit RX, have the FIFO read, then re-enter RX for the next packet. This inherently creates a gap where packets are missed. Falling back to the extended timeout approach: the radio naturally receives 2-3 of 6 fragments per burst, and retransmit requests (with 6000ms/2000ms timeouts for MIT) eventually collect all fragments. This gives ~75-83% first-try success rate.
| // processed per loop() call, and only when no new packet was arriving. | ||
| // Processing the entire buffer each iteration reduces latency and prevents | ||
| // the software buffer from growing unboundedly during bursts. | ||
| while (!_rxBuffer.empty()) { |
There was a problem hiding this comment.
@Geoffn-Hub if you have received anything from the Radio, you are trying to flush everything immediately out of the Ring-Buffer again until it is empty.
Is this eventually occupying the loop method for too long to fetch the next received bits from the Radio into the buffer ?
I think you had some code to break off the loop early already in Step 1 if something was received, i.e. somewhat before Step 2.
There was a problem hiding this comment.
Fair concern, but in practice the ring buffer stays very small — typical burst is 5-6 fragments, and processing each one (CRC check + dumpFragment) is microsecond-level work. The CMT2300A's single-packet FIFO means we only ever have 1 packet waiting in hardware at a time; the ~50-835ms gap between fragments (depending on inverter type) gives plenty of time for both drain and process.
The original code only processed one packet per loop() call and skipped processing entirely while _packetReceived was true — meaning the software buffer grew unboundedly during bursts. This change fixes that by processing everything each iteration.
That said, if you'd prefer a hybrid approach (process N packets per iteration, or yield back to Step 1 periodically), that's easy to add. In testing with the MIT-5000-8T (6 fragments at ~835ms intervals) and HMS-4CH (5 fragments at ~50ms), the buffer never exceeded ~3-4 entries.
Summary
This PR adds native support for the Hoymiles MIT-5000-8T 3-phase microinverter and includes an RF capture mode tool used to reverse-engineer the protocol.
Closes #2742
MIT-5000-8T Inverter Support
Key Findings
0x1520) reports 4 independent MPPT channels over RF, despite the "8T" name — each MPPT tracker has 2 panel inputs sharing a single trackerProtocol Behaviour Differences from HMS/HMT
Testing with a live MIT-5000-8T revealed several protocol differences that required OpenDTU changes:
SystemConfigPara response is 38 bytes (vs 48 for HMS/HMT). The hardcoded
SYSTEM_CONFIG_PARA_SIZEcheck was rejecting valid responses. Fix: made expected byte count configurable viasetExpectedByteCount(), with MIT_8CH setting 38 in its constructor.The MIT sends only 2 fragments per response burst, then waits for individual retransmit requests for each remaining fragment. The HMS/HMT sends all fragments in a single burst. Each retransmit cycle requires ~3 requests before the MIT responds. With 6 total fragments and 4 needing individual recovery at ~3 retransmits each, the default
MAX_RETRANSMIT_COUNTof 5 was insufficient. Increased to 20 for reliable reception.Retransmit RX window: The
SingleDataCommand(RequestFrame) timeout was increased from 100ms to 250ms to accommodate the MIT's slower response timing.Result: MIT RealTimeRunData now achieves ~100% success rate, with each poll cycle taking ~4-5 seconds due to the retransmit dance.
Validated Byte Assignments
All byte assignments were validated by cross-referencing passive RF captures from the MIT-5000-8T against DTU-Pro Modbus TCP data read via Home Assistant sensors:
Complete Decoded Capture (230W production, 2026-02-01)
Raw frames:
Unresolved Fields
Files Changed (MIT Support)
lib/Hoymiles/src/inverters/MIT_8CH.cpp— Inverter implementation with validated byte assignmentslib/Hoymiles/src/inverters/MIT_8CH.h— Headerlib/Hoymiles/src/Hoymiles.cpp— Register MIT_8CH for serial prefix0x1520lib/Hoymiles/src/parser/SystemConfigParaParser.cpp— Configurable expected byte countlib/Hoymiles/src/parser/SystemConfigParaParser.h—setExpectedByteCount()methodlib/Hoymiles/src/commands/CommandAbstract.h—MAX_RETRANSMIT_COUNTincreased to 20lib/Hoymiles/src/commands/SingleDataCommand.cpp— RequestFrame timeout 100ms → 250msRF Capture Mode
A diagnostic tool for reverse-engineering new inverter protocols by passively sniffing RF traffic.
How It Works
API
Files Changed (Capture Mode)
lib/Hoymiles/src/HoymilesRadio_CMT.cpp— Capture mode: channel hopping, frame logging, CRC error reportinglib/Hoymiles/src/HoymilesRadio_CMT.h— Capture mode state and configurationsrc/WebApi_dtu.cpp— REST API endpoints (/api/dtu/captureGET/POST)include/WebApi_dtu.h— Handler declarationsdocs/CaptureMode.md— Full documentationTesting
Tested on: