Skip to content

Commit 39ba886

Browse files
authored
Jupiter: Add more fields and unit-tests for parsing (#223)
I upgraded to firmware 140 today and noticed a few changes. One of the biggest changes is the possibility to set the depth of discharge. Thanks to [this tip](#216 (comment)) I was able to extract the command used to control it and expose it to Home Assistant. The BMS temperature now goes below 25 ºC and shows negative values. Unlike other temperatures, it doesn't need to be converted from `uint8` to `int8`, but still needs to be divided by 10. I also added parsing of the inverter temperature (behaves the same as BMS temperature) and some other fields that I could decode the meaning of. In addition to that, I changed the way firmware is reported to HA: it's now a quadruple of EMS, BMS, MPPT and inverter versions. This is how the version is reported when you do a firmware upgrade: ![Version display during firmware upgrade](https://github.com/user-attachments/assets/5ee76f05-532e-439e-a86f-328d1d85e589) And I also added unit-tests for Jupiter message parsing. Although, most credit goes to LLM here, for saving me from having to type so many `expect`s 😄 Here's a full list of changes: - Firmware 140 introduced an option to set the depth of discharge. Add a component that allows to control it. - Parse BMS, MPPT, and inverter version fields. Change friendly name of _Device Version_ to _EMS Version_. - During firmware upgrade, the version is composed of four component versions concatenated by the `.`: EMS.BMS.MPPT.INV. Expose firmware version to Home Assistant in the same way. - Add parsing of inverter fields: temperature, warning and error codes, as well as grid voltage, current, power, power factor, and frequency. I merely guessed that `g_` means "grid" from the reported values of `g_vol` (voltage) and `g_fre` (frequency). - Fix Surplus Feed-In toggle not applying. - Fix Surplus Feed-In state when device is actively feeding in surplus. - Add unit-tests for parsing Jupiter messages. - Caught by unit-tests: fix BMS Charge Voltage field parsing (use `bms:c_vol` instead of simply `c_vol`).
1 parent ece1eba commit 39ba886

File tree

4 files changed

+403
-12
lines changed

4 files changed

+403
-12
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
## [Next]
33

44
- Fix Home Assistant warning when surplus feed-in is unavailable on older HM firmware versions
5+
- Jupiter: Add Depth of Discharge control (needs firmware 140+). It is a reverse of the battery charge, so setting it to 75% means that the battery will only feed-in power when charged above 25%.
6+
- Jupiter: Add BMS, MPPT, and inverter version sensors. Change friendly name of _Device Version_ sensor to _EMS Version_, as reported by the Marstek app during firmware upgrade.
7+
- Jupiter: The firmware reported to Home Assistant is now composed of four values: `<EMS version>.<BMS version>.<MPPT version>.<INV version>`.
8+
- Jupiter: Add inverter metrics: temperature, error and warning codes, as well as grid voltage, current, power, power factor, and frequency.
9+
- Jupiter: Fix parsing of negative temperatures.
10+
- Jupiter: Fix Surplus Feed-In toggle not applying.
11+
- Jupiter: Fix Surplus Feed-In state when device is actively feeding in surplus.
12+
- Jupiter: Fix parsing of BMS Charge Voltage field.
513

614

715
## [1.5.3] - 2026-01-01

src/device/jupiter.ts

Lines changed: 197 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@ import { transformTemperature } from './helpers';
2424
* Command types supported by the Jupiter device (subset of Venus)
2525
*/
2626
enum CommandType {
27-
READ_DEVICE_INFO = 1, // -> ele_d=349,ele_m=2193,ele_y=0,pv1_p=94,pv1_s=1,pv2_p=77,pv2_s=1,pv3_p=41,pv3_s=1,pv4_p=60,pv4_s=1,grd_o=250,grd_t=1,gct_s=1,cel_s=0,cel_p=424,cel_c=83,err_t=0,wor_m=1,tim_0=12|0|23|59|127|800|1,tim_1=0|0|12|0|127|150|1,tim_2=0|0|0|0|255|0|0,tim_3=0|0|0|0|255|0|0,tim_4=0|0|0|0|255|0|0,cts_m=0,grd_d=285,grd_m=2018,dev_n=134,dev_i=106,dev_m=206,dev_b=209,dev_t=110,wif_s=75,ala_c=0,ful_d=1,ssid=xxxx,stop_s=10,htt_p=0,ct_t=4,phase_t=1,dchrg=1,seq_s=3
27+
READ_DEVICE_INFO = 1, // -> ele_d=349,ele_m=2193,ele_y=0,pv1_p=94,pv1_s=1,pv2_p=77,pv2_s=1,pv3_p=41,pv3_s=1,pv4_p=60,pv4_s=1,grd_o=250,grd_t=1,gct_s=1,cel_s=0,cel_p=424,cel_c=83,err_t=0,wor_m=1,tim_0=12|0|23|59|127|800|1,tim_1=0|0|12|0|127|150|1,tim_2=0|0|0|0|255|0|0,tim_3=0|0|0|0|255|0|0,tim_4=0|0|0|0|255|0|0,cts_m=0,grd_d=285,grd_m=2018,dev_n=134,dev_i=106,dev_m=206,dev_b=209,dev_t=110,wif_s=75,ala_c=0,ful_d=1,ssid=xxxx,stop_s=10,htt_p=0,ct_t=4,phase_t=1,dchrg=1,seq_s=3,ctrl_r=0,shelly_p=1010,c_ratio=100,b_lck=0,dod=88,total_b=1,online_b=1
2828
GET_FC41D_INFO = 10, // -> wifi_v=202409090159
2929
FACTORY_RESET = 5,
3030
SET_DEVICE_TIME = 4,
3131
SET_TIME_PERIOD = 3,
3232
SET_WORKING_MODE = 2,
3333
SURPLUS_FEED_IN = 13,
34-
GET_BMS_INFO = 14, // -> inv:g_state=1,w_state1=1,w_state2=1,i_err=0,i_war=0,g_vol=2399,g_cur=0,g_pf=0,g_fre=5000,b_vol=526,g_power=119,i_temp=31,mppt:m_state=244,m_err=0,m_temp=30,m_war=0,pv1=350|37|1304,pv2=349|39|1372,pv3=378|18|712,pv4=365|32|1180,b_vol=525,b_cur=85,base_v=221,pe_v=165,bms:c_vol=571,c_cur=500,d_cur=500,soc=33,soh=100,b_cap=5120,b_vol=5252,b_cur=63,b_temp=250,b_err=0,b_war=0,b_err2=0,b_war2=0,c_flag=192,s_flag=0,b_num=1,vol0=3280,vol1=3281,vol2=3283,vol3=3283,vol4=3283,vol5=3283,vol6=3280,vol7=3284,vol8=3283,vol9=3284,vol10=3282,vol11=3286,vol12=3277,vol13=3286,vol14=3283,vol15=3284,b_temp0=14,b_temp1=15,b_temp2=15,b_temp3=16,env_t=27,mos_t=20
34+
GET_BMS_INFO = 14, // -> inv:g_state=1,w_state1=1,w_state2=1,i_err=0,i_war=0,g_vol=2399,g_cur=0,g_pf=0,g_fre=5000,b_vol=526,g_power=119,i_temp=31,mppt:m_state=244,m_err=0,m_temp=30,m_war=0,pv1=350|37|1304,pv2=349|39|1372,pv3=378|18|712,pv4=365|32|1180,b_vol=525,b_cur=85,base_v=221,pe_v=165,fail_t=0,bms:c_vol=571,c_cur=500,d_cur=500,soc=33,soh=100,b_cap=5120,b_vol=5252,b_cur=63,b_temp=250,b_err=0,b_war=0,b_err2=0,b_war2=0,c_flag=192,s_flag=0,b_num=1,vol0=3280,vol1=3281,vol2=3283,vol3=3283,vol4=3283,vol5=3283,vol6=3280,vol7=3284,vol8=3283,vol9=3284,vol10=3282,vol11=3286,vol12=3277,vol13=3286,vol14=3283,vol15=3284,b_temp0=14,b_temp1=15,b_temp2=15,b_temp3=16,env_t=27,mos_t=20,lck=0
35+
DEPTH_OF_DISCHARGE = 56,
3536
}
3637

3738
function processCommand(command: CommandType, params: CommandParams = {}): string {
@@ -102,9 +103,21 @@ function registerRuntimeInfoMessage(message: BuildMessageFn) {
102103
isMessage: isJupiterRuntimeInfoMessage,
103104
publishPath: 'data',
104105
defaultState: {},
105-
getAdditionalDeviceInfo: (state: JupiterDeviceData) => ({
106-
firmwareVersion: state.deviceVersion?.toString(),
107-
}),
106+
getAdditionalDeviceInfo: (state: JupiterDeviceData) =>
107+
// While Marstek app only displays EMS version in the settings, during a
108+
// firmware upgrade it displays the version like this:
109+
// <EMS version>.<BMS version>.<MPPT version>.<INV version>
110+
// First try to compose it the same way...
111+
state.deviceVersion && state.bmsVersion && state.mpptVersion && state.inverterVersion
112+
? {
113+
firmwareVersion: `${state.deviceVersion}.${state.bmsVersion}.${state.mpptVersion}.${state.inverterVersion}`,
114+
}
115+
: // ...then fallback to EMS version when other fields are not available
116+
state.deviceVersion
117+
? {
118+
firmwareVersion: state.deviceVersion.toString(),
119+
}
120+
: {},
108121
pollInterval: globalPollInterval,
109122
controlsDeviceAvailability: true,
110123
};
@@ -392,7 +405,31 @@ function registerRuntimeInfoMessage(message: BuildMessageFn) {
392405
['deviceVersion'],
393406
sensorComponent<number>({
394407
id: 'device_version',
395-
name: 'Device Version',
408+
name: 'EMS Version',
409+
}),
410+
);
411+
field({ key: 'dev_b', path: ['bmsVersion'] });
412+
advertise(
413+
['bmsVersion'],
414+
sensorComponent<number>({
415+
id: 'bms_version',
416+
name: 'BMS Version',
417+
}),
418+
);
419+
field({ key: 'dev_m', path: ['mpptVersion'] });
420+
advertise(
421+
['mpptVersion'],
422+
sensorComponent<number>({
423+
id: 'mppt_version',
424+
name: 'MPPT Version',
425+
}),
426+
);
427+
field({ key: 'dev_i', path: ['inverterVersion'] });
428+
advertise(
429+
['inverterVersion'],
430+
sensorComponent<number>({
431+
id: 'inverter_version',
432+
name: 'Inverter Version',
396433
}),
397434
);
398435
field({ key: 'ssid', path: ['wifiName'], transform: v => v });
@@ -418,9 +455,44 @@ function registerRuntimeInfoMessage(message: BuildMessageFn) {
418455
);
419456
command('surplus-feed-in', {
420457
handler: ({ message, publishCallback, updateDeviceState }) => {
421-
const enable = message.toLowerCase() === 'true' || message === '1' || message === 'on';
458+
const enable =
459+
message.toLowerCase() === 'true' ||
460+
message === '1' ||
461+
// `3` is reported when surplus is being fed-in
462+
message === '3' ||
463+
message === 'on';
422464
updateDeviceState(() => ({ surplusFeedInEnabled: enable }));
423-
publishCallback(processCommand(CommandType.SURPLUS_FEED_IN, { ful_d: enable ? 1 : 0 }));
465+
// Yes, it's `full_d` in the command params
466+
publishCallback(processCommand(CommandType.SURPLUS_FEED_IN, { full_d: enable ? 1 : 0 }));
467+
},
468+
});
469+
470+
// Depth of Discharge (dod) (since firmware 140)
471+
field({ key: 'dod', path: ['depthOfDischarge'] });
472+
advertise(
473+
['depthOfDischarge'],
474+
numberComponent({
475+
id: 'depth_of_discharge',
476+
name: 'Depth of Discharge',
477+
unit_of_measurement: '%',
478+
device_class: 'battery',
479+
command: 'discharge-depth',
480+
min: 30,
481+
max: 88,
482+
step: 1,
483+
}),
484+
{ enabled: state => state.deviceVersion !== undefined && state.deviceVersion >= 140 },
485+
);
486+
command('discharge-depth', {
487+
handler: ({ message, publishCallback, updateDeviceState }) => {
488+
const dod = parseInt(message, 10);
489+
// Marstek app limits DoD to 30-88%
490+
if (isNaN(dod) || dod < 30 || dod > 88) {
491+
logger.warn('Invalid depth of discharge value (should be 30-88):', message);
492+
return;
493+
}
494+
updateDeviceState(() => ({ depthOfDischarge: dod }));
495+
publishCallback(processCommand(CommandType.DEPTH_OF_DISCHARGE, { dod }));
424496
},
425497
});
426498

@@ -889,11 +961,31 @@ function registerJupiterBMSInfoMessage(message: BuildMessageFn) {
889961
deviceClass: 'temperature',
890962
unitOfMeasurement: '°C',
891963
stateClass: 'measurement',
892-
// This value seems to never go below 250 (25.0°C)
964+
// On 134.30.207.107 this value never went below 250 (25.0°C).
965+
// On 140.34.213.110 it reports negative temperatures without the
966+
// need for `uint8` to `int8` conversion. Division by 10 is still
967+
// needed, though.
968+
transform: (v: string) => parseInt(v) / 10,
969+
},
970+
],
971+
// TODO: Maybe a more generic approach? E.g., split the field name by
972+
// ':' when parsing and use the right part?
973+
[
974+
'bms:c_vol',
975+
{
976+
id: 'chargeVoltage',
977+
deviceClass: 'voltage',
978+
unitOfMeasurement: 'V',
979+
// My unit always reports 600 which, when divided by 10, gives a
980+
// close to adequate 60 V charging voltage.
893981
transform: (v: string) => parseInt(v) / 10,
894982
},
895983
],
896-
['c_vol', { id: 'chargeVoltage', deviceClass: 'voltage', unitOfMeasurement: 'mV' }],
984+
// My unit always reports 75 for `c_cur` and 300 for `d_cur`. 75 mA is
985+
// too low, 75 A is too high. Dividing by 10 gives 7.5 A which looks
986+
// more reasonable. However, 30 A for `d_cur` seems way too high. These
987+
// values definitely need scaling, but I'm not sure what the correct
988+
// factors are.
897989
['c_cur', { id: 'chargeCurrent', deviceClass: 'current', unitOfMeasurement: 'mA' }],
898990
['d_cur', { id: 'dischargeCurrent', deviceClass: 'current', unitOfMeasurement: 'mA' }],
899991
['b_err', { id: 'error' }],
@@ -1001,6 +1093,101 @@ function registerJupiterBMSInfoMessage(message: BuildMessageFn) {
10011093
);
10021094
}
10031095
}
1096+
// Inverter fields
1097+
const inverterFields = [
1098+
[
1099+
'i_temp',
1100+
{
1101+
id: 'temperature',
1102+
deviceClass: 'temperature',
1103+
unitOfMeasurement: '°C',
1104+
stateClass: 'measurement',
1105+
transform: (value: string) => parseInt(value) / 10,
1106+
},
1107+
],
1108+
['i_err', { id: 'error' }],
1109+
['i_war', { id: 'warning' }],
1110+
[
1111+
'g_vol',
1112+
{
1113+
id: 'gridVoltage',
1114+
name: 'Grid Voltage',
1115+
deviceClass: 'voltage',
1116+
unitOfMeasurement: 'V',
1117+
stateClass: 'measurement',
1118+
transform: (value: string) => parseInt(value) / 10,
1119+
},
1120+
],
1121+
[
1122+
'g_cur',
1123+
{
1124+
id: 'gridCurrent',
1125+
name: 'Grid Current',
1126+
deviceClass: 'current',
1127+
unitOfMeasurement: 'A',
1128+
stateClass: 'measurement',
1129+
// TODO: Just a guess, needs verification. My unit always reports 0.
1130+
transform: (value: string) => parseInt(value) / 10,
1131+
},
1132+
],
1133+
[
1134+
'g_power',
1135+
{
1136+
id: 'gridPower',
1137+
name: 'Grid Power',
1138+
deviceClass: 'power',
1139+
unitOfMeasurement: 'W',
1140+
stateClass: 'measurement',
1141+
},
1142+
],
1143+
[
1144+
'g_pf',
1145+
{
1146+
id: 'gridPowerFactor',
1147+
name: 'Grid Power Factor',
1148+
deviceClass: 'power_factor',
1149+
// TODO: Power factor is usually either [0..1] or [0..100]. Home
1150+
// Assistant accepts both and relies on the unit of measurement to
1151+
// determine the range. My unit always reports 0, so can't verify.
1152+
// [0..100] is a good assumption because Marstek seems to always
1153+
// return integers that have to be divided to get fractional values.
1154+
unitOfMeasurement: '%',
1155+
stateClass: 'measurement',
1156+
},
1157+
],
1158+
[
1159+
'g_fre',
1160+
{
1161+
id: 'gridFrequency',
1162+
name: 'Grid Frequency',
1163+
deviceClass: 'frequency',
1164+
unitOfMeasurement: 'Hz',
1165+
stateClass: 'measurement',
1166+
transform: (value: string) => parseInt(value) / 100,
1167+
},
1168+
],
1169+
] as const;
1170+
for (const [key, info] of inverterFields) {
1171+
field({
1172+
key,
1173+
path: ['inverter', info.id],
1174+
transform: 'transform' in info ? info.transform : undefined,
1175+
});
1176+
advertise(
1177+
['inverter', info.id],
1178+
sensorComponent<number>({
1179+
id: `inverter_${info.id}`,
1180+
name:
1181+
'name' in info
1182+
? info.name
1183+
: `Inverter ${info.id.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`,
1184+
unit_of_measurement: 'unitOfMeasurement' in info ? info.unitOfMeasurement : undefined,
1185+
device_class: 'deviceClass' in info ? info.deviceClass : undefined,
1186+
state_class: 'stateClass' in info ? info.stateClass : undefined,
1187+
enabled_by_default: false,
1188+
}),
1189+
);
1190+
}
10041191
},
10051192
);
10061193
}

0 commit comments

Comments
 (0)