diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6e0b2..d293372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e6051e6..11e4196 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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**. diff --git a/custom_components/pvoutput_publisher/__init__.py b/custom_components/pvoutput_publisher/__init__.py index d4a433c..ade0ac8 100644 --- a/custom_components/pvoutput_publisher/__init__.py +++ b/custom_components/pvoutput_publisher/__init__.py @@ -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 @@ -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": @@ -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")) @@ -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, } @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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" diff --git a/custom_components/pvoutput_publisher/config_flow.py b/custom_components/pvoutput_publisher/config_flow.py index 6c0f7a3..de0e940 100644 --- a/custom_components/pvoutput_publisher/config_flow.py +++ b/custom_components/pvoutput_publisher/config_flow.py @@ -2,15 +2,25 @@ from homeassistant import config_entries from homeassistant.helpers import selector from homeassistant.core import callback +from homeassistant.data_entry_flow import section from .const import ( DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID, CONF_FREQUENCY, DEFAULT_FREQUENCY, 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_C1, CONF_NET + CONF_B1, CONF_B2, CONF_B3, CONF_B4, CONF_B5, CONF_B6, + CONF_C1, CONF_NET, CONF_DONATION_FIELDS ) +def _clean_system_input(user_input: dict) -> dict: + """Flatten donation section inputs into the main system dictionary.""" + cleaned = {**user_input} + if CONF_DONATION_FIELDS in cleaned: + donation_data = cleaned.pop(CONF_DONATION_FIELDS) or {} + cleaned.update(donation_data) + return cleaned + def _get_system_schema(existing_data=None): frequency_options = { "5": "5 minutes", "10": "10 minutes", "15": "15 minutes", @@ -95,20 +105,95 @@ def _get_system_schema(existing_data=None): selector.EntitySelectorConfig(domain="sensor", device_class=["voltage"]) ) - # v7 through v12 (Extended Data) + # Flags (Free / Standard) + schema[vol.Optional(CONF_C1, default=data.get(CONF_C1, False))] = selector.BooleanSelector() + schema[vol.Optional(CONF_NET, default=data.get(CONF_NET, False))] = selector.BooleanSelector() + + # Donation Section (v7-v12 & b1-b6) + # NOTE: Use 'or data' fallback in case CONF_DONATION_FIELDS explicitly exists with value None. + donation_data = data.get(CONF_DONATION_FIELDS) or data + donation_keys = [ + CONF_V7, CONF_V8, CONF_V9, CONF_V10, CONF_V11, CONF_V12, + CONF_B1, CONF_B2, CONF_B3, CONF_B4, CONF_B5, CONF_B6 + ] + has_donation_configured = any(bool(donation_data.get(k)) for k in donation_keys) + donation_schema = {} + + # v7 through v12 (Extended Data - Donation Feature) for v_key in [CONF_V7, CONF_V8, CONF_V9, CONF_V10, CONF_V11, CONF_V12]: - if data.get(v_key): - schema[vol.Optional(v_key, default=data.get(v_key))] = selector.EntitySelector( + if donation_data.get(v_key): + donation_schema[vol.Optional(v_key, default=donation_data.get(v_key))] = selector.EntitySelector( selector.EntitySelectorConfig(domain="sensor") ) else: - schema[vol.Optional(v_key)] = selector.EntitySelector( + donation_schema[vol.Optional(v_key)] = selector.EntitySelector( selector.EntitySelectorConfig(domain="sensor") ) - # Flags - schema[vol.Optional(CONF_C1, default=data.get(CONF_C1, False))] = selector.BooleanSelector() - schema[vol.Optional(CONF_NET, default=data.get(CONF_NET, False))] = selector.BooleanSelector() + # b1 (Battery Power - W) + if donation_data.get(CONF_B1): + donation_schema[vol.Optional(CONF_B1, default=donation_data.get(CONF_B1))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["power"]) + ) + else: + donation_schema[vol.Optional(CONF_B1)] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["power"]) + ) + + # b2 (Battery State of Charge - %) + if donation_data.get(CONF_B2): + donation_schema[vol.Optional(CONF_B2, default=donation_data.get(CONF_B2))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["battery"]) + ) + else: + donation_schema[vol.Optional(CONF_B2)] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["battery"]) + ) + + # b3 (Battery Size / Usable Capacity - Wh) + if donation_data.get(CONF_B3): + donation_schema[vol.Optional(CONF_B3, default=donation_data.get(CONF_B3))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor") + ) + else: + donation_schema[vol.Optional(CONF_B3)] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor") + ) + + # b4 (Battery Lifetime Charge - Wh) + if donation_data.get(CONF_B4): + donation_schema[vol.Optional(CONF_B4, default=donation_data.get(CONF_B4))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["energy"]) + ) + else: + donation_schema[vol.Optional(CONF_B4)] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["energy"]) + ) + + # b5 (Battery Lifetime Discharge - Wh) + if donation_data.get(CONF_B5): + donation_schema[vol.Optional(CONF_B5, default=donation_data.get(CONF_B5))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["energy"]) + ) + else: + donation_schema[vol.Optional(CONF_B5)] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["energy"]) + ) + + # b6 (Battery State - Code 0-9) + if donation_data.get(CONF_B6): + donation_schema[vol.Optional(CONF_B6, default=donation_data.get(CONF_B6))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor") + ) + else: + donation_schema[vol.Optional(CONF_B6)] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor") + ) + + schema[vol.Optional(CONF_DONATION_FIELDS)] = section( + vol.Schema(donation_schema), + options={"collapsed": not has_donation_configured} + ) return vol.Schema(schema) @@ -132,11 +217,12 @@ async def async_step_user(self, user_input=None): async def async_step_add_system(self, user_input=None): if user_input is not None: + cleaned_input = _clean_system_input(user_input) if self._editing_index is not None: - self._systems[self._editing_index] = user_input + self._systems[self._editing_index] = cleaned_input self._editing_index = None else: - self._systems.append(user_input) + self._systems.append(cleaned_input) return await self.async_step_systems_manager() @@ -206,11 +292,12 @@ async def async_step_init(self, user_input=None): async def async_step_add_system(self, user_input=None): if user_input is not None: + cleaned_input = _clean_system_input(user_input) if self._editing_index is not None: - self._systems[self._editing_index] = user_input + self._systems[self._editing_index] = cleaned_input self._editing_index = None else: - self._systems.append(user_input) + self._systems.append(cleaned_input) return await self.async_step_systems_manager() existing_data = self._systems[self._editing_index] if self._editing_index is not None else None diff --git a/custom_components/pvoutput_publisher/const.py b/custom_components/pvoutput_publisher/const.py index fa97abe..dad7372 100644 --- a/custom_components/pvoutput_publisher/const.py +++ b/custom_components/pvoutput_publisher/const.py @@ -21,10 +21,21 @@ CONF_V11 = "v11" CONF_V12 = "v12" +# V2 Battery Parameters +CONF_B1 = "b1" +CONF_B2 = "b2" +CONF_B3 = "b3" +CONF_B4 = "b4" +CONF_B5 = "b5" +CONF_B6 = "b6" + # V2 Flags CONF_C1 = "c1" CONF_NET = "n" +# Section Keys +CONF_DONATION_FIELDS = "donation_fields" + # Legacy V1 Configuration Keys (Kept for Migration) CONF_ENTITY_ID = "entity_id" CONF_SECONDARY_ENTITY_ID = "secondary_entity_id" diff --git a/custom_components/pvoutput_publisher/manifest.json b/custom_components/pvoutput_publisher/manifest.json index 40f10d1..a6cc0aa 100644 --- a/custom_components/pvoutput_publisher/manifest.json +++ b/custom_components/pvoutput_publisher/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "issue_tracker": "https://github.com/SourceLabOrg/HomeAssistant-PVOutputPublisher/issues", "requirements": [], - "version": "2.0.0" + "version": "2.1.0" } diff --git a/custom_components/pvoutput_publisher/translations/de.json b/custom_components/pvoutput_publisher/translations/de.json index 30c37a8..8698dfd 100644 --- a/custom_components/pvoutput_publisher/translations/de.json +++ b/custom_components/pvoutput_publisher/translations/de.json @@ -22,12 +22,6 @@ "v4": "v4: Leistungsverbrauch (W)", "v5": "v5: Temperatursensor", "v6": "v6: Spannungssensor", - "v7": "v7: Erweiterte Daten 1", - "v8": "v8: Erweiterte Daten 2", - "v9": "v9: Erweiterte Daten 3", - "v10": "v10: Erweiterte Daten 4", - "v11": "v11: Erweiterte Daten 5", - "v12": "v12: Erweiterte Daten 6", "c1": "Kumulatives Energie-Flag (c1=1)", "n": "Netto-Leistungserzeugungs-Flag (n=1)" }, @@ -42,14 +36,42 @@ "v4": "Momentaner Leistungsverbrauch in Watt (kW wird automatisch umgerechnet).", "v5": "Außentemperatur in °C oder °F (°F wird automatisch in °C umgerechnet).", "v6": "Netz-/Wechselrichterspannung in Volt.", - "v7": "Erweitertes Datenfeld 1 (erfordert PVOutput-Spendenkonto).", - "v8": "Erweitertes Datenfeld 2 (erfordert PVOutput-Spendenkonto).", - "v9": "Erweitertes Datenfeld 3 (erfordert PVOutput-Spendenkonto).", - "v10": "Erweitertes Datenfeld 4 (erfordert PVOutput-Spendenkonto).", - "v11": "Erweitertes Datenfeld 5 (erfordert PVOutput-Spendenkonto).", - "v12": "Erweitertes Datenfeld 6 (erfordert PVOutput-Spendenkonto).", "c1": "Aktivieren, wenn Ihre Energiesensoren (v1/v3) lebenslange Gesamtzählerstände liefern.", "n": "Aktivieren, wenn die Leistungserzeugung (v2) Netto-Einspeisung statt Bruttoerzeugung darstellt." + }, + "sections": { + "donation_fields": { + "name": "Spenden-Funktionen", + "description": "Erfordert ein aktives PVOutput-Spendenkonto.", + "data": { + "v7": "v7: Erweiterte Daten 1", + "v8": "v8: Erweiterte Daten 2", + "v9": "v9: Erweiterte Daten 3", + "v10": "v10: Erweiterte Daten 4", + "v11": "v11: Erweiterte Daten 5", + "v12": "v12: Erweiterte Daten 6", + "b1": "b1: Batterieleistung (W)", + "b2": "b2: Batterieladezustand (%)", + "b3": "b3: Nutzkapazität der Batterie (Wh)", + "b4": "b4: Lebenszeit-Batterieladung (Wh)", + "b5": "b5: Lebenszeit-Batterieentladung (Wh)", + "b6": "b6: Batteriestatus (Code 0-9)" + }, + "data_description": { + "v7": "Erweitertes Datenfeld 1 (erfordert PVOutput-Spendenkonto).", + "v8": "Erweitertes Datenfeld 2 (erfordert PVOutput-Spendenkonto).", + "v9": "Erweitertes Datenfeld 3 (erfordert PVOutput-Spendenkonto).", + "v10": "Erweitertes Datenfeld 4 (erfordert PVOutput-Spendenkonto).", + "v11": "Erweitertes Datenfeld 5 (erfordert PVOutput-Spendenkonto).", + "v12": "Erweitertes Datenfeld 6 (erfordert PVOutput-Spendenkonto).", + "b1": "Momentane Batterieleistung in Watt (kW wird automatisch umgerechnet). Positiv = Laden, negativ = Entladen.", + "b2": "Batterieladezustand in Prozent (0-100%).", + "b3": "Nutzbare Batteriekapazität in Wattstunden (kWh/MWh werden automatisch umgerechnet).", + "b4": "Kumulierte geladene Lebenszeit-Energie in Wattstunden (kWh/MWh werden automatisch umgerechnet).", + "b5": "Kumulierte entladene Lebenszeit-Energie in Wattstunden (kWh/MWh werden automatisch umgerechnet).", + "b6": "Numerischer Batteriestatuscode (0: Ungültig, 1: Standby, 2: Thermisch, 3: Aktiviert, 4: Fehler, 5-9: Benutzerdefiniert)." + } + } } }, "systems_manager": { @@ -94,12 +116,6 @@ "v4": "v4: Leistungsverbrauch (W)", "v5": "v5: Temperatursensor", "v6": "v6: Spannungssensor", - "v7": "v7: Erweiterte Daten 1", - "v8": "v8: Erweiterte Daten 2", - "v9": "v9: Erweiterte Daten 3", - "v10": "v10: Erweiterte Daten 4", - "v11": "v11: Erweiterte Daten 5", - "v12": "v12: Erweiterte Daten 6", "c1": "Kumulatives Energie-Flag (c1=1)", "n": "Netto-Leistungserzeugungs-Flag (n=1)" }, @@ -114,14 +130,42 @@ "v4": "Momentaner Leistungsverbrauch in Watt (kW wird automatisch umgerechnet).", "v5": "Außentemperatur in °C oder °F (°F wird automatisch in °C umgerechnet).", "v6": "Netz-/Wechselrichterspannung in Volt.", - "v7": "Erweitertes Datenfeld 1 (erfordert PVOutput-Spendenkonto).", - "v8": "Erweitertes Datenfeld 2 (erfordert PVOutput-Spendenkonto).", - "v9": "Erweitertes Datenfeld 3 (erfordert PVOutput-Spendenkonto).", - "v10": "Erweitertes Datenfeld 4 (erfordert PVOutput-Spendenkonto).", - "v11": "Erweitertes Datenfeld 5 (erfordert PVOutput-Spendenkonto).", - "v12": "Erweitertes Datenfeld 6 (erfordert PVOutput-Spendenkonto).", "c1": "Aktivieren, wenn Ihre Energiesensoren (v1/v3) lebenslange Gesamtzählerstände liefern.", "n": "Aktivieren, wenn die Leistungserzeugung (v2) Netto-Einspeisung statt Bruttoerzeugung darstellt." + }, + "sections": { + "donation_fields": { + "name": "Spenden-Funktionen", + "description": "Erfordert ein aktives PVOutput-Spendenkonto.", + "data": { + "v7": "v7: Erweiterte Daten 1", + "v8": "v8: Erweiterte Daten 2", + "v9": "v9: Erweiterte Daten 3", + "v10": "v10: Erweiterte Daten 4", + "v11": "v11: Erweiterte Daten 5", + "v12": "v12: Erweiterte Daten 6", + "b1": "b1: Batterieleistung (W)", + "b2": "b2: Batterieladezustand (%)", + "b3": "b3: Nutzkapazität der Batterie (Wh)", + "b4": "b4: Lebenszeit-Batterieladung (Wh)", + "b5": "b5: Lebenszeit-Batterieentladung (Wh)", + "b6": "b6: Batteriestatus (Code 0-9)" + }, + "data_description": { + "v7": "Erweitertes Datenfeld 1 (erfordert PVOutput-Spendenkonto).", + "v8": "Erweitertes Datenfeld 2 (erfordert PVOutput-Spendenkonto).", + "v9": "Erweitertes Datenfeld 3 (erfordert PVOutput-Spendenkonto).", + "v10": "Erweitertes Datenfeld 4 (erfordert PVOutput-Spendenkonto).", + "v11": "Erweitertes Datenfeld 5 (erfordert PVOutput-Spendenkonto).", + "v12": "Erweitertes Datenfeld 6 (erfordert PVOutput-Spendenkonto).", + "b1": "Momentane Batterieleistung in Watt (kW wird automatisch umgerechnet). Positiv = Laden, negativ = Entladen.", + "b2": "Batterieladezustand in Prozent (0-100%).", + "b3": "Nutzbare Batteriekapazität in Wattstunden (kWh/MWh werden automatisch umgerechnet).", + "b4": "Kumulierte geladene Lebenszeit-Energie in Wattstunden (kWh/MWh werden automatisch umgerechnet).", + "b5": "Kumulierte entladene Lebenszeit-Energie in Wattstunden (kWh/MWh werden automatisch umgerechnet).", + "b6": "Numerischer Batteriestatuscode (0: Ungültig, 1: Standby, 2: Thermisch, 3: Aktiviert, 4: Fehler, 5-9: Benutzerdefiniert)." + } + } } } } diff --git a/custom_components/pvoutput_publisher/translations/en.json b/custom_components/pvoutput_publisher/translations/en.json index f65d03b..f2cf86b 100644 --- a/custom_components/pvoutput_publisher/translations/en.json +++ b/custom_components/pvoutput_publisher/translations/en.json @@ -22,12 +22,6 @@ "v4": "v4: Power Consumption (W)", "v5": "v5: Temperature Sensor", "v6": "v6: Voltage Sensor", - "v7": "v7: Extended Metric 1", - "v8": "v8: Extended Metric 2", - "v9": "v9: Extended Metric 3", - "v10": "v10: Extended Metric 4", - "v11": "v11: Extended Metric 5", - "v12": "v12: Extended Metric 6", "c1": "Cumulative Energy Flag (c1=1)", "n": "Net Power Generation Flag (n=1)" }, @@ -42,14 +36,42 @@ "v4": "Instantaneous power consumed in Watts (converts kW automatically).", "v5": "Ambient temperature in °C or °F (converts °F to °C automatically).", "v6": "Line/Inverter voltage in Volts.", - "v7": "Extended data field 1 (requires a PVOutput donation account).", - "v8": "Extended data field 2 (requires a PVOutput donation account).", - "v9": "Extended data field 3 (requires a PVOutput donation account).", - "v10": "Extended data field 4 (requires a PVOutput donation account).", - "v11": "Extended data field 5 (requires a PVOutput donation account).", - "v12": "Extended data field 6 (requires a PVOutput donation account).", "c1": "Check if your energy sensors (v1/v3) provide lifetime cumulative counter values.", "n": "Check if power generation (v2) represents net export rather than gross generation." + }, + "sections": { + "donation_fields": { + "name": "Donation Features", + "description": "Requires an active PVOutput donation account.", + "data": { + "v7": "v7: Extended Metric 1", + "v8": "v8: Extended Metric 2", + "v9": "v9: Extended Metric 3", + "v10": "v10: Extended Metric 4", + "v11": "v11: Extended Metric 5", + "v12": "v12: Extended Metric 6", + "b1": "b1: Battery Power (W)", + "b2": "b2: Battery State of Charge (%)", + "b3": "b3: Battery Usable Capacity (Wh)", + "b4": "b4: Battery Lifetime Charge (Wh)", + "b5": "b5: Battery Lifetime Discharge (Wh)", + "b6": "b6: Battery State (Code 0-9)" + }, + "data_description": { + "v7": "Extended data field 1 (requires a PVOutput donation account).", + "v8": "Extended data field 2 (requires a PVOutput donation account).", + "v9": "Extended data field 3 (requires a PVOutput donation account).", + "v10": "Extended data field 4 (requires a PVOutput donation account).", + "v11": "Extended data field 5 (requires a PVOutput donation account).", + "v12": "Extended data field 6 (requires a PVOutput donation account).", + "b1": "Instantaneous battery power in Watts (converts kW automatically). Positive = charging, negative = discharging.", + "b2": "Battery state of charge percentage (0-100%).", + "b3": "Usable battery capacity in Watt-hours (converts kWh/MWh automatically).", + "b4": "Cumulative lifetime battery charge energy in Watt-hours (converts kWh/MWh automatically).", + "b5": "Cumulative lifetime battery discharge energy in Watt-hours (converts kWh/MWh automatically).", + "b6": "Numeric battery state code (0: Invalid, 1: Standby, 2: Thermal, 3: Enabled, 4: Fault, 5-9: User Defined)." + } + } } }, "systems_manager": { @@ -94,12 +116,6 @@ "v4": "v4: Power Consumption (W)", "v5": "v5: Temperature Sensor", "v6": "v6: Voltage Sensor", - "v7": "v7: Extended Metric 1", - "v8": "v8: Extended Metric 2", - "v9": "v9: Extended Metric 3", - "v10": "v10: Extended Metric 4", - "v11": "v11: Extended Metric 5", - "v12": "v12: Extended Metric 6", "c1": "Cumulative Energy Flag (c1=1)", "n": "Net Power Generation Flag (n=1)" }, @@ -114,14 +130,42 @@ "v4": "Instantaneous power consumed in Watts (converts kW automatically).", "v5": "Ambient temperature in °C or °F (converts °F to °C automatically).", "v6": "Line/Inverter voltage in Volts.", - "v7": "Extended data field 1 (requires a PVOutput donation account).", - "v8": "Extended data field 2 (requires a PVOutput donation account).", - "v9": "Extended data field 3 (requires a PVOutput donation account).", - "v10": "Extended data field 4 (requires a PVOutput donation account).", - "v11": "Extended data field 5 (requires a PVOutput donation account).", - "v12": "Extended data field 6 (requires a PVOutput donation account).", "c1": "Check if your energy sensors (v1/v3) provide lifetime cumulative counter values.", "n": "Check if power generation (v2) represents net export rather than gross generation." + }, + "sections": { + "donation_fields": { + "name": "Donation Features", + "description": "Requires an active PVOutput donation account.", + "data": { + "v7": "v7: Extended Metric 1", + "v8": "v8: Extended Metric 2", + "v9": "v9: Extended Metric 3", + "v10": "v10: Extended Metric 4", + "v11": "v11: Extended Metric 5", + "v12": "v12: Extended Metric 6", + "b1": "b1: Battery Power (W)", + "b2": "b2: Battery State of Charge (%)", + "b3": "b3: Battery Usable Capacity (Wh)", + "b4": "b4: Battery Lifetime Charge (Wh)", + "b5": "b5: Battery Lifetime Discharge (Wh)", + "b6": "b6: Battery State (Code 0-9)" + }, + "data_description": { + "v7": "Extended data field 1 (requires a PVOutput donation account).", + "v8": "Extended data field 2 (requires a PVOutput donation account).", + "v9": "Extended data field 3 (requires a PVOutput donation account).", + "v10": "Extended data field 4 (requires a PVOutput donation account).", + "v11": "Extended data field 5 (requires a PVOutput donation account).", + "v12": "Extended data field 6 (requires a PVOutput donation account).", + "b1": "Instantaneous battery power in Watts (converts kW automatically). Positive = charging, negative = discharging.", + "b2": "Battery state of charge percentage (0-100%).", + "b3": "Usable battery capacity in Watt-hours (converts kWh/MWh automatically).", + "b4": "Cumulative lifetime battery charge energy in Watt-hours (converts kWh/MWh automatically).", + "b5": "Cumulative lifetime battery discharge energy in Watt-hours (converts kWh/MWh automatically).", + "b6": "Numeric battery state code (0: Invalid, 1: Standby, 2: Thermal, 3: Enabled, 4: Fault, 5-9: User Defined)." + } + } } } } diff --git a/custom_components/pvoutput_publisher/translations/es.json b/custom_components/pvoutput_publisher/translations/es.json index e650d74..bdce8fb 100644 --- a/custom_components/pvoutput_publisher/translations/es.json +++ b/custom_components/pvoutput_publisher/translations/es.json @@ -22,12 +22,6 @@ "v4": "v4: Consumo de potencia (W)", "v5": "v5: Sensor de temperatura", "v6": "v6: Sensor de voltaje", - "v7": "v7: Dato extendido 1", - "v8": "v8: Dato extendido 2", - "v9": "v9: Dato extendido 3", - "v10": "v10: Dato extendido 4", - "v11": "v11: Dato extendido 5", - "v12": "v12: Dato extendido 6", "c1": "Indicador de energía acumulada (c1=1)", "n": "Indicador de potencia neta (n=1)" }, @@ -42,14 +36,42 @@ "v4": "Potencia consumida instantánea en vatios (convierte kW automáticamente).", "v5": "Temperatura ambiente en °C o °F (convierte °F a °C automáticamente).", "v6": "Voltaje de red/inversor en voltios.", - "v7": "Campo de datos extendidos 1 (requiere cuenta de donante en PVOutput).", - "v8": "Campo de datos extendidos 2 (requiere cuenta de donante en PVOutput).", - "v9": "Campo de datos extendidos 3 (requiere cuenta de donante en PVOutput).", - "v10": "Campo de datos extendidos 4 (requiere cuenta de donante en PVOutput).", - "v11": "Campo de datos extendidos 5 (requiere cuenta de donante en PVOutput).", - "v12": "Campo de datos extendidos 6 (requiere cuenta de donante en PVOutput).", "c1": "Marque si sus sensores de energía (v1/v3) proporcionan valores de contador acumulativo total.", "n": "Marque si la generación de potencia (v2) representa exportación neta en lugar de generación bruta." + }, + "sections": { + "donation_fields": { + "name": "Funciones de donación", + "description": "Requiere una cuenta de donación activa en PVOutput.", + "data": { + "v7": "v7: Dato extendido 1", + "v8": "v8: Dato extendido 2", + "v9": "v9: Dato extendido 3", + "v10": "v10: Dato extendido 4", + "v11": "v11: Dato extendido 5", + "v12": "v12: Dato extendido 6", + "b1": "b1: Potencia de batería (W)", + "b2": "b2: Estado de carga de batería (%)", + "b3": "b3: Capacidad útil de batería (Wh)", + "b4": "b4: Carga total acumulada (Wh)", + "b5": "b5: Descarga total acumulada (Wh)", + "b6": "b6: Estado de batería (Código 0-9)" + }, + "data_description": { + "v7": "Campo de datos extendidos 1 (requiere cuenta de donante en PVOutput).", + "v8": "Campo de datos extendidos 2 (requiere cuenta de donante en PVOutput).", + "v9": "Campo de datos extendidos 3 (requiere cuenta de donante en PVOutput).", + "v10": "Campo de datos extendidos 4 (requiere cuenta de donante en PVOutput).", + "v11": "Campo de datos extendidos 5 (requiere cuenta de donante en PVOutput).", + "v12": "Campo de datos extendidos 6 (requiere cuenta de donante en PVOutput).", + "b1": "Potencia instantánea de la batería en vatios (kW se convierte automáticamente). Positivo = cargando, negativo = descargando.", + "b2": "Porcentaje de estado de carga de la batería (0-100%).", + "b3": "Capacidad útil de la batería en vatios-hora (kWh/MWh se convierte automáticamente).", + "b4": "Energía de carga acumulada en la vida útil en vatios-hora (kWh/MWh se convierte automáticamente).", + "b5": "Energía de descarga acumulada en la vida útil en vatios-hora (kWh/MWh se convierte automáticamente).", + "b6": "Código numérico de estado de la batería (0: No válido, 1: En espera, 2: Térmico, 3: Habilitado, 4: Fallo, 5-9: Definido por usuario)." + } + } } }, "systems_manager": { @@ -94,12 +116,6 @@ "v4": "v4: Consumo de potencia (W)", "v5": "v5: Sensor de temperatura", "v6": "v6: Sensor de voltaje", - "v7": "v7: Dato extendido 1", - "v8": "v8: Dato extendido 2", - "v9": "v9: Dato extendido 3", - "v10": "v10: Dato extendido 4", - "v11": "v11: Dato extendido 5", - "v12": "v12: Dato extendido 6", "c1": "Indicador de energía acumulada (c1=1)", "n": "Indicador de potencia neta (n=1)" }, @@ -114,14 +130,42 @@ "v4": "Potencia consumida instantánea en vatios (convierte kW automáticamente).", "v5": "Temperatura ambiente en °C o °F (convierte °F a °C automáticamente).", "v6": "Voltaje de red/inversor en voltios.", - "v7": "Campo de datos extendidos 1 (requiere cuenta de donante en PVOutput).", - "v8": "Campo de datos extendidos 2 (requiere cuenta de donante en PVOutput).", - "v9": "Campo de datos extendidos 3 (requiere cuenta de donante en PVOutput).", - "v10": "Campo de datos extendidos 4 (requiere cuenta de donante en PVOutput).", - "v11": "Campo de datos extendidos 5 (requiere cuenta de donante en PVOutput).", - "v12": "Campo de datos extendidos 6 (requiere cuenta de donante en PVOutput).", "c1": "Marque si sus sensores de energía (v1/v3) proporcionan valores de contador acumulativo total.", "n": "Marque si la generación de potencia (v2) representa exportación neta en lugar de generación bruta." + }, + "sections": { + "donation_fields": { + "name": "Funciones de donación", + "description": "Requiere una cuenta de donación activa en PVOutput.", + "data": { + "v7": "v7: Dato extendido 1", + "v8": "v8: Dato extendido 2", + "v9": "v9: Dato extendido 3", + "v10": "v10: Dato extendido 4", + "v11": "v11: Dato extendido 5", + "v12": "v12: Dato extendido 6", + "b1": "b1: Potencia de batería (W)", + "b2": "b2: Estado de carga de batería (%)", + "b3": "b3: Capacidad útil de batería (Wh)", + "b4": "b4: Carga total acumulada (Wh)", + "b5": "b5: Descarga total acumulada (Wh)", + "b6": "b6: Estado de batería (Código 0-9)" + }, + "data_description": { + "v7": "Campo de datos extendidos 1 (requiere cuenta de donante en PVOutput).", + "v8": "Campo de datos extendidos 2 (requiere cuenta de donante en PVOutput).", + "v9": "Campo de datos extendidos 3 (requiere cuenta de donante en PVOutput).", + "v10": "Campo de datos extendidos 4 (requiere cuenta de donante en PVOutput).", + "v11": "Campo de datos extendidos 5 (requiere cuenta de donante en PVOutput).", + "v12": "Campo de datos extendidos 6 (requiere cuenta de donante en PVOutput).", + "b1": "Potencia instantánea de la batería en vatios (kW se convierte automáticamente). Positivo = cargando, negativo = descargando.", + "b2": "Porcentaje de estado de carga de la batería (0-100%).", + "b3": "Capacidad útil de la batería en vatios-hora (kWh/MWh se convierte automáticamente).", + "b4": "Energía de carga acumulada en la vida útil en vatios-hora (kWh/MWh se convierte automáticamente).", + "b5": "Energía de descarga acumulada en la vida útil en vatios-hora (kWh/MWh se convierte automáticamente).", + "b6": "Código numérico de estado de la batería (0: No válido, 1: En espera, 2: Térmico, 3: Habilitado, 4: Fallo, 5-9: Definido por usuario)." + } + } } } } diff --git a/custom_components/pvoutput_publisher/translations/ja.json b/custom_components/pvoutput_publisher/translations/ja.json index 5e77046..8072a44 100644 --- a/custom_components/pvoutput_publisher/translations/ja.json +++ b/custom_components/pvoutput_publisher/translations/ja.json @@ -22,12 +22,6 @@ "v4": "v4: 消費電力 (W)", "v5": "v5: 温度センサー", "v6": "v6: 電圧センサー", - "v7": "v7: 拡張データ 1", - "v8": "v8: 拡張データ 2", - "v9": "v9: 拡張データ 3", - "v10": "v10: 拡張データ 4", - "v11": "v11: 拡張データ 5", - "v12": "v12: 拡張データ 6", "c1": "累積電力量フラグ (c1=1)", "n": "純発電電力フラグ (n=1)" }, @@ -42,14 +36,42 @@ "v4": "瞬時消費電力 (W) (kW は自動変換されます)。", "v5": "外気温 (°C / °F) (°F から °C に自動変換されます)。", "v6": "電圧 (V)。", - "v7": "拡張データ 1 (寄付ユーザー機能)。", - "v8": "拡張データ 2 (寄付ユーザー機能)。", - "v9": "拡張データ 3 (寄付ユーザー機能)。", - "v10": "拡張データ 4 (寄付ユーザー機能)。", - "v11": "拡張データ 5 (寄付ユーザー機能)。", - "v12": "拡張データ 6 (寄付ユーザー機能)。", "c1": "電力量センサー (v1/v3) が累積生涯値を提供している場合はチェックしてください。", "n": "発電電力 (v2) が総発電量ではなく売電(Net)電力を表している場合はチェックしてください。" + }, + "sections": { + "donation_fields": { + "name": "寄付ユーザー機能", + "description": "有効なPVOutput寄付アカウントが必要です。", + "data": { + "v7": "v7: 拡張データ 1", + "v8": "v8: 拡張データ 2", + "v9": "v9: 拡張データ 3", + "v10": "v10: 拡張データ 4", + "v11": "v11: 拡張データ 5", + "v12": "v12: 拡張データ 6", + "b1": "b1: バッテリー電力 (W)", + "b2": "b2: バッテリー充電状態 (%)", + "b3": "b3: バッテリー実効容量 (Wh)", + "b4": "b4: バッテリー生涯充電量 (Wh)", + "b5": "b5: バッテリー生涯放電量 (Wh)", + "b6": "b6: バッテリー状態 (コード 0-9)" + }, + "data_description": { + "v7": "拡張データ 1 (寄付ユーザー機能)。", + "v8": "拡張データ 2 (寄付ユーザー機能)。", + "v9": "拡張データ 3 (寄付ユーザー機能)。", + "v10": "拡張データ 4 (寄付ユーザー機能)。", + "v11": "拡張データ 5 (寄付ユーザー機能)。", + "v12": "拡張データ 6 (寄付ユーザー機能)。", + "b1": "瞬時バッテリー電力(W、kWは自動変換)。正=充電、負=放電。", + "b2": "バッテリー充電状態率(0〜100%)。", + "b3": "バッテリーの有効容量(Wh、kWh/MWhは自動変換)。", + "b4": "生涯累積充電電力量(Wh、kWh/MWhは自動変換)。", + "b5": "生涯累積放電電力量(Wh、kWh/MWhは自動変換)。", + "b6": "バッテリー状態コード(0: 無効, 1: スタンバイ, 2: 熱管理, 3: 有効, 4: 異常, 5-9: ユーザー定義)。" + } + } } }, "systems_manager": { @@ -94,12 +116,6 @@ "v4": "v4: 消費電力 (W)", "v5": "v5: 温度センサー", "v6": "v6: 電圧センサー", - "v7": "v7: 拡張データ 1", - "v8": "v8: 拡張データ 2", - "v9": "v9: 拡張データ 3", - "v10": "v10: 拡張データ 4", - "v11": "v11: 拡張データ 5", - "v12": "v12: 拡張データ 6", "c1": "累積電力量フラグ (c1=1)", "n": "純発電電力フラグ (n=1)" }, @@ -114,14 +130,42 @@ "v4": "瞬時消費電力 (W) (kW は自動変換されます)。", "v5": "外気温 (°C / °F) (°F から °C に自動変換されます)。", "v6": "電圧 (V)。", - "v7": "拡張データ 1 (寄付ユーザー機能)。", - "v8": "拡張データ 2 (寄付ユーザー機能)。", - "v9": "拡張データ 3 (寄付ユーザー機能)。", - "v10": "拡張データ 4 (寄付ユーザー機能)。", - "v11": "拡張データ 5 (寄付ユーザー機能)。", - "v12": "拡張データ 6 (寄付ユーザー機能)。", "c1": "電力量センサー (v1/v3) が累積生涯値を提供している場合はチェックしてください。", "n": "発電電力 (v2) が総発電量ではなく売電(Net)電力を表している場合はチェックしてください。" + }, + "sections": { + "donation_fields": { + "name": "寄付ユーザー機能", + "description": "有効なPVOutput寄付アカウントが必要です。", + "data": { + "v7": "v7: 拡張データ 1", + "v8": "v8: 拡張データ 2", + "v9": "v9: 拡張データ 3", + "v10": "v10: 拡張データ 4", + "v11": "v11: 拡張データ 5", + "v12": "v12: 拡張データ 6", + "b1": "b1: バッテリー電力 (W)", + "b2": "b2: バッテリー充電状態 (%)", + "b3": "b3: バッテリー実効容量 (Wh)", + "b4": "b4: バッテリー生涯充電量 (Wh)", + "b5": "b5: バッテリー生涯放電量 (Wh)", + "b6": "b6: バッテリー状態 (コード 0-9)" + }, + "data_description": { + "v7": "拡張データ 1 (寄付ユーザー機能)。", + "v8": "拡張データ 2 (寄付ユーザー機能)。", + "v9": "拡張データ 3 (寄付ユーザー機能)。", + "v10": "拡張データ 4 (寄付ユーザー機能)。", + "v11": "拡張データ 5 (寄付ユーザー機能)。", + "v12": "拡張データ 6 (寄付ユーザー機能)。", + "b1": "瞬時バッテリー電力(W、kWは自動変換)。正=充電、負=放電。", + "b2": "バッテリー充電状態率(0〜100%)。", + "b3": "バッテリーの有効容量(Wh、kWh/MWhは自動変換)。", + "b4": "生涯累積充電電力量(Wh、kWh/MWhは自動変換)。", + "b5": "生涯累積放電電力量(Wh、kWh/MWhは自動変換)。", + "b6": "バッテリー状態コード(0: 無効, 1: スタンバイ, 2: 熱管理, 3: 有効, 4: 異常, 5-9: ユーザー定義)。" + } + } } } } diff --git a/custom_components/pvoutput_publisher/translations/zh-Hans.json b/custom_components/pvoutput_publisher/translations/zh-Hans.json index 6b10cdf..a5ed3c2 100644 --- a/custom_components/pvoutput_publisher/translations/zh-Hans.json +++ b/custom_components/pvoutput_publisher/translations/zh-Hans.json @@ -22,12 +22,6 @@ "v4": "v4: 用电功率 (W)", "v5": "v5: 温度传感器", "v6": "v6: 电压传感器", - "v7": "v7: 扩展数据 1", - "v8": "v8: 扩展数据 2", - "v9": "v9: 扩展数据 3", - "v10": "v10: 扩展数据 4", - "v11": "v11: 扩展数据 5", - "v12": "v12: 扩展数据 6", "c1": "累计电量标志 (c1=1)", "n": "净发电功率标志 (n=1)" }, @@ -42,14 +36,42 @@ "v4": "瞬时用电功率(单位瓦特,kW 会自动换算)。", "v5": "环境温度(°C 或 °F,°F 会自动转换为 °C)。", "v6": "电网/逆变器电压(单位伏特)。", - "v7": "扩展数据字段 1(需 PVOutput 赞助账户)。", - "v8": "扩展数据字段 2(需 PVOutput 赞助账户)。", - "v9": "扩展数据字段 3(需 PVOutput 赞助账户)。", - "v10": "扩展数据字段 4(需 PVOutput 赞助账户)。", - "v11": "扩展数据字段 5(需 PVOutput 赞助账户)。", - "v12": "扩展数据字段 6(需 PVOutput 赞助账户)。", "c1": "如果您的电量传感器 (v1/v3) 提供的是终身累计电量值,请勾选此项。", "n": "如果发电功率 (v2) 代表净上网功率而非总发电功率,请勾选此项。" + }, + "sections": { + "donation_fields": { + "name": "捐赠专享功能", + "description": "需要有效的 PVOutput 捐赠账户。", + "data": { + "v7": "v7: 扩展数据 1", + "v8": "v8: 扩展数据 2", + "v9": "v9: 扩展数据 3", + "v10": "v10: 扩展数据 4", + "v11": "v11: 扩展数据 5", + "v12": "v12: 扩展数据 6", + "b1": "b1: 电池功率 (W)", + "b2": "b2: 电池电量状态 / SOC (%)", + "b3": "b3: 电池可用容量 (Wh)", + "b4": "b4: 电池累计充电量 (Wh)", + "b5": "b5: 电池累计放电量 (Wh)", + "b6": "b6: 电池状态 (代码 0-9)" + }, + "data_description": { + "v7": "扩展数据字段 1(需 PVOutput 赞助账户)。", + "v8": "扩展数据字段 2(需 PVOutput 赞助账户)。", + "v9": "扩展数据字段 3(需 PVOutput 赞助账户)。", + "v10": "扩展数据字段 4(需 PVOutput 赞助账户)。", + "v11": "扩展数据字段 5(需 PVOutput 赞助账户)。", + "v12": "扩展数据字段 6(需 PVOutput 赞助账户)。", + "b1": "瞬时电池功率(瓦特,kW自动转换)。正值代表充电,负值代表放电。", + "b2": "电池荷电状态百分比(0-100%)。", + "b3": "可用电池容量(瓦时,kWh/MWh自动转换)。", + "b4": "全生命周期累计充电电量(瓦时,kWh/MWh自动转换)。", + "b5": "全生命周期累计放电电量(瓦时,kWh/MWh自动转换)。", + "b6": "数字电池状态代码(0: 无效, 1: 待机, 2: 热管理, 3: 启用, 4: 故障, 5-9: 用户自定义)。" + } + } } }, "systems_manager": { @@ -94,12 +116,6 @@ "v4": "v4: 用电功率 (W)", "v5": "v5: 温度传感器", "v6": "v6: 电压传感器", - "v7": "v7: 扩展数据 1", - "v8": "v8: 扩展数据 2", - "v9": "v9: 扩展数据 3", - "v10": "v10: 扩展数据 4", - "v11": "v11: 扩展数据 5", - "v12": "v12: 扩展数据 6", "c1": "累计电量标志 (c1=1)", "n": "净发电功率标志 (n=1)" }, @@ -114,14 +130,42 @@ "v4": "瞬时用电功率(单位瓦特,kW 会自动换算)。", "v5": "环境温度(°C 或 °F,°F 会自动转换为 °C)。", "v6": "电网/逆变器电压(单位伏特)。", - "v7": "扩展数据字段 1(需 PVOutput 赞助账户)。", - "v8": "扩展数据字段 2(需 PVOutput 赞助账户)。", - "v9": "扩展数据字段 3(需 PVOutput 赞助账户)。", - "v10": "扩展数据字段 4(需 PVOutput 赞助账户)。", - "v11": "扩展数据字段 5(需 PVOutput 赞助账户)。", - "v12": "扩展数据字段 6(需 PVOutput 赞助账户)。", "c1": "如果您的电量传感器 (v1/v3) 提供的是终身累计电量值,请勾选此项。", "n": "如果发电功率 (v2) 代表净上网功率而非总发电功率,请勾选此项。" + }, + "sections": { + "donation_fields": { + "name": "捐赠专享功能", + "description": "需要有效的 PVOutput 捐赠账户。", + "data": { + "v7": "v7: 扩展数据 1", + "v8": "v8: 扩展数据 2", + "v9": "v9: 扩展数据 3", + "v10": "v10: 扩展数据 4", + "v11": "v11: 扩展数据 5", + "v12": "v12: 扩展数据 6", + "b1": "b1: 电池功率 (W)", + "b2": "b2: 电池电量状态 / SOC (%)", + "b3": "b3: 电池可用容量 (Wh)", + "b4": "b4: 电池累计充电量 (Wh)", + "b5": "b5: 电池累计放电量 (Wh)", + "b6": "b6: 电池状态 (代码 0-9)" + }, + "data_description": { + "v7": "扩展数据字段 1(需 PVOutput 赞助账户)。", + "v8": "扩展数据字段 2(需 PVOutput 赞助账户)。", + "v9": "扩展数据字段 3(需 PVOutput 赞助账户)。", + "v10": "扩展数据字段 4(需 PVOutput 赞助账户)。", + "v11": "扩展数据字段 5(需 PVOutput 赞助账户)。", + "v12": "扩展数据字段 6(需 PVOutput 赞助账户)。", + "b1": "瞬时电池功率(瓦特,kW自动转换)。正值代表充电,负值代表放电。", + "b2": "电池荷电状态百分比(0-100%)。", + "b3": "可用电池容量(瓦时,kWh/MWh自动转换)。", + "b4": "全生命周期累计充电电量(瓦时,kWh/MWh自动转换)。", + "b5": "全生命周期累计放电电量(瓦时,kWh/MWh自动转换)。", + "b6": "数字电池状态代码(0: 无效, 1: 待机, 2: 热管理, 3: 启用, 4: 故障, 5-9: 用户自定义)。" + } + } } } } diff --git a/custom_components/pvoutput_publisher/translations/zh-Hant.json b/custom_components/pvoutput_publisher/translations/zh-Hant.json index 59cac33..6c199f3 100644 --- a/custom_components/pvoutput_publisher/translations/zh-Hant.json +++ b/custom_components/pvoutput_publisher/translations/zh-Hant.json @@ -22,12 +22,6 @@ "v4": "v4: 用電功率 (W)", "v5": "v5: 溫度感測器", "v6": "v6: 電壓感測器", - "v7": "v7: 擴充資料 1", - "v8": "v8: 擴充資料 2", - "v9": "v9: 擴充資料 3", - "v10": "v10: 擴充資料 4", - "v11": "v11: 擴充資料 5", - "v12": "v12: 擴充資料 6", "c1": "累計電量旗標 (c1=1)", "n": "淨發電功率旗標 (n=1)" }, @@ -42,14 +36,42 @@ "v4": "即時用電功率(單位瓦特,kW 會自動轉換)。", "v5": "環境溫度(°C 或 °F,°F 會自動轉換為 °C)。", "v6": "電網/變流器電壓(單位伏特)。", - "v7": "擴充資料欄位 1(需 PVOutput 贊助帳號)。", - "v8": "擴充資料欄位 2(需 PVOutput 贊助帳號)。", - "v9": "擴充資料欄位 3(需 PVOutput 贊助帳號)。", - "v10": "擴充資料欄位 4(需 PVOutput 贊助帳號)。", - "v11": "擴充資料欄位 5(需 PVOutput 贊助帳號)。", - "v12": "擴充資料欄位 6(需 PVOutput 贊助帳號)。", "c1": "如果您的電量感測器 (v1/v3) 提供的是累計總電量值,請勾選此項。", "n": "如果發電功率 (v2) 代表淨上網功率而非總發電功率,請勾選此項。" + }, + "sections": { + "donation_fields": { + "name": "贊助專享功能", + "description": "需要有效的 PVOutput 贊助帳戶。", + "data": { + "v7": "v7: 擴充資料 1", + "v8": "v8: 擴充資料 2", + "v9": "v9: 擴充資料 3", + "v10": "v10: 擴充資料 4", + "v11": "v11: 擴充資料 5", + "v12": "v12: 擴充資料 6", + "b1": "b1: 電池功率 (W)", + "b2": "b2: 電池電量狀態 / SOC (%)", + "b3": "b3: 電池可用容量 (Wh)", + "b4": "b4: 電池累計充電量 (Wh)", + "b5": "b5: 電池累計放電量 (Wh)", + "b6": "b6: 電池狀態 (代碼 0-9)" + }, + "data_description": { + "v7": "擴充資料欄位 1(需 PVOutput 贊助帳號)。", + "v8": "擴充資料欄位 2(需 PVOutput 贊助帳號)。", + "v9": "擴充資料欄位 3(需 PVOutput 贊助帳號)。", + "v10": "擴充資料欄位 4(需 PVOutput 贊助帳號)。", + "v11": "擴充資料欄位 5(需 PVOutput 贊助帳號)。", + "v12": "擴充資料欄位 6(需 PVOutput 贊助帳號)。", + "b1": "瞬時電池功率(瓦特,kW自動轉換)。正值代表充電,負值代表放電。", + "b2": "電池荷電狀態百分比(0-100%)。", + "b3": "可用電池容量(瓦時,kWh/MWh自動轉換)。", + "b4": "全生命週期累計充電電量(瓦時,kWh/MWh自動轉換)。", + "b5": "全生命週期累計放電電量(瓦時,kWh/MWh自動轉換)。", + "b6": "數字電池狀態代碼(0: 無效, 1: 待機, 2: 熱管理, 3: 啟用, 4: 故障, 5-9: 使用者自訂)。" + } + } } }, "systems_manager": { @@ -94,12 +116,6 @@ "v4": "v4: 用電功率 (W)", "v5": "v5: 溫度感測器", "v6": "v6: 電壓感測器", - "v7": "v7: 擴充資料 1", - "v8": "v8: 擴充資料 2", - "v9": "v9: 擴充資料 3", - "v10": "v10: 擴充資料 4", - "v11": "v11: 擴充資料 5", - "v12": "v12: 擴充資料 6", "c1": "累計電量旗標 (c1=1)", "n": "淨發電功率旗標 (n=1)" }, @@ -114,14 +130,42 @@ "v4": "即時用電功率(單位瓦特,kW 會自動轉換)。", "v5": "環境溫度(°C 或 °F,°F 會自動轉換為 °C)。", "v6": "電網/變流器電壓(單位伏特)。", - "v7": "擴充資料欄位 1(需 PVOutput 贊助帳號)。", - "v8": "擴充資料欄位 2(需 PVOutput 贊助帳號)。", - "v9": "擴充資料欄位 3(需 PVOutput 贊助帳號)。", - "v10": "擴充資料欄位 4(需 PVOutput 贊助帳號)。", - "v11": "擴充資料欄位 5(需 PVOutput 贊助帳號)。", - "v12": "擴充資料欄位 6(需 PVOutput 贊助帳號)。", "c1": "如果您的電量感測器 (v1/v3) 提供的是累計總電量值,請勾選此項。", "n": "如果發電功率 (v2) 代表淨上網功率而非總發電功率,請勾選此項。" + }, + "sections": { + "donation_fields": { + "name": "贊助專享功能", + "description": "需要有效的 PVOutput 贊助帳戶。", + "data": { + "v7": "v7: 擴充資料 1", + "v8": "v8: 擴充資料 2", + "v9": "v9: 擴充資料 3", + "v10": "v10: 擴充資料 4", + "v11": "v11: 擴充資料 5", + "v12": "v12: 擴充資料 6", + "b1": "b1: 電池功率 (W)", + "b2": "b2: 電池電量狀態 / SOC (%)", + "b3": "b3: 電池可用容量 (Wh)", + "b4": "b4: 電池累計充電量 (Wh)", + "b5": "b5: 電池累計放電量 (Wh)", + "b6": "b6: 電池狀態 (代碼 0-9)" + }, + "data_description": { + "v7": "擴充資料欄位 1(需 PVOutput 贊助帳號)。", + "v8": "擴充資料欄位 2(需 PVOutput 贊助帳號)。", + "v9": "擴充資料欄位 3(需 PVOutput 贊助帳號)。", + "v10": "擴充資料欄位 4(需 PVOutput 贊助帳號)。", + "v11": "擴充資料欄位 5(需 PVOutput 贊助帳號)。", + "v12": "擴充資料欄位 6(需 PVOutput 贊助帳號)。", + "b1": "瞬時電池功率(瓦特,kW自動轉換)。正值代表充電,負值代表放電。", + "b2": "電池荷電狀態百分比(0-100%)。", + "b3": "可用電池容量(瓦時,kWh/MWh自動轉換)。", + "b4": "全生命週期累計充電電量(瓦時,kWh/MWh自動轉換)。", + "b5": "全生命週期累計放電電量(瓦時,kWh/MWh自動轉換)。", + "b6": "數字電池狀態代碼(0: 無效, 1: 待機, 2: 熱管理, 3: 啟用, 4: 故障, 5-9: 使用者自訂)。" + } + } } } } diff --git a/docs/design_spec.md b/docs/design_spec.md index a4d113a..c90112d 100644 --- a/docs/design_spec.md +++ b/docs/design_spec.md @@ -45,6 +45,14 @@ CONF_V10 = "v10" # Extended Data 4 CONF_V11 = "v11" # Extended Data 5 CONF_V12 = "v12" # Extended Data 6 +# PVOutput Battery Parameters +CONF_B1 = "b1" # Battery Power (W) +CONF_B2 = "b2" # State of Charge (%) +CONF_B3 = "b3" # Battery Usable Capacity (Wh) +CONF_B4 = "b4" # Battery Lifetime Charge (Wh) +CONF_B5 = "b5" # Battery Lifetime Discharge (Wh) +CONF_B6 = "b6" # Battery State (0-9) + # PVOutput Flags CONF_C1 = "c1" # Cumulative Energy Flag (bool -> &c1=1) CONF_NET = "n" # Net Power Flag (bool -> &n=1) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 1456187..298c8a7 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,4 +1,4 @@ -"""Test the PVOutput Publisher config flow.""" +import voluptuous as vol from unittest.mock import patch import pytest @@ -6,11 +6,13 @@ from homeassistant.data_entry_flow import FlowResultType from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.pvoutput_publisher.config_flow import _get_system_schema from custom_components.pvoutput_publisher.const import ( DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID, CONF_FREQUENCY, 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_C1, CONF_NET + CONF_B1, CONF_B2, CONF_B3, CONF_B4, CONF_B5, CONF_B6, + CONF_C1, CONF_NET, CONF_DONATION_FIELDS ) @pytest.mark.asyncio @@ -41,7 +43,7 @@ async def test_initial_setup_flow(hass): assert result["type"] == FlowResultType.FORM assert result["step_id"] == "add_system" - # Submit First Solar System with V2 fields, notes, and flags + # Submit First Solar System with V2 fields, notes, battery (in donation section), and flags result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -52,6 +54,10 @@ async def test_initial_setup_flow(hass): CONF_V1: "sensor.inverter_energy", CONF_V2: "sensor.inverter_power", CONF_V6: "sensor.inverter_voltage", + CONF_DONATION_FIELDS: { + CONF_B1: "sensor.battery_power", + CONF_B2: "sensor.battery_soc", + }, CONF_C1: True, CONF_NET: False, } @@ -78,6 +84,8 @@ async def test_initial_setup_flow(hass): assert sys[CONF_V1] == "sensor.inverter_energy" assert sys[CONF_V2] == "sensor.inverter_power" assert sys[CONF_V6] == "sensor.inverter_voltage" + assert sys[CONF_B1] == "sensor.battery_power" + assert sys[CONF_B2] == "sensor.battery_soc" assert sys[CONF_C1] is True assert sys[CONF_NET] is False assert len(mock_setup.mock_calls) == 1 @@ -150,6 +158,9 @@ async def test_options_flow_manage_systems(hass): CONF_V1: "sensor.energy_1", CONF_V2: "sensor.power_1", CONF_V6: "sensor.new_voltage", + CONF_DONATION_FIELDS: { + CONF_B1: "sensor.battery_power", + }, CONF_C1: True, } ) @@ -168,7 +179,9 @@ async def test_options_flow_manage_systems(hass): CONF_FREQUENCY: "10", CONF_NOTES: "East roof", CONF_V2: "sensor.power_2", - CONF_V7: "sensor.battery_soc", + CONF_DONATION_FIELDS: { + CONF_V7: "sensor.battery_soc", + }, CONF_NET: True, } ) @@ -216,6 +229,12 @@ async def test_options_flow_edit_all_fields(hass): CONF_V10: "sensor.v10", CONF_V11: "sensor.v11", CONF_V12: "sensor.v12", + CONF_B1: "sensor.b1", + CONF_B2: "sensor.b2", + CONF_B3: "sensor.b3", + CONF_B4: "sensor.b4", + CONF_B5: "sensor.b5", + CONF_B6: "sensor.b6", CONF_C1: True, CONF_NET: True, } @@ -232,9 +251,37 @@ async def test_options_flow_edit_all_fields(hass): ) assert result["step_id"] == "add_system" - # Submit without changing + # Submit with donation fields nested as frontend sends them + frontend_submission = { + CONF_NAME: "Full System", + CONF_SYSTEM_ID: "999", + CONF_FREQUENCY: "15", + CONF_NOTES: "Full system notes", + CONF_V1: "sensor.v1", + CONF_V2: "sensor.v2", + CONF_V3: "sensor.v3", + CONF_V4: "sensor.v4", + CONF_V5: "sensor.v5", + CONF_V6: "sensor.v6", + CONF_DONATION_FIELDS: { + CONF_V7: "sensor.v7", + CONF_V8: "sensor.v8", + CONF_V9: "sensor.v9", + CONF_V10: "sensor.v10", + CONF_V11: "sensor.v11", + CONF_V12: "sensor.v12", + CONF_B1: "sensor.b1", + CONF_B2: "sensor.b2", + CONF_B3: "sensor.b3", + CONF_B4: "sensor.b4", + CONF_B5: "sensor.b5", + CONF_B6: "sensor.b6", + }, + CONF_C1: True, + CONF_NET: True, + } result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input=full_system + result["flow_id"], user_input=frontend_submission ) assert result["step_id"] == "systems_manager" @@ -271,3 +318,82 @@ async def test_setup_with_custom_temperature_sensor(hass): assert result["data"][CONF_SYSTEMS][0][CONF_V5] == "sensor.custom_outdoor_temperature" +def test_schema_donation_section_collapsed_logic(): + """Test that donation section starts collapsed when empty, and expanded when configured.""" + # 1. Empty / new system -> collapsed = True + empty_schema = _get_system_schema({}) + donation_sec_empty = empty_schema.schema[vol.Optional(CONF_DONATION_FIELDS)] + assert donation_sec_empty.options["collapsed"] is True + + # 2. System with only free sensors -> collapsed = True + free_only_schema = _get_system_schema({ + CONF_NAME: "Solar", + CONF_V1: "sensor.energy", + CONF_V2: "sensor.power", + CONF_V3: "sensor.consumption", + CONF_V4: "sensor.power_con", + CONF_V5: "sensor.temp", + CONF_V6: "sensor.voltage", + CONF_C1: True, + CONF_NET: False, + }) + donation_sec_free = free_only_schema.schema[vol.Optional(CONF_DONATION_FIELDS)] + assert donation_sec_free.options["collapsed"] is True + + # 3. System with each donation sensor individually -> collapsed = False + for k in [CONF_V7, CONF_V8, CONF_V9, CONF_V10, CONF_V11, CONF_V12, + CONF_B1, CONF_B2, CONF_B3, CONF_B4, CONF_B5, CONF_B6]: + schema = _get_system_schema({CONF_NAME: "Solar", k: "sensor.test"}) + sec = schema.schema[vol.Optional(CONF_DONATION_FIELDS)] + assert sec.options["collapsed"] is False, f"Expected collapsed=False when {k} is set" + + +@pytest.mark.asyncio +async def test_options_flow_form_section_collapse_state(hass): + """Test that editing an existing array in options flow properly reflects collapsed/expanded state.""" + free_system = { + CONF_NAME: "Free Array", + CONF_SYSTEM_ID: "111", + CONF_V1: "sensor.energy", + } + donation_system = { + CONF_NAME: "Battery Array", + CONF_SYSTEM_ID: "222", + CONF_B1: "sensor.battery_power", + } + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "key123", CONF_SYSTEMS: [free_system, donation_system]}, + version=2 + ) + entry.add_to_hass(hass) + + # 1. Edit free array -> donation section is collapsed + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"action": "edit_0"} + ) + assert result["step_id"] == "add_system" + schema = result["data_schema"] + sec = schema.schema[vol.Optional(CONF_DONATION_FIELDS)] + assert sec.options["collapsed"] is True + + # 2. Cancel and edit donation array -> donation section is expanded + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"action": "edit_1"} + ) + assert result["step_id"] == "add_system" + schema = result["data_schema"] + sec = schema.schema[vol.Optional(CONF_DONATION_FIELDS)] + assert sec.options["collapsed"] is False + + +def test_get_system_schema_with_none_donation_fields(): + """Test that _get_system_schema does not crash when donation_fields is explicitly None.""" + from custom_components.pvoutput_publisher.config_flow import _get_system_schema + schema = _get_system_schema({CONF_DONATION_FIELDS: None, CONF_NAME: "Test", CONF_SYSTEM_ID: "123"}) + assert schema is not None + + + diff --git a/tests/test_init.py b/tests/test_init.py index b6f36fa..2d8d987 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -22,6 +22,7 @@ DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID, CONF_FREQUENCY, 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, PVOUTPUT_API_URL @@ -78,19 +79,25 @@ async def test_setup_and_unload(hass: HomeAssistant): @pytest.mark.asyncio async def test_push_data_all_sensors_v2(hass: HomeAssistant): - """Test payload generation with all V2 sensors, extended metrics, and flags.""" + """Test payload generation with all V2 sensors, extended metrics, battery, and flags.""" hass.states.async_set("sensor.solar_energy", "12500", {"unit_of_measurement": "Wh"}) hass.states.async_set("sensor.solar_power", "3500", {"unit_of_measurement": "W"}) hass.states.async_set("sensor.home_energy", "4500", {"unit_of_measurement": "Wh"}) hass.states.async_set("sensor.home_power", "800", {"unit_of_measurement": "W"}) hass.states.async_set("sensor.outside_temp", "22.5", {"unit_of_measurement": "°C"}) hass.states.async_set("sensor.grid_voltage", "240.2", {"unit_of_measurement": "V"}) - hass.states.async_set("sensor.battery_soc", "88.5", {}) + hass.states.async_set("sensor.ext_7", "88.5", {}) hass.states.async_set("sensor.ext_8", "12", {}) hass.states.async_set("sensor.ext_9", "34", {}) hass.states.async_set("sensor.ext_10", "56", {}) hass.states.async_set("sensor.ext_11", "78", {}) hass.states.async_set("sensor.ext_12", "90", {}) + hass.states.async_set("sensor.battery_power", "1500", {"unit_of_measurement": "W"}) + hass.states.async_set("sensor.battery_soc", "75.5", {}) + hass.states.async_set("sensor.battery_size", "10000", {"unit_of_measurement": "Wh"}) + hass.states.async_set("sensor.battery_life_chg", "50000", {"unit_of_measurement": "Wh"}) + hass.states.async_set("sensor.battery_life_dis", "48000", {"unit_of_measurement": "Wh"}) + hass.states.async_set("sensor.battery_state", "3", {}) system_config = { CONF_NAME: "Test System", @@ -103,12 +110,18 @@ async def test_push_data_all_sensors_v2(hass: HomeAssistant): CONF_V4: "sensor.home_power", CONF_V5: "sensor.outside_temp", CONF_V6: "sensor.grid_voltage", - CONF_V7: "sensor.battery_soc", + CONF_V7: "sensor.ext_7", CONF_V8: "sensor.ext_8", CONF_V9: "sensor.ext_9", CONF_V10: "sensor.ext_10", CONF_V11: "sensor.ext_11", CONF_V12: "sensor.ext_12", + CONF_B1: "sensor.battery_power", + CONF_B2: "sensor.battery_soc", + CONF_B3: "sensor.battery_size", + CONF_B4: "sensor.battery_life_chg", + CONF_B5: "sensor.battery_life_dis", + CONF_B6: "sensor.battery_state", CONF_C1: True, CONF_NET: True, } @@ -129,11 +142,50 @@ async def test_push_data_all_sensors_v2(hass: HomeAssistant): assert "&v10=56" in payload assert "&v11=78" in payload assert "&v12=90" in payload + assert "&b1=1500" in payload # Battery Power (W) + assert "&b2=75.5" in payload # Battery SOC (%) + assert "&b3=10000" in payload # Battery Size (Wh) + assert "&b4=50000" in payload # Lifetime Charge (Wh) + assert "&b5=48000" in payload # Lifetime Discharge (Wh) + assert "&b6=3" in payload # Battery State code assert "&c1=1" in payload # Lifetime flag assert "&n=1" in payload # Net flag assert "Internal array notes" not in payload # Notes must NOT be sent to API +@pytest.mark.asyncio +async def test_push_data_battery_metrics_and_conversions(hass: HomeAssistant): + """Test battery power discharge (negative), kW and kWh conversion.""" + hass.states.async_set("sensor.battery_discharge_kw", "-2.4", {"unit_of_measurement": "kW"}) + hass.states.async_set("sensor.battery_soc_int", "90", {}) + hass.states.async_set("sensor.battery_cap_kwh", "13.5", {"unit_of_measurement": "kWh"}) + hass.states.async_set("sensor.battery_chg_mwh", "0.05", {"unit_of_measurement": "MWh"}) + hass.states.async_set("sensor.battery_dis_kwh", "45.2", {"unit_of_measurement": "kWh"}) + hass.states.async_set("sensor.battery_mode", "1", {}) + + system_config = { + CONF_NAME: "Battery Test", + CONF_SYSTEM_ID: MOCK_SYSTEM_ID, + CONF_FREQUENCY: "5", + CONF_B1: "sensor.battery_discharge_kw", + CONF_B2: "sensor.battery_soc_int", + CONF_B3: "sensor.battery_cap_kwh", + CONF_B4: "sensor.battery_chg_mwh", + CONF_B5: "sensor.battery_dis_kwh", + CONF_B6: "sensor.battery_mode", + } + + with aioresponses() as mock_aio: + payload = await setup_and_trigger(hass, mock_aio, system_config) + + assert "&b1=-2400" in payload # -2.4 kW -> -2400 W + assert "&b2=90" in payload # 90% SOC + assert "&b3=13500" in payload # 13.5 kWh -> 13500 Wh + assert "&b4=50000" in payload # 0.05 MWh -> 50000 Wh + assert "&b5=45200" in payload # 45.2 kWh -> 45200 Wh + assert "&b6=1" in payload # Standby state code + + @pytest.mark.asyncio async def test_push_data_conversions_and_scaling(hass: HomeAssistant): """Test that units like kW, kWh, MWh, and Fahrenheit are properly converted before sending.""" @@ -180,8 +232,24 @@ async def test_push_data_minimal(hass: HomeAssistant): payload = await setup_and_trigger(hass, mock_aio, system_config) assert "&v2=2000" in payload + # Verify free and donation parameters are omitted when not defined assert "&v1=" not in payload + assert "&v3=" not in payload + assert "&v4=" not in payload assert "&v5=" not in payload + assert "&v6=" not in payload + assert "&v7=" not in payload + assert "&v8=" not in payload + assert "&v9=" not in payload + assert "&v10=" not in payload + assert "&v11=" not in payload + assert "&v12=" not in payload + assert "&b1=" not in payload + assert "&b2=" not in payload + assert "&b3=" not in payload + assert "&b4=" not in payload + assert "&b5=" not in payload + assert "&b6=" not in payload @pytest.mark.asyncio @@ -311,6 +379,12 @@ async def test_async_migrate_entry_v1_to_v2_with_live_states(hass: HomeAssistant assert migrated[CONF_V4] is None assert migrated[CONF_V5] == "sensor.temp" assert migrated[CONF_V6] == "sensor.volts" + assert migrated[CONF_B1] is None + assert migrated[CONF_B2] is None + assert migrated[CONF_B3] is None + assert migrated[CONF_B4] is None + assert migrated[CONF_B5] is None + assert migrated[CONF_B6] is None assert migrated[CONF_C1] is True # total_increasing set c1=True assert migrated[CONF_NET] is False assert migrated[CONF_NOTES] == "" @@ -396,6 +470,12 @@ async def test_push_data_with_invalid_sensor_values(hass: HomeAssistant): hass.states.async_set("sensor.bad_v5", "err", {"unit_of_measurement": "°C"}) hass.states.async_set("sensor.bad_v6", "none", {}) hass.states.async_set("sensor.bad_v7", "xyz", {}) + hass.states.async_set("sensor.bad_b1", "invalid_power", {"unit_of_measurement": "kW"}) + hass.states.async_set("sensor.bad_b2", "bad_soc", {}) + hass.states.async_set("sensor.bad_b3", "bad_size", {}) + hass.states.async_set("sensor.bad_b4", "bad_life_chg", {}) + hass.states.async_set("sensor.bad_b5", "bad_life_dis", {}) + hass.states.async_set("sensor.bad_b6", "bad_state", {}) hass.states.async_set("sensor.good_v2", "1500", {"unit_of_measurement": "W"}) system_config = { @@ -409,6 +489,12 @@ async def test_push_data_with_invalid_sensor_values(hass: HomeAssistant): CONF_V5: "sensor.bad_v5", CONF_V6: "sensor.bad_v6", CONF_V7: "sensor.bad_v7", + CONF_B1: "sensor.bad_b1", + CONF_B2: "sensor.bad_b2", + CONF_B3: "sensor.bad_b3", + CONF_B4: "sensor.bad_b4", + CONF_B5: "sensor.bad_b5", + CONF_B6: "sensor.bad_b6", } entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: MOCK_API_KEY, CONF_SYSTEMS: [system_config]}, version=2) @@ -560,4 +646,54 @@ async def test_push_data_temperature_sensor_without_device_class(hass: HomeAssis assert "&v2=1500" in payload +@pytest.mark.asyncio +async def test_push_data_with_none_unit_of_measurement(hass: HomeAssistant): + """Test that sensors with unit_of_measurement: None (e.g. template/MQTT) do not raise AttributeError.""" + hass.states.async_set("sensor.none_unit_power", "3500", {"unit_of_measurement": None}) + hass.states.async_set("sensor.none_unit_energy", "12000", {"unit_of_measurement": None}) + hass.states.async_set("sensor.none_unit_batt", "1500", {"unit_of_measurement": None}) + + system_config = { + CONF_NAME: "None Unit System", + CONF_SYSTEM_ID: MOCK_SYSTEM_ID, + CONF_FREQUENCY: "5", + CONF_V1: "sensor.none_unit_energy", + CONF_V2: "sensor.none_unit_power", + CONF_B1: "sensor.none_unit_batt", + } + + with aioresponses() as mock_aio: + payload = await setup_and_trigger(hass, mock_aio, system_config) + + assert "&v1=12000" in payload + assert "&v2=3500" in payload + assert "&b1=1500" in payload + + +@pytest.mark.asyncio +async def test_battery_b6_range_validation_and_b1_mandatory(hass: HomeAssistant, caplog: pytest.LogCaptureFixture): + """Test that b6 codes outside 0-9 are omitted and warning is logged when b2-b6 exist without b1.""" + # Out-of-range b6 code (e.g. 99) + hass.states.async_set("sensor.batt_soc", "80", {"unit_of_measurement": "%"}) + hass.states.async_set("sensor.invalid_mode", "99", {}) + + system_config = { + CONF_NAME: "Battery Warning System", + CONF_SYSTEM_ID: MOCK_SYSTEM_ID, + CONF_FREQUENCY: "5", + # b1 intentionally omitted to test mandatory b1 warning + CONF_B2: "sensor.batt_soc", + CONF_B6: "sensor.invalid_mode", + } + + with aioresponses() as mock_aio: + payload = await setup_and_trigger(hass, mock_aio, system_config) + + assert "&b2=80" in payload + assert "&b6=" not in payload # 99 should be omitted due to being outside 0-9 + assert "Battery State (b6) value 99 is out of range (0-9)" in caplog.text + assert "Battery parameters (b2-b6) were provided without b1" in caplog.text + + +