Skip to content

Commit 4f478cf

Browse files
authored
Merge pull request #4 from SourceLabOrg/spp/AdditionalPowerSensor
Add dual-sensor support for simultaneous Power and Energy uploads
2 parents 6f444fa + 6a26d74 commit 4f478cf

11 files changed

Lines changed: 308 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
44

55
## 1.1.0 (Unreleased)
66

7-
### Bugfixes
8-
- Removes `device_class=temperature` restriction when picking your temperature sensor. (Issue#1)[https://github.com/SourceLabOrg/HomeAssistant-PVOutputPublisher/issues/1]
9-
107
### Feature Changes
118
- Switched to strict, clock-aligned scheduling to prevent time drift and perfectly sync with PVOutput intervals. (Issue#1)[https://github.com/SourceLabOrg/HomeAssistant-PVOutputPublisher/issues/1]
9+
- Added support for an optional secondary solar sensor to upload Power and Energy data simultaneously for maximum accuracy.
10+
- Add language support for Chinese (Simplified & Traditional)
11+
12+
### Bugfixes
13+
- Removes `device_class=temperature` restriction when picking your temperature sensor. (Issue#1)[https://github.com/SourceLabOrg/HomeAssistant-PVOutputPublisher/issues/1]
1214

1315
## 1.0.1 (03/23/2026)
1416
Setup and submitted to HACs!

README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,23 @@ Please note that the code and documentation for this project were primarily gene
1212
## Features
1313
* **UI Config Flow:** Fully configurable via the Home Assistant UI. No YAML required.
1414
* **Multi-System Support:** Publish data for multiple solar arrays or inverters to different PVOutput System IDs using a single API key.
15+
* **Strict Clock Alignment:** Synchronizes uploads to exact wall-clock intervals (e.g., :00, :05, :10) to prevent data drift and match PVOutput's native 5-minute buckets perfectly, even after Home Assistant restarts.
1516
* **Smart Data Detection:** Automatically formats the payload based on the units of your selected sensors (Watts vs. Watt-hours, Celsius vs. Fahrenheit).
16-
* **Lifetime Energy Support:** Automatically detects `state_class: total` sensors and flags PVOutput to calculate your daily yield and instantaneous power curves for you.
17+
* **Maximum Accuracy Dual-Sensors:** Support for selecting both Power and Energy sensors simultaneously to plot the most accurate live power curves without relying on backend estimation.
18+
* **Lifetime Energy Support:** Automatically detects `state_class: total` sensors and flags PVOutput to calculate your daily yield for you.
1719
* **Comprehensive Metrics:** Supports pushing Generation, Consumption, and Temperature data simultaneously.
1820
* **Last Upload Sensor:** Creates a timestamp entity in Home Assistant so you can monitor exactly when the last successful push occurred.
19-
* **Multi-Language Support:** Fully translated into English, Japanese, Spanish, and German.
21+
* **Multi-Language Support:** Fully translated into English, Japanese, Spanish, German, and Chinese (Simplified & Traditional).
2022

2123
---
2224

2325
## Smart Sensor Detection
2426
PVOutput requires data to be formatted precisely. This integration looks at the `unit_of_measurement` and `state_class` of your selected sensors and automatically handles the conversions:
2527

2628
### Generation & Consumption
27-
* **Power (Watts / kW):** Automatically converted to Watts and sent as `v2` (Generation) or `v4` (Consumption).
28-
* **Daily Energy (Wh / kWh):** Automatically converted to Watt-hours and sent as `v1` (Generation) or `v3` (Consumption).
29-
* **Lifetime Energy:** If your sensor tracks lifetime yield (e.g., `state_class: total_increasing`), the integration sends the `&c1=1` flag. PVOutput will automatically calculate your daily generation and live power curves by comparing the intervals.
29+
* **Single Sensor (Smart Detection):** You can select a single Power (W) or Energy (Wh) sensor. The integration will upload it, and PVOutput will automatically estimate the missing metric.
30+
* **Dual Sensors (Maximum Accuracy):** For the best results, select BOTH a Power and an Energy sensor in the UI configuration. The integration will automatically detect which is which and upload them simultaneously, providing exact live output and daily totals without requiring PVOutput to do any mathematical guessing.
31+
* **Lifetime Energy:** If your sensor tracks lifetime yield (e.g., `state_class: total_increasing`), the integration sends the `&c1=1` flag.
3032

3133
### Temperature
3234
* If your Home Assistant sensor uses Fahrenheit (`°F`), it will automatically be converted to Celsius before uploading, as PVOutput strictly requires Celsius for its `v5` parameter.
@@ -56,8 +58,9 @@ This integration is installed via [HACS](https://hacs.xyz/).
5658
5. Add your first system by providing:
5759
* **System Name:** A friendly name for your reference.
5860
* **System ID:** Your PVOutput System ID.
59-
* **Solar Generation Sensor:** Your inverter's power or energy sensor.
60-
* **Power/Energy Consumption Sensor:** (Optional) Your home's power draw or energy usage sensor.
61+
* **Primary Solar Sensor:** Your inverter's power (W) or energy (Wh) sensor.
62+
* **Secondary Solar Sensor:** (Optional) If you selected a Power (W) sensor above, select your Energy (Wh) sensor here, or vice versa, for maximum accuracy. If you only provide the primary sensor, PVOutput will automatically estimate the missing value, which may result in less accurate data.
63+
* **Consumption Sensor:** (Optional) Your home's power draw or energy usage sensor.
6164
* **Temperature Sensor:** (Optional) Outside temperature.
6265
* **Update Frequency:** How often to push data to PVOutput (5 to 180 minutes).
6366

custom_components/pvoutput_publisher/__init__.py

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
import aiohttp
3-
from datetime import datetime, timedelta
3+
from datetime import datetime
44

55
from homeassistant.config_entries import ConfigEntry
66
from homeassistant.core import HomeAssistant
@@ -12,8 +12,8 @@
1212

1313
from .const import (
1414
DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID,
15-
CONF_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID, CONF_TEMPERATURE_ENTITY_ID,
16-
CONF_FREQUENCY, PVOUTPUT_API_URL
15+
CONF_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID,
16+
CONF_TEMPERATURE_ENTITY_ID, CONF_FREQUENCY, PVOUTPUT_API_URL
1717
)
1818

1919
_LOGGER = logging.getLogger(__name__)
@@ -31,13 +31,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
3131
for system in systems:
3232
system_id = system[CONF_SYSTEM_ID]
3333
generation_ent_id = system[CONF_ENTITY_ID]
34+
secondary_generation_ent_id = system.get(CONF_SECONDARY_ENTITY_ID)
3435
consumption_ent_id = system.get(CONF_CONSUMPTION_ENTITY_ID)
3536
temperature_ent_id = system.get(CONF_TEMPERATURE_ENTITY_ID)
3637
frequency = int(system[CONF_FREQUENCY])
3738
sys_name = system.get(CONF_NAME, system_id)
3839

3940
# We pass loop variables as default arguments to avoid Python closure late-binding bugs
40-
async def push_data(now: datetime, sys_id=system_id, gen_id=generation_ent_id, con_id=consumption_ent_id, temp_id=temperature_ent_id, name=sys_name):
41+
async def push_data(now: datetime, sys_id=system_id, gen_id=generation_ent_id, sec_id=secondary_generation_ent_id, con_id=consumption_ent_id, temp_id=temperature_ent_id, name=sys_name):
4142
gen_state = hass.states.get(gen_id)
4243
if not gen_state or gen_state.state in ['unknown', 'unavailable']:
4344
return
@@ -59,28 +60,72 @@ async def push_data(now: datetime, sys_id=system_id, gen_id=generation_ent_id, c
5960
# This list will hold our human-readable log strings
6061
log_parts = []
6162

62-
# 1. Add Generation Data (v1 / v2)
63+
# Flags to prevent overwriting if user selects duplicate sensor types
64+
has_energy_v1 = False
65+
has_power_v2 = False
66+
67+
# 1A. Primary Generation Data
6368
if gen_unit in ["wh", "kwh", "mwh"]:
6469
raw_gen = gen_value
6570
if gen_unit == "kwh":
6671
gen_value *= 1000
6772
elif gen_unit == "mwh":
6873
gen_value *= 1000000
6974

70-
# Tell PVOutput to calculate daily yield if this is a lifetime sensor
7175
if gen_state_class in ["total", "total_increasing"]:
7276
payload += "&c1=1"
73-
log_parts.append(f"Gen (Lifetime): {raw_gen} {gen_unit} -> v1={int(gen_value)}")
77+
log_parts.append(f"Gen1 (Lifetime): {raw_gen} {gen_unit} -> v1={int(gen_value)}")
7478
else:
75-
log_parts.append(f"Gen (Daily): {raw_gen} {gen_unit} -> v1={int(gen_value)}")
79+
log_parts.append(f"Gen1 (Daily): {raw_gen} {gen_unit} -> v1={int(gen_value)}")
7680

7781
payload += f"&v1={int(gen_value)}"
82+
has_energy_v1 = True
7883
else:
7984
raw_gen = gen_value
8085
if gen_unit in ["kw", "kilowatt", "kilowatts"]:
8186
gen_value *= 1000
8287
payload += f"&v2={int(gen_value)}"
83-
log_parts.append(f"Gen (Power): {raw_gen} {gen_unit} -> v2={int(gen_value)}")
88+
log_parts.append(f"Gen1 (Power): {raw_gen} {gen_unit} -> v2={int(gen_value)}")
89+
has_power_v2 = True
90+
91+
# 1B. Secondary Generation Data (Optional)
92+
if sec_id:
93+
sec_state = hass.states.get(sec_id)
94+
if sec_state and sec_state.state not in ['unknown', 'unavailable']:
95+
try:
96+
sec_value = float(sec_state.state)
97+
sec_unit = sec_state.attributes.get("unit_of_measurement", "").lower()
98+
sec_state_class = sec_state.attributes.get("state_class", "").lower()
99+
raw_sec = sec_value
100+
101+
if sec_unit in ["wh", "kwh", "mwh"]:
102+
if has_energy_v1:
103+
_LOGGER.warning("PVOutput [%s]: Ignored secondary sensor. You selected two Energy (Wh) sensors.", name)
104+
else:
105+
if sec_unit == "kwh":
106+
sec_value *= 1000
107+
elif sec_unit == "mwh":
108+
sec_value *= 1000000
109+
110+
if sec_state_class in ["total", "total_increasing"]:
111+
payload += "&c1=1"
112+
log_parts.append(f"Gen2 (Lifetime): {raw_sec} {sec_unit} -> v1={int(sec_value)}")
113+
else:
114+
log_parts.append(f"Gen2 (Daily): {raw_sec} {sec_unit} -> v1={int(sec_value)}")
115+
116+
payload += f"&v1={int(sec_value)}"
117+
has_energy_v1 = True
118+
else:
119+
if has_power_v2:
120+
_LOGGER.warning("PVOutput [%s]: Ignored secondary sensor. You selected two Power (W) sensors.", name)
121+
else:
122+
if sec_unit in ["kw", "kilowatt", "kilowatts"]:
123+
sec_value *= 1000
124+
payload += f"&v2={int(sec_value)}"
125+
log_parts.append(f"Gen2 (Power): {raw_sec} {sec_unit} -> v2={int(sec_value)}")
126+
has_power_v2 = True
127+
except ValueError:
128+
pass
84129

85130
# 2. Add Optional Consumption Data (v3 / v4)
86131
if con_id:

custom_components/pvoutput_publisher/config_flow.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
from .const import (
77
DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID,
8-
CONF_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID, CONF_TEMPERATURE_ENTITY_ID,
9-
CONF_FREQUENCY, DEFAULT_FREQUENCY
8+
CONF_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID,
9+
CONF_TEMPERATURE_ENTITY_ID, CONF_FREQUENCY, DEFAULT_FREQUENCY
1010
)
1111

1212
def _get_system_schema(existing_data=None):
@@ -20,10 +20,20 @@ def _get_system_schema(existing_data=None):
2020
if existing_data:
2121
schema[vol.Required(CONF_NAME, default=existing_data.get(CONF_NAME, existing_data.get(CONF_SYSTEM_ID)))] = str
2222
schema[vol.Required(CONF_SYSTEM_ID, default=existing_data.get(CONF_SYSTEM_ID))] = str
23+
2324
schema[vol.Required(CONF_ENTITY_ID, default=existing_data.get(CONF_ENTITY_ID))] = selector.EntitySelector(
2425
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
2526
)
2627

28+
if existing_data.get(CONF_SECONDARY_ENTITY_ID):
29+
schema[vol.Optional(CONF_SECONDARY_ENTITY_ID, default=existing_data.get(CONF_SECONDARY_ENTITY_ID))] = selector.EntitySelector(
30+
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
31+
)
32+
else:
33+
schema[vol.Optional(CONF_SECONDARY_ENTITY_ID)] = selector.EntitySelector(
34+
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
35+
)
36+
2737
if existing_data.get(CONF_CONSUMPTION_ENTITY_ID):
2838
schema[vol.Optional(CONF_CONSUMPTION_ENTITY_ID, default=existing_data.get(CONF_CONSUMPTION_ENTITY_ID))] = selector.EntitySelector(
2939
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
@@ -50,6 +60,9 @@ def _get_system_schema(existing_data=None):
5060
schema[vol.Required(CONF_ENTITY_ID)] = selector.EntitySelector(
5161
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
5262
)
63+
schema[vol.Optional(CONF_SECONDARY_ENTITY_ID)] = selector.EntitySelector(
64+
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
65+
)
5366
schema[vol.Optional(CONF_CONSUMPTION_ENTITY_ID)] = selector.EntitySelector(
5467
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
5568
)

custom_components/pvoutput_publisher/const.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
CONF_NAME = "name"
66
CONF_SYSTEM_ID = "system_id"
77
CONF_ENTITY_ID = "entity_id"
8+
CONF_SECONDARY_ENTITY_ID = "secondary_entity_id"
89
CONF_CONSUMPTION_ENTITY_ID = "consumption_entity_id"
910
CONF_TEMPERATURE_ENTITY_ID = "temperature_entity_id"
1011
CONF_FREQUENCY = "frequency"
1112

12-
DEFAULT_FREQUENCY = 5
13+
DEFAULT_FREQUENCY = "5"
1314
PVOUTPUT_API_URL = "https://pvoutput.org/service/r2/addstatus.jsp"

custom_components/pvoutput_publisher/translations/de.json

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,20 @@
1010
},
1111
"add_system": {
1212
"title": "System Konfigurieren",
13-
"description": "Wählen Sie die Sensoren für dieses System aus.",
13+
"description": "Wählen Sie die Sensoren für dieses System aus.\n\n**Smarte Erkennung:** Sie können einen einzelnen Leistungs- (W) ODER Energie- (Wh) Sensor auswählen. Die Integration formatiert ihn und PVOutput schätzt den fehlenden Wert.\n\n**Maximale Genauigkeit:** Für die besten Ergebnisse wählen Sie SOWOHL einen Leistungs- als auch einen Energiesensor. Die Integration erkennt automatisch, welcher welcher ist, und lädt beide gleichzeitig hoch.",
1414
"data": {
1515
"name": "Systemname",
1616
"system_id": "System-ID",
17-
"entity_id": "Solarerzeugungs-Sensor",
18-
"consumption_entity_id": "Stromverbrauch-Sensor",
19-
"temperature_entity_id": "Temperatursensor",
17+
"entity_id": "Primärer Solarsensor",
18+
"secondary_entity_id": "Sekundärer Solarsensor (Optional)",
19+
"consumption_entity_id": "Stromverbrauch-Sensor (Optional)",
20+
"temperature_entity_id": "Temperatursensor (Optional)",
2021
"frequency": "Aktualisierungsintervall"
2122
},
2223
"data_description": {
2324
"name": "Ein benutzerfreundlicher Name für Ihre Übersicht.",
25+
"entity_id": "Der Leistungs- (W) oder Energie- (Wh) Sensor Ihres Wechselrichters.",
26+
"secondary_entity_id": "Wenn Sie oben einen Leistungs- (W) Sensor ausgewählt haben, wählen Sie hier Ihren Energie- (Wh) Sensor (oder umgekehrt) für maximale Genauigkeit. Wenn leer gelassen, schätzt PVOutput den fehlenden Wert automatisch.",
2427
"consumption_entity_id": "(Optional) Erfasst den Energieverbrauch Ihres Hauses.",
2528
"temperature_entity_id": "(Optional) Erfasst die Außentemperatur."
2629
}
@@ -55,16 +58,20 @@
5558
},
5659
"add_system": {
5760
"title": "System Konfigurieren",
61+
"description": "Wählen Sie die Sensoren für dieses System aus.\n\n**Smarte Erkennung:** Sie können einen einzelnen Leistungs- (W) ODER Energie- (Wh) Sensor auswählen. Die Integration formatiert ihn und PVOutput schätzt den fehlenden Wert.\n\n**Maximale Genauigkeit:** Für die besten Ergebnisse wählen Sie SOWOHL einen Leistungs- als auch einen Energiesensor. Die Integration erkennt automatisch, welcher welcher ist, und lädt beide gleichzeitig hoch.",
5862
"data": {
5963
"name": "Systemname",
6064
"system_id": "System-ID",
61-
"entity_id": "Solarerzeugungs-Sensor",
62-
"consumption_entity_id": "Stromverbrauch-Sensor",
63-
"temperature_entity_id": "Temperatursensor",
65+
"entity_id": "Primärer Solarsensor",
66+
"secondary_entity_id": "Sekundärer Solarsensor (Optional)",
67+
"consumption_entity_id": "Stromverbrauch-Sensor (Optional)",
68+
"temperature_entity_id": "Temperatursensor (Optional)",
6469
"frequency": "Aktualisierungsintervall"
6570
},
6671
"data_description": {
6772
"name": "Ein benutzerfreundlicher Name für Ihre Übersicht.",
73+
"entity_id": "Der Leistungs- (W) oder Energie- (Wh) Sensor Ihres Wechselrichters.",
74+
"secondary_entity_id": "Wenn Sie oben einen Leistungs- (W) Sensor ausgewählt haben, wählen Sie hier Ihren Energie- (Wh) Sensor (oder umgekehrt) für maximale Genauigkeit. Wenn leer gelassen, schätzt PVOutput den fehlenden Wert automatisch.",
6875
"consumption_entity_id": "(Optional) Erfasst den Energieverbrauch Ihres Hauses.",
6976
"temperature_entity_id": "(Optional) Erfasst die Außentemperatur."
7077
}

0 commit comments

Comments
 (0)