Skip to content

Commit

Permalink
Merge pull request #26 from Jalle19/pf
Browse files Browse the repository at this point in the history
Implement apparent power and power factor
  • Loading branch information
Jalle19 authored Oct 20, 2023
2 parents db03b2c + 4742447 commit 1a4ddb9
Show file tree
Hide file tree
Showing 13 changed files with 222 additions and 80 deletions.
4 changes: 3 additions & 1 deletion src/eachwatt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions src/iotawatt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type IotawattConfiguration = {
type IotawattStatusInput = {
channel: number
Watts: string
Pf?: number
}

type IotawattStatusOutput = {
Expand All @@ -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,
Expand All @@ -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)
}
}

Expand All @@ -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 (
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/publisher/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}
}

Expand Down
9 changes: 8 additions & 1 deletion src/publisher/influxdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ export class InfluxDBPublisherImpl implements PublisherImpl {

async publishSensorData(sensorData: PowerSensorData[]): Promise<void> {
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
Expand Down
39 changes: 22 additions & 17 deletions src/publisher/mqtt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export type MqttPublisherSettings = {
brokerUrl: string
homeAssistant?: {
autoDiscovery: boolean
deviceIdentifier?: string
}
}

Expand Down Expand Up @@ -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`)
})
Expand All @@ -56,12 +55,15 @@ export class MqttPublisherImpl implements PublisherImpl {

async publishCharacteristicsSensorData(sensorData: CharacteristicsSensorData[]): Promise<void> {
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)
}
Expand All @@ -71,11 +73,18 @@ export class MqttPublisherImpl implements PublisherImpl {

async publishSensorData(sensorData: PowerSensorData[]): Promise<void> {
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)
}
Expand All @@ -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'
}
}
117 changes: 81 additions & 36 deletions src/publisher/mqtt/homeassistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
// 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<void> => {
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,
})
}
30 changes: 23 additions & 7 deletions src/sensor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -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[] => {
Expand All @@ -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)
}
Loading

0 comments on commit 1a4ddb9

Please sign in to comment.