diff --git a/src/eachwatt.ts b/src/eachwatt.ts index 28718a7..8a50992 100644 --- a/src/eachwatt.ts +++ b/src/eachwatt.ts @@ -45,7 +45,9 @@ const mainPollerFunc = async (config: Config) => { // Round all numbers to one decimal point for (const data of sensorData) { - data.watts = Number(data.watts.toFixed(1)) + if (data.power !== undefined) { + data.power = Number(data.power.toFixed(1)) + } } // Publish data diff --git a/src/iotawatt.ts b/src/iotawatt.ts index 2416049..2bfc023 100644 --- a/src/iotawatt.ts +++ b/src/iotawatt.ts @@ -29,6 +29,7 @@ type IotawattConfiguration = { type IotawattStatusInput = { channel: number Watts: string + Pf?: number } type IotawattStatusOutput = { @@ -51,7 +52,12 @@ const getStatusUrl = (sensor: IotawattSensor): string => { return `http://${sensor.iotawatt.address}/status?state=&inputs=&outputs=` } -const getSensorValue = ( +const parseInputWattValue = (watts: string): number => { + // Can be " 0" or "423"... + return parseInt(watts.trim(), 10) +} + +const getSensorPowerValue = ( sensor: IotawattSensor, configuration: IotawattConfiguration, status: IotawattStatus, @@ -62,8 +68,7 @@ const getSensorValue = ( if (input.name === sensor.iotawatt.name) { const watts = status.inputs[i].Watts - // Can be " 0" or "423"... - return parseInt(watts.trim(), 10) + return parseInputWattValue(watts) } } @@ -74,7 +79,29 @@ const getSensorValue = ( } } - throw new Error(`Failed to find value for sensor ${sensor.iotawatt.name}`) + throw new Error(`Failed to find power value for sensor ${sensor.iotawatt.name}`) +} + +const getSensorPowerFactorValue = ( + sensor: IotawattSensor, + configuration: IotawattConfiguration, + status: IotawattStatus, +): number | undefined => { + // Power factor is only available for inputs + for (let i = 0; i < configuration.inputs.length; i++) { + const input = configuration.inputs[i] + if (input.name === sensor.iotawatt.name) { + // The power factor value cannot be trusted for small loads, and apparently this is done client-side on the + // IotaWatt web interface. Emulate the same logic here. + const watts = parseInputWattValue(status.inputs[i].Watts) + + return watts >= 50 ? status.inputs[i].Pf : undefined + } + } + + // Return undefined if the sensor doesn't have a corresponding input, we don't + // want to fail hard since the value is optional + return undefined } export const getSensorData: PowerSensorPollFunction = async ( @@ -92,7 +119,8 @@ export const getSensorData: PowerSensorPollFunction = async ( return { timestamp: timestamp, circuit: circuit, - watts: getSensorValue(sensor, configuration, status), + power: getSensorPowerValue(sensor, configuration, status), + powerFactor: getSensorPowerFactorValue(sensor, configuration, status), } } catch (e) { console.error((e as Error).message) diff --git a/src/publisher/console.ts b/src/publisher/console.ts index 9f8428d..af58b74 100644 --- a/src/publisher/console.ts +++ b/src/publisher/console.ts @@ -8,7 +8,7 @@ export interface ConsolePublisher extends Publisher { export class ConsolePublisherImpl implements PublisherImpl { publishSensorData(sensorData: PowerSensorData[]): void { for (const data of sensorData) { - console.log(`${data.timestamp}: ${data.circuit.name}: ${data.watts}W`) + console.log(`${data.timestamp}: ${data.circuit.name}: ${data.power}W`) } } diff --git a/src/publisher/influxdb.ts b/src/publisher/influxdb.ts index e2b3748..0ba7599 100644 --- a/src/publisher/influxdb.ts +++ b/src/publisher/influxdb.ts @@ -30,11 +30,18 @@ export class InfluxDBPublisherImpl implements PublisherImpl { async publishSensorData(sensorData: PowerSensorData[]): Promise { for (const data of sensorData) { + // Skip completely if sensor data is missing + if (data.power === undefined) { + continue + } + const power = new Point('power') .tag('circuit', data.circuit.name) .tag('circuitType', data.circuit.type as CircuitType) .tag('sensorType', data.circuit.sensor.type) - .floatField('watts', data.watts) + // TODO: Remove "watts", here for backward compatibility + .floatField('watts', data.power) + .floatField('power', data.power) .timestamp(data.timestamp) // Optional tags diff --git a/src/publisher/mqtt.ts b/src/publisher/mqtt.ts index e374b23..4e92aee 100644 --- a/src/publisher/mqtt.ts +++ b/src/publisher/mqtt.ts @@ -12,7 +12,6 @@ export type MqttPublisherSettings = { brokerUrl: string homeAssistant?: { autoDiscovery: boolean - deviceIdentifier?: string } } @@ -40,7 +39,7 @@ export class MqttPublisherImpl implements PublisherImpl { // Publish Home Assistant MQTT discovery messages if (this.settings.homeAssistant?.autoDiscovery) { - configureMqttDiscovery(this.config, this.getHomeAssistantDeviceIdentifier(), this.mqttClient) + configureMqttDiscovery(this.config, this.mqttClient) .then(() => { console.log(`Configured Home Assistant MQTT discovery`) }) @@ -56,12 +55,15 @@ export class MqttPublisherImpl implements PublisherImpl { async publishCharacteristicsSensorData(sensorData: CharacteristicsSensorData[]): Promise { for (const data of sensorData) { - const topicValueMap: TopicValueMap = new Map( - Object.entries({ - [createCharacteristicsSensorTopicName(data.characteristics, 'voltage')]: data.voltage, - [createCharacteristicsSensorTopicName(data.characteristics, 'frequency')]: data.frequency, - }), - ) + const topicValueMap: TopicValueMap = new Map() + + // Only publish when we have data + if (data.voltage !== undefined) { + topicValueMap.set(createCharacteristicsSensorTopicName(data.characteristics, 'voltage'), data.voltage) + } + if (data.frequency !== undefined) { + topicValueMap.set(createCharacteristicsSensorTopicName(data.characteristics, 'frequency'), data.frequency) + } await this.publishTopicValues(topicValueMap) } @@ -71,11 +73,18 @@ export class MqttPublisherImpl implements PublisherImpl { async publishSensorData(sensorData: PowerSensorData[]): Promise { for (const data of sensorData) { - const topicValueMap: TopicValueMap = new Map( - Object.entries({ - [createPowerSensorTopicName(data.circuit, 'power')]: data.watts, - }), - ) + const topicValueMap: TopicValueMap = new Map() + + // Only publish when we have data + if (data.power !== undefined) { + topicValueMap.set(createPowerSensorTopicName(data.circuit, 'power'), data.power) + } + if (data.apparentPower !== undefined) { + topicValueMap.set(createPowerSensorTopicName(data.circuit, 'apparentPower'), data.apparentPower) + } + if (data.powerFactor !== undefined) { + topicValueMap.set(createPowerSensorTopicName(data.circuit, 'powerFactor'), data.powerFactor) + } await this.publishTopicValues(topicValueMap) } @@ -100,8 +109,4 @@ export class MqttPublisherImpl implements PublisherImpl { // noinspection TypeScriptValidateTypes await this.mqttClient?.publishAsync(TOPIC_NAME_STATUS, 'online') } - - private getHomeAssistantDeviceIdentifier = (): string => { - return this.settings.homeAssistant?.deviceIdentifier ?? 'eachwatt' - } } diff --git a/src/publisher/mqtt/homeassistant.ts b/src/publisher/mqtt/homeassistant.ts index cee28bc..62ea73a 100644 --- a/src/publisher/mqtt/homeassistant.ts +++ b/src/publisher/mqtt/homeassistant.ts @@ -2,51 +2,96 @@ import { MqttClient } from 'mqtt' import { Config } from '../../config' import { TOPIC_NAME_STATUS } from '../mqtt' import { createPowerSensorTopicName, slugifyName } from './util' +import { supportsApparentPower, supportsPowerFactor } from '../../sensor' +import { Circuit } from '../../circuit' -export const configureMqttDiscovery = async ( - config: Config, - deviceIdentifier: string, - mqttClient: MqttClient, -): Promise => { - // The "device" object that is part of each sensor's configuration payload - const mqttDeviceInformation = { - 'name': deviceIdentifier, - 'model': 'Eachwatt', - 'identifiers': deviceIdentifier, +export const configureMqttDiscovery = async (config: Config, mqttClient: MqttClient): Promise => { + const promises = [] + + for (const circuit of config.circuits) { + // Add power sensors + promises.push(publishPowerSensorConfiguration(mqttClient, circuit)) + + // Add apparent power sensors + if (supportsApparentPower(circuit.sensor)) { + promises.push(publishApparentPowerSensorConfiguration(mqttClient, circuit)) + } + + // Add power factor sensors + if (supportsPowerFactor(circuit.sensor)) { + promises.push(publishPowerFactorSensorConfiguration(mqttClient, circuit)) + } } - const configurationBase = { + await Promise.all(promises) +} + +const getConfigurationBase = (): object => { + return { 'platform': 'mqtt', 'availability_topic': TOPIC_NAME_STATUS, - 'device': mqttDeviceInformation, + 'state_class': 'measurement', + 'device': { + 'name': 'eachwatt', + 'model': 'Eachwatt', + 'identifiers': 'eachwatt', + }, } +} - const promises = [] +const publishPowerSensorConfiguration = async (mqttClient: MqttClient, circuit: Circuit) => { + const entityName = slugifyName(circuit.name) + const uniqueId = `eachwatt_${entityName}_power` + const configurationTopicName = `homeassistant/sensor/${uniqueId}/config` + const configuration = { + ...getConfigurationBase(), + 'device_class': 'power', + 'unit_of_measurement': 'W', + 'name': `${circuit.name} power`, + 'unique_id': uniqueId, + 'object_id': uniqueId, + 'state_topic': createPowerSensorTopicName(circuit, 'power'), + } - for (const circuit of config.circuits) { - // Add power sensors - const entityName = slugifyName(circuit.name) - const uniqueId = `${deviceIdentifier}_${entityName}_power` - - const configuration = { - ...configurationBase, - 'state_class': 'measurement', - 'device_class': 'power', - 'unit_of_measurement': 'W', - 'name': `${circuit.name} power`, - 'unique_id': uniqueId, - 'object_id': uniqueId, - 'state_topic': createPowerSensorTopicName(circuit, 'power'), - } + return mqttClient.publishAsync(configurationTopicName, JSON.stringify(configuration), { + retain: true, + }) +} - // "retain" is used so that the entities will be available immediately after a Home Assistant restart - const configurationTopicName = `homeassistant/sensor/eachwatt/${entityName}/config` - promises.push( - mqttClient.publishAsync(configurationTopicName, JSON.stringify(configuration), { - retain: true, - }), - ) +const publishApparentPowerSensorConfiguration = async (mqttClient: MqttClient, circuit: Circuit) => { + const entityName = slugifyName(circuit.name) + const uniqueId = `eachwatt_${entityName}_apparentPower` + const configurationTopicName = `homeassistant/sensor/${uniqueId}/config` + const configuration = { + ...getConfigurationBase(), + 'device_class': 'apparent_power', + 'unit_of_measurement': 'VA', + 'name': `${circuit.name} apparent power`, + 'unique_id': uniqueId, + 'object_id': uniqueId, + 'state_topic': createPowerSensorTopicName(circuit, 'apparentPower'), } - await Promise.all(promises) + return mqttClient.publishAsync(configurationTopicName, JSON.stringify(configuration), { + retain: true, + }) +} + +const publishPowerFactorSensorConfiguration = async (mqttClient: MqttClient, circuit: Circuit) => { + const entityName = slugifyName(circuit.name) + const uniqueId = `eachwatt_${entityName}_powerFactor` + const configurationTopicName = `homeassistant/sensor/${uniqueId}/config` + const configuration = { + ...getConfigurationBase(), + 'device_class': 'power_factor', + 'name': `${circuit.name} power factor`, + 'unique_id': uniqueId, + 'object_id': uniqueId, + 'state_topic': createPowerSensorTopicName(circuit, 'powerFactor'), + } + + // "retain" is used so that the entities will be available immediately after a Home Assistant restart + return mqttClient.publishAsync(configurationTopicName, JSON.stringify(configuration), { + retain: true, + }) } diff --git a/src/sensor.ts b/src/sensor.ts index 7068f6b..d02e946 100644 --- a/src/sensor.ts +++ b/src/sensor.ts @@ -98,21 +98,25 @@ export interface UnmeteredSensor extends PowerSensor { export interface PowerSensorData { timestamp: number circuit: Circuit - watts: number + // Mandatory data. Undefined means the data was not available. + power?: number + // Optional data, not all sensor types support them + apparentPower?: number + powerFactor?: number } export type CharacteristicsSensorData = { timestamp: number characteristics: Characteristics - voltage: number - frequency: number + // Mandatory data. Undefined means the data was not available. + voltage?: number + frequency?: number } export const emptySensorData = (timestamp: number, circuit: Circuit): PowerSensorData => { return { timestamp, circuit, - watts: 0, } } @@ -123,13 +127,11 @@ export const emptyCharacteristicsSensorData = ( return { timestamp: timestamp, characteristics: characteristics, - voltage: 0, - frequency: 0, } } export const reduceToWatts = (sensorData: PowerSensorData[]): number => { - return sensorData.reduce((acc, data) => acc + data.watts, 0) + return sensorData.reduce((acc, data) => acc + (data.power ?? 0), 0) } export const untangleCircularDeps = (sensorData: PowerSensorData[]): PowerSensorData[] => { @@ -138,3 +140,17 @@ export const untangleCircularDeps = (sensorData: PowerSensorData[]): PowerSensor return d }) } + +const isShellyGen2EMSensor = (sensor: PowerSensor): boolean => { + return sensor.type === SensorType.Shelly && (sensor as ShellySensor).shelly.type === ShellyType.Gen2EM +} + +export const supportsApparentPower = (sensor: PowerSensor): boolean => { + // Only EM devices supports this + return isShellyGen2EMSensor(sensor) +} + +export const supportsPowerFactor = (sensor: PowerSensor): boolean => { + // IotaWatt sensors support power factor too, but only for inputs, not outputs + return isShellyGen2EMSensor(sensor) +} diff --git a/src/shelly.ts b/src/shelly.ts index 38d37d1..4925ac9 100644 --- a/src/shelly.ts +++ b/src/shelly.ts @@ -28,12 +28,18 @@ type Gen2SwitchGetStatusResult = { type Gen2EMGetStatusResult = { a_act_power: number + a_aprt_power: number + a_pf: number a_voltage: number a_freq: number b_act_power: number + b_aprt_power: number + b_pf: number b_voltage: number b_freq: number c_act_power: number + c_aprt_power: number + c_pf: number c_voltage: number c_freq: number } @@ -60,7 +66,7 @@ const parseGen1Response = (timestamp: number, circuit: Circuit, httpResponse: Ax return { timestamp: timestamp, circuit: circuit, - watts: data.meters[sensor.shelly.meter].power, + power: data.meters[sensor.shelly.meter].power, } } @@ -70,7 +76,7 @@ const parseGen2PMResponse = (timestamp: number, circuit: Circuit, httpResponse: return { timestamp: timestamp, circuit: circuit, - watts: data.apower, + power: data.apower, } } @@ -78,23 +84,33 @@ const parseGen2EMResponse = (timestamp: number, circuit: Circuit, httpResponse: const sensor = circuit.sensor as ShellySensor const data = httpResponse.data as Gen2EMGetStatusResult - let watts = 0 + let power = 0 + let apparentPower = 0 + let powerFactor = 0 switch (sensor.shelly.phase) { case 'a': - watts = data.a_act_power + power = data.a_act_power + apparentPower = data.a_aprt_power + powerFactor = data.a_pf break case 'b': - watts = data.b_act_power + power = data.b_act_power + apparentPower = data.b_aprt_power + powerFactor = data.b_pf break case 'c': - watts = data.c_act_power + power = data.c_act_power + apparentPower = data.b_aprt_power + powerFactor = data.b_pf break } return { timestamp: timestamp, circuit: circuit, - watts: watts, + power: power, + apparentPower: apparentPower, + powerFactor: powerFactor, } } diff --git a/src/unmetered.ts b/src/unmetered.ts index 7799a4e..c015ca9 100644 --- a/src/unmetered.ts +++ b/src/unmetered.ts @@ -19,6 +19,6 @@ export const getSensorData: PowerSensorPollFunction = async ( return { timestamp, circuit, - watts: Math.max(parentWatts - unmeteredWatts, 0), // Don't allow negative values + power: Math.max(parentWatts - unmeteredWatts, 0), // Don't allow negative values } } diff --git a/src/virtual.ts b/src/virtual.ts index 5d03ad8..0d925a2 100644 --- a/src/virtual.ts +++ b/src/virtual.ts @@ -18,6 +18,6 @@ export const getSensorData: PowerSensorPollFunction = async ( return { timestamp: timestamp, circuit: circuit, - watts: childrenSensorData.reduce((acc, data) => acc + data.watts, 0), + power: childrenSensorData.reduce((acc, data) => acc + (data.power ?? 0), 0), } } diff --git a/webif/src/lib/format.ts b/webif/src/lib/format.ts new file mode 100644 index 0000000..29f844c --- /dev/null +++ b/webif/src/lib/format.ts @@ -0,0 +1,3 @@ +export const formatPf = (pf) => { + return Number(pf).toFixed(2) +} diff --git a/webif/src/routes/Circuits.svelte b/webif/src/routes/Circuits.svelte index d2c62fe..1581a88 100644 --- a/webif/src/routes/Circuits.svelte +++ b/webif/src/routes/Circuits.svelte @@ -1,4 +1,6 @@ @@ -11,6 +13,8 @@ Circuit type Sensor type Power + Apparent power + Power factor @@ -20,7 +24,17 @@ {data.circuit.group ?? ''} {data.circuit.type} {data.circuit.sensor.type} - {data.watts}W + {data.power}W + + {#if data.apparentPower } + {data.apparentPower}VA + {/if} + + + {#if data.powerFactor } + {formatPf(data.powerFactor)} + {/if} + {/each} diff --git a/webif/src/routes/MainsPower.svelte b/webif/src/routes/MainsPower.svelte index 10eca3d..0d30d3c 100644 --- a/webif/src/routes/MainsPower.svelte +++ b/webif/src/routes/MainsPower.svelte @@ -5,8 +5,14 @@
{#each sensorData as data}
- {data.circuit.name} - {data.watts}W + {data.circuit.name} + {data.power}W + {#if data.apparentPower } + {data.apparentPower}VA + {/if} + {#if data.powerFactor } + pf {data.powerFactor} + {/if}
{/each}