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
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## 1.1.0 (Unreleased)

### Bugfixes
- Removes `device_class=temperature` restriction when picking your temperature sensor. (Issue#1)[https://github.com/SourceLabOrg/HomeAssistant-PVOutputPublisher/issues/1]

### Feature Changes
- 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]
- Added support for an optional secondary solar sensor to upload Power and Energy data simultaneously for maximum accuracy.
- Add language support for Chinese (Simplified & Traditional)

### Bugfixes
- Removes `device_class=temperature` restriction when picking your temperature sensor. (Issue#1)[https://github.com/SourceLabOrg/HomeAssistant-PVOutputPublisher/issues/1]

## 1.0.1 (03/23/2026)
Setup and submitted to HACs!
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ Please note that the code and documentation for this project were primarily gene
## Features
* **UI Config Flow:** Fully configurable via the Home Assistant UI. No YAML required.
* **Multi-System Support:** Publish data for multiple solar arrays or inverters to different PVOutput System IDs using a single API key.
* **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.
* **Smart Data Detection:** Automatically formats the payload based on the units of your selected sensors (Watts vs. Watt-hours, Celsius vs. Fahrenheit).
* **Lifetime Energy Support:** Automatically detects `state_class: total` sensors and flags PVOutput to calculate your daily yield and instantaneous power curves for you.
* **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.
* **Lifetime Energy Support:** Automatically detects `state_class: total` sensors and flags PVOutput to calculate your daily yield for you.
* **Comprehensive Metrics:** Supports pushing Generation, Consumption, and Temperature data simultaneously.
* **Last Upload Sensor:** Creates a timestamp entity in Home Assistant so you can monitor exactly when the last successful push occurred.
* **Multi-Language Support:** Fully translated into English, Japanese, Spanish, and German.
* **Multi-Language Support:** Fully translated into English, Japanese, Spanish, German, and Chinese (Simplified & Traditional).

---

## Smart Sensor Detection
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:

### Generation & Consumption
* **Power (Watts / kW):** Automatically converted to Watts and sent as `v2` (Generation) or `v4` (Consumption).
* **Daily Energy (Wh / kWh):** Automatically converted to Watt-hours and sent as `v1` (Generation) or `v3` (Consumption).
* **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.
* **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.
* **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.
* **Lifetime Energy:** If your sensor tracks lifetime yield (e.g., `state_class: total_increasing`), the integration sends the `&c1=1` flag.

### Temperature
* 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.
Expand Down Expand Up @@ -56,8 +58,9 @@ This integration is installed via [HACS](https://hacs.xyz/).
5. Add your first system by providing:
* **System Name:** A friendly name for your reference.
* **System ID:** Your PVOutput System ID.
* **Solar Generation Sensor:** Your inverter's power or energy sensor.
* **Power/Energy Consumption Sensor:** (Optional) Your home's power draw or energy usage sensor.
* **Primary Solar Sensor:** Your inverter's power (W) or energy (Wh) sensor.
* **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.
* **Consumption Sensor:** (Optional) Your home's power draw or energy usage sensor.
* **Temperature Sensor:** (Optional) Outside temperature.
* **Update Frequency:** How often to push data to PVOutput (5 to 180 minutes).

Expand Down
63 changes: 54 additions & 9 deletions custom_components/pvoutput_publisher/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
import aiohttp
from datetime import datetime, timedelta
from datetime import datetime

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
Expand All @@ -12,8 +12,8 @@

from .const import (
DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID,
CONF_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID, CONF_TEMPERATURE_ENTITY_ID,
CONF_FREQUENCY, PVOUTPUT_API_URL
CONF_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID,
CONF_TEMPERATURE_ENTITY_ID, CONF_FREQUENCY, PVOUTPUT_API_URL
)

_LOGGER = logging.getLogger(__name__)
Expand All @@ -31,13 +31,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
for system in systems:
system_id = system[CONF_SYSTEM_ID]
generation_ent_id = system[CONF_ENTITY_ID]
secondary_generation_ent_id = system.get(CONF_SECONDARY_ENTITY_ID)
consumption_ent_id = system.get(CONF_CONSUMPTION_ENTITY_ID)
temperature_ent_id = system.get(CONF_TEMPERATURE_ENTITY_ID)
frequency = int(system[CONF_FREQUENCY])
sys_name = system.get(CONF_NAME, system_id)

# We pass loop variables as default arguments to avoid Python closure late-binding bugs
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):
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):
gen_state = hass.states.get(gen_id)
if not gen_state or gen_state.state in ['unknown', 'unavailable']:
return
Expand All @@ -59,28 +60,72 @@ async def push_data(now: datetime, sys_id=system_id, gen_id=generation_ent_id, c
# This list will hold our human-readable log strings
log_parts = []

# 1. Add Generation Data (v1 / v2)
# Flags to prevent overwriting if user selects duplicate sensor types
has_energy_v1 = False
has_power_v2 = False

# 1A. Primary Generation Data
if gen_unit in ["wh", "kwh", "mwh"]:
raw_gen = gen_value
if gen_unit == "kwh":
gen_value *= 1000
elif gen_unit == "mwh":
gen_value *= 1000000

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

payload += f"&v1={int(gen_value)}"
has_energy_v1 = True
else:
raw_gen = gen_value
if gen_unit in ["kw", "kilowatt", "kilowatts"]:
gen_value *= 1000
payload += f"&v2={int(gen_value)}"
log_parts.append(f"Gen (Power): {raw_gen} {gen_unit} -> v2={int(gen_value)}")
log_parts.append(f"Gen1 (Power): {raw_gen} {gen_unit} -> v2={int(gen_value)}")
has_power_v2 = True

# 1B. Secondary Generation Data (Optional)
if sec_id:
sec_state = hass.states.get(sec_id)
if sec_state and sec_state.state not in ['unknown', 'unavailable']:
try:
sec_value = float(sec_state.state)
sec_unit = sec_state.attributes.get("unit_of_measurement", "").lower()
sec_state_class = sec_state.attributes.get("state_class", "").lower()
raw_sec = sec_value

if sec_unit in ["wh", "kwh", "mwh"]:
if has_energy_v1:
_LOGGER.warning("PVOutput [%s]: Ignored secondary sensor. You selected two Energy (Wh) sensors.", name)
else:
if sec_unit == "kwh":
sec_value *= 1000
elif sec_unit == "mwh":
sec_value *= 1000000

if sec_state_class in ["total", "total_increasing"]:
payload += "&c1=1"
log_parts.append(f"Gen2 (Lifetime): {raw_sec} {sec_unit} -> v1={int(sec_value)}")
else:
log_parts.append(f"Gen2 (Daily): {raw_sec} {sec_unit} -> v1={int(sec_value)}")

payload += f"&v1={int(sec_value)}"
has_energy_v1 = True
else:
if has_power_v2:
_LOGGER.warning("PVOutput [%s]: Ignored secondary sensor. You selected two Power (W) sensors.", name)
else:
if sec_unit in ["kw", "kilowatt", "kilowatts"]:
sec_value *= 1000
payload += f"&v2={int(sec_value)}"
log_parts.append(f"Gen2 (Power): {raw_sec} {sec_unit} -> v2={int(sec_value)}")
has_power_v2 = True
except ValueError:
pass

# 2. Add Optional Consumption Data (v3 / v4)
if con_id:
Expand Down
17 changes: 15 additions & 2 deletions custom_components/pvoutput_publisher/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

from .const import (
DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID,
CONF_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID, CONF_TEMPERATURE_ENTITY_ID,
CONF_FREQUENCY, DEFAULT_FREQUENCY
CONF_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID,
CONF_TEMPERATURE_ENTITY_ID, CONF_FREQUENCY, DEFAULT_FREQUENCY
)

def _get_system_schema(existing_data=None):
Expand All @@ -20,10 +20,20 @@ def _get_system_schema(existing_data=None):
if existing_data:
schema[vol.Required(CONF_NAME, default=existing_data.get(CONF_NAME, existing_data.get(CONF_SYSTEM_ID)))] = str
schema[vol.Required(CONF_SYSTEM_ID, default=existing_data.get(CONF_SYSTEM_ID))] = str

schema[vol.Required(CONF_ENTITY_ID, default=existing_data.get(CONF_ENTITY_ID))] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
)

if existing_data.get(CONF_SECONDARY_ENTITY_ID):
schema[vol.Optional(CONF_SECONDARY_ENTITY_ID, default=existing_data.get(CONF_SECONDARY_ENTITY_ID))] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
)
else:
schema[vol.Optional(CONF_SECONDARY_ENTITY_ID)] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
)

if existing_data.get(CONF_CONSUMPTION_ENTITY_ID):
schema[vol.Optional(CONF_CONSUMPTION_ENTITY_ID, default=existing_data.get(CONF_CONSUMPTION_ENTITY_ID))] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
Expand All @@ -50,6 +60,9 @@ def _get_system_schema(existing_data=None):
schema[vol.Required(CONF_ENTITY_ID)] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
)
schema[vol.Optional(CONF_SECONDARY_ENTITY_ID)] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
)
schema[vol.Optional(CONF_CONSUMPTION_ENTITY_ID)] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"])
)
Expand Down
3 changes: 2 additions & 1 deletion custom_components/pvoutput_publisher/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
CONF_NAME = "name"
CONF_SYSTEM_ID = "system_id"
CONF_ENTITY_ID = "entity_id"
CONF_SECONDARY_ENTITY_ID = "secondary_entity_id"
CONF_CONSUMPTION_ENTITY_ID = "consumption_entity_id"
CONF_TEMPERATURE_ENTITY_ID = "temperature_entity_id"
CONF_FREQUENCY = "frequency"

DEFAULT_FREQUENCY = 5
DEFAULT_FREQUENCY = "5"
PVOUTPUT_API_URL = "https://pvoutput.org/service/r2/addstatus.jsp"
21 changes: 14 additions & 7 deletions custom_components/pvoutput_publisher/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,20 @@
},
"add_system": {
"title": "System Konfigurieren",
"description": "Wählen Sie die Sensoren für dieses System aus.",
"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.",
"data": {
"name": "Systemname",
"system_id": "System-ID",
"entity_id": "Solarerzeugungs-Sensor",
"consumption_entity_id": "Stromverbrauch-Sensor",
"temperature_entity_id": "Temperatursensor",
"entity_id": "Primärer Solarsensor",
"secondary_entity_id": "Sekundärer Solarsensor (Optional)",
"consumption_entity_id": "Stromverbrauch-Sensor (Optional)",
"temperature_entity_id": "Temperatursensor (Optional)",
"frequency": "Aktualisierungsintervall"
},
"data_description": {
"name": "Ein benutzerfreundlicher Name für Ihre Übersicht.",
"entity_id": "Der Leistungs- (W) oder Energie- (Wh) Sensor Ihres Wechselrichters.",
"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.",
"consumption_entity_id": "(Optional) Erfasst den Energieverbrauch Ihres Hauses.",
"temperature_entity_id": "(Optional) Erfasst die Außentemperatur."
}
Expand Down Expand Up @@ -55,16 +58,20 @@
},
"add_system": {
"title": "System Konfigurieren",
"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.",
"data": {
"name": "Systemname",
"system_id": "System-ID",
"entity_id": "Solarerzeugungs-Sensor",
"consumption_entity_id": "Stromverbrauch-Sensor",
"temperature_entity_id": "Temperatursensor",
"entity_id": "Primärer Solarsensor",
"secondary_entity_id": "Sekundärer Solarsensor (Optional)",
"consumption_entity_id": "Stromverbrauch-Sensor (Optional)",
"temperature_entity_id": "Temperatursensor (Optional)",
"frequency": "Aktualisierungsintervall"
},
"data_description": {
"name": "Ein benutzerfreundlicher Name für Ihre Übersicht.",
"entity_id": "Der Leistungs- (W) oder Energie- (Wh) Sensor Ihres Wechselrichters.",
"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.",
"consumption_entity_id": "(Optional) Erfasst den Energieverbrauch Ihres Hauses.",
"temperature_entity_id": "(Optional) Erfasst die Außentemperatur."
}
Expand Down
Loading
Loading