Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## 2.1.0 (10/11/2026)

### Feature Changes
- Added support for Battery parameters `b1` through `b6` (PVOutput donation features):
- `b1`: Battery Power (W) with automatic kW to W conversion, signed for charging (+) and discharging (-).
- `b2`: Battery State of Charge (%) (0–100%).
- `b3`: Battery Usable Capacity / Size (Wh) with automatic kWh/MWh conversion.
- `b4`: Battery Lifetime Charge (Wh) with automatic kWh/MWh conversion.
- `b5`: Battery Lifetime Discharge (Wh) with automatic kWh/MWh conversion.
- `b6`: Battery State (0–9 numeric status code).

## 2.0.0 (10/11/2026)

### Breaking Changes / Architecture Upgrade
Expand Down
54 changes: 37 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,30 @@ Please note that the code and documentation for this project were primarily gene
---

## Features
* **UI Config Flow:** Fully configurable via the Home Assistant UI with 1-to-1 mappings to PVOutput API parameters. No YAML required.
* **UI Config Flow:** Fully configurable via the Home Assistant UI with 1-to-1 mappings to PVOutput API parameters. Free options are presented first, with donation features organized in a smart collapsible section. No YAML required.
* **Internal System Notes:** Save private reference notes for each solar array in the Home Assistant UI without sending them to the public API.
* **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 intervals, even across Home Assistant restarts.
* **Direct Parameter Mapping & Auto-Scaling:**
* **Standard Parameters (Free Accounts):**
* **v1 (Energy Generation):** Watt-hours (automatically converts `kWh` and `MWh`).
* **v2 (Power Generation):** Watts (automatically converts `kW`).
* **v3 (Energy Consumption):** Watt-hours (automatically converts `kWh` and `MWh`).
* **v4 (Power Consumption):** Watts (automatically converts `kW`).
* **v5 (Temperature):** Automatically converts Fahrenheit (`°F`) to Celsius (`°C`).
* **v6 (Voltage):** Inverter / Line voltage.
* **v7 – v12 (Extended Data):** Support for PVOutput donation account custom metrics.
* **Flags Support:**
* **Cumulative Flag (`c1=1`):** Informs PVOutput that energy readings represent lifetime cumulative values.
* **Net Flag (`n=1`):** Informs PVOutput that power readings represent net export.
* **Donation Features (PVOutput Donation Account Required):**
> [!NOTE]
> The following parameters require an active [PVOutput Donation Account](https://pvoutput.org/help/donation.html). PVOutput ignores these parameters on free accounts.
* **v7 – v12 (Extended Data):** Custom user-defined metrics (Extended Values 1 to 6).
* **b1 – b6 (Battery Parameters):**
* **b1 (Battery Power):** Watts (automatically converts `kW`; positive = charge, negative = discharge; *mandatory when uploading any battery data*).
* **b2 (State of Charge):** Percentage (0–100%).
* **b3 (Usable Capacity):** Watt-hours (automatically converts `kWh` and `MWh`).
* **b4 (Lifetime Charge):** Watt-hours (automatically converts `kWh` and `MWh`).
* **b5 (Lifetime Discharge):** Watt-hours (automatically converts `kWh` and `MWh`).
* **b6 (Battery State):** Numeric status code (0–9).
* **Last Upload Sensor:** Creates a timestamp entity in Home Assistant to monitor when the last successful push occurred.
* **Multi-Language Support:** Fully translated into English, Japanese, Spanish, German, and Chinese.

Expand All @@ -51,19 +60,30 @@ This integration is installed via [HACS](https://hacs.xyz/).
3. Search for **PVOutput Publisher**.
4. Enter your global **PVOutput API Key** (found in your PVOutput account settings).
5. Configure your solar array parameters:
* **System Name:** A friendly name for your reference.
* **System ID:** Your PVOutput System ID.
* **Update Frequency:** How often to push data (5 to 180 minutes).
* **Internal Notes:** (Optional) Private reference notes.
* **v1: Energy Generation (Wh):** (Optional) Generation energy sensor.
* **v2: Power Generation (W):** (Optional) Generation power sensor.
* **v3: Energy Consumption (Wh):** (Optional) Consumption energy sensor.
* **v4: Power Consumption (W):** (Optional) Consumption power sensor.
* **v5: Temperature:** (Optional) Outside temperature sensor.
* **v6: Voltage:** (Optional) Inverter/grid line voltage.
* **v7 – v12: Extended Data:** (Optional) Custom donation metrics.
* **Cumulative Flag (`c1=1`):** Check if energy sensors supply lifetime counter values.
* **Net Power Flag (`n=1`):** Check if power readings represent net export.

### General & Standard Settings (Free Accounts)
* **System Name:** A friendly name for your reference.
* **System ID:** Your PVOutput System ID.
* **Update Frequency:** How often to push data (5 to 180 minutes).
* **Internal Notes:** (Optional) Private reference notes.
* **v1: Energy Generation (Wh):** (Optional) Generation energy sensor.
* **v2: Power Generation (W):** (Optional) Generation power sensor.
* **v3: Energy Consumption (Wh):** (Optional) Consumption energy sensor.
* **v4: Power Consumption (W):** (Optional) Consumption power sensor.
* **v5: Temperature:** (Optional) Outside temperature sensor.
* **v6: Voltage:** (Optional) Inverter/grid line voltage.
* **Cumulative Flag (`c1=1`):** Check if energy sensors supply lifetime counter values.
* **Net Power Flag (`n=1`):** Check if power readings represent net export.

### Donation Features (PVOutput Donation Required)
*Expand the **Donation Features** section in the UI to configure:*
* **v7 – v12: Extended Data:** (Optional) Custom donation metrics.
* **b1: Battery Power (W):** (Optional) Instantaneous battery power (positive for charge, negative for discharge).
* **b2: Battery State of Charge (%):** (Optional) Battery charge percentage (0–100%).
* **b3: Battery Usable Capacity (Wh):** (Optional) Usable battery capacity.
* **b4: Battery Lifetime Charge (Wh):** (Optional) Cumulative lifetime battery charge energy.
* **b5: Battery Lifetime Discharge (Wh):** (Optional) Cumulative lifetime battery discharge energy.
* **b6: Battery State (Code 0-9):** (Optional) Numeric battery status code.

### Managing Multiple Systems
You can add, edit, or remove systems at any time. Simply go to **Settings > Devices & Services**, find the **PVOutput Publisher** integration card, and click **Configure**.
Expand Down
155 changes: 145 additions & 10 deletions custom_components/pvoutput_publisher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
CONF_FREQUENCY, PVOUTPUT_API_URL, CONF_NOTES,
CONF_V1, CONF_V2, CONF_V3, CONF_V4, CONF_V5, CONF_V6,
CONF_V7, CONF_V8, CONF_V9, CONF_V10, CONF_V11, CONF_V12,
CONF_B1, CONF_B2, CONF_B3, CONF_B4, CONF_B5, CONF_B6,
CONF_C1, CONF_NET,
CONF_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID,
CONF_TEMPERATURE_ENTITY_ID, CONF_VOLTAGE_ENTITY_ID, DEFAULT_FREQUENCY
Expand All @@ -33,9 +34,11 @@ def _determine_energy_or_power(hass: HomeAssistant, entity_id: str | None) -> tu
# 1. Try checking live state machine
state = hass.states.get(entity_id)
if state:
unit = state.attributes.get("unit_of_measurement", "").lower()
device_class = state.attributes.get("device_class", "").lower()
state_class = state.attributes.get("state_class", "").lower()
# NOTE: Use (attr or "").lower() defensively because dict.get("key", "")
# will return None if the attribute is explicitly set to None (e.g. MQTT/template sensors).
unit = (state.attributes.get("unit_of_measurement") or "").lower()
device_class = (state.attributes.get("device_class") or "").lower()
state_class = (state.attributes.get("state_class") or "").lower()
is_lifetime = state_class in ["total", "total_increasing"]

if unit in ["wh", "kwh", "mwh"] or device_class == "energy":
Expand Down Expand Up @@ -70,8 +73,12 @@ def _determine_energy_or_power(hass: HomeAssistant, entity_id: str | None) -> tu

def migrate_v1_system_to_v2(hass: HomeAssistant, system: dict) -> dict:
"""Convert a single v1 system dictionary to the v2 schema."""
# If already in v2 format (contains v1 or v2 keys), return as is
if CONF_V1 in system or CONF_V2 in system:
legacy_keys = [
CONF_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID,
CONF_TEMPERATURE_ENTITY_ID, CONF_VOLTAGE_ENTITY_ID
]
# If not containing legacy v1 keys, return as is
if not any(k in system for k in legacy_keys):
return system

name = system.get(CONF_NAME, system.get(CONF_SYSTEM_ID, "Solar System"))
Expand Down Expand Up @@ -148,6 +155,12 @@ def migrate_v1_system_to_v2(hass: HomeAssistant, system: dict) -> dict:
CONF_V10: None,
CONF_V11: None,
CONF_V12: None,
CONF_B1: None,
CONF_B2: None,
CONF_B3: None,
CONF_B4: None,
CONF_B5: None,
CONF_B6: None,
CONF_C1: c1,
CONF_NET: False,
}
Expand Down Expand Up @@ -201,7 +214,9 @@ async def push_data(now: datetime, sys_data=system, sys_id=system_id, name=sys_n
if v1_state and v1_state.state not in ['unknown', 'unavailable']:
try:
v1_val = float(v1_state.state)
unit = v1_state.attributes.get("unit_of_measurement", "").lower()
# NOTE: Use (attr or "").lower() defensively because dict.get("key", "")
# returns None if the attribute exists with value None (common in template/MQTT sensors).
unit = (v1_state.attributes.get("unit_of_measurement") or "").lower()
raw = v1_val
if unit == "kwh":
v1_val *= 1000
Expand All @@ -219,7 +234,7 @@ async def push_data(now: datetime, sys_data=system, sys_id=system_id, name=sys_n
if v2_state and v2_state.state not in ['unknown', 'unavailable']:
try:
v2_val = float(v2_state.state)
unit = v2_state.attributes.get("unit_of_measurement", "").lower()
unit = (v2_state.attributes.get("unit_of_measurement") or "").lower()
raw = v2_val
if unit in ["kw", "kilowatt", "kilowatts"]:
v2_val *= 1000
Expand All @@ -235,7 +250,7 @@ async def push_data(now: datetime, sys_data=system, sys_id=system_id, name=sys_n
if v3_state and v3_state.state not in ['unknown', 'unavailable']:
try:
v3_val = float(v3_state.state)
unit = v3_state.attributes.get("unit_of_measurement", "").lower()
unit = (v3_state.attributes.get("unit_of_measurement") or "").lower()
raw = v3_val
if unit == "kwh":
v3_val *= 1000
Expand All @@ -253,7 +268,7 @@ async def push_data(now: datetime, sys_data=system, sys_id=system_id, name=sys_n
if v4_state and v4_state.state not in ['unknown', 'unavailable']:
try:
v4_val = float(v4_state.state)
unit = v4_state.attributes.get("unit_of_measurement", "").lower()
unit = (v4_state.attributes.get("unit_of_measurement") or "").lower()
raw = v4_val
if unit in ["kw", "kilowatt", "kilowatts"]:
v4_val *= 1000
Expand All @@ -269,7 +284,7 @@ async def push_data(now: datetime, sys_data=system, sys_id=system_id, name=sys_n
if v5_state and v5_state.state not in ['unknown', 'unavailable']:
try:
temp_val = float(v5_state.state)
unit = v5_state.attributes.get("unit_of_measurement", "").lower()
unit = (v5_state.attributes.get("unit_of_measurement") or "").lower()
raw = temp_val
if unit in ["°f", "f"]:
temp_val = (temp_val - 32) * 5.0 / 9.0
Expand Down Expand Up @@ -306,6 +321,126 @@ async def push_data(now: datetime, sys_data=system, sys_id=system_id, name=sys_n
except ValueError:
pass

has_b1 = False
has_other_battery = False

# b1: Battery Power (W) (Positive = Charging, Negative = Discharging)
b1_id = sys_data.get(CONF_B1)
if b1_id:
b1_state = hass.states.get(b1_id)
if b1_state and b1_state.state not in ['unknown', 'unavailable']:
try:
b1_val = float(b1_state.state)
unit = (b1_state.attributes.get("unit_of_measurement") or "").lower()
raw = b1_val
if unit in ["kw", "kilowatt", "kilowatts"]:
b1_val *= 1000
payload += f"&b1={int(round(b1_val))}"
log_parts.append(f"b1 (Battery Power): {raw} {unit} -> {int(round(b1_val))}W")
has_b1 = True
except ValueError:
pass

# b2: Battery State of Charge (%)
b2_id = sys_data.get(CONF_B2)
if b2_id:
b2_state = hass.states.get(b2_id)
if b2_state and b2_state.state not in ['unknown', 'unavailable']:
try:
b2_val = float(b2_state.state)
val_str = f"{b2_val:g}"
payload += f"&b2={val_str}"
log_parts.append(f"b2 (Battery SOC): {val_str}%")
has_other_battery = True
except ValueError:
pass

# b3: Battery Size / Usable Capacity (Wh)
b3_id = sys_data.get(CONF_B3)
if b3_id:
b3_state = hass.states.get(b3_id)
if b3_state and b3_state.state not in ['unknown', 'unavailable']:
try:
b3_val = float(b3_state.state)
unit = (b3_state.attributes.get("unit_of_measurement") or "").lower()
raw = b3_val
if unit == "kwh":
b3_val *= 1000
elif unit == "mwh":
b3_val *= 1000000
payload += f"&b3={int(round(b3_val))}"
log_parts.append(f"b3 (Battery Size): {raw} {unit} -> {int(round(b3_val))}Wh")
has_other_battery = True
except ValueError:
pass

# b4: Battery Lifetime Charge (Wh)
b4_id = sys_data.get(CONF_B4)
if b4_id:
b4_state = hass.states.get(b4_id)
if b4_state and b4_state.state not in ['unknown', 'unavailable']:
try:
b4_val = float(b4_state.state)
unit = (b4_state.attributes.get("unit_of_measurement") or "").lower()
raw = b4_val
if unit == "kwh":
b4_val *= 1000
elif unit == "mwh":
b4_val *= 1000000
payload += f"&b4={int(round(b4_val))}"
log_parts.append(f"b4 (Battery Lifetime Charge): {raw} {unit} -> {int(round(b4_val))}Wh")
has_other_battery = True
except ValueError:
pass

# b5: Battery Lifetime Discharge (Wh)
b5_id = sys_data.get(CONF_B5)
if b5_id:
b5_state = hass.states.get(b5_id)
if b5_state and b5_state.state not in ['unknown', 'unavailable']:
try:
b5_val = float(b5_state.state)
unit = (b5_state.attributes.get("unit_of_measurement") or "").lower()
raw = b5_val
if unit == "kwh":
b5_val *= 1000
elif unit == "mwh":
b5_val *= 1000000
payload += f"&b5={int(round(b5_val))}"
log_parts.append(f"b5 (Battery Lifetime Discharge): {raw} {unit} -> {int(round(b5_val))}Wh")
has_other_battery = True
except ValueError:
pass

# b6: Battery State (0-9)
# PVOutput specification defines codes 0 through 9 (0=Unknown, 1=Charging, 2=Discharging, etc.).
b6_id = sys_data.get(CONF_B6)
if b6_id:
b6_state = hass.states.get(b6_id)
if b6_state and b6_state.state not in ['unknown', 'unavailable']:
try:
b6_val = int(float(b6_state.state))
if 0 <= b6_val <= 9:
payload += f"&b6={b6_val}"
log_parts.append(f"b6 (Battery State): {b6_val}")
has_other_battery = True
else:
_LOGGER.warning(
"PVOutput [%s | ID: %s] Battery State (b6) value %s is out of range (0-9). Omitting b6.",
name, sys_id, b6_val
)
except ValueError:
pass

# PVOutput specification: When sending battery data, b1 is mandatory;
# PVOutput will ignore b2-b6 if b1 is missing from the payload.
if has_other_battery and not has_b1:
_LOGGER.warning(
"PVOutput [%s | ID: %s] Battery parameters (b2-b6) were provided without b1 (Battery Power). "
"PVOutput requires b1 for battery data to be accepted; other battery parameters may be ignored.",
name, sys_id
)

# Flags: c1 (Cumulative) and n (Net)
if sys_data.get(CONF_C1):
payload += "&c1=1"
Expand Down
Loading
Loading