Skip to content

feat: Add MIT-5000-8T (MIT_8CH) inverter support + RF capture mode#2962

Open
Geoffn-Hub wants to merge 11 commits intotbnobody:masterfrom
Geoffn-Hub:feature/capture-mode
Open

feat: Add MIT-5000-8T (MIT_8CH) inverter support + RF capture mode#2962
Geoffn-Hub wants to merge 11 commits intotbnobody:masterfrom
Geoffn-Hub:feature/capture-mode

Conversation

@Geoffn-Hub
Copy link

@Geoffn-Hub Geoffn-Hub commented Feb 1, 2026

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

  • The MIT-5000-8T (serial prefix 0x1520) reports 4 independent MPPT channels over RF, despite the "8T" name — each MPPT tracker has 2 panel inputs sharing a single tracker
  • The inverter follows the HMT 3-phase pattern for AC fields (line-neutral voltages, line-line voltages, per-phase currents, power factor)
  • DC layout: 12 bytes per channel (UDC/IDC/PDC/YT[4]/YD)
  • Total payload: 92 bytes across 6 RF frames (01-05 + 0x86), reassembling to 88 data + 2 CRC16

Protocol Behaviour Differences from HMS/HMT

Testing with a live MIT-5000-8T revealed several protocol differences that required OpenDTU changes:

  1. SystemConfigPara response is 38 bytes (vs 48 for HMS/HMT). The hardcoded SYSTEM_CONFIG_PARA_SIZE check was rejecting valid responses. Fix: made expected byte count configurable via setExpectedByteCount(), with MIT_8CH setting 38 in its constructor.

  2. 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_COUNT of 5 was insufficient. Increased to 20 for reliable reception.

  3. 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:

Field RF Value HA Value Match
MPPT1 YT 80.368 kWh ~80.37 kWh
MPPT2 YT 65.066 kWh ~65.07 kWh
MPPT3 YT 155.147 kWh ~155.15 kWh
MPPT4 YT 151.566 kWh ~151.57 kWh
Frequency 50.00 Hz 50.00 Hz
PAC 225.4 W ~230 W
Temperature 12.0 °C 12.0 °C
Power Factor 1.000 1.000

Complete Decoded Capture (230W production, 2026-02-01)

Raw frames:

0x01: 01 00 01 01 51 00 67 01 5D 00 01 39 F0 00 25 01 62
0x02: 02 00 66 01 70 00 00 FE 2A 00 23 02 D3 00 7E 03 91
0x03: 03 00 02 5E 0B 00 58 02 B5 00 6F 03 03 00 02 50 0E
0x04: 04 00 64 09 0D 09 1C 09 1F 0F AB 0F D2 0F C1 13 88
0x05: 05 00 00 08 CE 00 00 00 E1 00 22 00 21 00 1E 03 E8
0x86: 86 00 78 00 03 00 00 00 00 00 1A 63 40

Unresolved Fields

  • Offset 68: Always 0 in captures — likely reserved
  • Offset 70: Value matches PAC exactly — possibly apparent power (S in VA). Needs capture under reactive load to confirm.

Files Changed (MIT Support)

  • lib/Hoymiles/src/inverters/MIT_8CH.cpp — Inverter implementation with validated byte assignments
  • lib/Hoymiles/src/inverters/MIT_8CH.h — Header
  • lib/Hoymiles/src/Hoymiles.cpp — Register MIT_8CH for serial prefix 0x1520
  • lib/Hoymiles/src/parser/SystemConfigParaParser.cpp — Configurable expected byte count
  • lib/Hoymiles/src/parser/SystemConfigParaParser.hsetExpectedByteCount() method
  • lib/Hoymiles/src/commands/CommandAbstract.hMAX_RETRANSMIT_COUNT increased to 20
  • lib/Hoymiles/src/commands/SingleDataCommand.cpp — RequestFrame timeout 100ms → 250ms

RF Capture Mode

A diagnostic tool for reverse-engineering new inverter protocols by passively sniffing RF traffic.

How It Works

  1. Channel hopping: CMT2300A radio sweeps all legal channels (863-870 MHz EU) with 50ms dwell time
  2. Frame logging: All valid CRC8 frames are logged to the serial console (WebSocket) with source/destination serial, RSSI, frequency, and hex dump
  3. Non-destructive: Operates alongside normal inverter polling infrastructure

API

# Enable
curl -X POST "http://admin:<pass>@<dtu-ip>/api/dtu/capture" \
  --data-urlencode 'data={"capture_mode":true}'

# Status
curl -s "http://admin:<pass>@<dtu-ip>/api/dtu/capture"
# {"capture_mode":true}

Files Changed (Capture Mode)

  • lib/Hoymiles/src/HoymilesRadio_CMT.cpp — Capture mode: channel hopping, frame logging, CRC error reporting
  • lib/Hoymiles/src/HoymilesRadio_CMT.h — Capture mode state and configuration
  • src/WebApi_dtu.cpp — REST API endpoints (/api/dtu/capture GET/POST)
  • include/WebApi_dtu.h — Handler declarations
  • docs/CaptureMode.md — Full documentation

Testing

Tested on:

  • Hardware: OpenDTU Fusion v2 board (ESP32-S3)
  • Inverter: MIT-5000-8T (SN: 1520a025566b) with 8 panels across 4 MPPT inputs
  • Conditions: Low power (~230W, winter) and medium power (~1.8kW) captures
  • Validation: Cross-referenced all fields against DTU-Pro Modbus TCP data via Home Assistant
  • Live polling: MIT RealTimeRunData achieving ~100% success rate with retransmit tuning, SystemConfigPara parsing correctly at 38 bytes, all 4 MPPT channels + 3-phase AC data updating in web UI

@tbnobody
Copy link
Owner

tbnobody commented Feb 1, 2026

Thank you!
In #2742 you wrote, that the main loop is too slow to handle the received packages (reading it from the CMT module). I just had a look at your code and didn't find any changes related to this. Is there still any improvement required to get this inverter working?

@Geoffn-Hub
Copy link
Author

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

@Geoffn-Hub
Copy link
Author

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

Miraz added 3 commits February 2, 2026 10:54
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.
@Geoffn-Hub Geoffn-Hub force-pushed the feature/capture-mode branch from 2744569 to 016c9af Compare February 2, 2026 10:54
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So here we could consider to run the Capture mode in promiscuous mode if we simply log received data regardless of the DTU ID.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Miraz added 8 commits February 2, 2026 13:00
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()) {
Copy link
Contributor

@stefan123t stefan123t Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

[Request] Support of new Hoymiles Inverter-Series MIT-4000/4500/5000-8T

4 participants