diff --git a/.gitignore b/.gitignore index 0d02882..cf9c2e4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .gradle gradle.properties build/ +.coverage ### STS ### .apt_generated diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a5323..ff6e0b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## 2.0.0 (10/11/2026) + +### Breaking Changes / Architecture Upgrade +- Upgraded configuration schema to **Version 2** with direct 1-to-1 parameter mappings to the PVOutput API (`v1`–`v12`, `c1` cumulative flag, `n` net flag). +- Automated, non-destructive migration (`async_migrate_entry`) from v1 to v2 on installation/startup with zero configuration loss. +- **Cumulative Energy Flag (`c1`) Migration Note:** + - Migrated systems with an Energy Generation sensor (`v1`) default the **Cumulative Energy Flag (`c1=1`)** to enabled. This matches PVOutput's requirement for lifetime counter sensors (such as inverter lifetime yield) so PVOutput calculates interval yields instead of rejecting the value as "Energy value too high". + - **How to verify your configuration:** Go to **Settings > Devices & Services > PVOutput Publisher > Configure**, then click **Edit** on your array. + - If your `v1` sensor is a lifetime yield counter (monotonically increasing and does not reset to 0 daily), keep **Cumulative Energy Flag (c1=1)** checked. + - If your `v1` sensor resets to 0 at midnight (daily yield), uncheck this box. + +### Feature Changes +- Added support for Extended Data parameters `v7` through `v12` (PVOutput donation features). +- Added support for explicit Net Flag (`n=1`) and Cumulative Energy Flag (`c1=1`). +- Added internal private **Notes** field per solar array for user reference. + ## 1.2.0 (06/05/2026) ### Feature Changes diff --git a/README.md b/README.md index 7f36d02..e6051e6 100644 --- a/README.md +++ b/README.md @@ -5,33 +5,28 @@ A custom Home Assistant integration that actively pushes your solar generation, Unlike the official Home Assistant PVOutput integration (which only *downloads* data), this integration acts as an automated publisher, bridging your local Home Assistant sensors to your public PVOutput dashboard. ## Disclaimer: AI-Generated Project -Please note that the code and documentation for this project were primarily generated with the assistance of an AI. While it has been actively tested and confirmed to work perfectly within Home Assistant, it is provided "as is." Please review the code and use it at your own discretion. +Please note that the code and documentation for this project were primarily generated with the assistance of an AI. While it has been actively tested and confirmed to work within Home Assistant, it is provided "as is." Please review the code and use it at your own discretion. --- ## Features -* **UI Config Flow:** Fully configurable via the Home Assistant UI. No YAML required. +* **UI Config Flow:** Fully configurable via the Home Assistant UI with 1-to-1 mappings to PVOutput API parameters. 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 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). -* **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, 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 -* **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. +* **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:** + * **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. +* **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. --- @@ -42,7 +37,7 @@ This integration is installed via [HACS](https://hacs.xyz/). 1. Open **HACS** in your Home Assistant instance. 2. Go to **Integrations**. 3. Click the three dots in the top right corner and select **Custom repositories**. -4. Paste the URL of this GitHub repository `https://github.com/SourceLabOrg/HomeAssistant-PVOutputPublisher` +4. Paste the URL of this GitHub repository: `https://github.com/SourceLabOrg/HomeAssistant-PVOutputPublisher` 5. Select **Integration** as the Category and click **Add**. 6. Search for **PVOutput Publisher** in HACS and click **Download**. 7. **Restart Home Assistant**. @@ -55,15 +50,20 @@ This integration is installed via [HACS](https://hacs.xyz/). 2. Click **Add Integration** in the bottom right corner. 3. Search for **PVOutput Publisher**. 4. Enter your global **PVOutput API Key** (found in your PVOutput account settings). -5. Add your first system by providing: +5. Configure your solar array parameters: * **System Name:** A friendly name for your reference. * **System ID:** Your PVOutput System ID. - * **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. - * **Voltage Sensor:** (Optional) Tracks grid/inverter operational voltage lines. - * **Update Frequency:** How often to push data to PVOutput (5 to 180 minutes). + * **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. ### 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**. @@ -74,17 +74,17 @@ You can add, edit, or remove systems at any time. Simply go to **Settings > Devi For every system you configure, this integration creates a sensor: * `sensor.pvoutput_[system_name]_last_upload` -This sensor tracks the exact date and time of the last successful HTTP 200 OK response from the PVOutput API. You can use this sensor in your Lovelace dashboards or to trigger automations (e.g., send a notification if data hasn't successfully published in over an hour). +This sensor tracks the exact date and time of the last successful HTTP 200 OK response from the PVOutput API. You can use this sensor in your Lovelace dashboards or to trigger automations (e.g., alert if data hasn't published in over an hour). --- ## Troubleshooting & Logs -If data is not appearing in PVOutput, check your Home Assistant logs (**Settings > System > Logs**). The integration gracefully handles network drops, but will print detailed error messages if the PVOutput API rejects your payload (e.g., invalid API key, incorrect System ID, or pushing data too far in the past). You can view the raw outbound payload by setting the logging level for `custom_components.pvoutput_publisher` to `info` in your `configuration.yaml`. +If data is not appearing in PVOutput, check your Home Assistant logs (**Settings > System > Logs**). The integration will log the exact formatted outbound metrics at the `INFO` level: ```yaml ## Logging logger: default: warning logs: - custom_components.pvoutput_publisher: debug + custom_components.pvoutput_publisher: info ``` diff --git a/custom_components/pvoutput_publisher/__init__.py b/custom_components/pvoutput_publisher/__init__.py index 0b7cc25..d4a433c 100644 --- a/custom_components/pvoutput_publisher/__init__.py +++ b/custom_components/pvoutput_publisher/__init__.py @@ -5,6 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.const import Platform +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -12,14 +13,162 @@ from .const import ( DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID, + 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_C1, CONF_NET, CONF_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID, - CONF_TEMPERATURE_ENTITY_ID, CONF_FREQUENCY, PVOUTPUT_API_URL, CONF_VOLTAGE_ENTITY_ID + CONF_TEMPERATURE_ENTITY_ID, CONF_VOLTAGE_ENTITY_ID, DEFAULT_FREQUENCY ) _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] +def _determine_energy_or_power(hass: HomeAssistant, entity_id: str | None) -> tuple[str | None, bool]: + """Helper to detect whether an entity is energy (v1/v3) or power (v2/v4) and if lifetime.""" + if not entity_id: + return None, False + + # 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() + is_lifetime = state_class in ["total", "total_increasing"] + + if unit in ["wh", "kwh", "mwh"] or device_class == "energy": + return "energy", is_lifetime + if unit in ["w", "kw", "kilowatt", "kilowatts"] or device_class == "power": + return "power", False + + # 2. Try checking Home Assistant Entity Registry + try: + registry = er.async_get(hass) + entry_obj = registry.async_get(entity_id) + if entry_obj: + unit = (entry_obj.unit_of_measurement or "").lower() + dc = (entry_obj.device_class or entry_obj.original_device_class or "").lower() + if unit in ["wh", "kwh", "mwh"] or dc == "energy": + return "energy", False + if unit in ["w", "kw"] or dc == "power": + return "power", False + except Exception: + pass + + # 3. String heuristics fallback + lower_id = entity_id.lower() + if any(k in lower_id for k in ["energy", "kwh", "wh", "yield", "total"]): + return "energy", False + if any(k in lower_id for k in ["power", "kw", "watt"]): + return "power", False + + # Default to power if cannot determine + return "power", False + + +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: + return system + + name = system.get(CONF_NAME, system.get(CONF_SYSTEM_ID, "Solar System")) + system_id = system.get(CONF_SYSTEM_ID, "") + frequency = str(system.get(CONF_FREQUENCY, DEFAULT_FREQUENCY)) + notes = system.get(CONF_NOTES, "") + + prim_id = system.get(CONF_ENTITY_ID) + sec_id = system.get(CONF_SECONDARY_ENTITY_ID) + con_id = system.get(CONF_CONSUMPTION_ENTITY_ID) + temp_id = system.get(CONF_TEMPERATURE_ENTITY_ID) + volt_id = system.get(CONF_VOLTAGE_ENTITY_ID) + + v1 = None + v2 = None + c1 = False + + if prim_id: + prim_type, _ = _determine_energy_or_power(hass, prim_id) + if prim_type == "energy": + v1 = prim_id + # NOTE: Home Assistant's Entity Registry stores unit_of_measurement and + # device_class on disk, but does NOT persist state_class. Because async_migrate_entry + # runs before other integrations initialize their live entity states at boot, + # state_class (e.g. total_increasing) cannot be read from hass.states here. + # In PVOutput, virtually all solar inverter energy sensors (Wh/kWh) report lifetime + # cumulative totals. Without c1=1, PVOutput rejects lifetime values as "Energy too high". + # We therefore default c1=True whenever an Energy sensor is migrated to v1. + c1 = True + if sec_id: + sec_type, _ = _determine_energy_or_power(hass, sec_id) + if sec_type == "power": + v2 = sec_id + else: + v2 = prim_id + if sec_id: + sec_type, _ = _determine_energy_or_power(hass, sec_id) + if sec_type == "energy": + v1 = sec_id + c1 = True + elif sec_id: + sec_type, _ = _determine_energy_or_power(hass, sec_id) + if sec_type == "energy": + v1 = sec_id + c1 = True + else: + v2 = sec_id + + v3 = None + v4 = None + if con_id: + con_type, _ = _determine_energy_or_power(hass, con_id) + if con_type == "energy": + v3 = con_id + if not v1: + c1 = True + else: + v4 = con_id + + return { + CONF_NAME: name, + CONF_SYSTEM_ID: system_id, + CONF_FREQUENCY: frequency, + CONF_NOTES: notes, + CONF_V1: v1, + CONF_V2: v2, + CONF_V3: v3, + CONF_V4: v4, + CONF_V5: temp_id, + CONF_V6: volt_id, + CONF_V7: None, + CONF_V8: None, + CONF_V9: None, + CONF_V10: None, + CONF_V11: None, + CONF_V12: None, + CONF_C1: c1, + CONF_NET: False, + } + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry to version 2.""" + _LOGGER.info("Migrating PVOutput Publisher configuration entry from version %s to 2", config_entry.version) + + if config_entry.version == 1: + new_data = {**config_entry.data} + raw_systems = new_data.get(CONF_SYSTEMS, []) + migrated_systems = [migrate_v1_system_to_v2(hass, sys) for sys in raw_systems] + new_data[CONF_SYSTEMS] = migrated_systems + + hass.config_entries.async_update_entry(config_entry, data=new_data, version=2) + _LOGGER.info("PVOutput Publisher migration to version 2 successful") + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) api_key = entry.data[CONF_API_KEY] @@ -28,162 +177,149 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_listeners = [] - for system in systems: + for raw_system in systems: + # Guarantee system is in v2 format even if migration was somehow bypassed + system = migrate_v1_system_to_v2(hass, raw_system) + 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) - voltage_ent_id = system.get(CONF_VOLTAGE_ENTITY_ID) - frequency = int(system[CONF_FREQUENCY]) + frequency = int(system.get(CONF_FREQUENCY, DEFAULT_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, sec_id=secondary_generation_ent_id, con_id=consumption_ent_id, temp_id=temperature_ent_id, volt_id=voltage_ent_id, name=sys_name): - gen_state = hass.states.get(gen_id) - if not gen_state or gen_state.state in ['unknown', 'unavailable']: - return - - try: - gen_value = float(gen_state.state) - except ValueError: - return - - gen_unit = gen_state.attributes.get("unit_of_measurement", "").lower() - gen_state_class = gen_state.attributes.get("state_class", "").lower() - + async def push_data(now: datetime, sys_data=system, sys_id=system_id, name=sys_name): # Localized time local_time = dt_util.now() d = local_time.strftime('%Y%m%d') t = local_time.strftime('%H:%M') payload = f"d={d}&t={t}" - - # This list will hold our human-readable log strings log_parts = [] - # Flags to prevent overwriting if user selects duplicate sensor types - has_energy_v1 = False - has_power_v2 = False - - # 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 - - if gen_state_class in ["total", "total_increasing"]: - payload += "&c1=1" - log_parts.append(f"Gen1 (Lifetime): {raw_gen} {gen_unit} -> v1={int(gen_value)}") - else: - 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"Gen1 (Power): {raw_gen} {gen_unit} -> v2={int(gen_value)}") - has_power_v2 = True - - # 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']: + # v1: Energy Generation (Wh) + v1_id = sys_data.get(CONF_V1) + if v1_id: + v1_state = hass.states.get(v1_id) + if v1_state and v1_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 + v1_val = float(v1_state.state) + unit = v1_state.attributes.get("unit_of_measurement", "").lower() + raw = v1_val + if unit == "kwh": + v1_val *= 1000 + elif unit == "mwh": + v1_val *= 1000000 + payload += f"&v1={int(v1_val)}" + log_parts.append(f"v1 (Energy Gen): {raw} {unit} -> {int(v1_val)}Wh") except ValueError: pass - # Add Optional Consumption Data (v3 / v4) - if con_id: - con_state = hass.states.get(con_id) - if con_state and con_state.state not in ['unknown', 'unavailable']: + # v2: Power Generation (W) + v2_id = sys_data.get(CONF_V2) + if v2_id: + v2_state = hass.states.get(v2_id) + if v2_state and v2_state.state not in ['unknown', 'unavailable']: try: - con_value = float(con_state.state) - con_unit = con_state.attributes.get("unit_of_measurement", "").lower() - raw_con = con_value - - if con_unit in ["wh", "kwh", "mwh"]: - if con_unit == "kwh": - con_value *= 1000 - elif con_unit == "mwh": - con_value *= 1000000 - payload += f"&v3={int(con_value)}" - log_parts.append(f"Con (Energy): {raw_con} {con_unit} -> v3={int(con_value)}") - else: - if con_unit in ["kw", "kilowatt", "kilowatts"]: - con_value *= 1000 - payload += f"&v4={int(con_value)}" - log_parts.append(f"Con (Power): {raw_con} {con_unit} -> v4={int(con_value)}") + v2_val = float(v2_state.state) + unit = v2_state.attributes.get("unit_of_measurement", "").lower() + raw = v2_val + if unit in ["kw", "kilowatt", "kilowatts"]: + v2_val *= 1000 + payload += f"&v2={int(v2_val)}" + log_parts.append(f"v2 (Power Gen): {raw} {unit} -> {int(v2_val)}W") except ValueError: pass - # Add Optional Temperature Data (v5) - if temp_id: - temp_state = hass.states.get(temp_id) - if temp_state and temp_state.state not in ['unknown', 'unavailable']: + # v3: Energy Consumption (Wh) + v3_id = sys_data.get(CONF_V3) + if v3_id: + v3_state = hass.states.get(v3_id) + if v3_state and v3_state.state not in ['unknown', 'unavailable']: try: - temp_value = float(temp_state.state) - temp_unit = temp_state.attributes.get("unit_of_measurement", "").lower() - raw_temp = temp_value + v3_val = float(v3_state.state) + unit = v3_state.attributes.get("unit_of_measurement", "").lower() + raw = v3_val + if unit == "kwh": + v3_val *= 1000 + elif unit == "mwh": + v3_val *= 1000000 + payload += f"&v3={int(v3_val)}" + log_parts.append(f"v3 (Energy Con): {raw} {unit} -> {int(v3_val)}Wh") + except ValueError: + pass - if temp_unit in ["°f", "f"]: - temp_value = (temp_value - 32) * 5.0 / 9.0 - log_parts.append(f"Temp: {raw_temp}°F -> v5={temp_value:.1f}°C") - else: - log_parts.append(f"Temp: {raw_temp}°C -> v5={temp_value:.1f}°C") + # v4: Power Consumption (W) + v4_id = sys_data.get(CONF_V4) + if v4_id: + v4_state = hass.states.get(v4_id) + 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() + raw = v4_val + if unit in ["kw", "kilowatt", "kilowatts"]: + v4_val *= 1000 + payload += f"&v4={int(v4_val)}" + log_parts.append(f"v4 (Power Con): {raw} {unit} -> {int(v4_val)}W") + except ValueError: + pass - payload += f"&v5={temp_value:.1f}" + # v5: Temperature (°C) + v5_id = sys_data.get(CONF_V5) + if v5_id: + v5_state = hass.states.get(v5_id) + 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() + raw = temp_val + if unit in ["°f", "f"]: + temp_val = (temp_val - 32) * 5.0 / 9.0 + log_parts.append(f"v5 (Temp): {raw}°F -> {temp_val:.1f}°C") + else: + log_parts.append(f"v5 (Temp): {raw}°C -> {temp_val:.1f}°C") + payload += f"&v5={temp_val:.1f}" except ValueError: pass - # Add Optional Voltage Data (v6) - if volt_id: - volt_state = hass.states.get(volt_id) + # v6: Voltage (V) + v6_id = sys_data.get(CONF_V6) + if v6_id: + volt_state = hass.states.get(v6_id) if volt_state and volt_state.state not in ['unknown', 'unavailable']: try: - volt_value = float(volt_state.state) - log_parts.append(f"Volt: {volt_value:.1f}V -> v6={volt_value:.1f}") - payload += f"&v6={volt_value:.1f}" + volt_val = float(volt_state.state) + log_parts.append(f"v6 (Volt): {volt_val:.1f}V") + payload += f"&v6={volt_val:.1f}" except ValueError: pass - # --- THE HUMAN-READABLE LOG --- - # We use INFO so it shows up without needing to enable debug logging + # v7 to v12: Extended Metrics + for v_key in [CONF_V7, CONF_V8, CONF_V9, CONF_V10, CONF_V11, CONF_V12]: + ext_id = sys_data.get(v_key) + if ext_id: + ext_state = hass.states.get(ext_id) + if ext_state and ext_state.state not in ['unknown', 'unavailable']: + try: + ext_val = float(ext_state.state) + val_str = f"{ext_val:g}" + payload += f"&{v_key}={val_str}" + log_parts.append(f"{v_key}: {val_str}") + except ValueError: + pass + + # Flags: c1 (Cumulative) and n (Net) + if sys_data.get(CONF_C1): + payload += "&c1=1" + log_parts.append("c1=1 (Cumulative)") + + if sys_data.get(CONF_NET): + payload += "&n=1" + log_parts.append("n=1 (Net)") + + # Do not push if no metrics are present + if not log_parts: + return + + # Human-readable info log _LOGGER.info("PVOutput [%s | ID: %s] Preparing to send: %s", name, sys_id, " | ".join(log_parts)) headers = { @@ -206,18 +342,14 @@ async def push_data(now: datetime, sys_id=system_id, gen_id=generation_ent_id, s # Smart clock-aligned scheduling (Cron style) if frequency < 60: - # Creates a list of exact minutes: [0, 5, 10, 15...] minutes = list(range(0, 60, frequency)) listener = async_track_time_change(hass, push_data, minute=minutes, second=0) elif frequency == 60: - # Every hour on the dot (xx:00:00) listener = async_track_time_change(hass, push_data, minute=0, second=0) elif frequency == 180: - # Every 3 hours on the dot (00:00, 03:00, 06:00...) hours = list(range(0, 24, 3)) listener = async_track_time_change(hass, push_data, hour=hours, minute=0, second=0) else: - # Safe fallback just in case listener = async_track_time_change(hass, push_data, minute=list(range(0, 60, 5)), second=0) remove_listeners.append(listener) diff --git a/custom_components/pvoutput_publisher/config_flow.py b/custom_components/pvoutput_publisher/config_flow.py index 8b2a3da..6c0f7a3 100644 --- a/custom_components/pvoutput_publisher/config_flow.py +++ b/custom_components/pvoutput_publisher/config_flow.py @@ -5,8 +5,10 @@ from .const import ( DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID, - CONF_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID, - CONF_TEMPERATURE_ENTITY_ID, CONF_FREQUENCY, DEFAULT_FREQUENCY, CONF_VOLTAGE_ENTITY_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 ) def _get_system_schema(existing_data=None): @@ -15,78 +17,103 @@ def _get_system_schema(existing_data=None): "30": "30 minutes", "60": "1 hour", "180": "3 hours" } - schema = {} + data = existing_data or {} - 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_NAME, default=data.get(CONF_NAME, data.get(CONF_SYSTEM_ID, ""))): str, + vol.Required(CONF_SYSTEM_ID, default=data.get(CONF_SYSTEM_ID, "")): str, + vol.Required(CONF_FREQUENCY, default=str(data.get(CONF_FREQUENCY, DEFAULT_FREQUENCY))): vol.In(frequency_options), + } - schema[vol.Required(CONF_ENTITY_ID, default=existing_data.get(CONF_ENTITY_ID))] = selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"]) + # Internal Notes (Optional, not sent to API) + if data.get(CONF_NOTES): + schema[vol.Optional(CONF_NOTES, default=data.get(CONF_NOTES))] = selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ) + else: + schema[vol.Optional(CONF_NOTES)] = selector.TextSelector( + selector.TextSelectorConfig(multiline=True) ) - 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"]) - ) - else: - schema[vol.Optional(CONF_CONSUMPTION_ENTITY_ID)] = selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"]) - ) - - if existing_data.get(CONF_TEMPERATURE_ENTITY_ID): - schema[vol.Optional(CONF_TEMPERATURE_ENTITY_ID, default=existing_data.get(CONF_TEMPERATURE_ENTITY_ID))] = selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor") - ) - else: - schema[vol.Optional(CONF_TEMPERATURE_ENTITY_ID)] = selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor") - ) - - if existing_data.get(CONF_VOLTAGE_ENTITY_ID): - schema[vol.Optional(CONF_VOLTAGE_ENTITY_ID, default=existing_data.get(CONF_VOLTAGE_ENTITY_ID))] = selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor", device_class=["voltage"]) - ) - else: - schema[vol.Optional(CONF_VOLTAGE_ENTITY_ID)] = selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor", device_class=["voltage"]) - ) + # v1 (Energy Generation - Wh) + if data.get(CONF_V1): + schema[vol.Optional(CONF_V1, default=data.get(CONF_V1))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["energy"]) + ) + else: + schema[vol.Optional(CONF_V1)] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["energy"]) + ) - schema[vol.Required(CONF_FREQUENCY, default=str(existing_data.get(CONF_FREQUENCY, "5")))] = vol.In(frequency_options) + # v2 (Power Generation - W) + if data.get(CONF_V2): + schema[vol.Optional(CONF_V2, default=data.get(CONF_V2))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["power"]) + ) + else: + schema[vol.Optional(CONF_V2)] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["power"]) + ) + # v3 (Energy Consumption - Wh) + if data.get(CONF_V3): + schema[vol.Optional(CONF_V3, default=data.get(CONF_V3))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["energy"]) + ) else: - schema[vol.Required(CONF_NAME)] = str - schema[vol.Required(CONF_SYSTEM_ID)] = str - schema[vol.Required(CONF_ENTITY_ID)] = selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"]) + schema[vol.Optional(CONF_V3)] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["energy"]) ) - schema[vol.Optional(CONF_SECONDARY_ENTITY_ID)] = selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"]) + + # v4 (Power Consumption - W) + if data.get(CONF_V4): + schema[vol.Optional(CONF_V4, default=data.get(CONF_V4))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["power"]) ) - schema[vol.Optional(CONF_CONSUMPTION_ENTITY_ID)] = selector.EntitySelector( - selector.EntitySelectorConfig(domain="sensor", device_class=["power", "energy"]) + else: + schema[vol.Optional(CONF_V4)] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["power"]) ) - schema[vol.Optional(CONF_TEMPERATURE_ENTITY_ID)] = selector.EntitySelector( + + # v5 (Temperature - °C / °F) + if data.get(CONF_V5): + schema[vol.Optional(CONF_V5, default=data.get(CONF_V5))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor") + ) + else: + schema[vol.Optional(CONF_V5)] = selector.EntitySelector( selector.EntitySelectorConfig(domain="sensor") ) - schema[vol.Optional(CONF_VOLTAGE_ENTITY_ID)] = selector.EntitySelector( + + # v6 (Voltage - V) + if data.get(CONF_V6): + schema[vol.Optional(CONF_V6, default=data.get(CONF_V6))] = selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=["voltage"]) + ) + else: + schema[vol.Optional(CONF_V6)] = selector.EntitySelector( selector.EntitySelectorConfig(domain="sensor", device_class=["voltage"]) ) - schema[vol.Required(CONF_FREQUENCY, default="5")] = vol.In(frequency_options) + + # v7 through v12 (Extended Data) + 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( + selector.EntitySelectorConfig(domain="sensor") + ) + else: + 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() return vol.Schema(schema) class PVOutputPusherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - VERSION = 1 + VERSION = 2 def __init__(self): self._api_key = None diff --git a/custom_components/pvoutput_publisher/const.py b/custom_components/pvoutput_publisher/const.py index e4a7ee5..fa97abe 100644 --- a/custom_components/pvoutput_publisher/const.py +++ b/custom_components/pvoutput_publisher/const.py @@ -4,11 +4,32 @@ CONF_SYSTEMS = "systems" CONF_NAME = "name" CONF_SYSTEM_ID = "system_id" +CONF_FREQUENCY = "frequency" +CONF_NOTES = "notes" + +# V2 Direct API Parameter Mappings +CONF_V1 = "v1" +CONF_V2 = "v2" +CONF_V3 = "v3" +CONF_V4 = "v4" +CONF_V5 = "v5" +CONF_V6 = "v6" +CONF_V7 = "v7" +CONF_V8 = "v8" +CONF_V9 = "v9" +CONF_V10 = "v10" +CONF_V11 = "v11" +CONF_V12 = "v12" + +# V2 Flags +CONF_C1 = "c1" +CONF_NET = "n" + +# Legacy V1 Configuration Keys (Kept for Migration) 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" CONF_VOLTAGE_ENTITY_ID = "voltage_entity_id" DEFAULT_FREQUENCY = "5" diff --git a/custom_components/pvoutput_publisher/manifest.json b/custom_components/pvoutput_publisher/manifest.json index bd22af0..40f10d1 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": "1.2.0" + "version": "2.0.0" } diff --git a/custom_components/pvoutput_publisher/translations/de.json b/custom_components/pvoutput_publisher/translations/de.json index 6707a71..30c37a8 100644 --- a/custom_components/pvoutput_publisher/translations/de.json +++ b/custom_components/pvoutput_publisher/translations/de.json @@ -9,30 +9,52 @@ } }, "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.", + "title": "Solaranlage konfigurieren", + "description": "Ordnen Sie Ihre Home Assistant-Sensoren den PVOutput-API-Parametern zu. Lassen Sie optionale Sensoren leer, falls nicht zutreffend.", "data": { "name": "Systemname", "system_id": "System-ID", - "entity_id": "Primärer Solarsensor", - "secondary_entity_id": "Sekundärer Solarsensor (Optional)", - "consumption_entity_id": "Stromverbrauch-Sensor (Optional)", - "temperature_entity_id": "Temperatursensor (Optional)", - "voltage_entity_id": "Spannungssensor (Optional)", - "frequency": "Aktualisierungsintervall" + "frequency": "Aktualisierungsintervall", + "notes": "Interne Notizen (Optional)", + "v1": "v1: Energieerzeugung (Wh)", + "v2": "v2: Leistungserzeugung (W)", + "v3": "v3: Energieverbrauch (Wh)", + "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)" }, "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.", - "voltage_entity_id": "(Optional) Erfasst die Betriebsspannung des Netzes/Wechselrichters." + "name": "Ein aussagekräftiger Name für Ihre Solaranlage in Home Assistant.", + "system_id": "Ihre PVOutput System-ID für diese Anlage.", + "frequency": "Wie oft Daten an PVOutput gesendet werden sollen.", + "notes": "Lokale Notizen zur eigenen Verwendung (werden nicht an PVOutput übertragen).", + "v1": "Kumulierte oder tägliche erzeugte Energie in Wh (kWh/MWh werden automatisch umgerechnet).", + "v2": "Momentane Erzeugungsleistung in Watt (kW wird automatisch umgerechnet).", + "v3": "Kumulierter oder täglicher Energieverbrauch in Wh (kWh/MWh werden automatisch umgerechnet).", + "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." } }, "systems_manager": { - "title": "Systeme Verwalten", - "description": "Wählen Sie eine Aktion zur Verwaltung Ihrer Anlagen aus.", + "title": "Systeme verwalten", + "description": "Wählen Sie unten eine Aktion aus, um Ihre Anlagen zu verwalten.", "data": { "action": "Aktion" } @@ -46,38 +68,60 @@ "options": { "step": { "systems_manager": { - "title": "PVOutput Publisher Verwalten", - "description": "Wählen Sie eine Aktion zur Aktualisierung Ihrer Einstellungen aus.", + "title": "PVOutput Publisher verwalten", + "description": "Wählen Sie unten eine Aktion aus, um Ihre Konfiguration zu aktualisieren.", "data": { "action": "Aktion" } }, "edit_api": { - "title": "API-Schlüssel Bearbeiten", + "title": "API-Schlüssel bearbeiten", "data": { "api_key": "API-Schlüssel" } }, "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.", + "title": "Solaranlage konfigurieren", + "description": "Ordnen Sie Ihre Home Assistant-Sensoren den PVOutput-API-Parametern zu. Lassen Sie optionale Sensoren leer, falls nicht zutreffend.", "data": { "name": "Systemname", "system_id": "System-ID", - "entity_id": "Primärer Solarsensor", - "secondary_entity_id": "Sekundärer Solarsensor (Optional)", - "consumption_entity_id": "Stromverbrauch-Sensor (Optional)", - "temperature_entity_id": "Temperatursensor (Optional)", - "voltage_entity_id": "Spannungssensor (Optional)", - "frequency": "Aktualisierungsintervall" + "frequency": "Aktualisierungsintervall", + "notes": "Interne Notizen (Optional)", + "v1": "v1: Energieerzeugung (Wh)", + "v2": "v2: Leistungserzeugung (W)", + "v3": "v3: Energieverbrauch (Wh)", + "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)" }, "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.", - "voltage_entity_id": "(Optional) Erfasst die Betriebsspannung des Netzes/Wechselrichters." + "name": "Ein aussagekräftiger Name für Ihre Solaranlage in Home Assistant.", + "system_id": "Ihre PVOutput System-ID für diese Anlage.", + "frequency": "Wie oft Daten an PVOutput gesendet werden sollen.", + "notes": "Lokale Notizen zur eigenen Verwendung (werden nicht an PVOutput übertragen).", + "v1": "Kumulierte oder tägliche erzeugte Energie in Wh (kWh/MWh werden automatisch umgerechnet).", + "v2": "Momentane Erzeugungsleistung in Watt (kW wird automatisch umgerechnet).", + "v3": "Kumulierter oder täglicher Energieverbrauch in Wh (kWh/MWh werden automatisch umgerechnet).", + "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." } } } diff --git a/custom_components/pvoutput_publisher/translations/en.json b/custom_components/pvoutput_publisher/translations/en.json index 119df6a..f65d03b 100644 --- a/custom_components/pvoutput_publisher/translations/en.json +++ b/custom_components/pvoutput_publisher/translations/en.json @@ -10,24 +10,46 @@ }, "add_system": { "title": "Configure Solar System", - "description": "Select the sensors for this solar array.\n\n**Smart Detection:** You can select a single Power (W) OR Energy (Wh) sensor. The integration will format it, and PVOutput will estimate the missing metric.\n\n**Maximum Accuracy:** For the best results, select BOTH a Power and an Energy sensor. The integration will automatically detect which is which and upload them simultaneously.", + "description": "Map your Home Assistant sensors to PVOutput API parameters. Leave any optional sensors blank if not applicable.", "data": { "name": "System Name", "system_id": "System ID", - "entity_id": "Primary Solar Sensor", - "secondary_entity_id": "Secondary Solar Sensor (Optional)", - "consumption_entity_id": "Consumption Sensor (Optional)", - "temperature_entity_id": "Temperature Sensor (Optional)", - "voltage_entity_id": "Voltage Sensor (Optional)", - "frequency": "Update Frequency" + "frequency": "Update Frequency", + "notes": "Internal Notes (Optional)", + "v1": "v1: Energy Generation (Wh)", + "v2": "v2: Power Generation (W)", + "v3": "v3: Energy Consumption (Wh)", + "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)" }, "data_description": { - "name": "A friendly name for your own reference.", - "entity_id": "Your inverter's power (W) or energy (Wh) sensor.", - "secondary_entity_id": "If you selected a Power (W) sensor above, select your Energy (Wh) sensor here (or vice versa) for maximum accuracy. If left blank, PVOutput will automatically estimate the missing value.", - "consumption_entity_id": "(Optional) Tracks your home's energy usage.", - "temperature_entity_id": "(Optional) Tracks outside temperature.", - "voltage_entity_id": "(Optional) Tracks grid/inverter operational voltage lines." + "name": "A friendly name for your solar array in Home Assistant.", + "system_id": "Your PVOutput System ID for this array.", + "frequency": "How often data should be uploaded to PVOutput.", + "notes": "Local notes for your own reference (stored locally, not sent to PVOutput).", + "v1": "Cumulative or daily energy generated in Wh (converts kWh/MWh automatically).", + "v2": "Instantaneous power generated in Watts (converts kW automatically).", + "v3": "Cumulative or daily energy consumed in Wh (converts kWh/MWh automatically).", + "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." } }, "systems_manager": { @@ -60,24 +82,46 @@ }, "add_system": { "title": "Configure Solar System", - "description": "Select the sensors for this solar array.\n\n**Smart Detection:** You can select a single Power (W) OR Energy (Wh) sensor. The integration will format it, and PVOutput will estimate the missing metric.\n\n**Maximum Accuracy:** For the best results, select BOTH a Power and an Energy sensor. The integration will automatically detect which is which and upload them simultaneously.", + "description": "Map your Home Assistant sensors to PVOutput API parameters. Leave any optional sensors blank if not applicable.", "data": { "name": "System Name", "system_id": "System ID", - "entity_id": "Primary Solar Sensor", - "secondary_entity_id": "Secondary Solar Sensor (Optional)", - "consumption_entity_id": "Consumption Sensor (Optional)", - "temperature_entity_id": "Temperature Sensor (Optional)", - "voltage_entity_id": "Voltage Sensor (Optional)", - "frequency": "Update Frequency" + "frequency": "Update Frequency", + "notes": "Internal Notes (Optional)", + "v1": "v1: Energy Generation (Wh)", + "v2": "v2: Power Generation (W)", + "v3": "v3: Energy Consumption (Wh)", + "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)" }, "data_description": { - "name": "A friendly name for your own reference.", - "entity_id": "Your inverter's power (W) or energy (Wh) sensor.", - "secondary_entity_id": "If you selected a Power (W) sensor above, select your Energy (Wh) sensor here (or vice versa) for maximum accuracy. If left blank, PVOutput will automatically estimate the missing value.", - "consumption_entity_id": "(Optional) Tracks your home's energy usage.", - "temperature_entity_id": "(Optional) Tracks outside temperature.", - "voltage_entity_id": "(Optional) Tracks grid/inverter operational voltage lines." + "name": "A friendly name for your solar array in Home Assistant.", + "system_id": "Your PVOutput System ID for this array.", + "frequency": "How often data should be uploaded to PVOutput.", + "notes": "Local notes for your own reference (stored locally, not sent to PVOutput).", + "v1": "Cumulative or daily energy generated in Wh (converts kWh/MWh automatically).", + "v2": "Instantaneous power generated in Watts (converts kW automatically).", + "v3": "Cumulative or daily energy consumed in Wh (converts kWh/MWh automatically).", + "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." } } } diff --git a/custom_components/pvoutput_publisher/translations/es.json b/custom_components/pvoutput_publisher/translations/es.json index b257433..e650d74 100644 --- a/custom_components/pvoutput_publisher/translations/es.json +++ b/custom_components/pvoutput_publisher/translations/es.json @@ -3,36 +3,58 @@ "step": { "user": { "title": "Configuración de PVOutput Publisher", - "description": "Introduzca su clave API global de PVOutput.", + "description": "Introduzca su clave de API global de PVOutput.", "data": { - "api_key": "Clave API" + "api_key": "Clave de API" } }, "add_system": { - "title": "Configurar Sistema", - "description": "Seleccione los sensores para este sistema solar.\n\n**Detección Inteligente:** Puede seleccionar un solo sensor de Potencia (W) O de Energía (Wh). La integración lo formateará y PVOutput estimará la métrica faltante.\n\n**Precisión Máxima:** Para obtener los mejores resultados, seleccione AMBOS sensores: uno de Potencia y uno de Energía. La integración detectará automáticamente cuál es cuál y los subirá simultáneamente.", + "title": "Configurar sistema solar", + "description": "Asigne sus sensores de Home Assistant a los parámetros de la API de PVOutput. Deje en blanco los sensores opcionales que no correspondan.", "data": { - "name": "Nombre del Sistema", - "system_id": "ID del Sistema", - "entity_id": "Sensor Solar Principal", - "secondary_entity_id": "Sensor Solar Secundario (Opcional)", - "consumption_entity_id": "Sensor de Consumo Eléctrico (Opcional)", - "temperature_entity_id": "Sensor de Temperatura (Opcional)", - "voltage_entity_id": "Sensor de Voltaje (Opcional)", - "frequency": "Frecuencia de Actualización" + "name": "Nombre del sistema", + "system_id": "ID del sistema", + "frequency": "Frecuencia de actualización", + "notes": "Notas internas (Opcional)", + "v1": "v1: Generación de energía (Wh)", + "v2": "v2: Generación de potencia (W)", + "v3": "v3: Consumo de energía (Wh)", + "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)" }, "data_description": { - "name": "Un nombre descriptivo para su propia referencia.", - "entity_id": "El sensor de potencia (W) o energía (Wh) de su inversor.", - "secondary_entity_id": "Si seleccionó un sensor de Potencia (W) arriba, seleccione aquí su sensor de Energía (Wh) (o viceversa) para obtener la máxima precisión. Si se deja en blanco, PVOutput estimará automáticamente el valor faltante.", - "consumption_entity_id": "(Opcional) Registra el uso de energía de su hogar.", - "temperature_entity_id": "(Opcional) Registra la temperatura exterior.", - "voltage_entity_id": "(Opcional) Registra el voltaje operativo de la red o del inversor." + "name": "Un nombre descriptivo para su sistema solar en Home Assistant.", + "system_id": "Su ID de sistema de PVOutput para esta instalación.", + "frequency": "Con qué frecuencia se deben enviar los datos a PVOutput.", + "notes": "Notas locales para su propia referencia (no se envían a PVOutput).", + "v1": "Energía generada acumulada o diaria en Wh (convierte kWh/MWh automáticamente).", + "v2": "Potencia generada instantánea en vatios (convierte kW automáticamente).", + "v3": "Energía consumida acumulada o diaria en Wh (convierte kWh/MWh automáticamente).", + "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." } }, "systems_manager": { - "title": "Gestionar Sistemas", - "description": "Seleccione una acción para gestionar sus sistemas.", + "title": "Administrar sistemas", + "description": "Seleccione una acción a continuación para gestionar sus instalaciones.", "data": { "action": "Acción" } @@ -46,38 +68,60 @@ "options": { "step": { "systems_manager": { - "title": "Gestionar PVOutput Publisher", - "description": "Seleccione una acción para actualizar su configuración.", + "title": "Administrar PVOutput Publisher", + "description": "Seleccione una acción a continuación para actualizar su configuración.", "data": { "action": "Acción" } }, "edit_api": { - "title": "Editar Clave API", + "title": "Editar clave de API", "data": { - "api_key": "Clave API" + "api_key": "Clave de API" } }, "add_system": { - "title": "Configurar Sistema", - "description": "Seleccione los sensores para este sistema solar.\n\n**Detección Inteligente:** Puede seleccionar un solo sensor de Potencia (W) O de Energía (Wh). La integración lo formateará y PVOutput estimará la métrica faltante.\n\n**Precisión Máxima:** Para obtener los mejores resultados, seleccione AMBOS sensores: uno de Potencia y uno de Energía. La integración detectará automáticamente cuál es cuál y los subirá simultáneamente.", + "title": "Configurar sistema solar", + "description": "Asigne sus sensores de Home Assistant a los parámetros de la API de PVOutput. Deje en blanco los sensores opcionales que no correspondan.", "data": { - "name": "Nombre del Sistema", - "system_id": "ID del Sistema", - "entity_id": "Sensor Solar Principal", - "secondary_entity_id": "Sensor Solar Secundario (Opcional)", - "consumption_entity_id": "Sensor de Consumo Eléctrico (Opcional)", - "temperature_entity_id": "Sensor de Temperatura (Opcional)", - "voltage_entity_id": "Sensor de Voltaje (Opcional)", - "frequency": "Frecuencia de Actualización" + "name": "Nombre del sistema", + "system_id": "ID del sistema", + "frequency": "Frecuencia de actualización", + "notes": "Notas internas (Opcional)", + "v1": "v1: Generación de energía (Wh)", + "v2": "v2: Generación de potencia (W)", + "v3": "v3: Consumo de energía (Wh)", + "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)" }, "data_description": { - "name": "Un nombre descriptivo para su propia referencia.", - "entity_id": "El sensor de potencia (W) o energía (Wh) de su inversor.", - "secondary_entity_id": "Si seleccionó un sensor de Potencia (W) arriba, seleccione aquí su sensor de Energía (Wh) (o viceversa) para obtener la máxima precisión. Si se deja en blanco, PVOutput estimará automáticamente el valor faltante.", - "consumption_entity_id": "(Opcional) Registra el uso de energía de su hogar.", - "temperature_entity_id": "(Opcional) Registra la temperatura exterior.", - "voltage_entity_id": "(Opcional) Registra el voltaje operativo de la red o del inversor." + "name": "Un nombre descriptivo para su sistema solar en Home Assistant.", + "system_id": "Su ID de sistema de PVOutput para esta instalación.", + "frequency": "Con qué frecuencia se deben enviar los datos a PVOutput.", + "notes": "Notas locales para su propia referencia (no se envían a PVOutput).", + "v1": "Energía generada acumulada o diaria en Wh (convierte kWh/MWh automáticamente).", + "v2": "Potencia generada instantánea en vatios (convierte kW automáticamente).", + "v3": "Energía consumida acumulada o diaria en Wh (convierte kWh/MWh automáticamente).", + "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." } } } diff --git a/custom_components/pvoutput_publisher/translations/ja.json b/custom_components/pvoutput_publisher/translations/ja.json index 6e7c620..5e77046 100644 --- a/custom_components/pvoutput_publisher/translations/ja.json +++ b/custom_components/pvoutput_publisher/translations/ja.json @@ -10,24 +10,46 @@ }, "add_system": { "title": "システムの設定", - "description": "この太陽光発電システム用のセンサーを選択してください。\n\n**スマート検出:** 電力 (W) または 電力量 (Wh) センサーのいずれか1つを選択できます。インテグレーションがフォーマットを自動調整し、不足している指標はPVOutputが推定します。\n\n**最大の精度:** 最高の結果を得るには、電力センサーと電力量センサーの両方を選択してください。インテグレーションが自動的に識別し、同時にアップロードします。", + "description": "Home Assistant のセンサーを PVOutput API パラメータにマッピングします。該当しないセンサーは空欄のままで構いません。", "data": { "name": "システム名", "system_id": "システム ID", - "entity_id": "メイン太陽光発電センサー", - "secondary_entity_id": "サブ太陽光発電センサー (オプション)", - "consumption_entity_id": "電力/エネルギー消費センサー (オプション)", - "temperature_entity_id": "温度センサー (オプション)", - "voltage_entity_id": "電圧センサー (オプション)", - "frequency": "更新頻度" + "frequency": "更新頻度", + "notes": "内部メモ (オプション)", + "v1": "v1: 発電電力量 (Wh)", + "v2": "v2: 発電電力 (W)", + "v3": "v3: 消費電力量 (Wh)", + "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)" }, "data_description": { - "name": "管理用のわかりやすい名前。", - "entity_id": "パワーコンディショナの電力 (W) または電力量 (Wh) センサー。", - "secondary_entity_id": "最大の精度を得るために、上で電力 (W) センサーを選択した場合は、ここで電力量 (Wh) センサーを選択してください(その逆も同様です)。空白の場合、PVOutputは不足している値を自動的に推定します。", - "consumption_entity_id": "(オプション) 自宅のエネルギー使用量を記録します。", - "temperature_entity_id": "(オプション) 外気温を記録します。", - "voltage_entity_id": "(オプション) グリッドまたはパワーコンディショナの動作電圧を記録します。" + "name": "Home Assistant 上での太陽光発電システムのわかりやすい名前。", + "system_id": "PVOutput のシステム ID。", + "frequency": "PVOutput へのデータ送信間隔。", + "notes": "ユーザー用ローカルメモ (PVOutput には送信されません)。", + "v1": "累積または日次発電電力量 (Wh) (kWh/MWh は自動変換されます)。", + "v2": "瞬時発電電力 (W) (kW は自動変換されます)。", + "v3": "累積または日次消費電力量 (Wh) (kWh/MWh は自動変換されます)。", + "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)電力を表している場合はチェックしてください。" } }, "systems_manager": { @@ -60,24 +82,46 @@ }, "add_system": { "title": "システムの設定", - "description": "この太陽光発電システム用のセンサーを選択してください。\n\n**スマート検出:** 電力 (W) または 電力量 (Wh) センサーのいずれか1つを選択できます。インテグレーションがフォーマットを自動調整し、不足している指標はPVOutputが推定します。\n\n**最大の精度:** 最高の結果を得るには、電力センサーと電力量センサーの両方を選択してください。インテグレーションが自動的に識別し、同時にアップロードします。", + "description": "Home Assistant のセンサーを PVOutput API パラメータにマッピングします。該当しないセンサーは空欄のままで構いません。", "data": { "name": "システム名", "system_id": "システム ID", - "entity_id": "メイン太陽光発電センサー", - "secondary_entity_id": "サブ太陽光発電センサー (オプション)", - "consumption_entity_id": "電力/エネルギー消費センサー (オプション)", - "temperature_entity_id": "温度センサー (オプション)", - "voltage_entity_id": "電圧センサー (オプション)", - "frequency": "更新頻度" + "frequency": "更新頻度", + "notes": "内部メモ (オプション)", + "v1": "v1: 発電電力量 (Wh)", + "v2": "v2: 発電電力 (W)", + "v3": "v3: 消費電力量 (Wh)", + "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)" }, "data_description": { - "name": "管理用のわかりやすい名前。", - "entity_id": "パワーコンディショナの電力 (W) または電力量 (Wh) センサー。", - "secondary_entity_id": "最大の精度を得るために、上で電力 (W) センサーを選択した場合は、ここで電力量 (Wh) センサーを選択してください(その逆も同様です)。空白の場合、PVOutputは不足している値を自動的に推定します。", - "consumption_entity_id": "(オプション) 自宅のエネルギー使用量を記録します。", - "temperature_entity_id": "(オプション) 外気温を記録します。", - "voltage_entity_id": "(オプション) グリッドまたはパワーコンディショナの動作電圧を記録します。" + "name": "Home Assistant 上での太陽光発電システムのわかりやすい名前。", + "system_id": "PVOutput のシステム ID。", + "frequency": "PVOutput へのデータ送信間隔。", + "notes": "ユーザー用ローカルメモ (PVOutput には送信されません)。", + "v1": "累積または日次発電電力量 (Wh) (kWh/MWh は自動変換されます)。", + "v2": "瞬時発電電力 (W) (kW は自動変換されます)。", + "v3": "累積または日次消費電力量 (Wh) (kWh/MWh は自動変換されます)。", + "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)電力を表している場合はチェックしてください。" } } } diff --git a/custom_components/pvoutput_publisher/translations/zh-Hans.json b/custom_components/pvoutput_publisher/translations/zh-Hans.json index 7b941a1..6b10cdf 100644 --- a/custom_components/pvoutput_publisher/translations/zh-Hans.json +++ b/custom_components/pvoutput_publisher/translations/zh-Hans.json @@ -2,82 +2,126 @@ "config": { "step": { "user": { - "title": "PVOutput Publisher 设置", - "description": "请输入您的全局 PVOutput API 密钥。", + "title": "PVOutput Publisher 配置", + "description": "请输入您的全局 PVOutput API Key。", "data": { - "api_key": "API 密钥" + "api_key": "API Key" } }, "add_system": { - "title": "设置太阳能系统", - "description": "为此太阳能阵列选择传感器。\n\n**智能检测:** 您可以选择单一的功率 (W) 或电量 (Wh) 传感器。系统将自动格式化,并由 PVOutput 估算缺失的指标。\n\n**最高精确度:** 为了获得最佳结果,请同时选择功率与电量传感器。系统将自动识别并同时上传两者的数据。", + "title": "配置太阳能发电系统", + "description": "将您的 Home Assistant 传感器映射到 PVOutput API 参数。不适用的可选传感器可留空。", "data": { "name": "系统名称", "system_id": "系统 ID", - "entity_id": "主要太阳能传感器", - "secondary_entity_id": "次要太阳能传感器 (选填)", - "consumption_entity_id": "用电消耗传感器 (选填)", - "temperature_entity_id": "温度传感器 (选填)", - "voltage_entity_id": "电压传感器 (选填)", - "frequency": "更新频率" + "frequency": "更新频率", + "notes": "内部备注(可选)", + "v1": "v1: 发电量 (Wh)", + "v2": "v2: 发电功率 (W)", + "v3": "v3: 用电量 (Wh)", + "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)" }, "data_description": { - "name": "供您自己参考的自定义名称。", - "entity_id": "您逆变器的功率 (W) 或电量 (Wh) 传感器。", - "secondary_entity_id": "如果您在上方选择了功率 (W) 传感器,请在此处选择电量 (Wh) 传感器(反之亦然)以达到最高精确度。如果留空,PVOutput 将自动估算缺失的值。", - "consumption_entity_id": "(选填) 追踪您家中的能源使用情况。", - "temperature_entity_id": "(选填) 追踪室外温度。", - "voltage_entity_id": "(选填) 追踪电网/逆变器的工作电压。" + "name": "该太阳能系统在 Home Assistant 中的友好名称。", + "system_id": "该系统在 PVOutput 中的系统 ID。", + "frequency": "向 PVOutput 上传数据的频率。", + "notes": "供您自己参考的本地备注(不会发送至 PVOutput)。", + "v1": "累计或每日发电量(单位 Wh,kWh/MWh 会自动换算)。", + "v2": "瞬时发电功率(单位瓦特,kW 会自动换算)。", + "v3": "累计或每日用电量(单位 Wh,kWh/MWh 会自动换算)。", + "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) 代表净上网功率而非总发电功率,请勾选此项。" } }, "systems_manager": { "title": "管理系统", - "description": "请选择下方的动作来管理您的阵列。", + "description": "在下方选择操作以管理您的系统。", "data": { - "action": "动作" + "action": "操作" } } }, "error": {}, "abort": { - "already_configured": "此集成已经设置完成。" + "already_configured": "该集成已经配置过了。" } }, "options": { "step": { "systems_manager": { "title": "管理 PVOutput Publisher", - "description": "请选择下方的动作来更新您的设置。", + "description": "在下方选择操作以更新您的配置。", "data": { - "action": "动作" + "action": "操作" } }, "edit_api": { - "title": "编辑 API 密钥", + "title": "修改 API Key", "data": { - "api_key": "API 密钥" + "api_key": "API Key" } }, "add_system": { - "title": "设置太阳能系统", - "description": "为此太阳能阵列选择传感器。\n\n**智能检测:** 您可以选择单一的功率 (W) 或电量 (Wh) 传感器。系统将自动格式化,并由 PVOutput 估算缺失的指标。\n\n**最高精确度:** 为了获得最佳结果,请同时选择功率与电量传感器。系统将自动识别并同时上传两者的数据。", + "title": "配置太阳能发电系统", + "description": "将您的 Home Assistant 传感器映射到 PVOutput API 参数。不适用的可选传感器可留空。", "data": { "name": "系统名称", "system_id": "系统 ID", - "entity_id": "主要太阳能传感器", - "secondary_entity_id": "次要太阳能传感器 (选填)", - "consumption_entity_id": "用电消耗传感器 (选填)", - "temperature_entity_id": "温度传感器 (选填)", - "voltage_entity_id": "电压传感器 (选填)", - "frequency": "更新频率" + "frequency": "更新频率", + "notes": "内部备注(可选)", + "v1": "v1: 发电量 (Wh)", + "v2": "v2: 发电功率 (W)", + "v3": "v3: 用电量 (Wh)", + "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)" }, "data_description": { - "name": "供您自己参考的自定义名称。", - "entity_id": "您逆变器的功率 (W) 或电量 (Wh) 传感器。", - "secondary_entity_id": "如果您在上方选择了功率 (W) 传感器,请在此处选择电量 (Wh) 传感器(反之亦然)以达到最高精确度。如果留空,PVOutput 将自动估算缺失的值。", - "consumption_entity_id": "(选填) 追踪您家中的能源使用情况。", - "temperature_entity_id": "(选填) 追踪室外温度。", - "voltage_entity_id": "(选填) 追踪电网/逆变器的工作电压。" + "name": "该太阳能系统在 Home Assistant 中的友好名称。", + "system_id": "该系统在 PVOutput 中的系统 ID。", + "frequency": "向 PVOutput 上传数据的频率。", + "notes": "供您自己参考的本地备注(不会发送至 PVOutput)。", + "v1": "累计或每日发电量(单位 Wh,kWh/MWh 会自动换算)。", + "v2": "瞬时发电功率(单位瓦特,kW 会自动换算)。", + "v3": "累计或每日用电量(单位 Wh,kWh/MWh 会自动换算)。", + "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) 代表净上网功率而非总发电功率,请勾选此项。" } } } diff --git a/custom_components/pvoutput_publisher/translations/zh-Hant.json b/custom_components/pvoutput_publisher/translations/zh-Hant.json index e096ac8..59cac33 100644 --- a/custom_components/pvoutput_publisher/translations/zh-Hant.json +++ b/custom_components/pvoutput_publisher/translations/zh-Hant.json @@ -3,81 +3,125 @@ "step": { "user": { "title": "PVOutput Publisher 設定", - "description": "請輸入您的全局 PVOutput API 金鑰。", + "description": "請輸入您的全域 PVOutput API Key。", "data": { - "api_key": "API 金鑰" + "api_key": "API Key" } }, "add_system": { - "title": "設定太陽能系統", - "description": "為此太陽能陣列選擇感測器。\n\n**智慧偵測:** 您可以選擇單一的功率 (W) 或電量 (Wh) 感測器。系統將自動格式化,並由 PVOutput 估算缺失的指標。\n\n**最高精確度:** 為了獲得最佳結果,請同時選擇功率與電量感測器。系統將自動識別並同時上傳兩者的數據。", + "title": "設定太陽能發電系統", + "description": "將您的 Home Assistant 感測器對應至 PVOutput API 參數。不適用的可選感測器可留空。", "data": { "name": "系統名稱", "system_id": "系統 ID", - "entity_id": "主要太陽能感測器", - "secondary_entity_id": "次要太陽能感測器 (選填)", - "consumption_entity_id": "用電消耗感測器 (選填)", - "temperature_entity_id": "溫度感測器 (選填)", - "voltage_entity_id": "電壓感測器 (選填)", - "frequency": "更新頻率" + "frequency": "更新頻率", + "notes": "內部備註(可選)", + "v1": "v1: 發電量 (Wh)", + "v2": "v2: 發電功率 (W)", + "v3": "v3: 用電量 (Wh)", + "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)" }, "data_description": { - "name": "供您自己參考的自訂名稱。", - "entity_id": "您逆變器的功率 (W) 或電量 (Wh) 感測器。", - "secondary_entity_id": "如果您在上方選擇了功率 (W) 感測器,請在此處選擇電量 (Wh) 感測器(反之亦然)以達到最高精確度。如果留空,PVOutput 將自動估算缺失的值。", - "consumption_entity_id": "(選填) 追蹤您家中的能源使用情況。", - "temperature_entity_id": "(選填) 追蹤室外溫度。", - "voltage_entity_id": "(選填) 追蹤電網/逆變器的工作電壓。" + "name": "該太陽能系統在 Home Assistant 中的自訂名稱。", + "system_id": "該系統在 PVOutput 中的系統 ID。", + "frequency": "向 PVOutput 上傳資料的頻率。", + "notes": "供您自己參考的本地備註(不會傳送至 PVOutput)。", + "v1": "累計或每日發電量(單位 Wh,kWh/MWh 會自動轉換)。", + "v2": "即時發電功率(單位瓦特,kW 會自動轉換)。", + "v3": "累計或每日用電量(單位 Wh,kWh/MWh 會自動轉換)。", + "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) 代表淨上網功率而非總發電功率,請勾選此項。" } }, "systems_manager": { "title": "管理系統", - "description": "請選擇下方的動作來管理您的陣列。", + "description": "在下方選擇操作以管理您的系統。", "data": { - "action": "動作" + "action": "操作" } } }, "error": {}, "abort": { - "already_configured": "此整合已經設定完成。" + "already_configured": "此整合已經設定過了。" } }, "options": { "step": { "systems_manager": { "title": "管理 PVOutput Publisher", - "description": "請選擇下方的動作來更新您的設定。", + "description": "在下方選擇操作以更新您的設定。", "data": { - "action": "動作" + "action": "操作" } }, "edit_api": { - "title": "編輯 API 金鑰", + "title": "修改 API Key", "data": { - "api_key": "API 金鑰" + "api_key": "API Key" } }, "add_system": { - "title": "設定太陽能系統", - "description": "為此太陽能陣列選擇感測器。\n\n**智慧偵測:** 您可以選擇單一的功率 (W) 或電量 (Wh) 感測器。系統將自動格式化,並由 PVOutput 估算缺失的指標。\n\n**最高精確度:** 為了獲得最佳結果,請同時選擇功率與電量感測器。系統將自動識別並同時上傳兩者的數據。", + "title": "設定太陽能發電系統", + "description": "將您的 Home Assistant 感測器對應至 PVOutput API 參數。不適用的可選感測器可留空。", "data": { "name": "系統名稱", "system_id": "系統 ID", - "entity_id": "主要太陽能感測器", - "secondary_entity_id": "次要太陽能感測器 (選填)", - "consumption_entity_id": "用電消耗感測器 (選填)", - "temperature_entity_id": "溫度感測器 (選填)", - "voltage_entity_id": "電壓感測器 (選填)", - "frequency": "更新頻率" + "frequency": "更新頻率", + "notes": "內部備註(可選)", + "v1": "v1: 發電量 (Wh)", + "v2": "v2: 發電功率 (W)", + "v3": "v3: 用電量 (Wh)", + "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)" }, "data_description": { - "name": "供您自己參考的自訂名稱。", - "entity_id": "您逆變器的功率 (W) 或電量 (Wh) 感測器。", - "secondary_entity_id": "如果您在上方選擇了功率 (W) 感測器,請在此處選擇電量 (Wh) 感測器(反之亦然)以達到最高精確度。如果留空,PVOutput 將自動估算缺失的值。", - "consumption_entity_id": "(選填) 追蹤您家中的能源使用情況。", - "temperature_entity_id": "(選填) 追蹤室外溫度。", - "voltage_entity_id": "(選填) 追蹤電網/逆變器的工作電壓。" + "name": "該太陽能系統在 Home Assistant 中的自訂名稱。", + "system_id": "該系統在 PVOutput 中的系統 ID。", + "frequency": "向 PVOutput 上傳資料的頻率。", + "notes": "供您自己參考的本地備註(不會傳送至 PVOutput)。", + "v1": "累計或每日發電量(單位 Wh,kWh/MWh 會自動轉換)。", + "v2": "即時發電功率(單位瓦特,kW 會自動轉換)。", + "v3": "累計或每日用電量(單位 Wh,kWh/MWh 會自動轉換)。", + "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) 代表淨上網功率而非總發電功率,請勾選此項。" } } } diff --git a/docs/design_spec.md b/docs/design_spec.md new file mode 100644 index 0000000..a4d113a --- /dev/null +++ b/docs/design_spec.md @@ -0,0 +1,174 @@ +# Design Specification: V2 Schema Migration & Unified Parameter Mapping + +## 1. Overview & Motivation +The **HomeAssistant-PVOutputPublisher** integration is transitioning from its legacy v1 configuration schema to a **unified, parameter-aligned v2 schema**. + +### Legacy (v1) Limitations: +* **Ambiguous Heuristics:** Used generic "Primary" and "Secondary" solar sensor inputs and attempted runtime auto-detection of Power ($W$) vs. Energy ($Wh$). +* **Limited Extensibility:** Could not support PVOutput extended parameters (`v7`–`v12`), explicit net flag (`n=1`), or manual cumulative flag overrides (`c1=1`). +* **Lack of User Context:** No ability to save internal notes for multi-system setups. + +### V2 Goals: +* **Unified UI:** A single, clear configuration form directly aligned with PVOutput's `addstatus.jsp` API parameters (`v1` through `v12`, `c1`, `n`). +* **Zero Disruption via Migration:** Automated, robust migration from Schema v1 to v2 on install/startup (`async_migrate_entry`). +* **Local Notes Field:** User-facing internal notes field stored per system, omitted from API payloads. +* **Maintainable Architecture:** Single schema, single UI flow, single payload generator, and high test coverage. + +--- + +## 2. V2 Data Model & Configuration Keys + +### 2.1 Configuration Keys (`const.py`) +```python +# Integration Scope +DOMAIN = "pvoutput_publisher" +CONF_API_KEY = "api_key" +CONF_SYSTEMS = "systems" + +# System Common +CONF_NAME = "name" +CONF_SYSTEM_ID = "system_id" +CONF_FREQUENCY = "frequency" +CONF_NOTES = "notes" # Internal notes (string, omitted from API) + +# PVOutput 1-to-1 API Parameters +CONF_V1 = "v1" # Energy Generation (Wh) +CONF_V2 = "v2" # Power Generation (W) +CONF_V3 = "v3" # Energy Consumption (Wh) +CONF_V4 = "v4" # Power Consumption (W) +CONF_V5 = "v5" # Temperature (°C / °F) +CONF_V6 = "v6" # Voltage (V) +CONF_V7 = "v7" # Extended Data 1 +CONF_V8 = "v8" # Extended Data 2 +CONF_V9 = "v9" # Extended Data 3 +CONF_V10 = "v10" # Extended Data 4 +CONF_V11 = "v11" # Extended Data 5 +CONF_V12 = "v12" # Extended Data 6 + +# PVOutput Flags +CONF_C1 = "c1" # Cumulative Energy Flag (bool -> &c1=1) +CONF_NET = "n" # Net Power Flag (bool -> &n=1) + +DEFAULT_FREQUENCY = "5" +PVOUTPUT_API_URL = "https://pvoutput.org/service/r2/addstatus.jsp" +``` + +--- + +## 3. V1 to V2 Migration Logic (`async_migrate_entry`) + +When Home Assistant loads a ConfigEntry with `entry.version == 1`, `async_migrate_entry(hass, entry)` is automatically invoked. + +``` +[V1 Config Entry] + ├── api_key + └── systems: [ { name, system_id, entity_id, secondary_entity_id, consumption_entity_id, temperature_entity_id, voltage_entity_id, frequency } ] + │ + ▼ (async_migrate_entry) + Inspect Entity Registry & Live States for each system: + 1. Primary (entity_id) & Secondary (secondary_entity_id): + - If unit in [Wh, kWh, MWh] or device_class == energy: + -> Map to v1 + -> If state_class in [total, total_increasing]: Set c1 = True + - If unit in [W, kW] or device_class == power: + -> Map to v2 + - If state/registry unavailable: Name fallback ("energy" in id -> v1, else v2) + 2. Consumption (consumption_entity_id): + - If unit in [Wh, kWh, MWh] or device_class == energy: + -> Map to v3 + - Else: + -> Map to v4 + 3. Temperature (temperature_entity_id) -> Map to v5 + 4. Voltage (voltage_entity_id) -> Map to v6 + 5. Notes (notes) -> Initialize as "" (empty string) + 6. Extended (v7..v12) -> Initialize as None / omitted + 7. Flags (c1, n) -> Default based on above or False + │ + ▼ +[V2 Config Entry (version=2)] Saved and returned to HA +``` + +### 3.1 Migration Safety Guarantees +* **Multi-Layer Introspection:** Checks 1) Live State machine (`hass.states`), 2) Entity Registry (`er.async_get(hass)`), and 3) Entity ID string heuristics (`"energy"`, `"power"`, `"kwh"`, `"w"`). +* **Non-Destructive:** If migration encounters an unrecognized structure, it preserves all existing keys while upgrading version to prevent data loss. +* **Test Verification:** Dedicated migration unit tests with mocked v1 entries verifying exact v2 outputs. + +--- + +## 4. UI & Form Schema (`config_flow.py`) + +Config flow and options flow use a single unified system schema `_get_system_schema(existing_data)`: + +| Field | Type / Selector | Description | +| :--- | :--- | :--- | +| `name` | Text (Required) | Friendly name of solar array | +| `system_id` | Text (Required) | PVOutput System ID | +| `frequency` | Select (Required) | Push frequency (5m, 10m, 15m, 30m, 1h, 3h) | +| `notes` | Text (Optional) | Internal reference notes (not sent to API) | +| `v1` | Sensor Selector (`device_class="energy"`) | **v1: Energy Generation** (Wh, converts kWh/MWh) | +| `v2` | Sensor Selector (`device_class="power"`) | **v2: Power Generation** (W, converts kW) | +| `v3` | Sensor Selector (`device_class="energy"`) | **v3: Energy Consumption** (Wh, converts kWh/MWh) | +| `v4` | Sensor Selector (`device_class="power"`) | **v4: Power Consumption** (W, converts kW) | +| `v5` | Sensor Selector (`device_class="temperature"`) | **v5: Temperature** (°C / °F with auto-conversion) | +| `v6` | Sensor Selector (`device_class="voltage"`) | **v6: Voltage** (V) | +| `v7` – `v12` | Sensor Selector (Optional) | **v7 – v12: Extended Data** (Donation features) | +| `c1` | Boolean Toggle | **Cumulative Flag (`c1=1`)**: Generation/Consumption is lifetime | +| `n` | Boolean Toggle | **Net Flag (`n=1`)**: Power generation is net export | + +--- + +## 5. Payload Generation & Push Logic (`__init__.py`) + +At scheduled intervals, for each configured v2 system: +1. Build timestamp: `d=YYYYMMDD&t=HH:MM`. +2. Inspect each mapped parameter `v1` through `v12`: + * Fetch entity state. Skip if `unknown`, `unavailable`, or invalid float. + * Apply unit conversions: + * `v1` / `v3`: `kwh` $\rightarrow \times 1000$, `mwh` $\rightarrow \times 10^6$. Output as integer. + * `v2` / `v4`: `kw` / `kilowatt(s)` $\rightarrow \times 1000$. Output as integer. + * `v5`: `°f` / `f` $\rightarrow (F - 32) \times \frac{5}{9}$. Output as float `%.1f`. + * `v6`: Output as float `%.1f`. + * `v7`–`v12`: Output as formatted numeric value. +3. Append flags: + * If `c1` is `True`: Append `&c1=1`. + * If `n` is `True`: Append `&n=1`. +4. Log human-readable summary at `INFO` level. +5. POST payload with headers (`X-Pvoutput-Apikey`, `X-Pvoutput-SystemId`). + +--- + +## 6. Implementation Roadmap & Running Checklist + +- [x] **Step 1: Constants & Data Model** + - Updated `custom_components/pvoutput_publisher/const.py` with `CONF_V1`..`CONF_V12`, `CONF_C1`, `CONF_NET`, `CONF_NOTES`. +- [x] **Step 2: Migration Engine** + - Implemented `async_migrate_entry(hass, config_entry)` in `__init__.py`. + - Implemented helper function `migrate_v1_system_to_v2(hass, system_data)`. +- [x] **Step 3: Configuration & Options Flow** + - Updated `PVOutputPusherConfigFlow.VERSION = 2`. + - Updated `_get_system_schema()` in `config_flow.py` for v2 fields and notes. +- [x] **Step 4: Push Pipeline in `__init__.py`** + - Updated `push_data()` to process v2 system fields, flags, and notes. +- [x] **Step 5: Base English Translations (`en.json`)** + - Added friendly labels and helper tooltips for v1..v12, c1, net, and notes in `en.json`. +- [x] **Step 6: Unit & Integration Tests** + - Added unit tests for `async_migrate_entry` (live state present, registry lookup, fallback naming, idempotency, duplicate sensors, secondary-only configs, consumption lifetime). + - Updated config flow tests for v2 schema and full field editing. + - Added specific regression tests for temperature sensors without `device_class: temperature`. + - Updated push payload tests for v2 parameters, flags, unit conversions, and notes exclusion. + - Test suite passing with 24/24 tests (95% coverage). +- [x] **Step 7: Documentation (`README.md`, `CHANGELOG.md`)** + - Updated `README.md` to document the V2 parameter mapping, notes field, and migration. + - Updated `CHANGELOG.md` with 2.0.0 release notes. +- [x] **Step 8: Multi-Language Translations (Final Phase)** + - Generated updated translations for `de.json`, `es.json`, `ja.json`, `zh-Hans.json`, `zh-Hant.json` matching `en.json`. + +--- + +## 7. Discoveries & Implementation Notes +*(Updated dynamically during implementation)* +- *2026-10-11:* Switched from dual-mode toggle to unified v2 architecture with automated `async_migrate_entry` to provide cleaner UX and zero configuration ambiguity. +- *2026-10-11:* English translation (`en.json`) updated first; other language files deferred to final phase per iterative development plan. +- *2026-10-11:* Migration logic defaults `c1=True` for migrated Energy generation sensors because Home Assistant's Entity Registry does not persist `state_class` to disk at boot time. This ensures lifetime energy sensors display `[x] Cumulative Energy Flag` in the UI and prevent PVOutput 400 Bad Request errors. +- *2026-10-11:* Migration logic refined to prevent duplicate sensor assignments (e.g. if a user configured two energy sensors in v1, secondary energy is safely ignored rather than mistakenly placed into `v2` Power). + diff --git a/docs/developers.md b/docs/developers.md new file mode 100644 index 0000000..8d49536 --- /dev/null +++ b/docs/developers.md @@ -0,0 +1,116 @@ +# Developer Guide + +Welcome to the development guide for **HomeAssistant-PVOutputPublisher**. This document outlines how to set up your local development environment, run tests, and prepare/publish releases. + +--- + +## 1. Development Environment Setup + +### Prerequisites +* **Python 3.13+** (matching current Home Assistant core requirements) +* `git` +* Optional: [`uv`](https://github.com/astral-sh/uv) (for ultra-fast environment setup) + +### Setup Steps + +1. **Clone the Repository:** + ```bash + git clone https://github.com/SourceLabOrg/HomeAssistant-PVOutputPublisher.git + cd HomeAssistant-PVOutputPublisher + ``` + +2. **Create and Activate a Virtual Environment:** + * Using standard `venv`: + ```bash + python3 -m venv venv + source venv/bin/activate + ``` + * Or using `uv`: + ```bash + uv venv + source .venv/bin/activate + ``` + +3. **Install Dependencies:** + ```bash + pip install -r requirements_test.txt + ``` + *(Or `uv pip install -r requirements_test.txt`)* + +--- + +## 2. Running Tests & Code Quality + +The integration uses [`pytest-homeassistant-custom-component`](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) for testing with Home Assistant's mock fixtures. + +### Run Test Suite +```bash +pytest +``` + +### Run with Coverage Report +```bash +pytest --cov=custom_components/pvoutput_publisher --cov-report=term-missing +``` + +--- + +## 3. Local Testing Against Home Assistant + +Since Home Assistant custom components are pure Python packages, there is **no build step**. + +1. Copy the [`custom_components/pvoutput_publisher/`](file:///Users/spowis/Documents/code/HomeAssistant-PVOutputPublisher/custom_components/pvoutput_publisher) folder into your Home Assistant installation: + ``` + /config/custom_components/pvoutput_publisher/ + ``` + * *Via SCP:* + ```bash + scp -r custom_components/pvoutput_publisher root@:/config/custom_components/ + ``` + * *Via Samba / Studio Code Server:* Drag and drop the folder into `/config/custom_components/`. + +2. **Restart Home Assistant:** + Navigate to **Settings > System > Restart**. + +3. Check logs in **Settings > System > Logs** to verify initialization, migration, and periodic upload payloads. + +--- + +## 4. Release Process + +To create a new release for users (via HACS and GitHub): + +### Step 1: Version Bumping +1. Update `"version"` in [`custom_components/pvoutput_publisher/manifest.json`](file:///Users/spowis/Documents/code/HomeAssistant-PVOutputPublisher/custom_components/pvoutput_publisher/manifest.json): + ```json + { + "version": "X.Y.Z" + } + ``` +2. Update [`CHANGELOG.md`](file:///Users/spowis/Documents/code/HomeAssistant-PVOutputPublisher/CHANGELOG.md): + * Add a new section `## X.Y.Z (MM/DD/YYYY)`. + * Document Breaking Changes, Feature Changes, Bug Fixes, and any specific Migration Notes. + +### Step 2: Verification +1. Run all unit tests to ensure 100% pass rate: + ```bash + pytest + ``` +2. Verify all translation files in [`translations/`](file:///Users/spowis/Documents/code/HomeAssistant-PVOutputPublisher/custom_components/pvoutput_publisher/translations) match any schema changes made in `config_flow.py`. + +### Step 3: Git Commit & Tag +1. Commit all changes: + ```bash + git add . + git commit -m "Release X.Y.Z" + ``` +2. Push branch and merge to `main`. + +### Step 4: Publish GitHub Release (HACS Changelog) +HACS pulls release changelogs directly from **GitHub Releases**: +1. On GitHub, navigate to **Releases > Draft a new release** (or use `gh release create X.Y.Z`). +2. Tag version: `X.Y.Z` (e.g. `2.0.0`). +3. Title: `Release X.Y.Z`. +4. **Description:** Copy and paste the exact markdown section from `CHANGELOG.md` for this version into the GitHub release description. +5. Click **Publish Release**. +6. HACS will automatically detect the new release and display the release notes inside Home Assistant for updating users. diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index d7f22cb..1456187 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -8,35 +8,32 @@ from custom_components.pvoutput_publisher.const import ( DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID, - CONF_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, CONF_FREQUENCY, - CONF_VOLTAGE_ENTITY_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 ) @pytest.mark.asyncio async def test_show_user_form(hass): """Test that the initial setup form is displayed correctly.""" - - # 1. Initialize the config flow result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - # 2. Assert the flow returns the expected form assert result["type"] == "form" assert result["step_id"] == "user" assert result.get("errors") is None @pytest.mark.asyncio async def test_initial_setup_flow(hass): - """Test the complete happy-path of the initial setup flow.""" - # 1. Start the Config Flow + """Test the complete happy-path of the initial setup flow in V2.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - # 2. Submit the API Key + # Submit API Key result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_API_KEY: "my_secret_key"} @@ -44,111 +41,116 @@ async def test_initial_setup_flow(hass): assert result["type"] == FlowResultType.FORM assert result["step_id"] == "add_system" - # 3. Submit the First Solar System + # Submit First Solar System with V2 fields, notes, and flags result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_NAME: "Main Array", CONF_SYSTEM_ID: "12345", - CONF_ENTITY_ID: "sensor.inverter_power", - CONF_SECONDARY_ENTITY_ID: "sensor.inverter_energy", - CONF_VOLTAGE_ENTITY_ID: "sensor.inverter_voltage", - CONF_FREQUENCY: "5" + CONF_FREQUENCY: "5", + CONF_NOTES: "Roof solar panels installed 2024", + CONF_V1: "sensor.inverter_energy", + CONF_V2: "sensor.inverter_power", + CONF_V6: "sensor.inverter_voltage", + CONF_C1: True, + CONF_NET: False, } ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "systems_manager" - # 4. Finish the Setup + # Finish Setup with patch("custom_components.pvoutput_publisher.async_setup_entry", return_value=True) as mock_setup: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"action": "finish"} ) - # 5. Verify the Final Output assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "PVOutput Publisher" - assert result["data"][CONF_API_KEY] == "my_secret_key" assert len(result["data"][CONF_SYSTEMS]) == 1 - assert result["data"][CONF_SYSTEMS][0][CONF_NAME] == "Main Array" - assert result["data"][CONF_SYSTEMS][0][CONF_VOLTAGE_ENTITY_ID] == "sensor.inverter_voltage" + + sys = result["data"][CONF_SYSTEMS][0] + assert sys[CONF_NAME] == "Main Array" + assert sys[CONF_SYSTEM_ID] == "12345" + assert sys[CONF_NOTES] == "Roof solar panels installed 2024" + assert sys[CONF_V1] == "sensor.inverter_energy" + assert sys[CONF_V2] == "sensor.inverter_power" + assert sys[CONF_V6] == "sensor.inverter_voltage" + assert sys[CONF_C1] is True + assert sys[CONF_NET] is False assert len(mock_setup.mock_calls) == 1 @pytest.mark.asyncio async def test_options_flow_edit_api_key(hass): - """Test that a user can successfully update their API key via the Options flow.""" - # 1. Create a mocked existing integration + """Test that a user can successfully update their API key via Options flow.""" entry = MockConfigEntry( domain=DOMAIN, - data={CONF_API_KEY: "old_key", CONF_SYSTEMS: []} + data={CONF_API_KEY: "old_key", CONF_SYSTEMS: []}, + version=2 ) entry.add_to_hass(hass) - # 2. Launch the options flow result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "systems_manager" - # 3. Select 'Edit API Key' from the dropdown result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"action": "edit_api"} ) assert result["step_id"] == "edit_api" - # 4. Submit the new key result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_API_KEY: "new_shiny_key"} ) assert result["step_id"] == "systems_manager" - # 5. Save and close with patch("custom_components.pvoutput_publisher.async_setup_entry", return_value=True): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"action": "finish"} ) - # Verify the underlying config entry was updated assert result["type"] == FlowResultType.CREATE_ENTRY assert entry.data[CONF_API_KEY] == "new_shiny_key" @pytest.mark.asyncio async def test_options_flow_manage_systems(hass): - """Test adding, editing, and removing solar arrays within the Options flow.""" - # 1. Create a mock integration with exactly one existing system + """Test adding, editing, and removing solar arrays within Options flow.""" initial_system = { CONF_NAME: "Original System", CONF_SYSTEM_ID: "111", - CONF_ENTITY_ID: "sensor.power_1", - CONF_FREQUENCY: "5" + CONF_FREQUENCY: "5", + CONF_NOTES: "Initial notes", + CONF_V2: "sensor.power_1", } entry = MockConfigEntry( domain=DOMAIN, - data={CONF_API_KEY: "key123", CONF_SYSTEMS: [initial_system]} + data={CONF_API_KEY: "key123", CONF_SYSTEMS: [initial_system]}, + version=2 ) entry.add_to_hass(hass) - # Launch Options result = await hass.config_entries.options.async_init(entry.entry_id) - # --- ACTION 1: EDIT THE EXISTING SYSTEM --- - # Select the first system (index 0) from the dropdown + # --- ACTION 1: EDIT EXISTING SYSTEM --- result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"action": "edit_0"} ) assert result["step_id"] == "add_system" - # Submit updated data for it result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_NAME: "Updated System", CONF_SYSTEM_ID: "111", - CONF_ENTITY_ID: "sensor.power_1", - CONF_VOLTAGE_ENTITY_ID: "sensor.new_voltage", - CONF_FREQUENCY: "5" + CONF_FREQUENCY: "5", + CONF_NOTES: "Updated notes", + CONF_V1: "sensor.energy_1", + CONF_V2: "sensor.power_1", + CONF_V6: "sensor.new_voltage", + CONF_C1: True, } ) assert result["step_id"] == "systems_manager" @@ -163,14 +165,16 @@ async def test_options_flow_manage_systems(hass): result["flow_id"], user_input={ CONF_NAME: "Secondary Array", CONF_SYSTEM_ID: "222", - CONF_ENTITY_ID: "sensor.power_2", - CONF_FREQUENCY: "10" + CONF_FREQUENCY: "10", + CONF_NOTES: "East roof", + CONF_V2: "sensor.power_2", + CONF_V7: "sensor.battery_soc", + CONF_NET: True, } ) assert result["step_id"] == "systems_manager" # --- ACTION 3: REMOVE THE FIRST SYSTEM --- - # We delete "Updated System" (which is at index 0) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"action": "remove_0"} ) @@ -183,9 +187,87 @@ async def test_options_flow_manage_systems(hass): ) assert result["type"] == FlowResultType.CREATE_ENTRY - - # After editing, adding, and removing, we should be left with ONLY the "Secondary Array" final_systems = entry.data[CONF_SYSTEMS] assert len(final_systems) == 1 assert final_systems[0][CONF_NAME] == "Secondary Array" assert final_systems[0][CONF_SYSTEM_ID] == "222" + assert final_systems[0][CONF_NOTES] == "East roof" + assert final_systems[0][CONF_V7] == "sensor.battery_soc" + assert final_systems[0][CONF_NET] is True + + +@pytest.mark.asyncio +async def test_options_flow_edit_all_fields(hass): + """Test editing an existing system where all fields are already configured.""" + full_system = { + 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_V7: "sensor.v7", + CONF_V8: "sensor.v8", + CONF_V9: "sensor.v9", + CONF_V10: "sensor.v10", + CONF_V11: "sensor.v11", + CONF_V12: "sensor.v12", + CONF_C1: True, + CONF_NET: True, + } + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "key123", CONF_SYSTEMS: [full_system]}, + version=2 + ) + entry.add_to_hass(hass) + + 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" + + # Submit without changing + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=full_system + ) + assert result["step_id"] == "systems_manager" + + +@pytest.mark.asyncio +async def test_setup_with_custom_temperature_sensor(hass): + """Test configuring a system with a custom temperature sensor without device class.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "key123"} + ) + # Configure system using a sensor that lacks device_class + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "Temp Test Array", + CONF_SYSTEM_ID: "4321", + CONF_FREQUENCY: "5", + CONF_V2: "sensor.solar_power", + CONF_V5: "sensor.custom_outdoor_temperature", + } + ) + assert result["step_id"] == "systems_manager" + with patch("custom_components.pvoutput_publisher.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"action": "finish"} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_SYSTEMS][0][CONF_V5] == "sensor.custom_outdoor_temperature" + + diff --git a/tests/test_init.py b/tests/test_init.py index dbd8552..b6f36fa 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,21 +1,29 @@ -"""Test the PVOutput Publisher initialization and payload generation.""" +"""Test the PVOutput Publisher initialization, migration, and payload generation.""" from datetime import timedelta import logging -from unittest.mock import patch +from unittest.mock import patch, MagicMock import pytest import aiohttp from aioresponses import aioresponses from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util +from homeassistant.helpers import entity_registry as er from pytest_homeassistant_custom_component.common import ( MockConfigEntry, async_fire_time_changed, ) +from custom_components.pvoutput_publisher import ( + async_migrate_entry, + migrate_v1_system_to_v2, + _determine_energy_or_power +) from custom_components.pvoutput_publisher.const import ( DOMAIN, CONF_API_KEY, CONF_SYSTEMS, CONF_NAME, CONF_SYSTEM_ID, - CONF_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, CONF_CONSUMPTION_ENTITY_ID, - CONF_TEMPERATURE_ENTITY_ID, CONF_VOLTAGE_ENTITY_ID, CONF_FREQUENCY, + 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_ENTITY_ID, CONF_SECONDARY_ENTITY_ID, + CONF_CONSUMPTION_ENTITY_ID, CONF_TEMPERATURE_ENTITY_ID, CONF_VOLTAGE_ENTITY_ID, PVOUTPUT_API_URL ) @@ -30,7 +38,7 @@ async def setup_and_trigger(hass: HomeAssistant, mock_aio: aioresponses, system_ CONF_SYSTEMS: [system_config] } - entry = MockConfigEntry(domain=DOMAIN, data=config_data) + entry = MockConfigEntry(domain=DOMAIN, data=config_data, version=2) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) @@ -54,7 +62,7 @@ async def setup_and_trigger(hass: HomeAssistant, mock_aio: aioresponses, system_ @pytest.mark.asyncio async def test_setup_and_unload(hass: HomeAssistant): """Test that the integration sets up and unloads successfully.""" - entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "123", CONF_SYSTEMS: []}) + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "123", CONF_SYSTEMS: []}, version=2) entry.add_to_hass(hass) # Test Setup @@ -69,105 +77,112 @@ async def test_setup_and_unload(hass: HomeAssistant): @pytest.mark.asyncio -async def test_push_data_all_sensors(hass: HomeAssistant): - """Test payload generation when ALL optional and required sensors are configured.""" - hass.states.async_set("sensor.solar_energy", "12500", {"unit_of_measurement": "Wh", "state_class": "total_increasing"}) +async def test_push_data_all_sensors_v2(hass: HomeAssistant): + """Test payload generation with all V2 sensors, extended metrics, 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_consumption", "800", {"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_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", {}) system_config = { CONF_NAME: "Test System", CONF_SYSTEM_ID: MOCK_SYSTEM_ID, CONF_FREQUENCY: "5", - CONF_ENTITY_ID: "sensor.solar_energy", - CONF_SECONDARY_ENTITY_ID: "sensor.solar_power", - CONF_CONSUMPTION_ENTITY_ID: "sensor.home_consumption", - CONF_TEMPERATURE_ENTITY_ID: "sensor.outside_temp", - CONF_VOLTAGE_ENTITY_ID: "sensor.grid_voltage" + CONF_NOTES: "Internal array notes", + CONF_V1: "sensor.solar_energy", + CONF_V2: "sensor.solar_power", + CONF_V3: "sensor.home_energy", + CONF_V4: "sensor.home_power", + CONF_V5: "sensor.outside_temp", + CONF_V6: "sensor.grid_voltage", + CONF_V7: "sensor.battery_soc", + 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_C1: True, + CONF_NET: True, } with aioresponses() as mock_aio: payload = await setup_and_trigger(hass, mock_aio, system_config) # Assertions - assert "&c1=1" in payload # Lifetime flag triggered - assert "&v1=12500" in payload # Energy - assert "&v2=3500" in payload # Power - assert "&v4=800" in payload # Consumption (Power) + assert "&v1=12500" in payload # Energy Gen + assert "&v2=3500" in payload # Power Gen + assert "&v3=4500" in payload # Energy Con + assert "&v4=800" in payload # Power Con assert "&v5=22.5" in payload # Temperature assert "&v6=240.2" in payload # Voltage + assert "&v7=88.5" in payload # Extended 1 + assert "&v8=12" in payload + assert "&v9=34" in payload + assert "&v10=56" in payload + assert "&v11=78" in payload + assert "&v12=90" in payload + 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_minimal(hass: HomeAssistant): - """Test payload generation when ONLY the primary required sensor is configured.""" - hass.states.async_set("sensor.solar_power_only", "2000", {"unit_of_measurement": "W"}) - - system_config = { - CONF_NAME: "Test Minimal", - CONF_SYSTEM_ID: MOCK_SYSTEM_ID, - CONF_FREQUENCY: "5", - CONF_ENTITY_ID: "sensor.solar_power_only", - } - - with aioresponses() as mock_aio: - payload = await setup_and_trigger(hass, mock_aio, system_config) - - assert "&v2=2000" in payload - assert "&v1=" not in payload # Energy shouldn't be there - assert "&v5=" not in payload # Temp shouldn't be there - - -@pytest.mark.asyncio -async def test_push_data_conversions(hass: HomeAssistant): - """Test that units like kW, kWh, and Fahrenheit are properly converted before sending.""" +async def test_push_data_conversions_and_scaling(hass: HomeAssistant): + """Test that units like kW, kWh, MWh, and Fahrenheit are properly converted before sending.""" hass.states.async_set("sensor.solar_energy_kwh", "15.5", {"unit_of_measurement": "kWh"}) hass.states.async_set("sensor.solar_power_kw", "2.5", {"unit_of_measurement": "kW"}) + hass.states.async_set("sensor.consumption_mwh", "0.02", {"unit_of_measurement": "MWh"}) + hass.states.async_set("sensor.consumption_kw", "1.2", {"unit_of_measurement": "kilowatt"}) hass.states.async_set("sensor.temp_f", "68", {"unit_of_measurement": "°F"}) system_config = { CONF_NAME: "Test Conversions", CONF_SYSTEM_ID: MOCK_SYSTEM_ID, CONF_FREQUENCY: "5", - CONF_ENTITY_ID: "sensor.solar_energy_kwh", - CONF_SECONDARY_ENTITY_ID: "sensor.solar_power_kw", - CONF_TEMPERATURE_ENTITY_ID: "sensor.temp_f", + CONF_V1: "sensor.solar_energy_kwh", + CONF_V2: "sensor.solar_power_kw", + CONF_V3: "sensor.consumption_mwh", + CONF_V4: "sensor.consumption_kw", + CONF_V5: "sensor.temp_f", } with aioresponses() as mock_aio: payload = await setup_and_trigger(hass, mock_aio, system_config) - assert "&v1=15500" in payload # 15.5 kWh -> 15500 Wh - assert "&v2=2500" in payload # 2.5 kW -> 2500 W - assert "&v5=20.0" in payload # 68 F -> 20.0 C + assert "&v1=15500" in payload # 15.5 kWh -> 15500 Wh + assert "&v2=2500" in payload # 2.5 kW -> 2500 W + assert "&v3=20000" in payload # 0.02 MWh -> 20000 Wh + assert "&v4=1200" in payload # 1.2 kW -> 1200 W + assert "&v5=20.0" in payload # 68°F -> 20.0°C @pytest.mark.asyncio -async def test_push_data_duplicate_sensor_types(hass: HomeAssistant, caplog): - """Test that the integration safely ignores the secondary sensor if both are the same type.""" - # User accidentally configured two Energy (Wh) sensors - hass.states.async_set("sensor.energy_1", "1000", {"unit_of_measurement": "Wh"}) - hass.states.async_set("sensor.energy_2", "2000", {"unit_of_measurement": "Wh"}) +async def test_push_data_minimal(hass: HomeAssistant): + """Test payload generation when ONLY power generation is configured.""" + hass.states.async_set("sensor.solar_power_only", "2000", {"unit_of_measurement": "W"}) system_config = { - CONF_NAME: "Duplicate System", + CONF_NAME: "Test Minimal", CONF_SYSTEM_ID: MOCK_SYSTEM_ID, CONF_FREQUENCY: "5", - CONF_ENTITY_ID: "sensor.energy_1", - CONF_SECONDARY_ENTITY_ID: "sensor.energy_2", + CONF_V2: "sensor.solar_power_only", } with aioresponses() as mock_aio: payload = await setup_and_trigger(hass, mock_aio, system_config) - # It should log a warning - assert "Ignored secondary sensor. You selected two Energy (Wh) sensors." in caplog.text + assert "&v2=2000" in payload + assert "&v1=" not in payload + assert "&v5=" not in payload - # It should only send the primary sensor data - assert "&v1=1000" in payload - assert "&v2=" not in payload @pytest.mark.asyncio async def test_api_error_response(hass: HomeAssistant, caplog): @@ -178,46 +193,42 @@ async def test_api_error_response(hass: HomeAssistant, caplog): CONF_NAME: "Error System", CONF_SYSTEM_ID: MOCK_SYSTEM_ID, CONF_FREQUENCY: "5", - CONF_ENTITY_ID: "sensor.solar_power", + CONF_V2: "sensor.solar_power", } - # Set up the mocked integration - entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: MOCK_API_KEY, CONF_SYSTEMS: [system_config]}) + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: MOCK_API_KEY, CONF_SYSTEMS: [system_config]}, version=2) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - # Intercept the POST request and FORCE a 400 Bad Request error with aioresponses() as mock_aio: mock_aio.post(PVOUTPUT_API_URL, status=400, body="Bad Request: Invalid System ID") - # Trigger the timer future = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() - # Assert that the error was caught and logged correctly assert "PVOutput API error for Error System" in caplog.text assert "Bad Request: Invalid System ID" in caplog.text + @pytest.mark.asyncio async def test_network_connection_error(hass: HomeAssistant, caplog): - """Test that the integration survives a hard network connection failure.""" + """Test that the integration survives a network connection failure.""" hass.states.async_set("sensor.solar_power", "2000", {"unit_of_measurement": "W"}) system_config = { CONF_NAME: "Offline System", CONF_SYSTEM_ID: MOCK_SYSTEM_ID, CONF_FREQUENCY: "5", - CONF_ENTITY_ID: "sensor.solar_power", + CONF_V2: "sensor.solar_power", } - entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: MOCK_API_KEY, CONF_SYSTEMS: [system_config]}) + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: MOCK_API_KEY, CONF_SYSTEMS: [system_config]}, version=2) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - # Force a hard network exception with aioresponses() as mock_aio: mock_aio.post(PVOUTPUT_API_URL, exception=aiohttp.ClientConnectionError("Connection refused")) @@ -225,23 +236,22 @@ async def test_network_connection_error(hass: HomeAssistant, caplog): async_fire_time_changed(hass, future) await hass.async_block_till_done() - # Assert that the warning was logged and the integration did not crash assert "Network error connecting to PVOutput for Offline System" in caplog.text + @pytest.mark.asyncio -async def test_unavailable_primary_sensor(hass: HomeAssistant, caplog): - """Test that uploads are skipped entirely if the primary sensor goes offline.""" - # Explicitly set the sensor to the 'unavailable' state +async def test_unavailable_sensors_skip_push(hass: HomeAssistant): + """Test that uploads are skipped entirely if configured sensors are offline or empty.""" hass.states.async_set("sensor.solar_power", "unavailable") system_config = { CONF_NAME: "Unavailable System", CONF_SYSTEM_ID: MOCK_SYSTEM_ID, CONF_FREQUENCY: "5", - CONF_ENTITY_ID: "sensor.solar_power", + CONF_V2: "sensor.solar_power", } - entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: MOCK_API_KEY, CONF_SYSTEMS: [system_config]}) + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: MOCK_API_KEY, CONF_SYSTEMS: [system_config]}, version=2) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -253,8 +263,301 @@ async def test_unavailable_primary_sensor(hass: HomeAssistant, caplog): async_fire_time_changed(hass, future) await hass.async_block_till_done() - # Extract the intercepted requests post_requests = mock_aio.requests.get(("POST", PVOUTPUT_API_URL)) - # Assert that NO request was ever made because the sensor was offline assert post_requests is None + + +# ===================== MIGRATION TESTS ===================== + +@pytest.mark.asyncio +async def test_async_migrate_entry_v1_to_v2_with_live_states(hass: HomeAssistant): + """Test migration from v1 to v2 with active state machine.""" + # Set up states + hass.states.async_set("sensor.inverter_energy", "1000", {"unit_of_measurement": "kWh", "state_class": "total_increasing"}) + hass.states.async_set("sensor.inverter_power", "3000", {"unit_of_measurement": "W"}) + hass.states.async_set("sensor.house_energy", "500", {"unit_of_measurement": "kWh"}) + hass.states.async_set("sensor.temp", "25", {"unit_of_measurement": "°C"}) + hass.states.async_set("sensor.volts", "230", {"unit_of_measurement": "V"}) + + v1_system = { + CONF_NAME: "Legacy System", + CONF_SYSTEM_ID: "55555", + CONF_ENTITY_ID: "sensor.inverter_energy", + CONF_SECONDARY_ENTITY_ID: "sensor.inverter_power", + CONF_CONSUMPTION_ENTITY_ID: "sensor.house_energy", + CONF_TEMPERATURE_ENTITY_ID: "sensor.temp", + CONF_VOLTAGE_ENTITY_ID: "sensor.volts", + CONF_FREQUENCY: "5", + } + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "migrated_key", CONF_SYSTEMS: [v1_system]}, + version=1 + ) + entry.add_to_hass(hass) + + result = await async_migrate_entry(hass, entry) + assert result is True + assert entry.version == 2 + + migrated = entry.data[CONF_SYSTEMS][0] + assert migrated[CONF_NAME] == "Legacy System" + assert migrated[CONF_SYSTEM_ID] == "55555" + assert migrated[CONF_V1] == "sensor.inverter_energy" + assert migrated[CONF_V2] == "sensor.inverter_power" + assert migrated[CONF_V3] == "sensor.house_energy" + assert migrated[CONF_V4] is None + assert migrated[CONF_V5] == "sensor.temp" + assert migrated[CONF_V6] == "sensor.volts" + assert migrated[CONF_C1] is True # total_increasing set c1=True + assert migrated[CONF_NET] is False + assert migrated[CONF_NOTES] == "" + + +@pytest.mark.asyncio +async def test_async_migrate_entry_v1_power_primary(hass: HomeAssistant): + """Test migration when primary sensor is power and secondary is energy.""" + hass.states.async_set("sensor.sol_pwr", "4000", {"unit_of_measurement": "W"}) + hass.states.async_set("sensor.sol_eng", "12000", {"unit_of_measurement": "Wh"}) + + v1_system = { + CONF_NAME: "Power First System", + CONF_SYSTEM_ID: "66666", + CONF_ENTITY_ID: "sensor.sol_pwr", + CONF_SECONDARY_ENTITY_ID: "sensor.sol_eng", + CONF_FREQUENCY: "10", + } + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "key", CONF_SYSTEMS: [v1_system]}, + version=1 + ) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + assert entry.version == 2 + + migrated = entry.data[CONF_SYSTEMS][0] + assert migrated[CONF_V2] == "sensor.sol_pwr" + assert migrated[CONF_V1] == "sensor.sol_eng" + + +@pytest.mark.asyncio +async def test_determine_energy_or_power_fallbacks(hass: HomeAssistant): + """Test helper entity determination fallback to entity registry and heuristics.""" + # Test None / empty + assert _determine_energy_or_power(hass, None) == (None, False) + assert _determine_energy_or_power(hass, "") == (None, False) + + # Test entity registry lookup + entity_reg = er.async_get(hass) + entity_reg.async_get_or_create( + "sensor", "test", "solar_reg_energy", + original_device_class="energy", + unit_of_measurement="kWh" + ) + assert _determine_energy_or_power(hass, "sensor.test_solar_reg_energy") == ("energy", False) + + entity_reg.async_get_or_create( + "sensor", "test", "solar_reg_power", + original_device_class="power", + unit_of_measurement="kW" + ) + assert _determine_energy_or_power(hass, "sensor.test_solar_reg_power") == ("power", False) + + # Test string heuristic fallback + assert _determine_energy_or_power(hass, "sensor.my_custom_daily_yield") == ("energy", False) + assert _determine_energy_or_power(hass, "sensor.inverter_power_kw") == ("power", False) + + +@pytest.mark.asyncio +async def test_migrate_already_v2_system(hass: HomeAssistant): + """Test that migrating an already v2 system is a no-op.""" + v2_system = { + CONF_NAME: "V2 System", + CONF_SYSTEM_ID: "777", + CONF_V1: "sensor.energy", + CONF_V2: "sensor.power", + } + migrated = migrate_v1_system_to_v2(hass, v2_system) + assert migrated == v2_system + + +@pytest.mark.asyncio +async def test_push_data_with_invalid_sensor_values(hass: HomeAssistant): + """Test that non-numeric sensor values are gracefully ignored.""" + hass.states.async_set("sensor.bad_v1", "not_a_number", {"unit_of_measurement": "kWh"}) + hass.states.async_set("sensor.bad_v2", "invalid", {"unit_of_measurement": "kW"}) + hass.states.async_set("sensor.bad_v3", "nan", {"unit_of_measurement": "kWh"}) + hass.states.async_set("sensor.bad_v4", "abc", {"unit_of_measurement": "kW"}) + 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.good_v2", "1500", {"unit_of_measurement": "W"}) + + system_config = { + CONF_NAME: "Invalid Value System", + CONF_SYSTEM_ID: MOCK_SYSTEM_ID, + CONF_FREQUENCY: "5", + CONF_V1: "sensor.bad_v1", + CONF_V2: "sensor.bad_v2", + CONF_V3: "sensor.bad_v3", + CONF_V4: "sensor.bad_v4", + CONF_V5: "sensor.bad_v5", + CONF_V6: "sensor.bad_v6", + CONF_V7: "sensor.bad_v7", + } + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: MOCK_API_KEY, CONF_SYSTEMS: [system_config]}, version=2) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with aioresponses() as mock_aio: + mock_aio.post(PVOUTPUT_API_URL, status=200) + + future = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + post_requests = mock_aio.requests.get(("POST", PVOUTPUT_API_URL)) + + # All sensors were invalid strings, so nothing should be posted + assert post_requests is None + + +@pytest.mark.asyncio +async def test_frequency_scheduling_options(hass: HomeAssistant): + """Test setup with 60 minute, 180 minute, and fallback frequencies.""" + systems = [ + {CONF_NAME: "Hourly", CONF_SYSTEM_ID: "1", CONF_FREQUENCY: "60", CONF_V2: "sensor.pwr"}, + {CONF_NAME: "Three Hourly", CONF_SYSTEM_ID: "2", CONF_FREQUENCY: "180", CONF_V2: "sensor.pwr"}, + {CONF_NAME: "Fallback", CONF_SYSTEM_ID: "3", CONF_FREQUENCY: "999", CONF_V2: "sensor.pwr"}, + ] + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: MOCK_API_KEY, CONF_SYSTEMS: systems}, version=2) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.data[DOMAIN][entry.entry_id]) == 3 + + +@pytest.mark.asyncio +async def test_async_migrate_entry_duplicate_energy_sensors(hass: HomeAssistant): + """Test migration when user mistakenly configured two energy sensors.""" + hass.states.async_set("sensor.sol_eng_1", "5000", {"unit_of_measurement": "Wh"}) + hass.states.async_set("sensor.sol_eng_2", "10000", {"unit_of_measurement": "Wh"}) + + v1_system = { + CONF_NAME: "Duplicate Energy System", + CONF_SYSTEM_ID: "888", + CONF_ENTITY_ID: "sensor.sol_eng_1", + CONF_SECONDARY_ENTITY_ID: "sensor.sol_eng_2", + CONF_FREQUENCY: "5", + } + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "key", CONF_SYSTEMS: [v1_system]}, version=1) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + migrated = entry.data[CONF_SYSTEMS][0] + # Primary is energy -> v1, secondary energy is ignored (v2 is None) + assert migrated[CONF_V1] == "sensor.sol_eng_1" + assert migrated[CONF_V2] is None + assert migrated[CONF_C1] is True + + +@pytest.mark.asyncio +async def test_async_migrate_entry_duplicate_power_sensors(hass: HomeAssistant): + """Test migration when user mistakenly configured two power sensors.""" + hass.states.async_set("sensor.sol_pwr_1", "3000", {"unit_of_measurement": "W"}) + hass.states.async_set("sensor.sol_pwr_2", "2000", {"unit_of_measurement": "W"}) + + v1_system = { + CONF_NAME: "Duplicate Power System", + CONF_SYSTEM_ID: "999", + CONF_ENTITY_ID: "sensor.sol_pwr_1", + CONF_SECONDARY_ENTITY_ID: "sensor.sol_pwr_2", + CONF_FREQUENCY: "5", + } + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "key", CONF_SYSTEMS: [v1_system]}, version=1) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + migrated = entry.data[CONF_SYSTEMS][0] + # Primary is power -> v2, secondary power is ignored (v1 is None) + assert migrated[CONF_V2] == "sensor.sol_pwr_1" + assert migrated[CONF_V1] is None + assert migrated[CONF_C1] is False + + +@pytest.mark.asyncio +async def test_async_migrate_entry_secondary_only(hass: HomeAssistant): + """Test migration when entity_id is missing but secondary_entity_id exists.""" + hass.states.async_set("sensor.sec_energy", "8000", {"unit_of_measurement": "Wh", "state_class": "total_increasing"}) + + v1_system = { + CONF_NAME: "Secondary Only System", + CONF_SYSTEM_ID: "1010", + CONF_SECONDARY_ENTITY_ID: "sensor.sec_energy", + CONF_FREQUENCY: "5", + } + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "key", CONF_SYSTEMS: [v1_system]}, version=1) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + migrated = entry.data[CONF_SYSTEMS][0] + assert migrated[CONF_V1] == "sensor.sec_energy" + assert migrated[CONF_V2] is None + assert migrated[CONF_C1] is True + + +@pytest.mark.asyncio +async def test_async_migrate_entry_consumption_lifetime_flag(hass: HomeAssistant): + """Test that consumption lifetime sensor sets c1=True when no generation sensor is present.""" + hass.states.async_set("sensor.con_lifetime", "15000", {"unit_of_measurement": "kWh", "state_class": "total_increasing"}) + + v1_system = { + CONF_NAME: "Consumption Only", + CONF_SYSTEM_ID: "2020", + CONF_CONSUMPTION_ENTITY_ID: "sensor.con_lifetime", + CONF_FREQUENCY: "5", + } + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "key", CONF_SYSTEMS: [v1_system]}, version=1) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + migrated = entry.data[CONF_SYSTEMS][0] + assert migrated[CONF_V3] == "sensor.con_lifetime" + assert migrated[CONF_C1] is True + + +@pytest.mark.asyncio +async def test_push_data_temperature_sensor_without_device_class(hass: HomeAssistant): + """Test that temperature sensors lacking device_class (e.g. weather/MQTT/template) upload successfully.""" + # Sensor has unit °C but NO device_class attribute + hass.states.async_set("sensor.outdoor_temp_no_dc", "23.8", {"unit_of_measurement": "°C"}) + hass.states.async_set("sensor.solar_power", "1500", {"unit_of_measurement": "W"}) + + system_config = { + CONF_NAME: "Weather Temp System", + CONF_SYSTEM_ID: MOCK_SYSTEM_ID, + CONF_FREQUENCY: "5", + CONF_V2: "sensor.solar_power", + CONF_V5: "sensor.outdoor_temp_no_dc", + } + + with aioresponses() as mock_aio: + payload = await setup_and_trigger(hass, mock_aio, system_config) + + assert "&v5=23.8" in payload + assert "&v2=1500" in payload + + +