From 6460aa10970aac1884d9e2cdd3aed1df34d523e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 12:36:11 +0000 Subject: [PATCH 1/4] Venus A: add PV input sensors and fix BMS voltage/temperature scaling Venus A (VNSA) reports per-string PV input power (pv1-pv4 in the form power|connected) that was never parsed, so no PV sensors appeared in MQTT/Home Assistant. Add PV1-PV4 power sensors, per-input connection binary sensors and a combined Total PV Power sensor for VNSA. The BMS battery voltage and charge voltage were published as raw centivolt/decivolt values (e.g. 4328 instead of 43.28 V); scale them to volts for all Venus variants. Venus A additionally encodes the cell and MOSFET temperatures 10x too high (164 instead of 16.4 C); scale those down for VNSA only, since the other Venus variants already report whole degrees. Fixes #218 https://claude.ai/code/session_01L5QLHLxrBV8zqDip55aKow --- CHANGELOG.md | 6 ++ src/device/venus.ts | 199 ++++++++++++++++++++++++++++++++++++++++++-- src/parser.test.ts | 56 +++++++++++++ src/types.ts | 11 +++ 4 files changed, 264 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78773c8..e885713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/device/venus.ts b/src/device/venus.ts index 20602b3..36b894f 100644 --- a/src/device/venus.ts +++ b/src/device/venus.ts @@ -109,6 +109,25 @@ function weekdaySetToBitMask(weekday: VenusTimePeriod['weekday']): number { return weekday.split('').reduce((mask, day) => mask | (1 << parseInt(day, 10)), 0); } +/** + * Parse a Venus PV input string from the device. + * + * Format: "|", e.g. "1076|1" where the first value is the + * input power in watts and the second value is the connection flag. + * Unconnected inputs report "0|0". + * + * @param value - PV input string + * @returns Parsed PV input info + */ +function parseVenusPvInput(value: string): { power: number; connected: boolean } { + const parts = value.split('|'); + const power = parseInt(parts[0], 10); + return { + power: Number.isNaN(power) ? 0 : power, + connected: parts[1] === '1', + }; +} + /** * Extract additional device info for Home Assistant discovery * @@ -150,7 +169,7 @@ function isVenusRuntimeInfoMessage(values: Record): boolean { registerDeviceDefinition( { - deviceTypes: ['HMG', 'VNSE3', 'VNSA', 'VNSD'], + deviceTypes: ['HMG', 'VNSE3', 'VNSD'], }, ({ message }) => { registerRuntimeInfoMessage(message); @@ -158,7 +177,23 @@ registerDeviceDefinition( }, ); -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, @@ -210,6 +245,122 @@ function registerRuntimeInfoMessage(message: BuildMessageFn) { }), ); + // PV / solar input information + if (withPvInputs) { + // Per-string PV input power (pv1..pv4 = "|") + field({ key: 'pv1', path: ['pv1Power'], transform: v => parseVenusPvInput(v).power }); + advertise( + ['pv1Power'], + sensorComponent({ + id: 'pv1_power', + name: 'PV1 Power', + device_class: 'power', + unit_of_measurement: 'W', + state_class: 'measurement', + }), + ); + field({ key: 'pv2', path: ['pv2Power'], transform: v => parseVenusPvInput(v).power }); + advertise( + ['pv2Power'], + sensorComponent({ + id: 'pv2_power', + name: 'PV2 Power', + device_class: 'power', + unit_of_measurement: 'W', + state_class: 'measurement', + }), + ); + field({ key: 'pv3', path: ['pv3Power'], transform: v => parseVenusPvInput(v).power }); + advertise( + ['pv3Power'], + sensorComponent({ + id: 'pv3_power', + name: 'PV3 Power', + device_class: 'power', + unit_of_measurement: 'W', + state_class: 'measurement', + }), + ); + field({ key: 'pv4', path: ['pv4Power'], transform: v => parseVenusPvInput(v).power }); + advertise( + ['pv4Power'], + sensorComponent({ + id: 'pv4_power', + name: 'PV4 Power', + device_class: 'power', + unit_of_measurement: 'W', + state_class: 'measurement', + }), + ); + + // Connection status per PV input + field({ key: 'pv1', path: ['pv1Connected'], transform: v => parseVenusPvInput(v).connected }); + advertise( + ['pv1Connected'], + binarySensorComponent({ + id: 'pv1_connected', + name: 'PV1 Connected', + device_class: 'connectivity', + icon: 'mdi:solar-power', + enabled_by_default: false, + }), + ); + field({ key: 'pv2', path: ['pv2Connected'], transform: v => parseVenusPvInput(v).connected }); + advertise( + ['pv2Connected'], + binarySensorComponent({ + id: 'pv2_connected', + name: 'PV2 Connected', + device_class: 'connectivity', + icon: 'mdi:solar-power', + enabled_by_default: false, + }), + ); + field({ key: 'pv3', path: ['pv3Connected'], transform: v => parseVenusPvInput(v).connected }); + advertise( + ['pv3Connected'], + binarySensorComponent({ + id: 'pv3_connected', + name: 'PV3 Connected', + device_class: 'connectivity', + icon: 'mdi:solar-power', + enabled_by_default: false, + }), + ); + field({ key: 'pv4', path: ['pv4Connected'], transform: v => parseVenusPvInput(v).connected }); + advertise( + ['pv4Connected'], + binarySensorComponent({ + id: 'pv4_connected', + name: 'PV4 Connected', + device_class: 'connectivity', + icon: 'mdi:solar-power', + enabled_by_default: false, + }), + ); + + // Total PV power across all inputs + field({ + key: ['pv1', 'pv2', 'pv3', 'pv4'], + path: ['totalPvPower'], + transform: values => + parseVenusPvInput(values.pv1).power + + parseVenusPvInput(values.pv2).power + + parseVenusPvInput(values.pv3).power + + parseVenusPvInput(values.pv4).power, + }); + advertise( + ['totalPvPower'], + sensorComponent({ + id: 'total_pv_power', + name: 'Total PV Power', + device_class: 'power', + unit_of_measurement: 'W', + state_class: 'measurement', + }), + ); + } + // Power information field({ key: 'tot_i', @@ -1267,7 +1418,10 @@ const requiredBMSFields = ['b_ver', 'b_soc', 'b_tp1', 'b_vo1']; function isVenusBmsInfoMessage(values: Record): boolean { return requiredBMSFields.every(field => field in values); } -function registerBMSInfoMessage(message: BuildMessageFn) { +function registerBMSInfoMessage( + message: BuildMessageFn, + { scaleTemperatures = false }: { scaleTemperatures?: boolean } = {}, +) { message( { refreshDataPayload: `cd=${CommandType.GET_BMS_INFO}`, @@ -1297,7 +1451,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({ @@ -1315,21 +1473,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({ diff --git a/src/parser.test.ts b/src/parser.test.ts index 81fc081..e57e066 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -4,6 +4,7 @@ import { HmiInverterDeviceData, JupiterBMSInfo, JupiterDeviceData, + VenusBMSInfo, VenusDeviceData, } from './types'; @@ -664,4 +665,59 @@ 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; + expect(result).toHaveProperty('pv1Power', 1076); + expect(result).toHaveProperty('pv2Power', 0); + expect(result).toHaveProperty('pv1Connected', true); + expect(result).toHaveProperty('pv2Connected', false); + expect(result).toHaveProperty('totalPvPower', 1076); + }); + + 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); + // 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); + }); }); diff --git a/src/types.ts b/src/types.ts index 5cab07d..78f8609 100644 --- a/src/types.ts +++ b/src/types.ts @@ -345,6 +345,17 @@ export interface VenusDeviceData extends BaseDeviceData { monthlyIncome?: number; totalIncome?: number; + // PV / solar input information + pv1Power?: number; + pv2Power?: number; + pv3Power?: number; + pv4Power?: number; + pv1Connected?: boolean; + pv2Connected?: boolean; + pv3Connected?: boolean; + pv4Connected?: boolean; + totalPvPower?: number; + // Grid information offGridPower?: number; combinedPower?: number; From 994ce3b84aaec1a533752e57dfdd2aa051275a7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 12:41:27 +0000 Subject: [PATCH 2/4] Venus A: use declarative transforms for PV inputs Replace the function-based field transforms added for the Venus A PV inputs with declarative transforms, which are introspectable and serializable to Jinja2 (function transforms are legacy). Add a declarative venusPvField('power' | 'connected') transform that extracts a field from the Venus PV input string ("|"), mirroring the existing mpptPvField transform, and use sum() for the combined Total PV Power sensor. https://claude.ai/code/session_01L5QLHLxrBV8zqDip55aKow --- src/device/venus.ts | 46 ++++++++++++------------------------------ src/transforms.test.ts | 29 ++++++++++++++++++++++++++ src/transforms.ts | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 33 deletions(-) diff --git a/src/device/venus.ts b/src/device/venus.ts index 36b894f..ba30dab 100644 --- a/src/device/venus.ts +++ b/src/device/venus.ts @@ -27,6 +27,8 @@ import { equalsBoolean, chain, inRange, + sum, + venusPvField, } from '../transforms'; /** @@ -109,25 +111,6 @@ function weekdaySetToBitMask(weekday: VenusTimePeriod['weekday']): number { return weekday.split('').reduce((mask, day) => mask | (1 << parseInt(day, 10)), 0); } -/** - * Parse a Venus PV input string from the device. - * - * Format: "|", e.g. "1076|1" where the first value is the - * input power in watts and the second value is the connection flag. - * Unconnected inputs report "0|0". - * - * @param value - PV input string - * @returns Parsed PV input info - */ -function parseVenusPvInput(value: string): { power: number; connected: boolean } { - const parts = value.split('|'); - const power = parseInt(parts[0], 10); - return { - power: Number.isNaN(power) ? 0 : power, - connected: parts[1] === '1', - }; -} - /** * Extract additional device info for Home Assistant discovery * @@ -248,7 +231,7 @@ function registerRuntimeInfoMessage( // PV / solar input information if (withPvInputs) { // Per-string PV input power (pv1..pv4 = "|") - field({ key: 'pv1', path: ['pv1Power'], transform: v => parseVenusPvInput(v).power }); + field({ key: 'pv1', path: ['pv1Power'], transform: venusPvField('power') }); advertise( ['pv1Power'], sensorComponent({ @@ -259,7 +242,7 @@ function registerRuntimeInfoMessage( state_class: 'measurement', }), ); - field({ key: 'pv2', path: ['pv2Power'], transform: v => parseVenusPvInput(v).power }); + field({ key: 'pv2', path: ['pv2Power'], transform: venusPvField('power') }); advertise( ['pv2Power'], sensorComponent({ @@ -270,7 +253,7 @@ function registerRuntimeInfoMessage( state_class: 'measurement', }), ); - field({ key: 'pv3', path: ['pv3Power'], transform: v => parseVenusPvInput(v).power }); + field({ key: 'pv3', path: ['pv3Power'], transform: venusPvField('power') }); advertise( ['pv3Power'], sensorComponent({ @@ -281,7 +264,7 @@ function registerRuntimeInfoMessage( state_class: 'measurement', }), ); - field({ key: 'pv4', path: ['pv4Power'], transform: v => parseVenusPvInput(v).power }); + field({ key: 'pv4', path: ['pv4Power'], transform: venusPvField('power') }); advertise( ['pv4Power'], sensorComponent({ @@ -294,7 +277,7 @@ function registerRuntimeInfoMessage( ); // Connection status per PV input - field({ key: 'pv1', path: ['pv1Connected'], transform: v => parseVenusPvInput(v).connected }); + field({ key: 'pv1', path: ['pv1Connected'], transform: venusPvField('connected') }); advertise( ['pv1Connected'], binarySensorComponent({ @@ -305,7 +288,7 @@ function registerRuntimeInfoMessage( enabled_by_default: false, }), ); - field({ key: 'pv2', path: ['pv2Connected'], transform: v => parseVenusPvInput(v).connected }); + field({ key: 'pv2', path: ['pv2Connected'], transform: venusPvField('connected') }); advertise( ['pv2Connected'], binarySensorComponent({ @@ -316,7 +299,7 @@ function registerRuntimeInfoMessage( enabled_by_default: false, }), ); - field({ key: 'pv3', path: ['pv3Connected'], transform: v => parseVenusPvInput(v).connected }); + field({ key: 'pv3', path: ['pv3Connected'], transform: venusPvField('connected') }); advertise( ['pv3Connected'], binarySensorComponent({ @@ -327,7 +310,7 @@ function registerRuntimeInfoMessage( enabled_by_default: false, }), ); - field({ key: 'pv4', path: ['pv4Connected'], transform: v => parseVenusPvInput(v).connected }); + field({ key: 'pv4', path: ['pv4Connected'], transform: venusPvField('connected') }); advertise( ['pv4Connected'], binarySensorComponent({ @@ -339,15 +322,12 @@ function registerRuntimeInfoMessage( }), ); - // Total PV power across all inputs + // Total PV power across all inputs. Each value is "|"; + // sum() parses the leading number from each, i.e. the per-input power. field({ key: ['pv1', 'pv2', 'pv3', 'pv4'], path: ['totalPvPower'], - transform: values => - parseVenusPvInput(values.pv1).power + - parseVenusPvInput(values.pv2).power + - parseVenusPvInput(values.pv3).power + - parseVenusPvInput(values.pv4).power, + transform: sum(), }); advertise( ['totalPvPower'], diff --git a/src/transforms.test.ts b/src/transforms.test.ts index 44e506f..32ddb42 100644 --- a/src/transforms.test.ts +++ b/src/transforms.test.ts @@ -19,6 +19,7 @@ import { chain, timePeriodField, mpptPvField, + venusPvField, bitMaskToWeekday, sum, min, @@ -369,6 +370,34 @@ describe('transforms', () => { }); }); + describe('venusPvField transform', () => { + const connectedPvInfo = '1076|1'; + const disconnectedPvInfo = '0|0'; + + it('should extract power', () => { + expect(executeTransform(venusPvField('power'), connectedPvInfo)).toBe(1076); + 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 }}{% 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'); diff --git a/src/transforms.ts b/src/transforms.ts index 7e9bccb..0214ad7 100644 --- a/src/transforms.ts +++ b/src/transforms.ts @@ -45,6 +45,7 @@ export type Transform = | LowByteTransform | TimePeriodFieldTransform | MPPTPVFieldTransform + | VenusPvFieldTransform | BitMaskToWeekdayTransform | ChainTransform; @@ -172,6 +173,12 @@ export interface MPPTPVFieldTransform { field: 'voltage' | 'current' | 'power'; } +/** Extract a field from a Venus PV input string (format: "POWER|CONNECTED") */ +export interface VenusPvFieldTransform { + type: 'venusPvField'; + field: 'power' | 'connected'; +} + /** Convert weekday bitmask to weekday set string */ export interface BitMaskToWeekdayTransform { type: 'bitMaskToWeekday'; @@ -314,6 +321,12 @@ export const mpptPvField = (field: MPPTPVFieldTransform['field']): MPPTPVFieldTr field, }); +/** Create a Venus PV input field transform */ +export const venusPvField = (field: VenusPvFieldTransform['field']): VenusPvFieldTransform => ({ + type: 'venusPvField', + field, +}); + /** Create a bitmask to weekday transform */ export const bitMaskToWeekday = (): BitMaskToWeekdayTransform => ({ type: 'bitMaskToWeekday' }); @@ -447,6 +460,9 @@ export function executeTransform( case 'mpptPvField': return executeMPPTPVField(value, transform.field); + case 'venusPvField': + return executeVenusPvField(value, transform.field); + case 'bitMaskToWeekday': { const bitmask = parseInt(value, 10); return '0123456' @@ -568,6 +584,17 @@ function executeMPPTPVField(value: string, field: MPPTPVFieldTransform['field']) } } +function executeVenusPvField( + value: string, + field: VenusPvFieldTransform['field'], +): number | boolean { + const parts = value.split('|'); + if (field === 'connected') { + return parts[1] === '1'; + } + return safeParseInt(parts[0]); +} + // ============================================================================= // Jinja2 Template Generation // ============================================================================= @@ -684,6 +711,9 @@ export function transformToJinja2( case 'mpptPvField': return generateMPPTPVFieldJinja2(valueExpr, transform.field); + case 'venusPvField': + return generateVenusPvFieldJinja2(valueExpr, transform.field); + case 'bitMaskToWeekday': // Convert bitmask to weekday set string - only mutate inside loop, output once at end return `{% set bm = ${valueExpr} | int(0) %}{% set days = '' %}{% for i in range(7) %}{% if bm | bitwise_and(2**i) %}{% set days = days ~ i %}{% endif %}{% endfor %}{{ days }}`; @@ -769,6 +799,17 @@ function generateMPPTPVFieldJinja2( return `{% set p = ${partsExpr} %}{% if p | length >= 3 %}{{ (p[${index}] | int) / 10 }}{% else %}0{% endif %}`; } +function generateVenusPvFieldJinja2( + valueExpr: string, + field: VenusPvFieldTransform['field'], +): string { + const partsExpr = `${valueExpr}.split('|')`; + if (field === 'connected') { + return `{% set p = ${partsExpr} %}{% if p | length >= 2 %}{{ p[1] == '1' }}{% else %}false{% endif %}`; + } + return `{% set p = ${partsExpr} %}{% if p | length >= 1 %}{{ p[0] | int }}{% else %}0{% endif %}`; +} + // ============================================================================= // Type Guards // ============================================================================= From 81bf944d002fd381492508d99a9aa30d2171f0bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 13:01:45 +0000 Subject: [PATCH 3/4] Venus A: scale PV input power by 0.1 (deciwatts -> watts) Confirmed against the decompiled Marstek app (v1.6.64, VenusPVModel in venus_realTime_controller.dart): each PV input value "|" stores parts[0] * 0.1 as pvNInverterValue, i.e. the power is reported in deciwatts. So pv1=1076 is 107.6 W, not 1076 W. - venusPvField('power') now divides the leading value by 10 - add an optional scale divisor to the sum() transform and use sum(10) for the Total PV Power sensor https://claude.ai/code/session_01L5QLHLxrBV8zqDip55aKow --- src/device/venus.ts | 7 ++++--- src/parser.test.ts | 5 +++-- src/transforms.test.ts | 18 +++++++++++++++--- src/transforms.ts | 20 +++++++++++++------- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/device/venus.ts b/src/device/venus.ts index ba30dab..4489264 100644 --- a/src/device/venus.ts +++ b/src/device/venus.ts @@ -322,12 +322,13 @@ function registerRuntimeInfoMessage( }), ); - // Total PV power across all inputs. Each value is "|"; - // sum() parses the leading number from each, i.e. the per-input power. + // Total PV power across all inputs. Each value is "|" + // 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(), + transform: sum(10), }); advertise( ['totalPvPower'], diff --git a/src/parser.test.ts b/src/parser.test.ts index e57e066..0f8d698 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -674,11 +674,12 @@ describe('MQTT Message Parser', () => { expect(parsed).toHaveProperty('data'); const result = parsed['data'] as VenusDeviceData; - expect(result).toHaveProperty('pv1Power', 1076); + // 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', 1076); + expect(result).toHaveProperty('totalPvPower', 107.6); }); test('does not expose PV inputs for non-Venus-A variants (VNSE3)', () => { diff --git a/src/transforms.test.ts b/src/transforms.test.ts index 32ddb42..e248276 100644 --- a/src/transforms.test.ts +++ b/src/transforms.test.ts @@ -374,8 +374,8 @@ describe('transforms', () => { const connectedPvInfo = '1076|1'; const disconnectedPvInfo = '0|0'; - it('should extract power', () => { - expect(executeTransform(venusPvField('power'), connectedPvInfo)).toBe(1076); + it('should extract power in watts (deciwatt input)', () => { + expect(executeTransform(venusPvField('power'), connectedPvInfo)).toBe(107.6); expect(executeTransform(venusPvField('power'), disconnectedPvInfo)).toBe(0); }); @@ -390,7 +390,7 @@ describe('transforms', () => { it('should generate correct Jinja2 templates', () => { expect(transformToJinja2(venusPvField('power'), 'value')).toBe( - "{% set p = value.split('|') %}{% if p | length >= 1 %}{{ p[0] | int }}{% else %}0{% endif %}", + "{% 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 %}", @@ -418,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', () => { diff --git a/src/transforms.ts b/src/transforms.ts index 0214ad7..4acfd4f 100644 --- a/src/transforms.ts +++ b/src/transforms.ts @@ -191,6 +191,7 @@ export interface BitMaskToWeekdayTransform { /** Sum multiple values */ export interface SumTransform { type: 'sum'; + scale?: number; } /** Get minimum value */ @@ -331,7 +332,7 @@ export const venusPvField = (field: VenusPvFieldTransform['field']): VenusPvFiel export const bitMaskToWeekday = (): BitMaskToWeekdayTransform => ({ type: 'bitMaskToWeekday' }); /** Create a sum transform */ -export const sum = (): SumTransform => ({ type: 'sum' }); +export const sum = (scale?: number): SumTransform => ({ type: 'sum', scale }); /** Create a min transform */ export const min = (scale?: number): MinTransform => ({ type: 'min', scale }); @@ -483,8 +484,10 @@ export function executeMultiKeyTransform( const numericValues = Object.values(values).map(v => parseFloat(v)); switch (transform.type) { - case 'sum': - return numericValues.reduce((acc, v) => acc + (isNaN(v) ? 0 : v), 0); + case 'sum': { + const total = numericValues.reduce((acc, v) => acc + (isNaN(v) ? 0 : v), 0); + return transform.scale ? total / transform.scale : total; + } case 'min': { const validValues = numericValues.filter(v => !isNaN(v)); @@ -592,7 +595,8 @@ function executeVenusPvField( if (field === 'connected') { return parts[1] === '1'; } - return safeParseInt(parts[0]); + // The power value is reported in deciwatts (e.g. 1076 -> 107.6 W). + return safeParseInt(parts[0]) / 10; } // ============================================================================= @@ -740,9 +744,11 @@ export function multiKeyTransformToJinja2( const filteredListExpr = `${rawListExpr} | reject('equalto', none) | list`; switch (transform.type) { - case 'sum': + case 'sum': { // For sum, we can use default 0 since adding 0 doesn't affect the result - return `{{ [${keys.map(k => `${valuePrefix}.${k} | float(0)`).join(', ')}] | sum }}`; + const summed = `[${keys.map(k => `${valuePrefix}.${k} | float(0)`).join(', ')}] | sum`; + return transform.scale ? `{{ (${summed}) / ${transform.scale} }}` : `{{ ${summed} }}`; + } case 'min': { const scaleExpr = transform.scale ? ` / ${transform.scale}` : ''; @@ -807,7 +813,7 @@ function generateVenusPvFieldJinja2( if (field === 'connected') { return `{% set p = ${partsExpr} %}{% if p | length >= 2 %}{{ p[1] == '1' }}{% else %}false{% endif %}`; } - return `{% set p = ${partsExpr} %}{% if p | length >= 1 %}{{ p[0] | int }}{% else %}0{% endif %}`; + return `{% set p = ${partsExpr} %}{% if p | length >= 1 %}{{ (p[0] | int) / 10 }}{% else %}0{% endif %}`; } // ============================================================================= From b02f14e5ea7f8f4738ed283b0713715bc53cbe00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 14:23:19 +0000 Subject: [PATCH 4/4] Venus A: dedupe PV input registration and lock b_tem scaling in tests - Refactor the repeated pv1-pv4 power/connection field+advertise calls into a loop over a typed list of PV inputs (behaviour unchanged: same ids, names, paths and transforms). - Add an assertion to the Venus A BMS test that the battery temperature (b_tem) stays unscaled at 16, guarding against the cell/MOSFET temperature scaling being applied to it. https://claude.ai/code/session_01L5QLHLxrBV8zqDip55aKow --- src/device/venus.ts | 121 ++++++++++++-------------------------------- src/parser.test.ts | 2 + 2 files changed, 33 insertions(+), 90 deletions(-) diff --git a/src/device/venus.ts b/src/device/venus.ts index 4489264..6549d33 100644 --- a/src/device/venus.ts +++ b/src/device/venus.ts @@ -230,97 +230,38 @@ function registerRuntimeInfoMessage( // PV / solar input information if (withPvInputs) { - // Per-string PV input power (pv1..pv4 = "|") - field({ key: 'pv1', path: ['pv1Power'], transform: venusPvField('power') }); - advertise( - ['pv1Power'], - sensorComponent({ - id: 'pv1_power', - name: 'PV1 Power', - device_class: 'power', - unit_of_measurement: 'W', - state_class: 'measurement', - }), - ); - field({ key: 'pv2', path: ['pv2Power'], transform: venusPvField('power') }); - advertise( - ['pv2Power'], - sensorComponent({ - id: 'pv2_power', - name: 'PV2 Power', - device_class: 'power', - unit_of_measurement: 'W', - state_class: 'measurement', - }), - ); - field({ key: 'pv3', path: ['pv3Power'], transform: venusPvField('power') }); - advertise( - ['pv3Power'], - sensorComponent({ - id: 'pv3_power', - name: 'PV3 Power', - device_class: 'power', - unit_of_measurement: 'W', - state_class: 'measurement', - }), - ); - field({ key: 'pv4', path: ['pv4Power'], transform: venusPvField('power') }); - advertise( - ['pv4Power'], - sensorComponent({ - id: 'pv4_power', - name: 'PV4 Power', - device_class: 'power', - unit_of_measurement: 'W', - state_class: 'measurement', - }), - ); + // Per-string PV input power and connection status (pvN = "|") + 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({ + id: `${pv.key}_power`, + name: `${pv.label} Power`, + device_class: 'power', + unit_of_measurement: 'W', + state_class: 'measurement', + }), + ); - // Connection status per PV input - field({ key: 'pv1', path: ['pv1Connected'], transform: venusPvField('connected') }); - advertise( - ['pv1Connected'], - binarySensorComponent({ - id: 'pv1_connected', - name: 'PV1 Connected', - device_class: 'connectivity', - icon: 'mdi:solar-power', - enabled_by_default: false, - }), - ); - field({ key: 'pv2', path: ['pv2Connected'], transform: venusPvField('connected') }); - advertise( - ['pv2Connected'], - binarySensorComponent({ - id: 'pv2_connected', - name: 'PV2 Connected', - device_class: 'connectivity', - icon: 'mdi:solar-power', - enabled_by_default: false, - }), - ); - field({ key: 'pv3', path: ['pv3Connected'], transform: venusPvField('connected') }); - advertise( - ['pv3Connected'], - binarySensorComponent({ - id: 'pv3_connected', - name: 'PV3 Connected', - device_class: 'connectivity', - icon: 'mdi:solar-power', - enabled_by_default: false, - }), - ); - field({ key: 'pv4', path: ['pv4Connected'], transform: venusPvField('connected') }); - advertise( - ['pv4Connected'], - binarySensorComponent({ - id: 'pv4_connected', - name: 'PV4 Connected', - device_class: 'connectivity', - icon: 'mdi:solar-power', - enabled_by_default: false, - }), - ); + 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 "|" // with power in deciwatts; sum() reads the leading number from each and diff --git a/src/parser.test.ts b/src/parser.test.ts index 0f8d698..32876b7 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -705,6 +705,8 @@ describe('MQTT Message Parser', () => { 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); });