Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
### Added

- Venus: Add *Depth of Discharge* number entity (`discharge-depth` command) so the battery's depth of discharge (`dod`) can be read and configured (30-88%). The entity is only advertised on devices that report the value (fixes #306)
- Venus A (VNSA): Expose per-string PV input power (PV1–PV4), their connection status and a combined Total PV Power sensor (fixes #218)

### Fixed

- Venus: Scale the BMS battery voltage and charge voltage to volts (they were previously published as raw centivolt/decivolt values, e.g. 4328 instead of 43.28 V) (fixes #218)
- Venus A (VNSA): Scale the BMS cell and MOSFET temperatures to °C (they were previously published 10× too high, e.g. 164 instead of 16.4 °C) (fixes #218)

## [1.7.0] - 2026-06-01

Expand Down
121 changes: 113 additions & 8 deletions src/device/venus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
equalsBoolean,
chain,
inRange,
sum,
venusPvField,
} from '../transforms';

/**
Expand Down Expand Up @@ -150,15 +152,31 @@ function isVenusRuntimeInfoMessage(values: Record<string, string>): boolean {

registerDeviceDefinition(
{
deviceTypes: ['HMG', 'VNSE3', 'VNSA', 'VNSD'],
deviceTypes: ['HMG', 'VNSE3', 'VNSD'],
},
({ message }) => {
registerRuntimeInfoMessage(message);
registerBMSInfoMessage(message);
},
);

function registerRuntimeInfoMessage(message: BuildMessageFn) {
// Venus A (VNSA) reports additional per-string PV input power and uses a
// different scaling for the BMS cell/MOSFET temperatures (factor of 10)
// compared to the other Venus variants.
registerDeviceDefinition(
{
deviceTypes: ['VNSA'],
},
({ message }) => {
registerRuntimeInfoMessage(message, { withPvInputs: true });
registerBMSInfoMessage(message, { scaleTemperatures: true });
},
);

function registerRuntimeInfoMessage(
message: BuildMessageFn,
{ withPvInputs = false }: { withPvInputs?: boolean } = {},
) {
let options = {
refreshDataPayload: 'cd=1',
isMessage: isVenusRuntimeInfoMessage,
Expand Down Expand Up @@ -210,6 +228,61 @@ function registerRuntimeInfoMessage(message: BuildMessageFn) {
}),
);

// PV / solar input information
if (withPvInputs) {
// Per-string PV input power and connection status (pvN = "<power>|<connected>")
const pvInputs = [
{ key: 'pv1', power: 'pv1Power', connected: 'pv1Connected', label: 'PV1' },
{ key: 'pv2', power: 'pv2Power', connected: 'pv2Connected', label: 'PV2' },
{ key: 'pv3', power: 'pv3Power', connected: 'pv3Connected', label: 'PV3' },
{ key: 'pv4', power: 'pv4Power', connected: 'pv4Connected', label: 'PV4' },
] as const;
for (const pv of pvInputs) {
field({ key: pv.key, path: [pv.power], transform: venusPvField('power') });
advertise(
[pv.power],
sensorComponent<number>({
id: `${pv.key}_power`,
name: `${pv.label} Power`,
device_class: 'power',
unit_of_measurement: 'W',
state_class: 'measurement',
}),
);

field({ key: pv.key, path: [pv.connected], transform: venusPvField('connected') });
advertise(
[pv.connected],
binarySensorComponent({
id: `${pv.key}_connected`,
name: `${pv.label} Connected`,
device_class: 'connectivity',
icon: 'mdi:solar-power',
enabled_by_default: false,
}),
);
}

// Total PV power across all inputs. Each value is "<power>|<connected>"
// with power in deciwatts; sum() reads the leading number from each and
// the scale converts the deciwatt total to watts.
field({
key: ['pv1', 'pv2', 'pv3', 'pv4'],
path: ['totalPvPower'],
transform: sum(10),
});
advertise(
['totalPvPower'],
sensorComponent<number>({
id: 'total_pv_power',
name: 'Total PV Power',
device_class: 'power',
unit_of_measurement: 'W',
state_class: 'measurement',
}),
);
}

// Power information
field({
key: 'tot_i',
Expand Down Expand Up @@ -1267,7 +1340,10 @@ const requiredBMSFields = ['b_ver', 'b_soc', 'b_tp1', 'b_vo1'];
function isVenusBmsInfoMessage(values: Record<string, string>): boolean {
return requiredBMSFields.every(field => field in values);
}
function registerBMSInfoMessage(message: BuildMessageFn) {
function registerBMSInfoMessage(
message: BuildMessageFn,
{ scaleTemperatures = false }: { scaleTemperatures?: boolean } = {},
) {
message<VenusBMSInfo>(
{
refreshDataPayload: `cd=${CommandType.GET_BMS_INFO}`,
Expand Down Expand Up @@ -1297,7 +1373,11 @@ function registerBMSInfoMessage(message: BuildMessageFn) {

for (let i = 1; i <= 4; i++) {
const key = `b_tp${i}`;
field({ key, path: ['cells', 'temperatures', i - 1] });
field({
key,
path: ['cells', 'temperatures', i - 1],
transform: scaleTemperatures ? divide(10) : undefined,
});
advertise(
['cells', 'temperatures', i - 1],
sensorComponent<number>({
Expand All @@ -1315,21 +1395,46 @@ function registerBMSInfoMessage(message: BuildMessageFn) {
['b_soc', { id: 'soc' }],
['b_soh', { id: 'soh' }],
['b_cap', { id: 'capacity' }],
['b_vol', { id: 'voltage', deviceClass: 'voltage', unitOfMeasurement: 'V' }],
// Battery pack voltage is reported in centivolts (e.g. 4328 -> 43.28 V)
[
'b_vol',
{ id: 'voltage', deviceClass: 'voltage', unitOfMeasurement: 'V', transform: divide(100) },
],
['b_cur', { id: 'current', deviceClass: 'current', unitOfMeasurement: 'mA' }],
['b_tem', { id: 'temperature', deviceClass: 'temperature', unitOfMeasurement: '°C' }],
['b_chv', { id: 'chargeVoltage', deviceClass: 'voltage', unitOfMeasurement: 'V' }],
// Charge voltage is reported in decivolts (e.g. 468 -> 46.8 V)
[
'b_chv',
{
id: 'chargeVoltage',
deviceClass: 'voltage',
unitOfMeasurement: 'V',
transform: divide(10),
},
],
['b_chf', { id: 'fullChargeCapacity' }],
['b_cpc', { id: 'cellCycle' }],
['b_err', { id: 'error' }],
['b_war', { id: 'warning' }],
['b_ret', { id: 'totalRuntime' }],
['b_ent', { id: 'energyThroughput' }],
['b_mot', { id: 'mosfetTemp', deviceClass: 'temperature', unitOfMeasurement: '°C' }],
[
'b_mot',
{
id: 'mosfetTemp',
deviceClass: 'temperature',
unitOfMeasurement: '°C',
transform: scaleTemperatures ? divide(10) : undefined,
},
],
] as const;

for (const [key, info] of bmsFields) {
field({ key, path: ['bms', info.id] });
field({
key,
path: ['bms', info.id],
transform: 'transform' in info ? info.transform : undefined,
});
advertise(
['bms', info.id],
sensorComponent<number>({
Expand Down
59 changes: 59 additions & 0 deletions src/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HmiInverterDeviceData,
JupiterBMSInfo,
JupiterDeviceData,
VenusBMSInfo,
VenusDeviceData,
} from './types';

Expand Down Expand Up @@ -664,4 +665,62 @@ describe('MQTT Message Parser', () => {
const result = parsed['data'] as VenusDeviceData;
expect(result.depthOfDischarge).toBeUndefined();
});

test('parses Venus A (VNSA) PV input power and connection status (issue #218)', () => {
// Real runtime reading from a Venus A: pv1 connected and producing, pv2-4 idle.
const message =
'cd=1,tot_i=0,tot_o=0,ele_d=0,ele_m=0,grd_d=0,grd_m=0,inc_d=1,inc_m=0,grd_f=0,grd_o=0,grd_t=1,gct_s=0,cel_s=2,cel_p=157,cel_c=75,err_t=800,err_a=8,dev_n=143,grd_y=0,wor_m=1,tim_0=0|0|0|0|0|0|0,cts_m=0,bac_u=0,tra_a=74,tra_i=1,tra_o=0,htt_p=0,prc_c=0,prc_d=1,wif_s=51,inc_a=0,set_v=4,mcp_w=1200,mdp_w=1200,ct_t=1,phase_t=0,dchrg_t=1,bms_v=106,fc_v=202409090159,wifi_n=xxxx,seq_s=1,ctrl_r=0,par=0,gen=0,ble=3,shelly_p=1010,c_ratio=100,udp=0,api=1,net=1,port=48977,inv_v=113,id=2|0|0|0|0,lk=0,bp=99,ei=0,eb=0,rp=107,gp=0,vp=0,mppt=0,pv1=1076|1,pv2=0|0,pv3=0|0,pv4=0|0,pack=1|1|1|0,pv=41|57,fu=1|0,em=0';
const parsed = parseMessage(message, 'VNSA-0', 'venusA123');

expect(parsed).toHaveProperty('data');
const result = parsed['data'] as VenusDeviceData;
// PV power is reported in deciwatts: 1076 -> 107.6 W
expect(result).toHaveProperty('pv1Power', 107.6);
expect(result).toHaveProperty('pv2Power', 0);
expect(result).toHaveProperty('pv1Connected', true);
expect(result).toHaveProperty('pv2Connected', false);
expect(result).toHaveProperty('totalPvPower', 107.6);
});

test('does not expose PV inputs for non-Venus-A variants (VNSE3)', () => {
const message =
'cd=1,tot_i=0,tot_o=0,ele_d=0,ele_m=0,grd_d=0,grd_m=0,inc_d=0,inc_m=0,grd_f=0,grd_o=0,grd_t=1,gct_s=1,cel_s=1,cel_p=40,cel_c=7,err_t=0,err_a=0,dev_n=148,grd_y=0,wor_m=0,inc_a=0,pv1=1076|1';
const parsed = parseMessage(message, 'VNSE3-0', 'venus123');

const result = parsed['data'] as VenusDeviceData;
expect(result).not.toHaveProperty('pv1Power');
expect(result).not.toHaveProperty('totalPvPower');
});

test('scales Venus A (VNSA) BMS voltages and temperatures (issue #218)', () => {
// Real BMS reading from a Venus A. Battery voltage is in centivolts,
// charge voltage in decivolts, cell/MOSFET temperatures in deci-degrees.
const message =
'cd=14,b_ver=106,b_chv=468,b_rci=400,b_rdi=400,b_soc=75,b_soh=0,b_cap=2080,b_vol=4328,b_cur=20,b_tem=16,b_chf=3,b_slf=0,b_cpc=157,b_err=0,b_war=0,b_ret=0,b_ent=228,b_mot=173,b_tp1=164,b_tp2=166,b_tp3=168,b_tp4=166,b_vo1=3334,b_vo2=3332,b_vo3=3333,b_vo4=3333,b_vo5=3334,b_vo6=3335,b_vo7=3334,b_vo8=3334,b_vo9=3334,b_vo10=3334,b_vo11=3333,b_vo12=3333,b_vo13=3333,b_vo14=0,b_vo15=0,b_vo16=0';
const parsed = parseMessage(message, 'VNSA-0', 'venusA123');

expect(parsed).toHaveProperty('bms');
const result = parsed['bms'] as VenusBMSInfo;
expect(result.bms?.voltage).toBeCloseTo(43.28);
expect(result.bms?.chargeVoltage).toBeCloseTo(46.8);
expect(result.bms?.mosfetTemp).toBeCloseTo(17.3);
expect(result.cells?.temperatures?.[0]).toBeCloseTo(16.4);
// Battery temperature (b_tem) is already in whole degrees and must stay unscaled.
expect(result.bms?.temperature).toBe(16);
// Cell voltages are already reported in mV and stay unscaled.
expect(result.cells?.voltages?.[0]).toBe(3334);
});

test('Venus E (VNSE3) BMS scales voltages but keeps raw temperatures', () => {
const message =
'cd=14,b_ver=212,b_chv=571,b_soc=65,b_soh=100,b_cap=5120,b_vol=5223,b_cur=-94,b_tem=25,b_chf=192,b_cpc=332,b_err=0,b_war=0,b_ret=0,b_ent=0,b_mot=23,b_tp1=18,b_tp2=19,b_tp3=18,b_tp4=19,b_vo1=3265,b_vo2=3265,b_vo3=3265,b_vo4=3265,b_vo5=3264,b_vo6=3264,b_vo7=3265,b_vo8=3265,b_vo9=3264,b_vo10=3265,b_vo11=3264,b_vo12=3265,b_vo13=3265,b_vo14=3265,b_vo15=3264,b_vo16=3262';
const parsed = parseMessage(message, 'VNSE3-0', 'venus123');

const result = parsed['bms'] as VenusBMSInfo;
expect(result.bms?.voltage).toBeCloseTo(52.23);
expect(result.bms?.chargeVoltage).toBeCloseTo(57.1);
// Other Venus variants already report temperatures in whole degrees.
expect(result.bms?.mosfetTemp).toBe(23);
expect(result.cells?.temperatures?.[0]).toBe(18);
});
});
41 changes: 41 additions & 0 deletions src/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
chain,
timePeriodField,
mpptPvField,
venusPvField,
bitMaskToWeekday,
sum,
min,
Expand Down Expand Up @@ -369,6 +370,34 @@ describe('transforms', () => {
});
});

describe('venusPvField transform', () => {
const connectedPvInfo = '1076|1';
const disconnectedPvInfo = '0|0';

it('should extract power in watts (deciwatt input)', () => {
expect(executeTransform(venusPvField('power'), connectedPvInfo)).toBe(107.6);
expect(executeTransform(venusPvField('power'), disconnectedPvInfo)).toBe(0);
});

it('should extract connection status', () => {
expect(executeTransform(venusPvField('connected'), connectedPvInfo)).toBe(true);
expect(executeTransform(venusPvField('connected'), disconnectedPvInfo)).toBe(false);
});

it('should return 0 power for invalid input', () => {
expect(executeTransform(venusPvField('power'), 'invalid')).toBe(0);
});

it('should generate correct Jinja2 templates', () => {
expect(transformToJinja2(venusPvField('power'), 'value')).toBe(
"{% set p = value.split('|') %}{% if p | length >= 1 %}{{ (p[0] | int) / 10 }}{% else %}0{% endif %}",
);
expect(transformToJinja2(venusPvField('connected'), 'value')).toBe(
"{% set p = value.split('|') %}{% if p | length >= 2 %}{{ p[1] == '1' }}{% else %}false{% endif %}",
);
});
});

describe('bitMaskToWeekday transform', () => {
it('should convert bitmask to weekday set', () => {
expect(executeTransform(bitMaskToWeekday(), '127')).toBe('0123456');
Expand All @@ -389,11 +418,23 @@ describe('transforms', () => {
expect(executeMultiKeyTransform(sum(), { a: '100', b: 'invalid' })).toBe(100);
});

it('should apply an optional scale divisor', () => {
expect(executeMultiKeyTransform(sum(10), { a: '1076', b: '0' })).toBe(107.6);
// Leading number is parsed from pipe-delimited values
expect(executeMultiKeyTransform(sum(10), { pv1: '1076|1', pv2: '0|0' })).toBe(107.6);
});

it('should generate correct Jinja2 template', () => {
expect(multiKeyTransformToJinja2(sum(), ['w1', 'w2'], 'value_json')).toBe(
'{{ [value_json.w1 | float(0), value_json.w2 | float(0)] | sum }}',
);
});

it('should generate correct Jinja2 template with scale', () => {
expect(multiKeyTransformToJinja2(sum(10), ['pv1', 'pv2'], 'value_json')).toBe(
'{{ ([value_json.pv1 | float(0), value_json.pv2 | float(0)] | sum) / 10 }}',
);
});
});

describe('min transform', () => {
Expand Down
Loading
Loading