Skip to content

Additional Power Kit Field Mappings #3

@maverick-applications

Description

@maverick-applications

ATTENTION: This was done via bluetooth and may not match CAN exactly.

We found that the CAN fields matched bluetooth and likely will match in the reverse direction but this needs to be confirmed. Just wanted to share these findings as your work was instrumental to ours.

wattzup: 4 new V3 struct keys + a names field type, cross-validated on BLE

TL;DR

An independent BLE reverse-engineering effort against the EcoFlow Power
Hub confirms wattzup's V3 wire format byte-for-byte on a different
transport. Along the way we catalogued four V3 record types not in
lib/can/structs.json yet (66:21, AC:21, 9A:21, D2:21) and one
new field type (names, length-prefixed ASCII list). Offered upstream
under MIT.

Cross-transport validation

V3 as parsed by canlog.js is identical on the Power Hub's BLE notify
characteristic (0x002c on the EcoFlow Power Hub Power Kit hardware).
XOR-by-slo, the [rty, mrt].join(':') lookup against structs.json,
the s16 serial prefix at +0, LE multi-byte integers — all of it works
against the BLE byte stream with no transport-specific adaptation. The
V3 layer is transport-agnostic. lib/can/structs.json used verbatim
covers ~96% of captured BLE frames.

Proposed addition 1: 4 new struct keys for structs.json

66:21 — AC panel channel name list (53 B; names HIGH, header u32 LOW)

Fires periodically from the AC smart panel (smo 53-01). 6 captured
samples, byte-identical. Carries the user-set breaker names. Header
u32 at +12 sampled as 3, doesn't correlate with name count.

"66:21": {
    "channels":  [  1,  "u8",    "ac_panel.channel_count" ],
    "_unk_u32":  [ 12,  "u32",   "ac_panel.unknown_header_u32" ],
    "names":     [ 16,  "names", "ac_panel.channel_names" ]
}

AC:21 — DC-side smart-panel channel name list (86 B; names HIGH, header u32 LOW)

Same shape as 66:21 from the DC smart panel (smo 54-01). List
starts at +21 instead of +16; header u32 at +17 sampled as 7.

"AC:21": {
    "channels":  [  1,  "u8",    "ld_dc.channel_count" ],
    "_unk_u32":  [ 17,  "u32",   "ld_dc.unknown_header_u32" ],
    "names":     [ 21,  "names", "ld_dc.channel_names" ]
}

9A:21 — Per-module session-start sync (57 B; marker HIGH, preamble/flag LOW)

Fires once per session from MPPT, DC_IN, DC_OUT (smo 05-01, 50-01,
51-01). Constant 3-byte marker 0xa8 0x0b 0x6a at offsets 2-4 across
all three modules. Preamble at +0 and 4-byte flag at +5 vary per module
— preamble might be a hash of the module SN, unconfirmed.

"9A:21": {
    "preamble":  [  0,  "u8",    "9a.preamble" ],
    "_marker0":  [  2,  "u8",    "9a.marker0" ],
    "_marker1":  [  3,  "u8",    "9a.marker1" ],
    "_marker2":  [  4,  "u8",    "9a.marker2" ],
    "flag":      [  5,  "u32",   "9a.flag" ]
}

D2:21 — BMS extended / calibration (80 B; SN/floats HIGH, semantics LOW)

Fires once per session from the BMS (smo 03-01). Heads up: the
SN starts at offset 1, not 0 — a shift from every other module
struct with a serial. Possibly a fingerprint of a different
encoder/version on the firmware side.

Four IEEE-754 floats at +62 / +66 / +70 / +74 with sample values
1.9, 70.0, 5.0, 20.0. Pattern suggests battery calibration
constants (SOH ratio, max temp °C, current limit A, min SoC %)
but state-change captures needed to confirm.

"D2:21": {
    "serial":    [  1,  "s16",   "bms.extended.moduleSn" ],
    "_counter1": [ 17,  "u32",   "bms.extended.counter1" ],
    "_counter2": [ 21,  "u32",   "bms.extended.counter2" ],
    "f1_unk":    [ 62,  "f32",   "bms.extended.f1" ],
    "f2_unk":    [ 66,  "f32",   "bms.extended.f2" ],
    "f3_unk":    [ 70,  "f32",   "bms.extended.f3" ],
    "f4_unk":    [ 74,  "f32",   "bms.extended.f4" ]
}

Leading-underscore names mark LOW-confidence entries so the catalog
stays honest about what's pinned vs speculative.

Proposed addition 2: New names field type for canlog.js

66:21 and AC:21 carry a length-prefixed name list:

<u8 length> <ASCII length-bytes> <u32 trailing flag>

repeating until end of payload (or zero-length terminator). The
existing str branch reads a single one; the list variant is ~6 lines.

Proposed JS to add to decode() in lib/can/canlog.js after the
'str' branch:

} else if (type === 'names') {
    const out = [];
    let i = pos;
    while (i < buf.length) {
        const slen = buf.readUint8(i);
        if (slen === 0 || slen > 32 || i + 1 + slen > buf.length) break;
        out.push(dat.slice(i + 1, i + 1 + slen).map(v => String.fromCharCode(v)).join(''));
        i += 1 + slen + 4;
        used += 1 + slen + 4;
    }
    rec[key] = out;
}

Python reference (from our ef_decoder.py _decode_field):

if typ == "names":
    out = []
    i = pos
    while i < n:
        slen = payload[i]
        if slen == 0 or slen > 32 or i + 1 + slen > n:
            break
        try:
            out.append(payload[i+1:i+1+slen].decode("ascii"))
        except UnicodeDecodeError:
            break
        i += 1 + slen + 4
    return out

The trailing u32 between names is consistent across samples but its
meaning is unknown (per-channel flag/bitmask, or padding). Decoder
treats it as opaque skip.

Open questions

  • Is D2:21 the same frame on CAN, and what do the four floats mean?
    Captures across products with varying SOH / temp / current limits
    would pin them down.
  • Is 0xa8 0x0b 0x6a in 9A:21 a known session/heartbeat signature
    on the CAN side? Looks like a per-module "I'm present" announce.
  • Do PSDH (M1095-PSDH-xxxxx, smo 04-01) frames appear on CAN? On
    BLE the PSDH does broadcast — but only intermittently (see addendum
    §R2.4).

Licensing

wattzup is treated as MIT in our project, attribution kept alongside
the verbatim structs.json. The struct entries and names decoder
above are offered under MIT.


Addendum (Round 2)

Subsequent work on the same Power Hub yielded substantially more
decoded structure than the first contribution. The headline findings
suitable for wattzup upstream:

R2.1: smo-keyed struct selection — 28:21 has TWO different layouts

The biggest finding: struct key 28:21 is broadcast by both DC_IN
(smo 50-01) and DC_OUT (smo 51-01) with entirely different field
layouts.
Wattzup's current 28:21 entry mixes them — some fields
are correct for one smo and wrong for the other.

The Android app handles this with two separate parsers:

  • parseBBC_IN_HeartBeat (= u50.g.v() in the decomp) parses DC_IN
  • parseBBC_OUT_HeartBeat (= u50.g.x()) parses DC_OUT

Both consume frames with rty:mrt = 28:21, but their byte layouts share
nothing past the 20-byte common prefix (serial + errCode + modVer).

Proposed lookup change (we implemented this in our Python decoder as a
3-tier match, and it's a clean addition to wattzup's [rty,mrt] join):

// before
const key = `${rty}:${mrt}`;
// after — preferred order, fall back from most specific to least
const smoKey = `${rty}:${mrt}@${mad}-${mda}`;  // "28:21@50-01"
const typKey = `${rty}:${mrt}`;                // "28:21"
const rtyKey = `${rty}`;                       // "28"
const struct = structs[smoKey] ?? structs[typKey] ?? structs[rtyKey];

This is backward-compatible — existing RR:MM and RR keys still
match. Only new RR:MM@MA-DA keys take precedence when present.

R2.2: 28:21@50-01 — full DC_IN heartbeat (36 fields, was 14)

Decomp-derived from parseBBC_IN_HeartBeat. Each field's offset is
exact from the source.

"28:21@50-01": {
    "serial":             [   0, "s16", "bbcIn._serial_.moduleSn"             ],
    "errCode":            [  16, "u16", "bbcIn._serial_.errCode"              ],
    "modVer":             [  20, "dqr", "bbcIn._serial_.moduleVersion"        ],
    "battVol":            [  24, "u16", "bbcIn._serial_.batteryVol (mV)"      ],
    "batteryCur":         [  40, "i32", "bbcIn._serial_.batteryCur (mA)"      ],
    "batteryWatt":        [  44, "i32", "bbcIn._serial_.batteryWatt (W)"      ],
    "l1Curr":             [  48, "i32", "bbcIn._serial_.l1Cur (mA)"           ],
    "l2Curr":             [  52, "i32", "bbcIn._serial_.l2Cur (mA)"           ],
    "hs1Temp":            [  56, "i16", "bbcIn._serial_.hs1Temp"              ],
    "hs2Temp":            [  58, "i16", "bbcIn._serial_.hs2Temp"              ],
    "pcbTemp":            [  60, "i16", "bbcIn._serial_.pcbTemp"              ],
    "workMode":           [  62,  "u8", "bbcIn._serial_.workMode"             ],
    "chargePause":        [  63,  "u8", "bbcIn._serial_.chargePause"          ],
    "maxConfigChargeCur": [  64, "u32", "bbcIn._serial_.maxConfigChargeCur (mA)" ],
    "dayEnergy":          [  68, "u32", "bbcIn._serial_.dayEnergy"            ],
    "totalEnergy":        [  72, "u32", "bbcIn._serial_.totalEnergy"          ],
    "dcInputState":       [  76,  "u8", "bbcIn._serial_.dcInputState"         ],
    "bpOnlinePos":        [  77,  "u8", "bbcIn._serial_.bpOnlinePos"          ],
    "inHwType":           [  78,  "u8", "bbcIn._serial_.inHwType"             ],
    "chargeType":         [  79,  "u8", "bbcIn._serial_.chargeType"           ],
    "isCarMoving":        [  80,  "u8", "bbcIn._serial_.isCarMoving"          ],
    "cfgShakeDisable":    [  81,  "u8", "bbcIn._serial_.cfgShakeDisable"      ],
    "altCableLength":     [  82, "u32", "bbcIn._serial_.altCableLength (centi-feet)" ],
    "lengthUnitFlag":     [  86,  "u8", "bbcIn._serial_.lengthUnitFlag"       ],
    "chargeMode":         [  87,  "u8", "bbcIn._serial_.chargeMode"           ],
    "warnCode":           [  88, "u16", "bbcIn._serial_.warnCode"             ],
    "eventCode":          [  90, "u16", "bbcIn._serial_.eventCode"            ],
    "workMode2":          [  92,  "u8", "bbcIn._serial_.workMode2"            ],
    "allowDsgOn":         [  93,  "u8", "bbcIn._serial_.allowDsgOn"           ],
    "altLimitVol":        [  94, "u16", "bbcIn._serial_.altLimitVol (decivolts)" ],
    "altVolLimitEnable":  [  96,  "u8", "bbcIn._serial_.altVolLimitEnable"    ],
    "thirdWatts":         [  97, "i32", "bbcIn._serial_.thirdWatts"           ],
    "setPortType":        [ 101,  "u8", "bbcIn._serial_.setPortType"          ],
    "thirdCurr":          [ 102, "i32", "bbcIn._serial_.thirdCurr (mA)"       ],
    "weakLight":          [ 106,  "u8", "bbcIn._serial_.weakLight"            ],
    "alternatorMode":     [ 107,  "u8", "bbcIn._serial_.alternatorMode"       ]
}

Important correction to wattzup's current 28:21:

  • altVoltLmt is at +94 as u16 (decivolts), not the existing u8 entry.
  • Wattzup's current 28:21 has battVol at +24 (correct for DC_IN
    side) but also ldOutVol at +36 — that's actually a DC_OUT field
    (see R2.3). Splitting via smo-keyed lookup resolves this.

R2.3: 28:21@51-01 — full DC_OUT heartbeat (33 fields, new)

Decomp-derived from parseBBC_OUT_HeartBeat.

"28:21@51-01": {
    "serial":            [   0, "s16", "bbcOut._serial_.moduleSn"            ],
    "errCode":           [  16, "u16", "bbcOut._serial_.errCode"             ],
    "modVer":            [  20, "dqr", "bbcOut._serial_.moduleVersion"       ],
    "batteryVol":        [  24, "u32", "bbcOut._serial_.batteryVol (mV)"     ],
    "batteryCur":        [  28, "i32", "bbcOut._serial_.batteryCur (mA)"     ],
    "batteryWatt":       [  32, "i32", "bbcOut._serial_.batteryWatt (W)"     ],
    "ldOutVol":          [  36, "u32", "bbcOut._serial_.ldOutVol (mV)"       ],
    "ldOutCur":          [  40, "i32", "bbcOut._serial_.ldOutCur (mA)"       ],
    "ldOutWatt":         [  44, "i32", "bbcOut._serial_.ldOutWatt (W)"       ],
    "l1Cur":             [  48, "i32", "bbcOut._serial_.l1Cur (mA)"          ],
    "l2Cur":             [  52, "i32", "bbcOut._serial_.l2Cur (mA)"          ],
    "hs1Temp":           [  56, "i16", "bbcOut._serial_.hs1Temp"             ],
    "hs2Temp":           [  58, "i16", "bbcOut._serial_.hs2Temp"             ],
    "pcbTemp":           [  60, "i16", "bbcOut._serial_.pcbTemp"             ],
    "workMode":          [  62,  "u8", "bbcOut._serial_.workMode"            ],
    "dcSwitchMode":      [  63,  "u8", "bbcOut._serial_.dcSwitchMode"        ],
    "dcOutputVolTag":    [  64,  "u8", "bbcOut._serial_.dcOutputVolTag (0=12V, 1=24V)" ],
    "dayEnergy":         [  68, "u32", "bbcOut._serial_.dayEnergy"           ],
    "totalEnergy":       [  72, "u32", "bbcOut._serial_.totalEnergy"         ],
    "warnCode":          [  76, "u16", "bbcOut._serial_.warnCode"            ],
    "eventCode":         [  78, "u16", "bbcOut._serial_.eventCode"           ],
    "standbyTime":       [  80, "u16", "bbcOut._serial_.standbyTime (min)"   ],
    "beepEn":            [  82,  "u8", "bbcOut._serial_.beepEn"              ],
    "cableConnet":       [  83,  "u8", "bbcOut._serial_.cableConnet"         ],
    "exPower":           [  84, "u16", "bbcOut._serial_.exPower"             ],
    "hubWorkMode":       [  86,  "u8", "bbcOut._serial_.hubWorkMode"         ],
    "sccIsLowPwr":       [  87,  "u8", "bbcOut._serial_.sccIsLowPwr"         ],
    "bbcInIsLowPwr":     [  88,  "u8", "bbcOut._serial_.bbcInIsLowPwr"       ],
    "icIsLowPwr":        [  89,  "u8", "bbcOut._serial_.icIsLowPwr"          ],
    "batEquipmentWatts": [  90, "u32", "bbcOut._serial_.batEquipmentWatts"   ],
    "noLoadPwr":         [  94, "u16", "bbcOut._serial_.noLoadPwr"           ],
    "batVoltIsOk":       [  96,  "u8", "bbcOut._serial_.batVoltIsOk"         ],
    "dcAlwaysOn":        [  97,  "u8", "bbcOut._serial_.dcAlwaysOn"          ]
}

R2.4: AD:21.dcChRelay — per-DC-circuit relay bitmap

Wattzup's existing AD:21.dcChRelay is defined as u16 at offset 136
but the field's semantics aren't documented. We verified live by
toggling each circuit 1-6 individually:

  • Low byte (offset 136) is a per-circuit relay bitmap: bit N-1 = 1
    when circuit N is ON.
  • High byte (offset 137) mirrors the low byte plus a constant
    0xC0 base. Suspected "online status" mirror — bit N-1 set means
    circuit N is both online AND on. Bits 14, 15 (= bits 6, 7 of high
    byte) always set.

So dcChRelay reads, e.g.:

  • All channels off + nothing online: 0xC000
  • Channel 4 on: 0xC808 (bit 3 + 0xC0 in high; bit 3 in low)

Treating it as u16 is correct; downstream code can mask with 0x00FF
to get just the relay states and (value >> 8) & 0x3F to get the
"online" mirror.

R2.5: 4F:21.bmsStandbyTime at +29 (new field)

Wattzup's 4F:21 ends at +27 (totalAmp). Bytes +29 holds the BMS
pack standby timer (minutes, 0 = always on), writable via the app's
settings screen. Verified live by round-tripping set_bms_standby_time
0 ↔ 60.

"bmsStandbyTime": [ 29, "u8", "bmsTotal.standbyTime (minutes)" ]

R2.6: 68:21 — SoC limit readbacks at +91 / +92 (new fields)

Wattzup has 68 (rty-only fallback) with the per-pack fields. For
68:21 specifically (the Power Hub variant), two writable-readback
fields appear at:

"chgUpLimit": [ 91, "u8", "bp5000._serial_.chgUpLimit (% — UPS max charge SoC readback)" ],
"dsgDnLimit": [ 92, "u8", "bp5000._serial_.dsgDnLimit (% — UPS min discharge SoC readback)" ]

Located via before/after-diffing captures around user-initiated limit
changes. These mirror the 4F:21.bmsChgUpln / bmsChgDnln totals
fields wattzup already has.

R2.7: F8:21@04-01 — PSDH AC charger config readbacks (new fields)

Wattzup's F8 covers most of the PSDH heartbeat. Two new field IDs
for AC charger control state:

"acChargeEnable":   [ 64, "u8", "icHigh._serial_.appCfg.acChargeEnable (0/1)" ],
"acSupplyPriority": [ 80, "u8", "icHigh._serial_.appCfg.acSupplyPriority (1=Grid, 2=Battery)" ]

Verified live via a 4-toggle test (Grid/Battery/Grid/Battery with 2 s
gaps). acMxCurSer (+63) and passByMxCur (+67) wattzup already has
— we confirmed they're the AC charger's max-charge-current and
input-current-cap settings respectively.

PSDH (smo 04-01) does broadcast on BLE but only intermittently; we
sometimes need to trigger an AC-charger action to get a fresh
heartbeat.

R2.8: CRC8 algorithm + wire byte 4 semantics (corrects TX-side reading)

Wattzup's crc16.js is correct, but for TX frames, wire byte 4 is
CRC8 of the first 4 bytes (magic + version + length LE)
, not the
"record type" (rty) wattzup labels it as. The rty/rrf naming applies
correctly to RX but mis-describes TX.

For RX you can ignore this — the byte happens to equal rty because
the hub computes the same CRC8 over the same prefix and that prefix
happens to vary with frame type. But for constructing novel TX
frames
the byte must be computed as CRC8.

The algorithm (from CrcUtils.calcCrc8 in the decompiled app):

const CRC8_TABLE = [
    0,7,14,9,28,27,18,21,56,63,54,49,36,35,42,45,112,119,126,121,108,107,98,101,72,79,70,65,84,83,90,93,
    224,231,238,233,252,251,242,245,216,223,214,209,196,195,202,205,144,151,158,153,140,139,130,133,168,175,166,161,180,179,186,189,
    199,192,201,206,219,220,213,210,255,248,241,246,227,228,237,234,183,176,185,190,171,172,165,162,143,136,129,134,147,148,157,154,
    39,32,41,46,59,60,53,50,31,24,17,22,3,4,13,10,87,80,89,94,75,76,69,66,111,104,97,102,115,116,125,122,
    137,142,135,128,149,146,155,156,177,182,191,184,173,170,163,164,249,254,247,240,229,226,235,236,193,198,207,200,221,218,211,212,
    105,110,103,96,117,114,123,124,81,86,95,88,77,74,67,68,25,30,23,16,5,2,11,12,33,38,47,40,61,58,51,52,
    78,73,64,71,82,85,92,91,118,113,120,127,106,109,100,99,62,57,48,55,34,37,44,43,6,1,8,15,26,29,20,19,
    174,169,160,167,178,181,188,187,150,145,152,159,138,141,132,131,222,217,208,215,194,197,204,203,230,225,232,239,250,253,244,243,
];

function crc8(data) {
    let b = 0;
    for (const x of data) b = CRC8_TABLE[(b ^ x) & 0xff];
    return b;
}

Verified byte-identical against six distinct captured TX frame headers.

Also worth noting for TX framing:

  • Byte 5 is isAck > -1 ? 0x0e : 0x0d. In practice the EcoFlow app
    always passes isAck=-1 via a Kotlin synthetic-default override, so
    the byte is always 0x0d on the wire. Wattzup's rrf label for this
    position is only meaningful on RX.
  • Bytes 10-11 are literal 00 00 on TX. Wattzup calls them
    product_id but the app's EFBleManager treats them as constants.

R2.9: V3 control direction — full command catalog

Wattzup is RX-focused. For the EcoFlow Power Hub Power Kit, we've
identified 27 BLE-exposed control commands (V3 raw payloads via
commandToV3Byte). Most use cmd_set + cmd_id pairs that map cleanly
to wattzup's [rty, mrt] struct-key naming — wattzup could host these
as a sibling "commands" catalog. Example pattern:

"commands": {
    "set_max_charge_soc": {
        "cmd_set":  "0x20", "cmd_id": "0x31",
        "dst": "0x03", "dSrc": 1, "dDst": 0,
        "payload": "u8 percent",
        "readback": "4F:21[+15] = sent value"
    },
    "circuit_set": {
        "cmd_set":  "0x54", "cmd_id": "0x10",
        "dst": "0x54", "dSrc": 1, "dDst": 1,
        "payload": "u8 = (state << 7) | (circuit_id - 1)",
        "readback": "AD:21[+136] bit (circuit_id - 1)"
    }
    // ... 25 more
}

Full table with decomp source for each in our project at
docs/CAPABILITIES.md §2 and docs/FIELDS.md Appendix A. Happy to
share this with wattzup if desired.

R2 Open questions remaining

  • 9A:21 preamble byte semantics — we still don't know what generates
    the per-module varying byte at offset 0.
  • 66:21 / AC:21 header u32 — still unidentified (samples were 3
    and 7, no obvious correlation).
  • D2:21 extended floats — still need state-varying captures to
    confirm the SOH%/temp-limit/current-limit/min-SoC hypothesis.

Licensing (unchanged)

Everything above is offered under MIT for upstream inclusion.
Attribution: independent BLE reverse-engineering against the EcoFlow
Power Hub (M10-prefix variant), 2026-05.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions