diff --git a/.github/workflows/hacs.yaml b/.github/workflows/hacs.yaml index 67b4bce..53fa08b 100644 --- a/.github/workflows/hacs.yaml +++ b/.github/workflows/hacs.yaml @@ -11,7 +11,7 @@ jobs: name: HACS Action runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v2" + - uses: "actions/checkout@v6" - name: HACS Action uses: "hacs/action@main" with: diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml index a9bf5c7..9b2b2f0 100644 --- a/.github/workflows/hassfest.yaml +++ b/.github/workflows/hassfest.yaml @@ -10,5 +10,5 @@ jobs: validate: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v2" + - uses: "actions/checkout@v6" - uses: "home-assistant/actions/hassfest@master" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ed7a86a..f944e2c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,41 +5,23 @@ on: types: - published +permissions: + contents: write + jobs: - release: - name: Release goecharger_api2 + release_zip_file: + name: Build & Upload HACS zip release runs-on: ubuntu-latest - permissions: - contents: write steps: - # Step 1: Check out the code - - name: ⤵️ Check out code from GitHub - uses: actions/checkout@v4 - - # Step 2: (Optional, but recommended) Install yq (in case it's not present) - #- name: Install yq - # run: sudo apt-get update && sudo apt-get install -y yq - - # Step 3: Update version in manifest.json to match the release tag - #- name: 🔢 Adjust version number - # shell: bash - # run: | - # version="${{ github.event.release.tag_name }}" - # version="${version,,}" # convert to lowercase (e.g., V2024.10.1 -> v2024.10.1) - # version="${version#v}" # remove leading 'v' if present - # yq e -P -o=json \ - # -i ".version = \"${version}\"" \ - # "${{ github.workspace }}/custom_components/goecharger_api2/manifest.json" + - name: Checkout repository + uses: actions/checkout@v6 - # Step 4: Create a ZIP archive of the component directory - - name: 📦 Create zipped release package - shell: bash + - name: Build zip archive run: | cd "${{ github.workspace }}/custom_components/goecharger_api2" - zip goecharger_api2.zip -r ./ + zip -r "${{ github.workspace }}/goecharger_api2.zip" . -x "*__pycache__*" -x "*.pyc" - # Step 5: Upload ZIP as a release asset (for HACS) - - name: ⬆️ Upload zip to release - uses: softprops/action-gh-release@v2 + - name: Upload zip to GitHub Release + uses: softprops/action-gh-release@v3 with: - files: ${{ github.workspace }}/custom_components/goecharger_api2/goecharger_api2.zip \ No newline at end of file + files: ${{ github.workspace }}/goecharger_api2.zip \ No newline at end of file diff --git a/.github/workflows/test-report.yaml b/.github/workflows/test-report.yaml new file mode 100644 index 0000000..a07607e --- /dev/null +++ b/.github/workflows/test-report.yaml @@ -0,0 +1,69 @@ +name: Test report (manual) + +# Builds the full test report (line, branch and path coverage, criticality) for a version of +# marq24/ha-goecharger-api2. Started by hand only; nothing is committed. The report is shown in the job +# summary and kept as an artifact. It clones the chosen version fresh and copies only tests/integration and +# tests/tools of this branch into it. + +on: + workflow_dispatch: + inputs: + ref: + description: "Tag or branch of marq24/ha-goecharger-api2 (empty = latest release)" + required: false + default: "" + +permissions: + contents: read + +jobs: + report: + runs-on: ubuntu-latest + steps: + - name: Check out the tests (this branch) + uses: actions/checkout@v4 + + - name: Decide which upstream version to measure + id: ref + env: + GH_TOKEN: ${{ github.token }} + INPUT_REF: ${{ inputs.ref }} + run: | + ref="$INPUT_REF" + [ -n "$ref" ] || ref="$(gh api repos/marq24/ha-goecharger-api2/releases/latest --jq .tag_name)" + echo "ref=$ref" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Install test dependencies + run: pip install -r requirements_dev.txt msgpack pytest-cov radon + + - name: Clone the upstream version and add the tests + env: + REF: ${{ steps.ref.outputs.ref }} + run: | + git clone --depth 1 --branch "$REF" https://github.com/marq24/ha-goecharger-api2 "$RUNNER_TEMP/src" + mkdir -p "$RUNNER_TEMP/src/tests" + cp -R tests/integration tests/tools "$RUNNER_TEMP/src/tests/" + + - name: Measure and build the report + working-directory: ${{ runner.temp }}/src + env: + TESTS_COMMIT: ${{ github.sha }} + SOURCE_REF: ${{ steps.ref.outputs.ref }} + run: sh tests/tools/measure.sh "$RUNNER_TEMP/report" + + - name: Show the report + if: always() + run: | + [ -f "$RUNNER_TEMP/report/testreport.md" ] && cat "$RUNNER_TEMP/report/testreport.md" >> "$GITHUB_STEP_SUMMARY" || true + + - name: Keep the report + if: always() + uses: actions/upload-artifact@v4 + with: + name: testreport-${{ steps.ref.outputs.ref }} + path: ${{ runner.temp }}/report + if-no-files-found: ignore diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..d0f0dc3 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,50 @@ +name: Tests + +# Runs the regression tests of tests/integration. Tests marked known_bug or needs_decision describe +# findings that are still open: they run in a second, non-blocking step and are listed in the job summary. +# Manual full report (coverage): test-report.yaml. + +on: + push: + branches: [main] + paths: + - "custom_components/**" + - "tests/**" + - "requirements_dev.txt" + - ".github/workflows/tests.yaml" + pull_request: + paths: + - "custom_components/**" + - "tests/**" + - "requirements_dev.txt" + - ".github/workflows/tests.yaml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements_dev.txt + + - name: Install test dependencies + run: | + python -m pip install -r requirements_dev.txt msgpack + python -m pip show homeassistant pytest-homeassistant-custom-component | grep -E "^(Name|Version)" + + - name: Blocking tests + run: python -m pytest tests/integration -p no:cacheprovider -q -m "not known_bug and not needs_decision" + + - name: Open findings (known_bug, needs_decision) + continue-on-error: true + run: | + python -m pytest tests/integration -p no:cacheprovider -q -m "known_bug or needs_decision" | tee open-findings.txt + { echo "### Open findings"; echo; echo '```'; tail -n 40 open-findings.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 2fd0046..59b2155 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ -# Home Assistant Integration 'go-eCharger & go-eController [via WebSocket & APIv2]' +# Home Assistant Integration 'go-eCharger & go-eController [via WebSocket & APIv2]' ![logo](https://github.com/marq24/ha-goecharger-api2/raw/main/logo.png) Support for all go-eCharger Wallboxes & go-eController's supporting the APIv2 or a WebSocket connection. This also included other branded Wallboxes that are based on the go-eCharger hardware (like the Fronius Wattpilot) — as long as they support the WebSocket or HTTP APIv2 connection. +[![hacs_badge][hacsbadge]][hacs] [![hainstall][hainstallbadge]][hainstall] [![Wero][werobadge]][wero] [![wise][wisebadge]][wise] [![Revolut][revolutbadge]][revolut] [![PayPal][paypalbadge]][paypal] [![github][ghsbadge]][ghs] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + > [!WARNING] > ## Disclaimer > Please be aware that we are developing this integration to the best of our knowledge and belief, but can't give a guarantee. Therefore, use this integration **at your own risk**. @@ -17,9 +19,8 @@ So it's either enabled HTTP v2 API or password to use the websocket [[see instru > [!IMPORTANT] -> __Please note__, _that this integration is __not official__, nor is it supported by the go-e manufacture from Austrian. I (marq24) am not affiliated with go-e.com in any way. This integration is based on the go-e API and the available go-e API documentation. (and some reverse engineering)_. +> __Please note__, _that this integration is __not official__, nor is it supported by the go-e manufacture from Austria. I (marq24) am not affiliated with go-e.com in any way. This integration is based on the go-e API and the available go-e API documentation. (and some reverse engineering)_. -[![hacs_badge][hacsbadge]][hacs] [![hainstall][hainstallbadge]][hainstall] [![Wero][werobadge]][wero] [![wise][wisebadge]][wise] [![Revolut][revolutbadge]][revolut] [![PayPal][paypalbadge]][paypal] [![github][ghsbadge]][ghs] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] ## Latest successfully tested go-eCharger Firmware Versions / go-eController Firmware Versions diff --git a/custom_components/goecharger_api2/__init__.py b/custom_components/goecharger_api2/__init__.py index 3830864..9c062d6 100644 --- a/custom_components/goecharger_api2/__init__.py +++ b/custom_components/goecharger_api2/__init__.py @@ -20,22 +20,20 @@ CONF_DELAY, EVENT_HOMEASSISTANT_STARTED ) +from homeassistant.components import persistent_notification from homeassistant.core import HomeAssistant, Event, SupportsResponse, CoreState from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( config_validation as config_val, - entity_registry as entity_reg, device_registry as device_reg ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntry, async_entries_for_config_entry from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.event import async_track_time_interval, async_call_later from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.loader import async_get_integration -from packaging.version import Version from custom_components.goecharger_api2.pygoecharger_ha import ( GoeChargerApiV2Bridge, @@ -55,12 +53,13 @@ STARTUP_MESSAGE, SERVICE_SET_PV_DATA, SERVICE_STOP_CHARGING, + SERVICE_SET_TEMPORARY_CURRENT, CONF_11KWLIMIT, CONF_INTEGRATION_TYPE, CONFIG_VERSION, CONFIG_MINOR_VERSION ) from .entity import CustomFriendlyNameEntity -from .service import GoeChargerApiV2Service +from .service import GoeChargerApiV2Service, add_config_entry_to_service, remove_config_entry_from_service _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -70,9 +69,11 @@ WEBSOCKET_WATCHDOG_INTERVAL: Final = timedelta(minutes=5, seconds=1) COMMUNICATION_MODE_WEBSOCKET: Final = "WEBSOCKET" COMMUNICATION_MODE_HTTPGET: Final = "HTTPGET" +ACTIVE_CHARGER_COUNTER: Final = "active_charger_counter" +IGNORE_CONFIG_ENTRY_UPDATES = False async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): - if config_entry.version < CONFIG_VERSION: + if config_entry.version == 1: if config_entry.data is not None and len(config_entry.data) > 0: _LOGGER.debug(f"Migrating configuration from version {config_entry.version}.{config_entry.minor_version}") if config_entry.options is not None and len(config_entry.options): @@ -81,6 +82,23 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): new_data = config_entry.data hass.config_entries.async_update_entry(config_entry, data=new_data, options={}, version=CONFIG_VERSION, minor_version=CONFIG_MINOR_VERSION) _LOGGER.debug(f"Migration to configuration version {config_entry.version}.{config_entry.minor_version} successful") + + # migrate device registry entries -> actually we DELETE ALL... + if config_entry.version == 2 and config_entry.minor_version == 0: + try: + _LOGGER.debug(f"Migrating configuration from version {config_entry.version}.{config_entry.minor_version}") + a_device_reg = device_reg.async_get(hass) + if a_device_reg is not None: + all_devices = device_reg.async_entries_for_config_entry(a_device_reg, config_entry.entry_id) + for a_device in all_devices: + _LOGGER.debug(f"async_migrate_entry(): PURGING device: {a_device.id}") + a_device_reg.async_remove_device(device_id=a_device.id) + except BaseException as ex: + _LOGGER.warning(f"async_migrate_entry(): failed: {type(ex).__name__} - {ex}") + + hass.config_entries.async_update_entry(config_entry, version=CONFIG_VERSION, minor_version=CONFIG_MINOR_VERSION) + _LOGGER.info(f"async_migrate_entry(): Migration to configuration version {config_entry.version}.{config_entry.minor_version} successful") + return True @@ -89,13 +107,60 @@ async def async_setup(hass: HomeAssistant, config: dict): # pylint: disable=unu return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): - if DOMAIN not in hass.data: - the_integration = await async_get_integration(hass, DOMAIN) - intg_version = the_integration.version if the_integration is not None else "UNKNOWN" - _LOGGER.info(STARTUP_MESSAGE % intg_version) - hass.data.setdefault(DOMAIN, {"manifest_version": intg_version}) +def handle_heavy_user(hass: HomeAssistant, lang_map): + async def check_count_of_running_integrations(hass: HomeAssistant): + try: + _LOGGER.debug(f"check_count_of_running_integrations(): init") + await asyncio.sleep(120) + _LOGGER.debug(f"check_count_of_running_integrations(): ready to start") + loaded_entries = hass.config_entries.async_loaded_entries(DOMAIN) + if len(loaded_entries) > 8: + last_notify_ts = int(loaded_entries[0].data.get("heavy_usage_notification", 0)) + elapsed = time.time() - last_notify_ts + if elapsed > (60 * 24 * 60 * 60): + try: + notify_id = f"{DOMAIN}_heavy_user_notification" + persistent_notification.async_create(hass, + title=lang_map["heavy_user_title"], + message=lang_map["heavy_user_msg"], + notification_id=notify_id) + + all_entries = hass.config_entries.async_entries(DOMAIN) + global IGNORE_CONFIG_ENTRY_UPDATES + IGNORE_CONFIG_ENTRY_UPDATES = True + ts = int(time.time()) + for a_entry in all_entries: + new_data = {**a_entry.data} + new_data["heavy_usage_notification"] = ts + hass.config_entries.async_update_entry(a_entry, data=new_data) + IGNORE_CONFIG_ENTRY_UPDATES = False + async def auto_dismiss(delay_seconds: int): + try: + await asyncio.sleep(delay_seconds) + except asyncio.CancelledError: + _LOGGER.debug(f"auto_dismiss() will be cancelled...") + await hass.services.async_call("persistent_notification", "dismiss", {"notification_id": notify_id}) + + # show the message for 4h ater the start - then auto cancel it + a_task = hass.async_create_task(auto_dismiss(4*60*60)) + hass.data[DOMAIN]["checker_task"] = a_task + + except BaseException as e: + _LOGGER.debug(f"persistent_notification() caused {type(e).__name__} - {e}", stack_info=True) + else: + _LOGGER.debug(f"persistent_notification() just notified less then 30 days ago") + else: + _LOGGER.debug(f"persistent_notification() loaded entries is: {loaded_entries}") + + except asyncio.CancelledError: + _LOGGER.debug(f"check_count_of_running_integrations() will be cancelled...") + + task = hass.async_create_task(check_count_of_running_integrations(hass)) + hass.data[DOMAIN]["checker_task"] = task + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): # ok starting the init sequence... lang = hass.config.language.lower() if lang in TRANSLATIONS: @@ -103,17 +168,26 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): else: lang_map = TRANSLATIONS["en"] + if DOMAIN not in hass.data: + the_integration = await async_get_integration(hass, DOMAIN) + intg_version = the_integration.version if the_integration is not None else "UNKNOWN" + _LOGGER.info(STARTUP_MESSAGE % intg_version) + hass.data.setdefault(DOMAIN, {"manifest_version": intg_version}) + handle_heavy_user(hass, lang_map) + coordinator = GoeChargerDataUpdateCoordinator(hass, config_entry) a_pwd = config_entry.data.get(CONF_PASSWORD, None) if a_pwd is not None and len(a_pwd.strip()) > 0: use_websocket = True try: if not await coordinator.start_websocket_and_wait_for_first_data(is_integration_init=True): - _LOGGER.warning(f"The coordinator.start_websocket_and_wait_for_first_data() as returned FALSE") + _LOGGER.warning(f"async_setup_entry(): The coordinator.start_websocket_and_wait_for_first_data() as returned FALSE") raise ConfigEntryNotReady(lang_map["coord_no_device_data"]) except BaseException as exc: - _LOGGER.error(f"Error starting websocket connection: {type(exc).__name__} - {exc}", stack_info=True) + if isinstance(exc, ConfigEntryNotReady): + raise exc + _LOGGER.error(f"async_setup_entry(): Error starting websocket connection: {type(exc).__name__} - {exc}", stack_info=True) raise ConfigEntryNotReady(lang_map["websocket_start_failed"]) else: use_websocket = False @@ -130,27 +204,31 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): # initialize our service... if coordinator.intg_type == INTG_TYPE.CHARGER.value: - service = GoeChargerApiV2Service(hass, config_entry, coordinator) - hass.services.async_register(DOMAIN, SERVICE_SET_PV_DATA, service.set_pv_data, - supports_response=SupportsResponse.OPTIONAL) - hass.services.async_register(DOMAIN, SERVICE_STOP_CHARGING, service.stop_charging, - supports_response=SupportsResponse.OPTIONAL) - - # start the limit to 16a limiiter... - if coordinator.limit_to16a: - asyncio.create_task(coordinator.check_for_16a_limit(hass, config_entry.entry_id)) - - # checker that cleaing orphan/outdated device registry entries... - asyncio.create_task(coordinator.cleanup_device_registry(hass)) + if ACTIVE_CHARGER_COUNTER not in hass.data[DOMAIN]: + hass.data[DOMAIN][ACTIVE_CHARGER_COUNTER] = 0 + hass.data[DOMAIN][ACTIVE_CHARGER_COUNTER] = hass.data[DOMAIN][ACTIVE_CHARGER_COUNTER] + 1 + + if not hass.services.has_service(DOMAIN, SERVICE_SET_PV_DATA): + _LOGGER.info(f"async_setup_entry(): Activate go-eCharger SERVICES") + service = GoeChargerApiV2Service(hass, config_entry, coordinator) + hass.services.async_register(DOMAIN, SERVICE_SET_PV_DATA, service.set_pv_data, + supports_response=SupportsResponse.OPTIONAL) + hass.services.async_register(DOMAIN, SERVICE_SET_TEMPORARY_CURRENT, service.set_temporary_current_limit, + supports_response=SupportsResponse.OPTIONAL) + hass.services.async_register(DOMAIN, SERVICE_STOP_CHARGING, service.stop_charging, + supports_response=SupportsResponse.OPTIONAL) + else: + # we need to let the service know the config-entry... + add_config_entry_to_service(config_entry, coordinator) # finally, register the websocket-connection watchdog... if use_websocket: # ws watchdog... if hass.state is CoreState.running: - _LOGGER.debug(f"starting watchdog INSTANTLY") + _LOGGER.debug(f"async_setup_entry(): starting watchdog INSTANTLY") await coordinator.start_watchdog() else: - _LOGGER.debug(f"starting watchdog delayed... (when EVENT_HOMEASSISTANT_STARTED is fired)") + _LOGGER.debug(f"async_setup_entry(): starting watchdog delayed... (when EVENT_HOMEASSISTANT_STARTED is fired)") hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, coordinator.start_watchdog) config_entry.async_on_unload(config_entry.add_update_listener(entry_update_listener)) @@ -158,138 +236,47 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): return True -def check_unload_services(hass: HomeAssistant): - active_integration_configs = hass.config_entries.async_entries(domain=DOMAIN, include_disabled=False, include_ignore=False) - if active_integration_configs is not None and len(active_integration_configs) > 0: - return False - else: - return True - - async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) if unload_ok: - if DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN]: - coordinator = hass.data[DOMAIN][config_entry.entry_id] - coordinator.stop_watchdog() - coordinator.clear_data() - hass.data[DOMAIN].pop(config_entry.entry_id) - - # ONLY remove the SERVICES if this is the LAST ACTIVE config_entry that will be unloaded! - if check_unload_services(hass): - hass.services.async_remove(DOMAIN, SERVICE_SET_PV_DATA) - hass.services.async_remove(DOMAIN, SERVICE_STOP_CHARGING) + if DOMAIN in hass.data: + if config_entry.entry_id in hass.data[DOMAIN]: + coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator.cancel_existing_debouncer() + coordinator.stop_watchdog() + coordinator.clear_data() + hass.data[DOMAIN].pop(config_entry.entry_id) + if coordinator.intg_type == INTG_TYPE.CHARGER.value: + hass.data[DOMAIN][ACTIVE_CHARGER_COUNTER] = hass.data[DOMAIN][ACTIVE_CHARGER_COUNTER] - 1 + remove_config_entry_from_service(config_entry) + + # ONLY remove the SERVICES if this is the LAST ACTIVE config_entry that will be unloaded! + if hass.data[DOMAIN][ACTIVE_CHARGER_COUNTER] == 0: + _LOGGER.info(f"async_unload_entry(): Unloading last active go-eCharger - will remove SERVICES") + hass.services.async_remove(DOMAIN, SERVICE_SET_PV_DATA) + hass.services.async_remove(DOMAIN, SERVICE_STOP_CHARGING) + hass.services.async_remove(DOMAIN, SERVICE_SET_TEMPORARY_CURRENT) + + # if there is a 'active checker task', we cancel it... (and do not care that + # this task will be "killed" when the first integration entry will be + # unloaded) + if "checker_task" in hass.data[DOMAIN]: + task = hass.data[DOMAIN]["checker_task"] + if not task.done(): + task.cancel() + hass.data[DOMAIN].pop("checker_task") return unload_ok async def entry_update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - _LOGGER.debug(f"entry_update_listener() called for entry: {config_entry.entry_id}") - await hass.config_entries.async_reload(config_entry.entry_id) - - -@staticmethod -async def check_and_write_to_16a(hass: HomeAssistant, config_entry_id: str, bridge: GoeChargerApiV2Bridge, is_lbgroup_id_set: bool): - _LOGGER.info(f"checking entities") - tags = [] - if hass is not None: - a_entity_reg = entity_reg.async_get(hass) - if a_entity_reg is not None: - MAX_A: Final = 16 - # we query from the HA entity registry all entities that are created by this - # 'config_entry' -> we use here just default api calls [no more hacks!] - key_list = [] - for entity in entity_reg.async_entries_for_config_entry(registry=a_entity_reg, config_entry_id=config_entry_id): - if entity.original_device_class == NumberDeviceClass.CURRENT: - if "max" in entity.capabilities: - if entity.capabilities["max"] == MAX_A: - # we must check if the entity is disabled, or if it is orphaned... - if entity.disabled is None or entity.disabled is False: - if hasattr(entity, "orphaned_timestamp") is False or entity.orphaned_timestamp is None: - if entity.translation_key not in key_list: - key_list.append(entity.translation_key) - _LOGGER.info(f"16A checker: adding entity {entity.translation_key} to list") - else: - _LOGGER.debug(f"16A checker: entity {entity.translation_key} already in list") - else: - _LOGGER.debug(f"16A checker: ignoring entity {entity.translation_key} because it is orphaned") - else: - _LOGGER.debug(f"16A checker: ignoring entity {entity.translation_key} because it is disabled") - - if len(key_list) > 0: - _LOGGER.info(f"16A checker: found {len(key_list)} entities with max current of {MAX_A}A - {key_list}") - final_key_list = [] - final_dics = {} - for a_key in key_list: - if '_' in a_key: - res = a_key.split('_') - if res[0] not in final_dics: - final_dics[res[0]] = [] - final_dics[res[0]].append(res[1]) - a_key = res[0] - - if a_key not in final_key_list: - final_key_list.append(a_key) - - try: - res = await bridge._read_filtered_data(filters=",".join(final_key_list), log_info="16A checker") - keys_to_patch = [] - for a_res_key in res.keys(): - res_obj = res[a_res_key] - if isinstance(res_obj, int): - # WE MUST/SHOULD check, if this is really a value, that MUST be adjusted to 16A limit - # since we have some ExtNumberEntityDescription where the 'check_16a_limit is FALSE' - # - # mhhhh - thinking - but then in the EntityDescription.check_16a_limit = False, then - # the entity.capabilities["max"] will not be set to 16A ?! - so this might already - # solve my problem... - if res_obj > MAX_A: - res[a_res_key] = MAX_A - if a_res_key not in keys_to_patch: - keys_to_patch.append(a_res_key) - elif isinstance(res_obj, dict): - #_LOGGER.warning(f"found dict in 16A check: {res_obj}") - vals_to_check = final_dics.get(a_res_key) - for val in vals_to_check: - # WE MUST/SHOULD check, if this... see comment #216+ - if res_obj[val] > MAX_A: - res[a_res_key][val] = MAX_A - if a_res_key not in keys_to_patch: - keys_to_patch.append(a_res_key) - - _LOGGER.info(f"16A checker: reduce the following keys: {keys_to_patch}") - for a_key in keys_to_patch: - if is_lbgroup_id_set and a_key in [Tag.LOT.key]: - _LOGGER.debug(f"16A checker: skipping key {a_key}, since loadbalancer group id is set") - continue - _LOGGER.info(f"reduce {a_key} to 16A -> writing {res[a_key]}") - await bridge.write_value_to_key(a_key, res[a_key]) - except Exception as e: - _LOGGER.error(f"16A checker: Error while forcing 16A settings:", e) - -@staticmethod -async def check_device_registry(hass: HomeAssistant): - _LOGGER.info(f"check device registry...") - if hass is not None: - a_device_reg = device_reg.async_get(hass) - if a_device_reg is not None: - key_list = [] - all_domain_devices = [] - for a_config_entry in hass.config_entries.async_entries(DOMAIN): - all_domain_devices.extend(async_entries_for_config_entry(a_device_reg, a_config_entry.entry_id)) - for a_device_entry in all_domain_devices: - if hasattr(a_device_entry, "identifiers"): - ident_value = a_device_entry.identifiers - if len(next(iter(ident_value))) != 4: - _LOGGER.debug(f"found a OLD {DOMAIN} DeviceEntry: {a_device_entry}") - key_list.append(a_device_entry.id) - - if len(key_list) > 0: - _LOGGER.info(f"NEED TO DELETE old {DOMAIN} DeviceEntries: {key_list}") - for a_device_entry_id in key_list: - a_device_reg.async_remove_device(device_id=a_device_entry_id) - + global IGNORE_CONFIG_ENTRY_UPDATES + if not IGNORE_CONFIG_ENTRY_UPDATES: + _LOGGER.debug(f"entry_update_listener(): called for entry: {config_entry.entry_id}") + await hass.config_entries.async_reload(config_entry.entry_id) + else: + _LOGGER.debug(f"entry_update_listener(): IGNORE for entry: {config_entry.entry_id} (IGNORE_CONFIG_ENTRY_UPDATES=True)") class GoeChargerDataUpdateCoordinator(DataUpdateCoordinator): @@ -446,7 +433,7 @@ async def start_websocket_and_wait_for_first_data(self, is_integration_init:bool self._ws_start_task = self._config_entry.async_create_background_task(self.hass, target, "ws_connection") if self._ws_start_task is None: - raise ConfigEntryNotReady("start_websocket_and_wait_for_first_data(): Could not create websocket connect task!") + raise ConfigEntryNotReady(self.lang_map["ws_start_task_is_none"]) # 3. Wait ONLY until the first message is processed (or time out) try: @@ -457,7 +444,7 @@ async def start_websocket_and_wait_for_first_data(self, is_integration_init:bool _LOGGER.warning("start_websocket_and_wait_for_first_data(): Connection to websocket timed out after one minute!") self._ws_start_task.cancel() # NOT SURE WHAT TO DO NOW ???! - raise ConfigEntryNotReady("start_websocket_and_wait_for_first_data(): Could not fetch essential data") + raise ConfigEntryNotReady(self.lang_map["ws_fetch_essential_data_timed_out"]) # we must check, if connected_event has errors... if connected_event.has_errors(): @@ -477,8 +464,11 @@ async def start_websocket_and_wait_for_first_data(self, is_integration_init:bool _LOGGER.debug(f"start_websocket_and_wait_for_first_data(): task created {self._ws_start_task.get_coro()}") async_call_later(self.hass, 10, self.call_later_update_device_registry) - - _LOGGER.info(f"Essential device data is available after WebSocket connection has been established - that's just so GREAT! Available keys: {list(self.data.keys())} so let's move on...") + if self.data is not None and hasattr(self.data, "keys"): + l_data = list(self.data.keys()) + else: + l_data = "[self.data is NONE?! - or no 'keys()']" + _LOGGER.info(f"Essential device data is available after WebSocket connection has been established - that's just so GREAT! Available keys: {l_data} so let's move on...") return True async def start_watchdog(self, event=None): @@ -534,6 +524,7 @@ def clear_data(self): self._CLIENT_COMMUNICATION_ERROR_TS = 0 self._CLIENT_COMMUNICATION_ERROR_COUNT = 0 self._RESTART_TRIGGERED = False + self.cancel_existing_debouncer() self._debounced_update_task = None async def trigger_restart_delayed(self) -> None: @@ -545,9 +536,12 @@ async def trigger_restart_delayed(self) -> None: _LOGGER.info(f"trigger_restart_delayed(): --- RELOAD INTEGRATION NOW ---") await self.hass.config_entries.async_reload(self._config_entry.entry_id) - def request_update_in_sec(self, seconds: float): + def cancel_existing_debouncer(self): if self._debounced_update_task is not None and not self._debounced_update_task.done(): self._debounced_update_task.cancel() + + def request_update_in_sec(self, seconds: float): + self.cancel_existing_debouncer() self._debounced_update_task = asyncio.create_task(self._debounce_coordinator_update(seconds)) async def _debounce_coordinator_update(self, seconds: float): @@ -555,7 +549,7 @@ async def _debounce_coordinator_update(self, seconds: float): await self.async_refresh() def handle_write_result(self, a_type, value, key, result, entity): - if result is None and self.bridge.ws_connected: + if self.bridge.ws_connected and (result is None or len(result) == 0): # when using websocket - the writing result will be handled # already in the ws-response processor... return @@ -591,7 +585,7 @@ def _handle_client_connection_error(self, msg: str, exception: Exception): self._CLIENT_COMMUNICATION_ERROR_TS = time.time() self._CLIENT_COMMUNICATION_ERROR_COUNT += 1 if self._CLIENT_COMMUNICATION_ERROR_COUNT > 5: - _LOGGER.warning(f"{msg}: Too many ClientConnectionError #{self._CLIENT_COMMUNICATION_ERROR_COUNT} while fetching data:{type(exception).__name__} - {exception} - will try to restart integration.", stack_info=True) + _LOGGER.warning(f"{msg}: Too many ClientConnectionError #{self._CLIENT_COMMUNICATION_ERROR_COUNT} while fetching data: {type(exception).__name__} - {exception} - will try to restart integration.", stack_info=True) if not self._RESTART_TRIGGERED: _LOGGER.info(f"{msg}: TRIGGER RESTART...") self._RESTART_TRIGGERED = True @@ -635,7 +629,7 @@ async def _async_update_data(self) -> dict: _LOGGER.error(f"_async_update_data(): unexpected: {type(other).__name__} - {other}") raise UpdateFailed() from other - async def async_write_key(self, key: str, value, entity: Entity = None) -> dict: + async def async_write_key(self, key: str, value, entity: Entity = None, require_ack: bool = False) -> dict: if self._CLIENT_COMMUNICATION_ERROR_TS + CLIENT_COMMUNICATION_ERROR_DELAY_IN_SECONDS > time.time(): time_info = CLIENT_COMMUNICATION_ERROR_DELAY_IN_SECONDS - (time.time() - self._CLIENT_COMMUNICATION_ERROR_TS) _LOGGER.info(f"async_write_key(): skipping due to client communication error for the next {time_info} seconds") @@ -645,16 +639,16 @@ async def async_write_key(self, key: str, value, entity: Entity = None) -> dict: raise ValueError("async_write_key(): RESTART is TRIGGERED (waiting for random sleep delay)") try: - result = await self.bridge.write_value_to_key(key, value) + result = await self.bridge.write_value_to_key(key, value, require_ack=require_ack) self.handle_write_result("single", value, key, result, entity) return result except ClientConnectionError as cc_err: self._handle_client_connection_error("async_write_key()", cc_err) - raise ValueError(f"ClientConnectionError while writing {key} to wallbox: {type(cc_err.__name__)} - {cc_err}") from cc_err + raise ValueError(f"ClientConnectionError while writing {key} to wallbox: {type(cc_err).__name__} - {cc_err}") from cc_err except Exception as e: - _LOGGER.error(f"Error while writing single {key} to wallbox: {e}") - raise ValueError(f"Exception while writing {key} to wallbox: {e}") from e + _LOGGER.error(f"Error while writing single {key} to wallbox: {type(e).__name__} - {e}") + raise ValueError(f"Exception while writing {key} to wallbox: {type(e).__name__} - {e}") from e async def async_write_multiple_keys(self, attr:dict, key: str, value, entity: Entity = None) -> dict: if self._CLIENT_COMMUNICATION_ERROR_TS + CLIENT_COMMUNICATION_ERROR_DELAY_IN_SECONDS > time.time(): @@ -670,9 +664,9 @@ async def async_write_multiple_keys(self, attr:dict, key: str, value, entity: En self.handle_write_result("multiple", value, key, result, entity) return result - except ClientConnectionError as exception: - self._handle_client_connection_error(f"async_write_multiple_keys(): {type(exception).__name__} - {exception}") - raise ValueError(f"ClientConnectionError while writing multiple {key} to wallbox: {type(exception).__name__} - {exception}") from exception + except ClientConnectionError as cce: + self._handle_client_connection_error(f"async_write_multiple_keys(): {type(cce).__name__} - {cce}", cce) + raise ValueError(f"ClientConnectionError while writing multiple {key} to wallbox: {type(cce).__name__} - {cce}") from cce except Exception as e: _LOGGER.error(f"Error while writing multiple {key} to wallbox: {type(e).__name__} - {e}") raise ValueError(f"Exception while writing multiple {key} to wallbox: {type(e).__name__} - {e}") from e @@ -740,46 +734,62 @@ async def read_versions(self): else: _LOGGER.info(f"active cards {self.available_cards_idx}") - # check for the 16A limiter... - # we now always try to read a possible ccl object and use there the 'cableCurrentLimit' - if there - # is no ccl or we have an error, we make use of the adi as fallback (if it's not core_wallbox)... - wb_has_16a_cable_limit = None - use_adi_as_fallback = not self._is_core_wallbox - if Tag.CLL.key in self.bridge._versions: - ccl_obj = self.bridge._versions.none_null_get(Tag.CLL.key, {}) - if len(ccl_obj) > 0: - ccl_cable_limit_val = ccl_obj.get("cableCurrentLimit", "-1") - _LOGGER.debug(f"read_versions(): read CLL:cableCurrentLimit: '{ccl_cable_limit_val}'") - try: - ccl_cable_limit_int = int(ccl_cable_limit_val) - if ccl_cable_limit_int > 0: - wb_has_16a_cable_limit = ccl_cable_limit_int <= 16 - use_adi_as_fallback = False - else: - _LOGGER.debug(f"read_versions(): no 'cableCurrentLimit' found in {ccl_obj}") - except BaseException as exc: - _LOGGER.debug(f"read_versions(): try to handle CLL:cableCurrentLimit caused: {type(exc).__name__} - {exc}") - if self._is_core_wallbox: - wb_has_16a_cable_limit = True - else: - _LOGGER.debug(f"read_versions(): CLL object is empty! {ccl_obj}") - - if wb_has_16a_cable_limit is None and use_adi_as_fallback: - wb_has_16a_cable_limit = self.bridge._versions.none_null_get(Tag.ADI.key, False) - - # if we haven't seen any 'wb_has_16a_cable_limit' yet, then we enable the limit just to be sure... - if wb_has_16a_cable_limit is None: - _LOGGER.debug(f"read_versions(): enforcing 'wb_has_16a_cable_limit=True' cause previously the code did not set any value") - wb_has_16a_cable_limit = True - - self.limit_to16a = (self._config_entry.data.get(CONF_11KWLIMIT, False) - or self.bridge._versions.none_null_get(Tag.VAR.key, -1) == 11 - or wb_has_16a_cable_limit) + # # check for the 16A limiter... + # # we now always try to read a possible ccl object and use there the 'cableCurrentLimit' - if there + # # is no ccl or we have an error, we make use of the adi as fallback (if it's not core_wallbox)... + # wb_has_16a_cable_limit = None + # use_adi_as_fallback = not self._is_core_wallbox + # if Tag.CLL.key in self.bridge._versions: + # ccl_obj = self.bridge._versions.none_null_get(Tag.CLL.key, {}) + # if len(ccl_obj) > 0: + # ccl_cable_limit_val = ccl_obj.get("cableCurrentLimit", "-1") + # try: + # ccl_cable_limit_int = int(ccl_cable_limit_val) + # if ccl_cable_limit_int > 0: + # _LOGGER.debug(f"read_versions(): read CLL:cableCurrentLimit: '{ccl_cable_limit_val}'") + # wb_has_16a_cable_limit = ccl_cable_limit_int <= 16 + # use_adi_as_fallback = False + # else: + # _LOGGER.debug(f"read_versions(): no 'cableCurrentLimit' found in CLL object, but there IS content: {ccl_obj} - will not enforce the 16A CABLE limit") + # # 20266/10/28 this is astounding for me - as reported in #176 a 'go-eCharger_V5' running + # # FW '59.4' does NOT contain the key 'cableCurrentLimit' - I even installed the '59.4' FW + # # on my Flex and there the key is still present also in that 'old' FW... so I must assume, + # # that ONLY a FLEX Wallbox will contain this 'cableCurrentLimit' key :-/ + # wb_has_16a_cable_limit = False + # use_adi_as_fallback = False + # + # except BaseException as exc: + # _LOGGER.debug(f"read_versions(): try to handle CLL:cableCurrentLimit caused: {type(exc).__name__} - {exc}") + # else: + # _LOGGER.debug(f"read_versions(): CLL object is empty! {ccl_obj} (or was not present) - we probably check ADI key...") + # + # # checking the cbl value + # # if wb_has_16a_cable_limit is None and Tag.CBL in self.bridge._versions: + # # cbl_value = int(self.bridge._versions.none_null_get(Tag.CBL.key, 9999999)) + # # if cbl_value < 9999999: + # # _LOGGER.debug(f"read_versions(): CBL fallback -> read value is '{cbl_value}'") + # # wb_has_16a_cable_limit = cbl_value <= 16 + # + # # so the cll AND the cbl check did not work... + # if wb_has_16a_cable_limit is None: + # if use_adi_as_fallback: + # wb_has_16a_cable_limit = self.bridge._versions.none_null_get(Tag.ADI.key, False) + # _LOGGER.debug(f"read_versions(): USE Fallback to API-ADI key in order to set a possible 16A limit - with the result ADI is: {wb_has_16a_cable_limit} (or not present)") + # + # # if we haven't seen any 'wb_has_16a_cable_limit' yet, then we ENFORCE the limit + # # since I assume it's better to have a limit before doing any harm... (sorry 22Kw Owners) + # if wb_has_16a_cable_limit is None: + # _LOGGER.debug(f"read_versions(): enforcing 'wb_has_16a_cable_limit=True' cause previously the code did not set any value") + # wb_has_16a_cable_limit = True + + a = self._config_entry.data.get(CONF_11KWLIMIT, False) + b = self.bridge._versions.none_null_get(Tag.VAR.key, -1) == 11 + self.limit_to16a = a or b #or wb_has_16a_cable_limit if (self.limit_to16a): - _LOGGER.info(f"read_versions(): Charger LIMIT to 16A is active") + _LOGGER.info(f"read_versions(): Charger LIMIT to 16A is active [CONF_11KWLIMIT: {a}, VAR==11: {b}]")#, DETECTED CABLE-LIMIT: {wb_has_16a_cable_limit}]") else: - _LOGGER.info(f"read_versions(): Charger NO LIMIT detected") + _LOGGER.info(f"read_versions(): Charger NO LIMIT detected [CONF_11KWLIMIT: {a}, VAR==11: {b}]")#, DETECTED CABLE-LIMIT: {wb_has_16a_cable_limit}]") else: # no additional controller stuff... but we need to init some variables self.limit_to16a = False @@ -804,52 +814,28 @@ async def read_versions(self): else: sw_version = "UNKNOWN" - if self.mode == LAN: - self._device_info_dict = { - # be careful when adjusting the 'identifiers' -> since this will create probably new DeviceEntries - # and there exists also code which CLEAN all Devices that does not have 4 (four) identifier values!! - #"identifiers": {(DOMAIN, f"lan@.@{self.intg_type.lower()}@.@{self._serial}")}, - "identifiers": {( - DOMAIN, - self._serial, - self._config_entry.data.get(CONF_HOST), - self._config_entry.title)}, - "manufacturer": MANUFACTURER, - "name": self._config_entry.title, - "model": f"{self._device_info_model_raw}", - "sw_version": f"{sw_version}" - # hw_version - } - else: - self._device_info_dict = { - # be careful when adjusting the 'identifiers' -> since this will create probably new DeviceEntries - # and there exists also code which CLEAN all Devices that does not have 4 (four) identifier values!! - #"identifiers": {(DOMAIN, f"wan@.@{self.intg_type.lower()}@.@{self._serial}")}, - "identifiers": {( - DOMAIN, - self._serial, - self._config_entry.data.get(CONF_TOKEN), - self._config_entry.title)}, - "manufacturer": MANUFACTURER, - "name": self._config_entry.title, - "model": f"{self._device_info_model_raw}", - "sw_version": f"{sw_version}" - # hw_version - } + self._device_info_dict = { + # be careful when adjusting the 'identifiers' -> since this will create probably new DeviceEntries + # and there exists also code which CLEAN all Devices that does not have 4 (four) identifier values!! + "identifiers": {( + DOMAIN, + self.intg_type, + self.mode, + self._serial)}, + "manufacturer": MANUFACTURER, + "name": self._config_entry.title, + "model": f"{self._device_info_model_raw}", + "sw_version": f"{sw_version}" + # hw_version + } return True - async def check_for_16a_limit(self, hass, entry_id): - _LOGGER.debug(f"check relevant entities for 16A limit... in 15sec") - await asyncio.sleep(15) - _LOGGER.debug(f"check relevant entities for 16A limit NOW!") - await check_and_write_to_16a(hass=hass, config_entry_id=entry_id, bridge=self.bridge, is_lbgroup_id_set=self.is_loadbalancer_group_id_set) - - async def cleanup_device_registry(self, hass: HomeAssistant): - _LOGGER.debug(f"check device registry for orphan {DOMAIN} entries... in 20sec") - await asyncio.sleep(20) - _LOGGER.debug(f"check device registry for orphan {DOMAIN} entries NOW!") - await check_device_registry(hass=hass) + # async def check_for_16a_limit(self, hass, entry_id): + # _LOGGER.debug(f"check relevant entities for 16A limit... in 15sec") + # await asyncio.sleep(15) + # _LOGGER.debug(f"check relevant entities for 16A limit NOW!") + # await check_and_write_to_16a(hass=hass, config_entry_id=entry_id, bridge=self.bridge, is_lbgroup_id_set=self.is_loadbalancer_group_id_set) def is_valid_charger_entity(self, description: EntityDescription) -> bool: if description.key == Tag.TMA.key and hasattr(description, "idx"): @@ -881,6 +867,9 @@ def __init__(self, entity_type:str, coordinator: GoeChargerDataUpdateCoordinator # make sure that we keep the CASE of the key! self.data_key = description.key + if hasattr(description, "tag"): + self.tag:Tag = description.tag + # tuple_idx must have description.translation_key ! if hasattr(description, "tuple_idx") and description.tuple_idx is not None: if description.translation_key is not None: diff --git a/custom_components/goecharger_api2/brand/icon.png b/custom_components/goecharger_api2/brand/icon.png new file mode 100644 index 0000000..4acc840 Binary files /dev/null and b/custom_components/goecharger_api2/brand/icon.png differ diff --git a/custom_components/goecharger_api2/brand/icon48.png b/custom_components/goecharger_api2/brand/icon48.png new file mode 100644 index 0000000..a752703 Binary files /dev/null and b/custom_components/goecharger_api2/brand/icon48.png differ diff --git a/custom_components/goecharger_api2/brand/icon@2x.png b/custom_components/goecharger_api2/brand/icon@2x.png new file mode 100644 index 0000000..e85bc86 Binary files /dev/null and b/custom_components/goecharger_api2/brand/icon@2x.png differ diff --git a/custom_components/goecharger_api2/brand/logo.png b/custom_components/goecharger_api2/brand/logo.png new file mode 100644 index 0000000..622285f Binary files /dev/null and b/custom_components/goecharger_api2/brand/logo.png differ diff --git a/custom_components/goecharger_api2/brand/logo@2x.png b/custom_components/goecharger_api2/brand/logo@2x.png new file mode 100644 index 0000000..4a77716 Binary files /dev/null and b/custom_components/goecharger_api2/brand/logo@2x.png differ diff --git a/custom_components/goecharger_api2/config_flow.py b/custom_components/goecharger_api2/config_flow.py index 159505a..a76d32a 100644 --- a/custom_components/goecharger_api2/config_flow.py +++ b/custom_components/goecharger_api2/config_flow.py @@ -278,7 +278,7 @@ async def _check_ws_connection_and_flags(self, client:GoeChargerApiV2Bridge): _LOGGER.debug("_check_ws_connection_and_flags(): Waiting for the ws connection to be established (max 30 seconds)") await asyncio.wait_for(ready_event.wait(), timeout=30.0) if ready_event.has_errors(): - _LOGGER.debug(f"_check_ws_connection_and_flags(): ws connection failed! errors: {ready_event.errors}") + _LOGGER.debug(f"_check_ws_connection_and_flags(): ws connection failed! errors: {ready_event._error}") return False if ready_event.is_set(): diff --git a/custom_components/goecharger_api2/const.py b/custom_components/goecharger_api2/const.py index 76df37e..727877b 100644 --- a/custom_components/goecharger_api2/const.py +++ b/custom_components/goecharger_api2/const.py @@ -23,7 +23,7 @@ ISSUE_URL: Final = "https://github.com/marq24/ha-goecharger-api2/issues" CONFIG_VERSION: Final = 2 -CONFIG_MINOR_VERSION: Final = 0 +CONFIG_MINOR_VERSION: Final = 1 STARTUP_MESSAGE: Final = f""" ------------------------------------------------------------------- @@ -36,6 +36,7 @@ SERVICE_SET_PV_DATA: Final = "set_pv_data" SERVICE_STOP_CHARGING: Final = "stop_charging" +SERVICE_SET_TEMPORARY_CURRENT: Final = "set_temporary_current_limit" CONF_11KWLIMIT: Final = "limit_to_11kw" CONF_INTEGRATION_TYPE = "integration_type" @@ -56,6 +57,7 @@ class ExtButtonEntityDescription(ButtonEntityDescription): @dataclass(frozen=True) class ExtNumberEntityDescription(NumberEntityDescription): + tag: Tag | None = None write_zero_as_null: bool | None = None handle_as_float: bool | None = None factor: int | None = None @@ -542,8 +544,8 @@ class ExtSwitchEntityDescription(SwitchEntityDescription): # pgt ExtNumberEntityDescription( key=Tag.PGT.key, - native_max_value=5000, - native_min_value=-5000, + native_max_value=35000, + native_min_value=-35000, native_step=1, #entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfPower.WATT, @@ -694,6 +696,19 @@ class ExtSwitchEntityDescription(SwitchEntityDescription): icon="mdi:ab-testing", entity_registry_enabled_default=False ), + ExtNumberEntityDescription( + tag=Tag.TCL, + key=Tag.TCL.key, + check_16a_limit=True, + native_min_value=6, + native_max_value=32, + native_step=1, + mode=NumberMode.BOX, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=NumberDeviceClass.CURRENT, + icon="mdi:current-ac", + entity_registry_enabled_default=False, + ), # zfo ExtNumberEntityDescription( key=Tag.ZFO.key, @@ -2324,8 +2339,8 @@ class ExtSwitchEntityDescription(SwitchEntityDescription): entity_registry_enabled_default=True ), ] -CONTROLLER_NUMBER_SENSORS = [ -] +CONTROLLER_NUMBER_SENSORS = [] + CONTROLLER_SELECT_SENSORS = [ ExtSelectEntityDescription( key=Tag.IPS.key, diff --git a/custom_components/goecharger_api2/manifest.json b/custom_components/goecharger_api2/manifest.json index 746795c..52c13e4 100644 --- a/custom_components/goecharger_api2/manifest.json +++ b/custom_components/goecharger_api2/manifest.json @@ -1,10 +1,7 @@ { "domain": "goecharger_api2", "name": "go-e APIv2 Connect", - "codeowners": [ - "@marq24", - "@s3ppo" - ], + "codeowners": ["@marq24", "@s3ppo"], "config_flow": true, "dependencies": [], "documentation": "https://github.com/marq24/ha-goecharger-api2", @@ -12,5 +9,5 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/marq24/ha-goecharger-api2/issues", "requirements": ["msgpack>=1.1.2"], - "version": "2026.10.2" + "version": "2026.11.0" } diff --git a/custom_components/goecharger_api2/number.py b/custom_components/goecharger_api2/number.py index 5368fdc..211981e 100644 --- a/custom_components/goecharger_api2/number.py +++ b/custom_components/goecharger_api2/number.py @@ -35,12 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, add_ entity = GoeChargerNumber(coordinator, description) entities.append(entity) - - # currently only the 'Tag.AMP.key' entity needs to be modified - # based on MIN and MAX AMP settings... [have in mind that - # 'data_key' is not unique !!!] - if entity.data_key == Tag.AMP.key: - coordinator.entities[entity.data_key] = entity else: for description in CONTROLLER_NUMBER_SENSORS: entity = GoeChargerNumber(coordinator, description) @@ -53,18 +47,62 @@ class GoeChargerNumber(GoeChargerBaseEntity, NumberEntity): def __init__(self, coordinator: GoeChargerDataUpdateCoordinator, description: ExtNumberEntityDescription): super().__init__(entity_type=Platform.NUMBER, coordinator=coordinator, description=description) + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + + # currently only the 'Tag.AMP.key' entity needs to be modified + # based on MIN and MAX AMP settings... [have in mind that + # 'data_key' is not unique !!!] + if self.data_key == Tag.AMP.key: + self.coordinator.entities[self.data_key] = self + + @property + def available(self): + super_val = super().available + if self.tag is not None and hasattr(self.tag, "tag_func_get_min_max_bounds"): + try: + self.tag.tag_func_get_min_max_bounds(self.coordinator.data, self.coordinator.limit_to16a) + return super_val and self.data_key in self.coordinator.data + except (ValueError, TypeError): + return False + return super_val + + @property + def native_min_value(self): + super_val = super().native_min_value + if self.tag is not None and hasattr(self.tag, "tag_func_get_min_max_bounds"): + try: + return self.tag.tag_func_get_min_max_bounds(self.coordinator.data, self.coordinator.limit_to16a)[0] + except (ValueError, TypeError): + pass + + return super_val + + @property + def native_max_value(self): + super_val = super().native_max_value + if self.tag is not None and hasattr(self.tag, "tag_func_get_min_max_bounds"): + try: + return self.tag.tag_func_get_min_max_bounds(self.coordinator.data, self.coordinator.limit_to16a)[1] + except (ValueError, TypeError): + pass + + return super_val + @property def native_value(self): try: value = None if self.coordinator.data is not None: + value = (self.coordinator.data or {}).get(self.data_key) if self.entity_description.idx is not None: - value = self.coordinator.data[self.data_key][self.entity_description.idx] - else: - value = self.coordinator.data[self.data_key] + # this is still some sort of silly access... + value = value[self.entity_description.idx] if value is None or value == "": return "unknown" + elif self.entity_description.handle_as_float is not None and self.entity_description.handle_as_float: if self.entity_description.factor is not None and self.entity_description.factor > 0: value = float(int(value) / self.entity_description.factor) @@ -85,38 +123,37 @@ def native_value(self): return value async def async_set_native_value(self, value) -> None: + validate_write_success = False + if self.tag is not None: + if hasattr(self.tag, "tag_func_validate_input"): + # ok let's verify the value that we should set... + value = self.tag.tag_func_validate_input(value, self.coordinator.data, self.coordinator.limit_to16a) + + validate_write_success = hasattr(self.tag, "tag_func_validate_output") + # _LOGGER.info(f"set_native {self.data_key} {value} idx? {self.entity_description.idx}) factor: {self.entity_description.factor} float? {self.entity_description.handle_as_float}") try: - if self.entity_description.idx is not None: - # we have to write all values of the object... [not only the set one] - obj = self.coordinator.data[self.data_key] - - if int(value) == 0 and self.entity_description.write_zero_as_null is not None and self.entity_description.write_zero_as_null: - obj[self.entity_description.idx] = None - elif self.entity_description.factor is not None and self.entity_description.factor > 0: - # no special handling for 'handle_as_float' here - since the float's just exist in the GUI... if the - # backend these values are (keep my fingers crossed) always int's - obj[self.entity_description.idx] = int(value * self.entity_description.factor) - elif self.entity_description.handle_as_float is not None and self.entity_description.handle_as_float: - obj[self.entity_description.idx] = float(value) - else: - # we will write all numbers as integer's [no decimal's/fractions!!!] - obj[self.entity_description.idx] = int(value) - - await self.coordinator.async_write_key(self.data_key, obj, self) + desc = self.entity_description + if int(value) == 0 and desc.write_zero_as_null: + final_value_to_set = None + elif desc.factor and desc.factor > 0: + final_value_to_set = int(value * desc.factor) + elif desc.handle_as_float: + final_value_to_set = float(value) + else: + final_value_to_set = int(value) + if desc.idx is None: + final_object_to_write = final_value_to_set else: - if int(value) == 0 and self.entity_description.write_zero_as_null is not None and self.entity_description.write_zero_as_null: - await self.coordinator.async_write_key(self.data_key, None, self) - elif self.entity_description.factor is not None and self.entity_description.factor > 0: - # no special handling for 'handle_as_float' here - since the float's just exist in the GUI... if the - # backend these values are (keep my fingers crossed) always int's - await self.coordinator.async_write_key(self.data_key, int(value * self.entity_description.factor), self) - elif self.entity_description.handle_as_float is not None and self.entity_description.handle_as_float: - await self.coordinator.async_write_key(self.data_key, float(value), self) - else: - # we will write all numbers as integer's [no decimal's/fractions!!!] - await self.coordinator.async_write_key(self.data_key, int(value), self) + final_object_to_write = self.coordinator.data[self.data_key] + final_object_to_write[desc.idx] = final_value_to_set + + result = await self.coordinator.async_write_key(self.data_key, final_object_to_write, self, require_ack=validate_write_success) + + # must we verify that the setting of the value was successful? + if validate_write_success and result: + self.tag.tag_func_validate_output(result, value) except ValueError: return "unavailable" @@ -151,4 +188,4 @@ def check_amp_min_max(self, value): }, blocking=False, ) - ) + ) \ No newline at end of file diff --git a/custom_components/goecharger_api2/pygoecharger_ha/__init__.py b/custom_components/goecharger_api2/pygoecharger_ha/__init__.py index 4733731..0493dac 100644 --- a/custom_components/goecharger_api2/pygoecharger_ha/__init__.py +++ b/custom_components/goecharger_api2/pygoecharger_ha/__init__.py @@ -6,6 +6,7 @@ import logging import random import secrets +from asyncio import Future from collections import ChainMap import time from typing import Any, Dict, Optional @@ -133,7 +134,10 @@ class GoeChargerApiV2Bridge: def __init__(self, web_session, intg_type:str, host:str|None=None, access_password:str|None=None, serial:str|None=None, token:str|None=None, lang:str = "en") -> None: + + self.host_url = None self.ws_url = None + if host is not None: # THIS IS LAN ACCESS... self.token = None @@ -217,6 +221,7 @@ def __init__(self, web_session, intg_type:str, self._ws_hashed_password = None self._ws_request_id_counter = 0 self._ws_request_storage = ExpiringStore() + self._ws_pending_ack: [str, Future] = {} self._ws_debounced_update_task = None self._ws_LAST_UPDATE = 0 self._ws_LAST_NEW_DATA_NOTIFY = 0 @@ -232,6 +237,8 @@ def available_fields(self) -> int: return len(self._versions) + len(self._states) + len(self._config) + len(self._ws_states) def clear_data(self): + if self._ws_debounced_update_task is not None and not self._ws_debounced_update_task.done(): + self._ws_debounced_update_task.cancel() self._LAST_CONFIG_UPDATE_TS = 0 self._LAST_FULL_STATE_UPDATE_TS = 0 self._REQUEST_IDS_DATA = False @@ -274,7 +281,9 @@ async def read_versions(self): self._versions = self._read_filtered_data_from_ws_source(filters=self._FILTER_VERSIONS, log_info="read_versions (via websocket)") else: for attempt in range(5): - self._versions = await self._read_filtered_data(filters=self._FILTER_VERSIONS, log_info=f"read_versions (attempt {attempt+1})") + ret = await self._read_filtered_data(filters=self._FILTER_VERSIONS, log_info=f"read_versions (attempt {attempt+1})") + if ret is not None and len(ret) > 0 and isinstance(ret, NonNullDict): + self._versions = ret if self._versions is not None and len(self._versions) > 0: break if attempt < 4: @@ -364,7 +373,9 @@ async def read_all_states(self): if self.ws_connected: self._states = self._read_filtered_data_from_ws_source(filters=self._FILTER_ALL_STATES, log_info="read_all_states") else: - self._states = await self._read_filtered_data(filters=self._FILTER_ALL_STATES, log_info="read_all_states") + ret = await self._read_filtered_data(filters=self._FILTER_ALL_STATES, log_info="read_all_states") + if ret is not None and len(ret) > 0 and isinstance(ret, NonNullDict): + self._states = ret if len(self._states) > 0: self._LAST_FULL_STATE_UPDATE_TS = time.time() @@ -380,7 +391,9 @@ async def read_all_config(self): if self.ws_connected: self._config = self._read_filtered_data_from_ws_source(filters=self._FILTER_ALL_CONFIG, log_info="read_all_config") else: - self._config = await self._read_filtered_data(filters=self._FILTER_ALL_CONFIG, log_info="read_all_config") + ret = await self._read_filtered_data(filters=self._FILTER_ALL_CONFIG, log_info="read_all_config") + if ret is not None and len(ret) > 0 and isinstance(ret, NonNullDict): + self._config = ret if len(self._config) > 0: self._LAST_CONFIG_UPDATE_TS = time.time() @@ -490,23 +503,22 @@ async def _read_all_data(self) -> NonNullDict: _LOGGER.warning(f"_read_all_data(): BaseException: {type(err).__name__}: {err}") return {} - async def write_value_to_key(self, key, value) -> dict: + async def write_value_to_key(self, key, value, require_ack: bool = False) -> dict: is_button_press = value is not None and str(value) == IS_TRIGGER - if not is_button_press and self.ws_connected and self._ws_connection is not None: - await self._ws_send_command(key, value) - self._ws_LAST_NEW_DATA_NOTIFY = 0 - return None + if self.ws_connected and self._ws_connection is not None: + if is_button_press: + # ignoring button presses when connected via WebSockets + return {key: value} + else: + if await self._ws_send_command(key, value, wait_for_response=require_ack): + self._ws_LAST_NEW_DATA_NOTIFY = 0 + return {key: value} + else: + return {} else: - if value is None: - args = f"{key}=null" - elif isinstance(value, (bool, int, float)): - args = {key: str(value).lower()} - elif isinstance(value, dict): - args = {key: json.dumps(value).replace(' ','')} - elif isinstance(value, str) and value == IS_TRIGGER: + if is_button_press: # BUTTON's have only effect for POLLING (will not do anything when connected via WebSockets) - # these are special trigger actions that we want to call from the FE... match key: case Tag.INTERNAL_FORCE_CONFIG_READ.key: @@ -519,9 +531,16 @@ async def write_value_to_key(self, key, value) -> dict: return {key: value} else: - args = {key: '"'+str(value)+'"'} + if value is None: + args = f"{key}=null" + elif isinstance(value, (bool, int, float)): + args = {key: str(value).lower()} + elif isinstance(value, dict): + args = {key: json.dumps(value).replace(' ','')} + else: + args = {key: '"'+str(value)+'"'} - return await self._write_values_int(args, a_result_key = key, a_result_value = value) + return await self._write_values_int(args, a_result_key = key, a_result_value = value) async def write_multiple_values_to_keys(self, args, key, value) -> dict: if self.ws_connected and self._ws_connection is not None: @@ -530,7 +549,7 @@ async def write_multiple_values_to_keys(self, args, key, value) -> dict: await asyncio.sleep(0.75) self._ws_LAST_NEW_DATA_NOTIFY = 0 - return None + return {} else: params = {} for a_key, a_value in args.items(): @@ -556,7 +575,7 @@ async def _write_values_int(self, a_params, a_result_key, a_result_value) -> dic try: r_json = await res.json() if r_json is not None and len(r_json) > 0: - if a_result_key in r_json and r_json[a_result_key]: + if a_result_key in r_json and r_json[a_result_key] is True: # ignore 'force-update' for 'ids' (PV surplus charging) if a_result_key != Tag.IDS.key: self._LAST_CONFIG_UPDATE_TS = 0 @@ -574,7 +593,7 @@ async def _write_values_int(self, a_params, a_result_key, a_result_value) -> dic except aiohttp.ClientResponseError as io_exc: _LOGGER.warning(f"_write_values_int(): ClientResponseError while 'await res.json(): {io_exc}") - elif res.status == 500 and int(res.headers['Content-Length']) > 0: + elif res.status == 500 and (res.content_length or 0) > 0: try: r_json = await res.json() return {"err": r_json} @@ -611,6 +630,7 @@ def ws_check_last_update(self) -> bool: async def ws_close(self, ws): """Close the WebSocket connection cleanly.""" + self._fail_pending_acknowledgements() if self._ws_serial is not None: _LOGGER.debug(f"ws_close(): for '{self._ws_serial}' called") else: @@ -711,9 +731,15 @@ def _ws_normalize_dict(self, data: dict) -> dict: result[key_str] = self._ws_normalize_value(value) return result - async def _ws_send_command(self, key: str, value): - """Send a setValue command via WebSocket""" - _LOGGER.debug(f"_ws_send_command(): Sending {key}:{value} via WebSocket...") + def _fail_pending_acknowledgements(self): + """Wake bounded confirmation waits when a connection disappears.""" + for pending in self._ws_pending_ack.values(): + if not pending.done(): + pending.set_result(False) + self._ws_pending_ack.clear() + + async def _ws_send_command(self, key: str, value, wait_for_response: bool = False): + """Optionally wait for the matching inner setValue response, not just send_json.""" self._ws_request_id_counter += 1 the_request_id_to_use = int(self._ws_request_id_counter) @@ -723,40 +749,54 @@ async def _ws_send_command(self, key: str, value): #if value is None: # value = "null" - original_message = { - "type": "setValue", - "requestId": the_request_id_to_use, - "key": key, - "value": value - } - - # for debugging purpose we want to keep the original message - self._ws_request_storage.set(str(the_request_id_to_use), original_message) - - if self._ws_secured: - # the local websocket needs special handling... - payload = json.dumps(original_message) - h = hmac.new(bytearray(self._ws_hashed_password), bytearray(payload.encode()), hashlib.sha256) - secure_message = { - "type": "securedMsg", - "data": payload, - "requestId": f"{original_message['requestId']}sm", - "hmac": h.hexdigest() + request_key = str(the_request_id_to_use) + pending = asyncio.get_running_loop().create_future() if wait_for_response else None + if pending is not None: + self._ws_pending_ack[request_key] = pending + try: + original_message = { + "type": "setValue", + "requestId": the_request_id_to_use, + "key": key, + "value": value } - # Pack as msgpack with 0x00 prefix - # _LOGGER.debug(f"_ws_send_command(): Sending {key}={value} as SECURED BYTES") - # msg_packed = b'\x00' + msgpack.packb(secure_message) - # await self._ws_connection.send_bytes(msg_packed) + # for debugging purpose we want to keep the original message + self._ws_request_storage.set(str(the_request_id_to_use), original_message) + + if self._ws_secured: + # the local websocket needs special handling... + payload = json.dumps(original_message) + h = hmac.new(bytearray(self._ws_hashed_password), bytearray(payload.encode()), hashlib.sha256) + secure_message = { + "type": "securedMsg", + "data": payload, + "requestId": f"{original_message['requestId']}sm", + "hmac": h.hexdigest() + } - _LOGGER.debug(f"_ws_send_command(): Sending {key}={value} as SECURED JSON") - await self._ws_connection.send_json(secure_message) - else: - # the cloud API can receive real JSON data... - _LOGGER.debug(f"_ws_send_command(): Sending {key}={value} as JSON") - await self._ws_connection.send_json(original_message) + # Pack as msgpack with 0x00 prefix + # _LOGGER.debug(f"_ws_send_command(): Sending {key}={value} as SECURED BYTES") + # msg_packed = b'\x00' + msgpack.packb(secure_message) + # await self._ws_connection.send_bytes(msg_packed) - return True + _LOGGER.debug(f"_ws_send_command(): Sending {key}={value} as SECURED JSON") + await self._ws_connection.send_json(secure_message) + else: + # the cloud API can receive real JSON data... + _LOGGER.debug(f"_ws_send_command(): Sending {key}={value} as JSON") + await self._ws_connection.send_json(original_message) + + if pending is not None: + return await asyncio.wait_for(pending, timeout=3) + return True + except asyncio.TimeoutError: + _LOGGER.warning("Timed out waiting for charger acknowledgement for %s", key) + return False + finally: + self._ws_pending_ack.pop(request_key, None) + if pending is not None and not pending.done(): + pending.cancel() async def ws_connect(self, a_event: TargetedEvent=None, keep_ws_states_after_close: bool=False): """Connect to WebSocket with full authentication and message handling""" @@ -853,7 +893,7 @@ async def ws_connect(self, a_event: TargetedEvent=None, keep_ws_states_after_clo self._ws_hashed_password = self._ws_compute_hashed_password(hash_type, self.access_password, serial) _logging_pwd = self._ws_hashed_password.decode('utf-8') - _LOGGER.debug(f"ws_connect(): Computed hashed password {hash_type}: {_logging_pwd[:6]}...{_logging_pwd[-6:]}") + _LOGGER.debug(f"ws_connect(): Computed hashed password {hash_type}: {_logging_pwd[:3]}...{_logging_pwd[-3:]}") token1 = normalized_auth.get('token1') token2 = normalized_auth.get('token2') @@ -990,6 +1030,7 @@ async def ws_connect(self, a_event: TargetedEvent=None, keep_ws_states_after_clo # self.ws_connected flag... -> but we must keep it ON self.ws_connected = True + self._fail_pending_acknowledgements() self.ws_connection_start = 0 self._ws_connection = None self._ws_LAST_UPDATE = 0 @@ -1029,10 +1070,16 @@ def extract_ws_message_data(self, data:dict): #_LOGGER.debug(f"extract_ws_message_data(): Received 'deltaStatus' with {len(filtered_data)} keys") elif msg_type == 'response': + pending = self._ws_pending_ack.get(str(data.get('requestId', ''))) + if pending is not None and not pending.done(): + # A secured envelope acknowledgement (e.g. '1sm') is not the + # inner setValue response ('1') and cannot complete this future. + pending.set_result(data.get('success') is True) + the_request_payload = None if 'requestId' in data: the_request_id = data.get('requestId', 'INVALID') - the_request_payload = self._ws_request_storage.get(the_request_id) + the_request_payload = self._ws_request_storage.get(str(the_request_id)) if data.get('success'): status_data = data.get('status', {}) diff --git a/custom_components/goecharger_api2/pygoecharger_ha/const.py b/custom_components/goecharger_api2/pygoecharger_ha/const.py index 498fa96..d5af386 100644 --- a/custom_components/goecharger_api2/pygoecharger_ha/const.py +++ b/custom_components/goecharger_api2/pygoecharger_ha/const.py @@ -20,21 +20,21 @@ FILTER_SYSTEMS: Final = "oem,sse,typ,var,hai,cae" -FILTER_VERSIONS_NOCARDS: Final = f"ccrv,typ,log,fwc,fwv,var,cll,adi" +FILTER_VERSIONS_NOCARDS: Final = f"ccrv,typ,log,fwc,fwv,var" FILTER_VERSIONS: Final = f"{FILTER_VERSIONS_NOCARDS},{FILTER_CARDS_ID_CLASSIC},{FILTER_CARDS_ID_FWV60}" -FILTER_MIN_STATES: Final = "car,modelStatus,err,nrg,tma,trx" +FILTER_MIN_STATES: Final = "car,modelStatus,err,nrg,tcl,tma,trx" FILTER_IDS_ADDON: Final = ",pakku,ppv,pgrid" # rbt: is the reboot time - and "looks like", that all other timestamps use the rbt "as" start point FILTER_TIMES_ADDON: Final = ",rbt,fsptws,inva,lbp,lccfc,lccfi,lcctc,lfspt,lmsc,lpsc,lcs" -FILTER_ALL_STATES: Final = "alw,acu,adi,amt,atp,awcp,car,{CARDS_ENERGY_FILTER},cbl,ccu,ccw,cdi,cll,cus,ctrls,deltaa,deltap,di1,err,eto,ffb,fhz,fsp,fsptws,inva,lbp,lccfc,lccfi,lcctc,lck,lfspt,lmsc,loa,lpsc,mcpea,mmp,modelStatus,nif,nrg,pakku,pgrid,pha,pnp,ppv,pvopt_averagePAkku,pvopt_averagePGrid,pvopt_averagePPv,pwm,rbc,rbt,rfb,rssi,rst,tlf,tls,tma,tpa,trx,wh,wsms,wst" +FILTER_ALL_STATES: Final = "alw,acu,adi,amt,atp,awcp,car,{CARDS_ENERGY_FILTER},cbl,ccu,ccw,cdi,cll,cus,ctrls,deltaa,deltap,di1,err,eto,ffb,fhz,fsp,fsptws,inva,lbp,lccfc,lccfi,lcctc,lck,lfspt,lmsc,loa,lpsc,mcpea,mmp,modelStatus,nif,nrg,pakku,pgrid,pha,pnp,ppv,pvopt_averagePAkku,pvopt_averagePGrid,pvopt_averagePPv,pwm,rbc,rbt,rfb,rssi,rst,tcl,tlf,tls,tma,tpa,trx,wh,wsms,wst" FILTER_ALL_CONFIG: Final = "acp,acs,ama,amp,ara,ate,att,awc,awe,awp,bac,{CARDS_ID_FILTER},cch,cco,cfi,cid,clp,cmse,ct,cwc,cwe,dwo,esk,fmt,fna,frc,frm,fst,fup,fzf,hai,hsa,iso2ena,lbr,lmo,loe,lof,log,lop,lot,loty,lse,map,mca,mci,mcpd,mptwt,mpwst,nmo,ocppe,pgt,po,psh,psm,psmd,rdbf,rdbs,rdef,rdes,rdre,rdpl,sch_satur,sch_sund,sch_week,sdp,sh,spl3,su,sua,sumd,tds,tof,upo,ust,zfo" FILTER_NOT_USED: Final = "mcc,mcca,mce,mcr,mcs,mcu,men,mlr,mlra,mqcn,mqg,mqss,msb,msp,msr,mtp,ocppai,ocppi,rdbfe,rdbse,rdefe,rdese,rdree,rdple" # found api-keys that are not documented (yet) ?! -FILTER_UNKNOWN_COMON: Final = "aus,ccd,cle,clea,cmmr,cmp,cms,csa,data,die,dii,dll,hla,isgo,la1,la3,lbl,lopr,lrc,lri,lrr,lwf,ocppao,ocppcm,ocppcs,ocppf,ocppla,ocpplo,ocppti,pdi,pgr,rde,smd,tcl,tsi,tzt,ufa,ufe,ufm,ufs,wbw,wda,wsl" +FILTER_UNKNOWN_COMON: Final = "aus,ccd,cle,clea,cmmr,cmp,cms,csa,data,die,dii,dll,hla,isgo,la1,la3,lbl,lopr,lrc,lri,lrr,lwf,ocppao,ocppcm,ocppcs,ocppf,ocppla,ocpplo,ocppti,pdi,pgr,rde,smd,tsi,tzt,ufa,ufe,ufm,ufs,wbw,wda,wsl" FILTER_UNKNOWN_FW59_X_BETA: Final = "bar,dsrc,evt,gmtr,gsa,lto,mhe,mht,ocppdp,ocppmp,ocpptp,orsch,pco,rdbfe,rdbse,rdefe,rdese,rdple,rdree,rmaf,rmav,rmif,rmiv,rsa,rsre,rsrr,tab" FILTER_UNKNOWN_FW56_2_BETA: Final = "bar,gmtr,gsa,mhe,mht,pco,rmaf,rmav,rmif,rmiv,rsa,rsre,rsrr" @@ -110,10 +110,13 @@ class CT_VALUES(Enum): TRANSLATIONS: Final = { "de": { + "heavy_user_title": "Möchtest Du nicht die Entwicklung unterstützen?", + "heavy_user_msg": "Du hast mehr als 8 aktive 'go-e APIv2 Connect' Integrationseinträge. Als intensiver Nutzer solltest Du Dir überlegen, ob Du die Weiterentwicklung und Pflege nicht unterstützen möchtest?\n\nBitte besuche das [Projekt go-e APIv2 Connect auf GitHub](https://github.com/marq24/ha-goecharger-api2/blob/main/docs/SUPPORT.md), um mehr zu erfahren.\n\nDanke für Deine Aufmerksamkeit @marq24", "coord_null_data": "Es konnten keine Daten abgerufen werden. Bitte prüfe Dein Home Assistant System Protokoll auf mögliche Fehlermeldungen der Integration.", "coord_no_device_data": "Es konnten keine Daten zu Deinem konfigurierten Gerät abgerufen werden. Bitte prüfe Dein Home Assistant System Protokoll auf mögliche Fehlermeldungen der Integration.", "websocket_start_failed": "Der Websocket konnte innerhalb von einer Minute nicht gestartet werden. Bitte prüfe Dein Home Assistant System Protokoll auf mögliche Fehlermeldungen der Integration.", - + "ws_start_task_is_none": "start_websocket_and_wait_for_first_data(): WebSocket-Verbindungs-Task konnte nicht erstellt werden!", + "ws_fetch_essential_data_timed_out": "start_websocket_and_wait_for_first_data(): Zwingend erforderliche Daten nicht verfügbar - Timeout!", "car": { 0: "Unbekannt/Fehler", 1: "Inaktiv/Frei", @@ -283,9 +286,13 @@ class CT_VALUES(Enum): "cll_unsymetrycurrentlimit": "Stromlimit Asymmetrie" }, "en": { + "heavy_user_title": "Wouldn't you like to support the development ?", + "heavy_user_msg": "You have more than 8 active 'go-e APIv2 Connect' integration entries. As a heavy user, you should consider supporting its further development and maintenance.\n\nPlease visit the [project go-e APIv2 Connect at GitHub](https://github.com/marq24/ha-goecharger-api2/blob/main/docs/SUPPORT.md) to learn more.\n\nThanks for your attention @marq24", "coord_null_data": "Coordinator could not provided any data. Please check your Home Assistant system log for possible error messages.", "coord_no_device_data": "Coordinator could not fetch essential information from your configured device. Please check your Home Assistant system log for possible error messages.", "websocket_start_failed": "The Websocket could not be started within one minute. Please check your Home Assistant system log for possible error messages.", + "ws_start_task_is_none": "start_websocket_and_wait_for_first_data(): Could not create websocket connect task!", + "ws_fetch_essential_data_timed_out": "start_websocket_and_wait_for_first_data(): Could not fetch essential data - Timeout", "car": { 0: "Unknown/Error", diff --git a/custom_components/goecharger_api2/pygoecharger_ha/keys.py b/custom_components/goecharger_api2/pygoecharger_ha/keys.py index 3b86819..05cfec8 100644 --- a/custom_components/goecharger_api2/pygoecharger_ha/keys.py +++ b/custom_components/goecharger_api2/pygoecharger_ha/keys.py @@ -1,21 +1,89 @@ import logging -from enum import Enum -from typing import ( - NamedTuple, Final -) +import math +import types -# from aenum import Enum, extend_enum +from enum import Enum +from numbers import Number +from typing import NamedTuple, Final +from collections.abc import Mapping _LOGGER: logging.Logger = logging.getLogger(__package__) IS_TRIGGER: Final = "TRIGGER" +def is_finite_number(value) -> bool: + """NaN and infinity are Numbers, but would be sent to the charger as invalid JSON.""" + if isinstance(value, bool) or not isinstance(value, Number): + return False + try: + return math.isfinite(float(value)) + except (OverflowError, TypeError, ValueError): + return False + class CAT(Enum): CONFIG = "CONF" STATUS = "STAT" OTHER = "OTHE" CONSTANT = "CONS" +# MARQ24: I am quite unconfident, if I would like this approach - but currently the TAG needs special methods +# at least that's what was introduced with PR #177 +class _TCLTagLogic: + @staticmethod + def tcl_get_min_max_bounds(self, data: Mapping, limit_to_16a: bool = False) -> tuple[int, int]: + """Require known hardware/user bounds rather than guessing an upper limit.""" + #_LOGGER.debug("_TCLTagLogic.tag_func_get_min_max_bounds() called") + if not isinstance(data, Mapping): + raise ValueError("Charger data is unavailable") + + variant = data.get(Tag.VAR.key) + if isinstance(variant, bool) or variant not in (11, 22): + raise ValueError("The charger current rating is unknown") + + lower, upper = data.get(Tag.MCA.key), data.get(Tag.AMA.key) + for value in (lower, upper): + if not is_finite_number(value): + raise ValueError("The charger current bounds are unavailable") + + reported = data.get(self.key) + if reported is not None and ( + not is_finite_number(reported) or reported < 0 or reported != int(reported) + ): + raise ValueError("The temporary current limit is not a valid reported value") + maximum = min(16 if variant == 11 or limit_to_16a else 32, math.floor(upper)) + minimum = max(6, math.ceil(lower)) + if minimum > maximum: + raise ValueError("The charger current bounds are inconsistent") + return minimum, maximum + + @staticmethod + def tcl_validate_input(self, value, data: Mapping, limit_to_16a: bool = False) -> int: + """Only accept whole amperes in range; zero/null reset semantics are not assumed.""" + #_LOGGER.debug("_TCLTagLogic.tcl_validate_input() called") + if not isinstance(data, Mapping) or self.key not in data: + raise ValueError("This charger has not reported temporary-current-limit support") + + minimum, maximum = self.tag_func_get_min_max_bounds(data, limit_to_16a) + if not is_finite_number(value): + raise ValueError("Current must be a finite number of whole amperes") + + if value != int(value) or not minimum <= value <= maximum: + raise ValueError(f"Current must be between {minimum} and {maximum} whole amperes") + + return int(value) + + @staticmethod + def tcl_validate_output(self, result, current: int) -> None: + """The existing bridge returns the submitted value only after a successful write.""" + #_LOGGER.debug("_TCLTagLogic.tcl_validate_output() called") + if not isinstance(result, dict) or isinstance(result.get(self.key), bool) or result.get(self.key) != current: + raise ValueError("The charger did not acknowledge the temporary current limit") + +_TCL_METHODS: Final = { + "tag_func_get_min_max_bounds": _TCLTagLogic.tcl_get_min_max_bounds, + "tag_func_validate_input": _TCLTagLogic.tcl_validate_input, + "tag_func_validate_output": _TCLTagLogic.tcl_validate_output, +} class ApiKey(NamedTuple): key: str @@ -25,13 +93,21 @@ class ApiKey(NamedTuple): # see https://github.com/goecharger/go-eCharger-API-v2/blob/main/apikeys-en.md for details class Tag(ApiKey, Enum): - def __hash__(self) -> int: return hash(self.key) def __str__(self): return self.key + # marq24: this is all hideous - but looks like that is the "only" way + # how to attach custom methods to a Tag implementation + def __getattr__(self, name): + if name.startswith("tag_func_"): + if self == Tag.TCL and name in _TCL_METHODS: + return types.MethodType(_TCL_METHODS[name], self) + + raise AttributeError(f"Tag object has no attribute '{name}'") + # allowChargePause (car compatiblity) ACP = ApiKey(key="acp", cat=CAT.CONFIG, writeable=True) # access_control user setting (Open=0, Wait=1) @@ -624,7 +700,9 @@ def __str__(self): # rsrr | R/W | TYPE | Config | rampupAtStartAndReconnectionRate in %/s # smd | R | TYPE | Status | smart meter data # tab | R | TYPE | Status | time at boot in utc in milliseconds, add rbt to get to current utc time - # tcl | R/W | TYPE | Config | temporary current limit (does not change the user current limit, will be reset after 10min if not updated regulary) + + # tcl | R/W | TYPE | Config | temporary current limit (does not change the user current limit, will be reset after 10min if not updated regularly) + TCL = ApiKey(key="tcl", cat=CAT.CONFIG, writeable=True) # tsi | R | TYPE | Status | transaction start rfidid (only available when sendRfid) # tzt | R/W | TYPE | Config | timezone type, freetext string for app selection # ufa | R/W | TYPE | Config | Underfrequency Control activation threshold diff --git a/custom_components/goecharger_api2/service.py b/custom_components/goecharger_api2/service.py index 9f4f779..765c550 100644 --- a/custom_components/goecharger_api2/service.py +++ b/custom_components/goecharger_api2/service.py @@ -5,29 +5,44 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import ServiceCall, HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from custom_components.goecharger_api2.const import WAN from custom_components.goecharger_api2.pygoecharger_ha.const import INTG_TYPE -from custom_components.goecharger_api2.pygoecharger_ha.keys import Tag +from custom_components.goecharger_api2.pygoecharger_ha.keys import Tag, is_finite_number _LOGGER: logging.Logger = logging.getLogger(__package__) -_COORDINATOR_PER_CONFIGENTRYID_MAP = {} +_COORDINATOR_PER_CONFIG_ENTRY_ID_MAP: dict[str, DataUpdateCoordinator] = {} + +def add_config_entry_to_service(config: ConfigEntry, coordinator: DataUpdateCoordinator): + global _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP + _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP[config.entry_id] = coordinator + _LOGGER.debug(f"add_config_entry_to_service(): Added config entry {config.entry_id} to _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP now: {len(_COORDINATOR_PER_CONFIG_ENTRY_ID_MAP)} entries") + +def remove_config_entry_from_service(config: ConfigEntry): + global _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP + if config.entry_id in _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP: + _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP.pop(config.entry_id) + _LOGGER.debug(f"remove_config_entry_from_service(): Removed config entry {config.entry_id} from _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP now: {len(_COORDINATOR_PER_CONFIG_ENTRY_ID_MAP)} entries") class GoeChargerApiV2Service(): - def __init__(self, hass: HomeAssistant, config: ConfigEntry, coordinator): # pylint: disable=unused-argument + def __init__(self, hass: HomeAssistant, config: ConfigEntry, coordinator: DataUpdateCoordinator): # pylint: disable=unused-argument """Initialize the sensor.""" self._hass = hass self._config = config - _COORDINATOR_PER_CONFIGENTRYID_MAP[config.entry_id] = coordinator + # when we create a new instance of the service, we will reset the map, so that only + # the last config entry is used for the service calls + global _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP + _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP = {} + _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP[config.entry_id] = coordinator + _LOGGER.debug(f"__init__(): Added config entry {config.entry_id} to _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP now: {len(_COORDINATOR_PER_CONFIG_ENTRY_ID_MAP)} entries") + self._stop_in_progress = False async def set_pv_data(self, call: ServiceCall): - config_id = call.data.get('configid', None) - if config_id is None: - coordinator = next(iter(_COORDINATOR_PER_CONFIGENTRYID_MAP.values())) - else: - coordinator = _COORDINATOR_PER_CONFIGENTRYID_MAP[config_id] + config_id, coordinator = self.get_coordinator(call) _LOGGER.debug(f"Service-set_pv_data(): Provided config_id: {config_id} -> using {coordinator.name} serial: {coordinator._serial}") # before we're going to 'post' any data to the cloud, we will check if there is ANY vehicle connected @@ -45,10 +60,10 @@ async def set_pv_data(self, call: ServiceCall): pgrid = call.data.get('pgrid', None) ppv = call.data.get('ppv', 0) pakku = call.data.get('pakku', 0) - if pgrid is not None and isinstance(pgrid, Number): - if not isinstance(ppv, Number): + if pgrid is not None and is_finite_number(pgrid): + if not is_finite_number(ppv): ppv = 0 - if not isinstance(pakku, Number): + if not is_finite_number(pakku): pakku = 0 payload = { @@ -66,33 +81,41 @@ async def set_pv_data(self, call: ServiceCall): "response": resp } - except ValueError as exc: + except (ValueError, TypeError) as exc: + _LOGGER.debug(f"Service-set_pv_data(): Error {type(exc).__name__}: {exc}") if call.return_response: return {"error": str(exc), "date": str(datetime.datetime.now().time())} if call.return_response: return {"error": "No Grid Power provided (or false data)", "date": str(datetime.datetime.now().time())} + async def set_temporary_current_limit(self, call: ServiceCall): + config_id, coordinator = self.get_coordinator(call) + _LOGGER.debug(f"Service-set_temporary_current_limit(): Provided config_id: {config_id} -> using {coordinator.name} serial: {coordinator._serial}") + try: + validated_input_current = Tag.TCL.tag_func_validate_input(call.data.get("current"), coordinator.data, coordinator.limit_to16a) + result_current = await coordinator.async_write_key(Tag.TCL.key, validated_input_current, require_ack=True) + Tag.TCL.tag_func_validate_output(result_current, validated_input_current) + except ValueError as exc: + _LOGGER.debug(f"Service-set_temporary_current_limit(): ValueError: {exc}") + raise ServiceValidationError(str(exc)) from exc + + if call.return_response: + return {"success": True, "current": result_current, "acknowledged": True} + async def stop_charging(self, call: ServiceCall): if not self._stop_in_progress: + _LOGGER.debug(f"Service-stop_charging(): Force STOP_CHARGING") + config_id, coordinator = self.get_coordinator(call) self._stop_in_progress = True - _LOGGER.debug(f"Force STOP_CHARGING") - - config_id = call.data.get('configid', None) - if config_id is None: - coordinator = next(iter(_COORDINATOR_PER_CONFIGENTRYID_MAP.values())) - else: - coordinator = _COORDINATOR_PER_CONFIGENTRYID_MAP[config_id] - _LOGGER.debug(f"Provided config_id: {config_id} -> using {coordinator.name} serial: {coordinator._serial}") - + _LOGGER.debug(f"Service-stop_charging(): Provided config_id: {config_id} -> using {coordinator.name} serial: {coordinator._serial}") try: - resp = await coordinator.async_write_key(Tag.FRC.key, 1) + resp = await coordinator.async_write_key(Tag.FRC.key, 1, require_ack=True) if Tag.FRC.key in resp: - _LOGGER.debug(f"STOP_CHARGING: waiting for 5 minutes...") + _LOGGER.debug(f"Service-stop_charging(): STOP_CHARGING: waiting for 5 minutes...") await asyncio.sleep(300) - _LOGGER.debug(f"STOP_CHARGING: 5 minutes are over... disable charging LOCK again") - - resp = await coordinator.async_write_key(Tag.FRC.key, 0) + _LOGGER.debug(f"Service-stop_charging(): STOP_CHARGING: 5 minutes are over... disable charging LOCK again") + resp = await coordinator.async_write_key(Tag.FRC.key, 0, require_ack=True) self._stop_in_progress = False if call.return_response: @@ -104,15 +127,41 @@ async def stop_charging(self, call: ServiceCall): else: _LOGGER.debug(f"response does not contain {Tag.FRC.key}: {resp}") - self._stop_in_progress = False if call.return_response: return {"error": "A STOP_CHARGING request could not be send", "date": str(datetime.datetime.now().time())} - except ValueError as exc: + except (ValueError, TypeError) as exc: + _LOGGER.debug(f"Service-stop_charging(): Error {type(exc).__name__}: {exc}") if call.return_response: return {"error": str(exc), "date": str(datetime.datetime.now().time())} - + finally: + self._stop_in_progress = False else: if call.return_response: return {"error": "A STOP_CHARGING request is already in progress", "date": str(datetime.datetime.now().time())} + + def get_coordinator(self, call): + global _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP + config_id = call.data.get('configid', None) + if config_id is None: + if len(_COORDINATOR_PER_CONFIG_ENTRY_ID_MAP) == 1: + coordinator = next(iter(_COORDINATOR_PER_CONFIG_ENTRY_ID_MAP.values())) + else: + _LOGGER.warning(f"get_coordinator(): no 'config_id' provided but length of _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP != 1: {len(_COORDINATOR_PER_CONFIG_ENTRY_ID_MAP)}") + raise ServiceValidationError(f"No 'config_id' provided BUT zero (or multiple) goecharger_api2 config entries registered with the service.") + else: + # Fetch the config entry + entry = self._hass.config_entries.async_get_entry(config_id) + # Check if it exists and is not disabled + if entry is not None and not entry.disabled_by: + if config_id in _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP: + coordinator = _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP[config_id] + else: + _LOGGER.warning(f"get_coordinator(): Provided config_id: {config_id} does not exist in _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP") + raise ServiceValidationError(f"The selected goecharger_api2 config entry ({config_id}) is not in the internal _COORDINATOR_PER_CONFIG_ENTRY_ID_MAP dictionary.") + else: + _LOGGER.warning(f"get_coordinator(): Provided config_id: {config_id} does not exist or is disabled in config entries") + raise ServiceValidationError(f"The selected goecharger_api2 config entry ({config_id}) is disabled or invalid.") + + return config_id, coordinator \ No newline at end of file diff --git a/custom_components/goecharger_api2/services.yaml b/custom_components/goecharger_api2/services.yaml index f948cdf..af4b401 100644 --- a/custom_components/goecharger_api2/services.yaml +++ b/custom_components/goecharger_api2/services.yaml @@ -65,4 +65,27 @@ stop_charging: required: false selector: config_entry: - integration: "goecharger_api2" \ No newline at end of file + integration: "goecharger_api2" +set_temporary_current_limit: + name: Set temporary current limit + description: >- + Set or renew an expiring limit without changing requested/maximum current. + The charger expires it after approximately ten minutes without renewal. + This is not a charging Stop or a failsafe watchdog. + fields: + configid: + name: Charger + required: true + selector: + config_entry: + integration: goecharger_api2 + current: + name: Current + required: true + selector: + number: + min: 6 + max: 32 + step: 1 + unit_of_measurement: A + mode: box diff --git a/custom_components/goecharger_api2/translations/de.json b/custom_components/goecharger_api2/translations/de.json index 3ca4597..1cdca1f 100644 --- a/custom_components/goecharger_api2/translations/de.json +++ b/custom_components/goecharger_api2/translations/de.json @@ -112,6 +112,7 @@ "number": { "ama": {"name": "Max. Stromlimit"}, "amp": {"name": "Angeforderter Strom"}, + "tcl": {"name": "Temporäres Stromlimit"}, "ate": {"name": "Next-Trip Lademenge"}, "att": {"name": "Next-Trip Beenden um (Min. nach 0 Uhr)"}, "awp": {"name": "Schwellenwert für Günstigen-Strompreis (ohne Gebühren!)"}, diff --git a/custom_components/goecharger_api2/translations/en.json b/custom_components/goecharger_api2/translations/en.json index 6352eab..bde96db 100644 --- a/custom_components/goecharger_api2/translations/en.json +++ b/custom_components/goecharger_api2/translations/en.json @@ -112,6 +112,7 @@ "number": { "ama": {"name": "Maximum current limit"}, "amp": {"name": "Requested current"}, + "tcl": {"name": "Temporary current limit"}, "ate": {"name": "Automatic stop energy"}, "att": {"name": "Automatic stop time"}, "awp": {"name": "Awattar maximum price threshold"}, diff --git a/docs/PVSURPLUS.md b/docs/PVSURPLUS.md index 335ef3e..19fde41 100644 --- a/docs/PVSURPLUS.md +++ b/docs/PVSURPLUS.md @@ -1,8 +1,7 @@ -# PV Surplus Charging with the go-eCharger +# PV Surplus Charging with the go-eCharger -![logo](https://github.com/marq24/ha-goecharger-api2/raw/main/logo.png) +[![hacs_badge][hacsbadge]][hacs] [![hainstall][hainstallbadge]][hainstall] [![Wero][werobadge]][wero] [![wise][wisebadge]][wise] [![Revolut][revolutbadge]][revolut] [![PayPal][paypalbadge]][paypal] [![github][ghsbadge]][ghs] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] - [![Wero][werobadge]][wero] [![Revolut][revolutbadge]][revolut] [![PayPal][paypalbadge]][paypal] [![github][ghsbadge]][ghs] [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] ## Enable PV Surplus Charging via HA automation @@ -184,20 +183,30 @@ action: mode: single ``` + +[hacs]: https://hacs.xyz +[hacsbadge]: https://img.shields.io/badge/HACS-Default-blue?style=for-the-badge&logo=homeassistantcommunitystore&logoColor=ccc + +[hainstall]: https://my.home-assistant.io/redirect/config_flow_start/?domain=goecharger_api2 +[hainstallbadge]: https://img.shields.io/badge/dynamic/json?style=for-the-badge&logo=home-assistant&logoColor=ccc&label=usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.goecharger_api2.total + [ghs]: https://github.com/sponsors/marq24 [ghsbadge]: https://img.shields.io/github/sponsors/marq24?style=for-the-badge&logo=github&logoColor=ccc&link=https%3A%2F%2Fgithub.com%2Fsponsors%2Fmarq24&label=Sponsors [buymecoffee]: https://www.buymeacoffee.com/marquardt24 -[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a-coffee-blue.svg?style=for-the-badge&logo=buymeacoffee&logoColor=ccc +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a-coffee-blue.svg?style=for-the-badge&logo=buymeacoffee&logoColor=ccc&link=https%3A%2F%2Fwww.buymeacoffee.com%2Fmarquardt24 [buymecoffee2]: https://buymeacoffee.com/marquardt24/membership -[buymecoffeebadge2]: https://img.shields.io/badge/coffee-subs-blue.svg?style=for-the-badge&logo=buymeacoffee&logoColor=ccc +[buymecoffeebadge2]: https://img.shields.io/badge/coffee-subs-blue.svg?style=for-the-badge&logo=buymeacoffee&logoColor=ccc&link=https%3A%2F%2Fbuymeacoffee.com%2Fmarquardt24%2Fmembership [paypal]: https://paypal.me/marq24 -[paypalbadge]: https://img.shields.io/badge/paypal-me-blue.svg?style=for-the-badge&logo=paypal&logoColor=ccc +[paypalbadge]: https://img.shields.io/badge/paypal-me-blue.svg?style=for-the-badge&logo=paypal&logoColor=ccc&link=https%3A%2F%2Fpaypal.me%2Fmarq24 [wero]: https://share.weropay.eu/p/1/c/6O371wjUW5 [werobadge]: https://img.shields.io/badge/_wero-me_-blue.svg?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZwogICByb2xlPSJpbWciCiAgIHZpZXdCb3g9IjAgMCA0Mi4wNDY1MDEgNDAuODg2NyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgo+CiAgPGcKICAgICBjbGlwLXBhdGg9InVybCgjY2xpcDApIgogICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01Ny4zODE4KSI+CiAgICA8cGF0aAogICAgICAgZD0ibSA3OC40MDUxLDMwLjM1NzQgYyAwLDAgLTAuMDE4NSwwIC0wLjAyNzgsMCAtNC4zMTg0LDAgLTcuMzQ2MiwtMi41NzY5IC04LjY0NjEsLTUuOTg4NyBIIDk5LjA2OTggQyA5OS4zMDU3LDIzLjA4NDkgOTkuNDI4MywyMS43NzExIDk5LjQyODMsMjAuNDQxIDk5LjQyODMsOS43NTY3MyA5MS43Mzc1LDAuMDEzODc4NyA3OC40MDUxLDAgdiAxMC41MjcgYyA0LjM0MzksMC4wMTE2IDcuMzQxNiwyLjU4MzcgOC42Mjc2LDUuOTg4NyBoIC0yOS4yOTcgYyAtMC4yMzM2LDEuMjgzNyAtMC4zNTM5LDIuNTk3NiAtMC4zNTM5LDMuOTI3NiAwLDEwLjY5MTMgNy43MDAyLDIwLjQ0MzQgMjAuOTk1NSwyMC40NDM0IDAuMDA5MywwIDAuMDE4NSwwIDAuMDI3OCwwIHYgLTEwLjUyNyB6IgogICAgICAgZmlsbD0iI0NDQ0NDQyIvPgogICAgPHBhdGgKICAgICAgIGQ9Im0gNzguMzc3NCw0MC44ODQ0IGMgMC40NTEsMCAwLjg5NTEsLTAuMDEzOSAxLjMzNDYsLTAuMDM0NyAyLjcwMTcsLTAuMTM2NSA1LjE1MzUsLTAuNjgwMSA3LjMzOTMsLTEuNTU2NyAyLjE4NTgsLTAuODc2NyA0LjEwNTcsLTIuMDgxOCA1LjczODcsLTMuNTM5MSAxLjYzMywtMS40NTczIDIuOTgxNSwtMy4xNjQzIDQuMDI3LC01LjA0NDkgMC45NTA2LC0xLjcwOTQgMS42NDQ1LC0zLjU1OTkgMi4wNzk0LC01LjQ5MTMgSCA4Ni42NzIgYyAtMC4yNDk4LDAuNTE1OCAtMC41NDEzLDEuMDA4NSAtMC44NzQ0LDEuNDY4OCAtMC40NTU2LDAuNjI5MSAtMC45ODk5LDEuMjAwNSAtMS41OTYsMS42OTMyIC0wLjYwNiwwLjQ5MjcgLTEuMjg2LDAuOTA5IC0yLjAzNTQsMS4yMzA2IC0wLjc0OTUsMC4zMjE1IC0xLjU2NiwwLjU0ODIgLTIuNDQ5NSwwLjY2MTUgLTAuNDMwMywwLjA1NTUgLTAuODc0NCwwLjA4NzkgLTEuMzM0NywwLjA4NzkgLTIuNzUwMiwwIC00Ljk3NzYsLTEuMDQ3OCAtNi41NjY3LC0yLjY4NzggbCAtNy45NDc2LDcuOTQ3OCBjIDMuNTM2NiwzLjIyOTIgOC40NDI2LDUuMjY0NyAxNC41MTY2LDUuMjY0NyB6IgogICAgICAgZmlsbD0idXJsKCNwYWludDApIgogICAgICAgc3R5bGU9ImZpbGw6dXJsKCNwYWludDApIiAvPgogICAgPHBhdGgKICAgICAgIGQ9Ik0gNzguMzc3NywwIEMgNjcuMTAxNiwwIDU5Ljg1MDIsNy4wMTMzNyA1Ny45MDcyLDE1LjY2OTEgSCA3MC4wOTcgYyAxLjQ1NzIsLTIuOTgxNyA0LjMyNzcsLTUuMTQyMSA4LjI4MDcsLTUuMTQyMSAzLjE1MDMsMCA1LjU5NTIsMS4zNDYyIDcuMTkzNSwzLjM4MTggTCA5My41OTA1LDUuODg5MiBDIDkwLjAwNzYsMi4zMDE1NSA4NC44NTY1LDAuMDAyMzEzMTIgNzguMzc1MywwLjAwMjMxMzEyIFoiCiAgICAgICBmaWxsPSJ1cmwoI3BhaW50MSkiCiAgICAgICBzdHlsZT0iZmlsbDp1cmwoI3BhaW50MSkiIC8+CiAgPC9nPgogIDxkZWZzPgogICAgPGxpbmVhckdyYWRpZW50CiAgICAgICBpZD0icGFpbnQwIgogICAgICAgeDE9IjkyLjc0MzY5OCIKICAgICAgIHkxPSIxOC4wMjYxOTkiCiAgICAgICB4Mj0iNzQuNzU0NTAxIgogICAgICAgeTI9IjQwLjMxMDIiCiAgICAgICBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC4wMiIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIKICAgICAgICAgc3RvcC1vcGFjaXR5PSIwIi8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC4zOSIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIKICAgICAgICAgc3RvcC1vcGFjaXR5PSIwLjY2Ii8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC42OCIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9InBhaW50MSIKICAgICAgIHgxPSI2MS4yNzA0MDEiCiAgICAgICB5MT0iMjMuMDE3Nzk5IgogICAgICAgeDI9Ijc5Ljc1NDUwMSIKICAgICAgIHkyPSI0LjUzNDI5OTkiCiAgICAgICBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC4wMiIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIKICAgICAgICAgc3RvcC1vcGFjaXR5PSIwIi8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC4zOSIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIKICAgICAgICAgc3RvcC1vcGFjaXR5PSIwLjY2Ii8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC42OCIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDxjbGlwUGF0aAogICAgICAgaWQ9ImNsaXAwIj4KICAgICAgPHJlY3QKICAgICAgICAgd2lkdGg9IjE3Ny45MSIKICAgICAgICAgaGVpZ2h0PSI0MSIKICAgICAgICAgZmlsbD0iI2ZmZmZmZiIKICAgICAgICAgeD0iMCIKICAgICAgICAgeT0iMCIgLz4KICAgIDwvY2xpcFBhdGg+CiAgPC9kZWZzPgo8L3N2Zz4= [revolut]: https://revolut.me/marq24 -[revolutbadge]: https://img.shields.io/badge/_revolut-me_-blue.svg?style=for-the-badge&logo=revolut&logoColor=ccc \ No newline at end of file +[revolutbadge]: https://img.shields.io/badge/_revolut-me_-blue.svg?style=for-the-badge&logo=revolut&logoColor=ccc&link=https%3A%2F%2Frevolut.me%2Fmarq24 + +[wise]: https://wise.com/pay/me/matthiasm1046 +[wisebadge]: https://img.shields.io/badge/_wise-me_-blue.svg?style=for-the-badge&logo=wise&logoColor=ccc&link=https%3A%2F%2Fwise.com%2Fpay%2Fme%2Fmatthiasm1046 \ No newline at end of file diff --git a/docs/SUPPORT.md b/docs/SUPPORT.md new file mode 100644 index 0000000..a0b0a03 --- /dev/null +++ b/docs/SUPPORT.md @@ -0,0 +1,62 @@ +# Support the maintainance & development + +[![hacs_badge][hacsbadge]][hacs] [![hainstall][hainstallbadge]][hainstall] + +--- + +Either you just found this page manually (quite unlikely) or you have been directed from your Home Assistant to this page. + +When you are **directed from your Home Assistant**, then you have currently **more than 6 active running configurations** for this integration running. This makes you a heavy user of this integration and I would kindly ask you **to support the maintenance and development**. + +You have different options to do that: + +### Become a monthly supporter +- [![github][ghsbadge]][ghs] +- [![BuyMeCoffee2][buymecoffeebadge2]][buymecoffee2] + +### or a one time support via: +- [![Wero][werobadge]][wero] +- [![Revolut][revolutbadge]][revolut] +- [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] +- [![wise][wisebadge]][wise] +- [![PayPal][paypalbadge]][paypal] + +If you have already supported the development in the past - never mind - THANKS for your support! + + + +--- + +[hacs]: https://hacs.xyz +[hacsbadge]: https://img.shields.io/badge/HACS-Default-blue?style=for-the-badge&logo=homeassistantcommunitystore&logoColor=ccc + +[hainstall]: https://my.home-assistant.io/redirect/config_flow_start/?domain=goecharger_api2 +[hainstallbadge]: https://img.shields.io/badge/dynamic/json?style=for-the-badge&logo=home-assistant&logoColor=ccc&label=usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.goecharger_api2.total + +[ghs]: https://github.com/sponsors/marq24 +[ghsbadge]: https://img.shields.io/github/sponsors/marq24?style=for-the-badge&logo=github&logoColor=ccc&link=https%3A%2F%2Fgithub.com%2Fsponsors%2Fmarq24&label=Sponsors + +[buymecoffee]: https://www.buymeacoffee.com/marquardt24 +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a-coffee-blue.svg?style=for-the-badge&logo=buymeacoffee&logoColor=ccc&link=https%3A%2F%2Fwww.buymeacoffee.com%2Fmarquardt24 + +[buymecoffee2]: https://buymeacoffee.com/marquardt24/membership +[buymecoffeebadge2]: https://img.shields.io/badge/coffee-subs-blue.svg?style=for-the-badge&logo=buymeacoffee&logoColor=ccc&link=https%3A%2F%2Fbuymeacoffee.com%2Fmarquardt24%2Fmembership + +[paypal]: https://paypal.me/marq24 +[paypalbadge]: https://img.shields.io/badge/paypal-me-blue.svg?style=for-the-badge&logo=paypal&logoColor=ccc&link=https%3A%2F%2Fpaypal.me%2Fmarq24 + +[wero]: https://share.weropay.eu/p/1/c/6O371wjUW5 +[werobadge]: https://img.shields.io/badge/_wero-me_-blue.svg?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZwogICByb2xlPSJpbWciCiAgIHZpZXdCb3g9IjAgMCA0Mi4wNDY1MDEgNDAuODg2NyIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgo+CiAgPGcKICAgICBjbGlwLXBhdGg9InVybCgjY2xpcDApIgogICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01Ny4zODE4KSI+CiAgICA8cGF0aAogICAgICAgZD0ibSA3OC40MDUxLDMwLjM1NzQgYyAwLDAgLTAuMDE4NSwwIC0wLjAyNzgsMCAtNC4zMTg0LDAgLTcuMzQ2MiwtMi41NzY5IC04LjY0NjEsLTUuOTg4NyBIIDk5LjA2OTggQyA5OS4zMDU3LDIzLjA4NDkgOTkuNDI4MywyMS43NzExIDk5LjQyODMsMjAuNDQxIDk5LjQyODMsOS43NTY3MyA5MS43Mzc1LDAuMDEzODc4NyA3OC40MDUxLDAgdiAxMC41MjcgYyA0LjM0MzksMC4wMTE2IDcuMzQxNiwyLjU4MzcgOC42Mjc2LDUuOTg4NyBoIC0yOS4yOTcgYyAtMC4yMzM2LDEuMjgzNyAtMC4zNTM5LDIuNTk3NiAtMC4zNTM5LDMuOTI3NiAwLDEwLjY5MTMgNy43MDAyLDIwLjQ0MzQgMjAuOTk1NSwyMC40NDM0IDAuMDA5MywwIDAuMDE4NSwwIDAuMDI3OCwwIHYgLTEwLjUyNyB6IgogICAgICAgZmlsbD0iI0NDQ0NDQyIvPgogICAgPHBhdGgKICAgICAgIGQ9Im0gNzguMzc3NCw0MC44ODQ0IGMgMC40NTEsMCAwLjg5NTEsLTAuMDEzOSAxLjMzNDYsLTAuMDM0NyAyLjcwMTcsLTAuMTM2NSA1LjE1MzUsLTAuNjgwMSA3LjMzOTMsLTEuNTU2NyAyLjE4NTgsLTAuODc2NyA0LjEwNTcsLTIuMDgxOCA1LjczODcsLTMuNTM5MSAxLjYzMywtMS40NTczIDIuOTgxNSwtMy4xNjQzIDQuMDI3LC01LjA0NDkgMC45NTA2LC0xLjcwOTQgMS42NDQ1LC0zLjU1OTkgMi4wNzk0LC01LjQ5MTMgSCA4Ni42NzIgYyAtMC4yNDk4LDAuNTE1OCAtMC41NDEzLDEuMDA4NSAtMC44NzQ0LDEuNDY4OCAtMC40NTU2LDAuNjI5MSAtMC45ODk5LDEuMjAwNSAtMS41OTYsMS42OTMyIC0wLjYwNiwwLjQ5MjcgLTEuMjg2LDAuOTA5IC0yLjAzNTQsMS4yMzA2IC0wLjc0OTUsMC4zMjE1IC0xLjU2NiwwLjU0ODIgLTIuNDQ5NSwwLjY2MTUgLTAuNDMwMywwLjA1NTUgLTAuODc0NCwwLjA4NzkgLTEuMzM0NywwLjA4NzkgLTIuNzUwMiwwIC00Ljk3NzYsLTEuMDQ3OCAtNi41NjY3LC0yLjY4NzggbCAtNy45NDc2LDcuOTQ3OCBjIDMuNTM2NiwzLjIyOTIgOC40NDI2LDUuMjY0NyAxNC41MTY2LDUuMjY0NyB6IgogICAgICAgZmlsbD0idXJsKCNwYWludDApIgogICAgICAgc3R5bGU9ImZpbGw6dXJsKCNwYWludDApIiAvPgogICAgPHBhdGgKICAgICAgIGQ9Ik0gNzguMzc3NywwIEMgNjcuMTAxNiwwIDU5Ljg1MDIsNy4wMTMzNyA1Ny45MDcyLDE1LjY2OTEgSCA3MC4wOTcgYyAxLjQ1NzIsLTIuOTgxNyA0LjMyNzcsLTUuMTQyMSA4LjI4MDcsLTUuMTQyMSAzLjE1MDMsMCA1LjU5NTIsMS4zNDYyIDcuMTkzNSwzLjM4MTggTCA5My41OTA1LDUuODg5MiBDIDkwLjAwNzYsMi4zMDE1NSA4NC44NTY1LDAuMDAyMzEzMTIgNzguMzc1MywwLjAwMjMxMzEyIFoiCiAgICAgICBmaWxsPSJ1cmwoI3BhaW50MSkiCiAgICAgICBzdHlsZT0iZmlsbDp1cmwoI3BhaW50MSkiIC8+CiAgPC9nPgogIDxkZWZzPgogICAgPGxpbmVhckdyYWRpZW50CiAgICAgICBpZD0icGFpbnQwIgogICAgICAgeDE9IjkyLjc0MzY5OCIKICAgICAgIHkxPSIxOC4wMjYxOTkiCiAgICAgICB4Mj0iNzQuNzU0NTAxIgogICAgICAgeTI9IjQwLjMxMDIiCiAgICAgICBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC4wMiIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIKICAgICAgICAgc3RvcC1vcGFjaXR5PSIwIi8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC4zOSIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIKICAgICAgICAgc3RvcC1vcGFjaXR5PSIwLjY2Ii8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC42OCIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDxsaW5lYXJHcmFkaWVudAogICAgICAgaWQ9InBhaW50MSIKICAgICAgIHgxPSI2MS4yNzA0MDEiCiAgICAgICB5MT0iMjMuMDE3Nzk5IgogICAgICAgeDI9Ijc5Ljc1NDUwMSIKICAgICAgIHkyPSI0LjUzNDI5OTkiCiAgICAgICBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC4wMiIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIKICAgICAgICAgc3RvcC1vcGFjaXR5PSIwIi8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC4zOSIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIKICAgICAgICAgc3RvcC1vcGFjaXR5PSIwLjY2Ii8+CiAgICAgIDxzdG9wCiAgICAgICAgIG9mZnNldD0iMC42OCIKICAgICAgICAgc3RvcC1jb2xvcj0iI0NDQ0NDQyIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDxjbGlwUGF0aAogICAgICAgaWQ9ImNsaXAwIj4KICAgICAgPHJlY3QKICAgICAgICAgd2lkdGg9IjE3Ny45MSIKICAgICAgICAgaGVpZ2h0PSI0MSIKICAgICAgICAgZmlsbD0iI2ZmZmZmZiIKICAgICAgICAgeD0iMCIKICAgICAgICAgeT0iMCIgLz4KICAgIDwvY2xpcFBhdGg+CiAgPC9kZWZzPgo8L3N2Zz4= + +[revolut]: https://revolut.me/marq24 +[revolutbadge]: https://img.shields.io/badge/_revolut-me_-blue.svg?style=for-the-badge&logo=revolut&logoColor=ccc&link=https%3A%2F%2Frevolut.me%2Fmarq24 + +[wise]: https://wise.com/pay/me/matthiasm1046 +[wisebadge]: https://img.shields.io/badge/_wise-me_-blue.svg?style=for-the-badge&logo=wise&logoColor=ccc&link=https%3A%2F%2Fwise.com%2Fpay%2Fme%2Fmatthiasm1046 \ No newline at end of file diff --git a/docs/quality/README.md b/docs/quality/README.md new file mode 100644 index 0000000..5926950 --- /dev/null +++ b/docs/quality/README.md @@ -0,0 +1,8 @@ +# Quality documentation + +Written with AI assistance (Claude Code) and reviewed by Oliver. Created in the fork `OliEder/ha-goecharger-api2` (branch `tests/regression-suite`) and merged into this repository with PR #178. + +- `arc42-ha-goecharger-api2.md`: architecture documentation (arc42, German) reconstructed from code, README and the official go-e API documentation. Statements are marked as code, documentation or inference. Chapter 11 lists risks and shows which of them have been fixed upstream. +- `testkonzept-ha-goecharger-api2.md`: test concept (scope, levels, markers, current state, gaps). + +Tests: `tests/integration/`. Measurement report: manually with `.github/workflows/test-report.yaml`. Tools: `tests/tools/`. diff --git a/docs/quality/arc42-ha-goecharger-api2.md b/docs/quality/arc42-ha-goecharger-api2.md new file mode 100644 index 0000000..ed2fedb --- /dev/null +++ b/docs/quality/arc42-ha-goecharger-api2.md @@ -0,0 +1,536 @@ +# arc42 – Architekturdokumentation: `ha-goecharger-api2` (go-e APIv2 Connect) + +| | | +|---|---| +| **Gegenstand** | Home-Assistant-Integration `goecharger_api2` für go-eCharger-Wallboxen und go-eController | +| **Quelle** | `marq24/ha-goecharger-api2`, Branch `main`, Commit `98af3e0` (2026-10-28). Letzte Version (Release) ist `2026.10.8` (Commit `03b0fc3`, 2026-10-27); `main` enthält danach noch einen Commit zur 16-A-Erkennung. Risiken und Bausteine wurden am 2026-10-29 gegen diesen Stand aktualisiert | +| **Erstellt** | 2026-10-27, rekonstruiert aus Code, README, `docs/PVSURPLUS.md`, Workflows und Commit-Historie | +| **Status** | Arbeitsunterlage, mit KI-Unterstützung (Claude Code) erstellt und von Oliver geprüft. Kein Bestandteil des Upstream-Repositories; der Maintainer war an der Erstellung nicht beteiligt. | + +**Lesehinweis zur Verlässlichkeit.** Aussagen sind in drei Stufen markiert: +- **[Code]**: direkt aus dem Quelltext gelesen, mit Datei und Zeile. +- **[Doku]**: steht so im README oder in `docs/`. +- **[Schluss]**: von mir abgeleitet (Absicht, Bewertung). Das ist keine Aussage des Autors. + +Nichts davon wurde gegen einen laufenden Charger geprüft, außer wo ausdrücklich vermerkt. + +--- + +## 1. Einführung und Ziele + +### 1.1 Aufgabenstellung +Die Integration bindet go-eCharger-Wallboxen (und Wallboxen auf deren Hardwarebasis, zum Beispiel Fronius Wattpilot) sowie den go-eController in Home Assistant (HA) ein [Doku]. + +Sie liefert: +- **Lesen:** Status- und Konfigurationswerte als HA-Entitäten. +- **Schreiben:** Einstellungen der Wallbox aus HA heraus. +- **PV-Überschussladen ohne Zusatzhardware:** Eine HA-Automation schickt Netz-, PV- und Batterieleistung über einen Service an die Wallbox [Doku]. + +Umfang laut Code [Code, `const.py`]: + +| Plattform | Charger | Controller | +|---|---|---| +| sensor | 103 | 74 | +| number | 36 | 0 | +| switch | 18 | 10 | +| binary_sensor | 16 | 0 | +| select | 15 | 6 | +| button | 3 | 1 | + +Dazu 319 definierte API-Schlüssel (`Tag`) in `pygoecharger_ha/keys.py`. Die Zahlen sind gezählte `Ext*EntityDescription(`-Aufrufe und können durch bedingte Filterung (Abschnitt 5.2) zur Laufzeit niedriger liegen. + +### 1.2 Qualitätsziele + +| Prio | Ziel | Bedeutung hier | +|---|---|---| +| 1 | **Robustheit gegenüber Geräteverhalten** | Firmware ändert sich (55 bis 60.x), Verbindungen brechen ab, Wallboxen antworten unterschiedlich. Die Integration soll weiterlaufen. | +| 2 | **Aktualität bei geringer Last** | Push per WebSocket statt Polling; im Polling-Betrieb bewusst gedrosselt (Hibernation-Modus). | +| 3 | **Geringer Konfigurationsaufwand** | Alles über die HA-Oberfläche; Entitäten werden aus Beschreibungen erzeugt. | +| 4 | **Sicherer Betrieb der Steuerung** | Schreibzugriffe wirken auf ein physisches Gerät (Stromfluss, Fahrzeug). | + +[Schluss] Die Reihenfolge ist aus dem Code abgeleitet (viel Aufwand bei Reconnect, Watchdog, Firmware-Weichen, wenig bei Tests und Eingabevalidierung). + +### 1.3 Stakeholder + +| Rolle | Erwartung | +|---|---| +| Endnutzer (HA-Betreiber) | Funktioniert nach Firmware-Updates, kein Log-Spam, PV-Laden stabil | +| Maintainer (marq24, s3ppo für den Controller) | Wartbar mit wenig Freizeit | +| Beitragende (zum Beispiel MrCodeLabs mit dem Blueprint) | Klare Erweiterungspunkte | +| go-e GmbH | Nicht beteiligt. Die Integration ist inoffiziell [Doku]. | + +--- + +## 2. Randbedingungen + +| Typ | Randbedingung | +|---|---| +| Plattform | Home Assistant, `hacs.json` verlangt mindestens `2023.8.0`; `requirements_dev.txt` zieht `homeassistant>=2024.8.2`. Der Code nutzt neuere APIs (zum Beispiel `async_update_reload_and_abort`, `_get_reconfigure_entry`). Die tatsächlich unterstützte Mindestversion ist nicht eindeutig [Schluss]. | +| Sprache/Laufzeit | Python, asynchron (`asyncio`, `aiohttp`) | +| Abhängigkeiten | `msgpack>=1.1.2` (manifest); im Code zusätzlich `bcrypt`, `packaging`, `awesomeversion`, `aiohttp` (kommen aus HA oder werden vorausgesetzt). `bcrypt` steht **nicht** im Manifest [Code]. | +| Geräte-API | Undokumentierte WebSocket-Schnittstelle (nachgebaut) und dokumentierte HTTP-API v2 der go-e GmbH | +| Verteilung | HACS (Zip-Release); Lizenz siehe `LICENSE` | +| Organisation | Ein Hauptautor, Freizeitprojekt. Keine automatisierten Tests im Upstream-Repository (die Tests aus PR #171 wurden wieder entfernt). | +| Gerätegrenzen | Die Wallbox verwirft PV-Daten nach 5 Sekunden [Doku]. Die Konfiguration wird im Polling nur selten gelesen. | + +--- + +## 3. Kontextabgrenzung + +### 3.1 Fachlicher Kontext + +```mermaid +flowchart LR + User[Nutzer / Dashboard] + Auto[HA-Automation
oder Blueprint] + HA[Home Assistant] + INT[Integration
goecharger_api2] + WB[go-eCharger / go-eController
LAN] + Cloud[go-e Cloud
api.v3.go-e.io / app.v3.go-e.io] + PV[PV-, Netz-, Batterie-Sensoren
anderer Integrationen] + + User --> HA + PV --> Auto + Auto -- "goecharger_api2.set_pv_data (alle 5 s)" --> INT + Auto -- "goecharger_api2.stop_charging" --> INT + HA --- INT + INT -- "HTTP /api/status, /api/set
oder WebSocket /ws" --> WB + INT -- "HTTPS + Bearer-Token
oder WSS" --> Cloud + Cloud --- WB +``` + +### 3.2 Technischer Kontext (Schnittstellen) + +| Kanal | Richtung | Protokoll | Authentifizierung | +|---|---|---|---| +| LAN-Polling | HA → Wallbox | `GET http(s):///api/status?filter=…`, `GET …/api/set?key=value` | keine (Freischaltung „HTTP API v2“ in der go-e-App) | +| LAN-WebSocket | HA ↔ Wallbox | `ws(s):///ws`, JSON/MessagePack | Challenge-Response mit PBKDF2-SHA512 oder bcrypt, `securedMsg` mit HMAC-SHA256 | +| Cloud-Polling | HA → Cloud | `GET https://.api.v3.go-e.io/api/status`, `/api/set` | `Authorization: Bearer ` | +| Cloud-WebSocket | HA ↔ Cloud | `wss://app.v3.go-e.io/` | Token-Header plus Wallbox-Passwort-Challenge | +| HA-Dienste | Automation → Integration | `goecharger_api2.set_pv_data`, `goecharger_api2.stop_charging` | HA-Berechtigungen | +| Konfiguration | Nutzer → HA | Config-Flow (Anlegen, Rekonfigurieren) | HA-Login | + +[Code] Betriebsart wird **nicht** ausgewählt, sondern ergibt sich aus der Konfiguration: Ist im Config-Entry ein Passwort hinterlegt, nutzt die Integration WebSocket, sonst HTTP-Polling (`__init__.py:123`ff, `pygoecharger_ha/__init__.py:151`ff). + +### 3.3 Offizielle Schnittstellendokumentation der go-e GmbH + +Quelle: GitHub-Repository [`goecharger/go-eCharger-API-v2`](https://github.com/goecharger/go-eCharger-API-v2), Stand Commit `b4d7f85` vom 2026-08-12. Status der go-e-Dienste: https://status.go-e.com/status/go-e. Eine lokale Kopie der gelesenen Dateien liegt in `go-e-api-docs-b4d7f85/`. + +| Dokument | Inhalt | Von der Integration genutzt? | +|---|---|---| +| `API_KEYS_FIRMWARE/apikeys-en.md` | Alle API-Schlüssel mit Lesen/Schreiben, Typ, Kategorie, Beschreibung (rund 400 KB). Zusätzlich eigene Listen für Firmware 59.4, 60.2, 60.3 und 60.4 | Ja, Grundlage von `keys.py` und den Filtern | +| `http-en.md` | Lokale HTTP-API: `/api/status` (optional `filter=`), `/api/set` | Ja (Polling) | +| `cloudapi-en.md` | Cloud-HTTP-API über `https://.api.v3.go-e.io` mit Token | Ja (Cloud-Polling) | +| `mqtt-en.md` | MQTT, ab Firmware 051.5 Schreiben nur nach ausdrücklicher Freigabe | Nein | +| `modbus-en.md`, `ocpp-en.md` | Modbus und OCPP | Nein | +| `gridapi-en.md` | „Coming soon“, ohne Inhalt | Nein | +| `Changelogs_Gemini/`, `Changelogs_PRO/` | Firmware-Änderungen 60.3 bis 60.5 | Nur als Hintergrund | + +**Wichtig:** Eine **WebSocket-Schnittstelle ist in der offiziellen Dokumentation nicht beschrieben.** Das README der Integration nennt sie „undocumented“. Anmeldung und Nachrichtenformat sind nachgebaut (Beobachtung der App und des Wattpilot-Projekts, siehe Kommentare in `ws_connect` und `_ws_compute_hashed_password`). Damit liegt der bevorzugte Datenpfad außerhalb dessen, was go-e zusichert (Abschnitt 11, R16). + +**Was die offizielle Doku für die Integration festlegt** + +| Thema | Offizielle Aussage | Bezug zur Integration | +|---|---|---| +| Aktivierung lokal | HTTP-API muss in der App aktiviert sein (Schlüssel `hai`) | README und Config-Flow verlangen das, falls kein Passwort für WebSocket genutzt wird | +| Gesamtabzug | `/api/status` ohne Filter: „higher system load, lots of traffic, shouldn't be used for productive setups“ | Integration nutzt immer `filter=…` (Abschnitt 6.3), passt dazu | +| Filter | Komma-getrennte Schlüssel, ab Firmware 051.4. Davor eine JSON-Liste mit rund 10 Einträgen | Integration verwendet nur die neue Form; das README fordert Firmware ab 56.1 | +| Schreiben | Jeder Wert als URL-Parameter, JSON-kodiert (Zeichenketten in Anführungszeichen). Antwort: je Schlüssel `true` oder eine Fehlermeldung | Entspricht `_write_values_int` (`'"'+str(value)+'"'`) | +| Rechenlogik | Die Lade-Logik läuft einmal pro Sekunde, geänderte Einstellungen wirken nach etwa einer weiteren Sekunde | Erklärt, warum Schreiben und Wirkung zeitversetzt erscheinen | +| PV-Daten `ids` | `{"pGrid", "pPv", "pAkku"}`; `pGrid < 0` Einspeisung, `pAkku < 0` Batterie lädt, `pPv > 0` Erzeugung, `pPv < 0` Standby. „Needed all 5 seconds. Can be read back within 10 seconds after set.“ `pPv` und `pAkku` optional | Entspricht dem Dienst `set_pv_data`; die Oberfläche begrenzt `ppv` auf Werte ab 0 (`services.yaml`), obwohl negative Werte laut Doku „Standby“ bedeuten | +| Cloud-Aktivierung | Firmware ab 052, App ab 2.3; Schlüssel `cae` oder App-Schalter | Integration liest `cae` im Config-Flow | +| Cloud-Token | In der App sichtbar oder über den Schlüssel `cak` lesbar; neuer Token durch Setzen von `cak` | Integration liest `cak` nicht, speichert aber den Token im Config-Entry | +| Cloud-Rate-Limit | Keins, aber nicht öfter als einmal pro Sekunde abfragen | Integration: Cloud-Intervall mindestens 30 s | +| Cloud `/api/status` | 403 offline oder Cloud-API aus; 404 keine aktuellen Daten; 200 mit Daten, Header `last-modified`. Parameter `age` (Standard 30 s), `filter` | Integration wertet nur 200 und 400 aus und nutzt `age` nicht | +| Cloud `/api/set` | 403, 404 (Charger offline), 422 (alle Schlüssel abgelehnt), **202** laut Doku (mindestens ein Schlüssel gesendet, ohne Garantie); laut Maintainer liefert die echte Cloud 200 und die Doku ist hier falsch | Integration behandelt in `_write_values_int` nur 200 und 500 als Antwort (Abschnitt 11, R15, kein Fehler) | + +--- + +## 4. Lösungsstrategie + +| Ziel | Ansatz | Wo | +|---|---|---| +| Robustheit | Pro Config-Entry ein `DataUpdateCoordinator`; Fehlerzähler mit 2 Minuten Sperre; nach mehr als 5 Verbindungsfehlern verzögerter Neustart (1 bis 5 Minuten zufällig); WebSocket-Watchdog alle 5 Minuten 1 Sekunde | `__init__.py` | +| Aktualität bei geringer Last | WebSocket-Push mit Entprellung (mindestens 1 Sekunde zwischen HA-Updates, sonst Scan-Intervall); im Polling: „Hibernation“ (nur 6 Schlüssel, solange kein Fahrzeug steckt), Konfiguration höchstens stündlich | `pygoecharger_ha/__init__.py`, `const.py` (Filter) | +| Wenig Code pro Entität | Deklarative Entity-Descriptions in `const.py`; je Plattform eine generische Klasse, die über `key`, `idx`, `tuple_idx`, `lookup`, `factor` den Wert aus dem Datenspeicher liest | `const.py`, `sensor.py`, `number.py`, … | +| Firmware-Vielfalt | Verzweigungen nach Gerätetyp (Core/Phoenix gegenüber klassisch), Firmware-Version (`cards`-Objekt gegenüber Einzelschlüsseln), vorhandenen Schlüsseln | `read_versions()`, `is_valid_charger_entity()` | +| PV-Überschuss ohne Hardware | Service `set_pv_data` schreibt den Schlüssel `ids` mit `pGrid`, `pPv`, `pAkku`; der Blueprint liefert die Messwerte | `service.py`, `example/blueprint/` | +| Trennung | Geräte-Protokoll in der Teilbibliothek `pygoecharger_ha` (kennt Home Assistant nicht, nutzt aber `aiohttp`, `msgpack`, `bcrypt`) | `pygoecharger_ha/` | + +--- + +## 5. Bausteinsicht + +### 5.1 Ebene 1: Gesamtsystem + +```mermaid +flowchart TB + subgraph Integration["custom_components/goecharger_api2"] + INIT["__init__.py
Setup, Coordinator, Basisentität"] + CF["config_flow.py
Anlegen, Rekonfigurieren, Verbindungstest"] + SVC["service.py
set_pv_data, stop_charging"] + PLAT["Plattformen
sensor, number, select,
switch, button, binary_sensor"] + CONST["const.py
Entity-Beschreibungen"] + ENT["entity.py
Anzeigename-Anpassung"] + subgraph Lib["pygoecharger_ha"] + BR["__init__.py
Bridge: HTTP + WebSocket"] + KEYS["keys.py
319 API-Schlüssel (Tag)"] + PC["const.py
Filter, Übersetzungen, Enums"] + end + end + HAcore[Home Assistant Core] + INIT --> BR + INIT --> ENT + PLAT --> INIT + PLAT --> CONST + SVC --> INIT + CF --> BR + BR --> KEYS + BR --> PC + HAcore --> INIT + HAcore --> CF +``` + +### 5.2 Ebene 2: Bausteine + +| Baustein | Verantwortung | Wichtige Punkte | +|---|---|---| +| `GoeChargerDataUpdateCoordinator` (`__init__.py`) | Hält Bridge und Datenzustand je Config-Entry; Polling-Zyklus; Schreibzugriffe; Fehlerbehandlung; WebSocket-Start und Watchdog; Gerätemerkmale | `data` ist eine `ChainMap(_ws_states, _config, _states, _versions)`. Verbindungsfehler gehen über `_handle_client_connection_error`. | +| `GoeChargerApiV2Bridge` (`pygoecharger_ha/__init__.py`) | Wandelt zwischen Gerät und Dictionary: Lesen mit Filter, Schreiben, WebSocket-Handshake und -Nachrichten | Wählt Pfad über `ws_connected`. Schreiben im WebSocket-Modus: sendet den Befehl und gibt `{key: value}` zurück (Einzelschreiben) oder `{}` (Mehrfachschreiben), **bevor** das Gerät bestätigt hat; die Bestätigung kommt später als `response`. Im HTTP-Modus: Ergebnis `{key: value}` oder `{"err": …}`. | +| `GoeChargerBaseEntity` (`__init__.py`) | Gemeinsame Basis: Übersetzungsschlüssel, Entity-ID, `unique_id`, `device_info`, Verfügbarkeit | Entity-ID `…goe__` (LAN) oder `…goe_wan__…` (Cloud). | +| `CustomFriendlyNameEntity` (`entity.py`) | Überschreibt den Anzeigenamen ab HA 2026.2 | Greift auf die private Methode `_Entity__async_calculate_state` zu, also fragil bei HA-Updates [Schluss]. | +| Plattformen | Legen Entitäten aus den Beschreibungslisten an; filtern über `is_valid_charger_entity()` | Wertzugriff mit `idx` (Listen), `tuple_idx` (verschachtelt), `lookup` (Zahl→Text), `factor`, `differential_base_key`. | +| `service.py` | Zwei HA-Dienste | `set_pv_data`: Netzleistung Pflicht, PV und Batterie optional. `stop_charging`: setzt `frc=1`, wartet 300 s, setzt `frc=0`. Ein Register der Charger (`add_config_entry_to_service`, `remove_config_entry_from_service`) bildet `configid` auf den Koordinator ab; `get_coordinator` lehnt unbekannte oder deaktivierte Entries mit `ServiceValidationError` ab. Ohne `configid` wird nur dann ein Charger gewählt, wenn genau einer registriert ist, sonst folgt ebenfalls ein `ServiceValidationError` (E10). | +| `config_flow.py` | Assistent LAN/Cloud, Verbindungstest über WebSocket oder HTTP, Rekonfiguration | Titel und Typ aus den gelesenen Gerätedaten. | +| `const.py` / `pygoecharger_ha/const.py` | Entity-Beschreibungen; API-Filter (welche Schlüssel für welchen Zyklus); Übersetzungen; Enums (`CAR_VALUES`, `CT_VALUES`) | `FILTER_*`-Zeichenketten bestimmen den Datenumfang je Zyklus. | +| `example/blueprint/…` | Blueprint für den PV-Überschuss | Eigene Qualitätssicherung im Blueprint (Messwertprüfung, Zielcharger) seit PR #171. | + +### 5.3 Zustand und Datenmodell (zentral) + +| Speicher | Inhalt | Wird gefüllt durch | +|---|---|---| +| `_versions` | System- und Versionsdaten (`typ`, `fwv`, `cards`, `cll`, …) | `read_versions()` | +| `_states` | Statuswerte | Polling (`FILTER_ALL_STATES`, im Leerlauf `FILTER_MIN_STATES`) | +| `_config` | Konfigurationswerte | Polling höchstens stündlich (`FILTER_ALL_CONFIG`) | +| `_ws_states` | Alles, was per WebSocket ankommt (Vollstatus, Delta, Antworten) | WebSocket-Nachrichten | + +Die Reihenfolge in der `ChainMap` gibt WebSocket-Werten Vorrang. [Doku] Das README nennt für die Konfiguration „alle 24 Stunden“; im Code steht eine Stunde (`__init__.py`/Bridge `read_all`: `+ 3600`). Die Dokumentation und der Code weichen also voneinander ab. + +--- + +## 6. Laufzeitsicht + +### 6.1 Start (Setup) + +```mermaid +sequenceDiagram + participant HA as HA Core + participant I as async_setup_entry + participant C as Coordinator + participant B as Bridge + participant W as Wallbox + HA->>I: Config-Entry laden + I->>C: Coordinator anlegen + alt Passwort im Entry + I->>C: start_websocket_and_wait_for_first_data() + C->>B: ws_connect() als Hintergrundtask + B->>W: Hello / Auth / Vollstatus + B-->>C: Pflicht-Tags vorhanden (max. 60 s) + C->>C: read_versions() + else Kein Passwort + I->>C: async_refresh() (HTTP) + I->>C: read_versions() + end + I->>HA: Plattformen laden + I->>HA: Services anmelden (nur beim ersten Charger) + opt Limit 16 A + I->>C: Hintergrundtask check_for_16a_limit() + end + opt WebSocket + I->>C: Watchdog starten + end +``` + +Fehler beim Start führen zu `ConfigEntryNotReady`; HA wiederholt das später selbst. + +### 6.2 WebSocket-Verbindung und Anmeldung [Code, `ws_connect`] + +1. Verbinden mit `ws_url` (und im Cloud-Fall mit dem Bearer-Token). +2. `hello` empfangen: Seriennummer, Firmware, ob gesichert. +3. `authRequired` empfangen: Hash-Typ (`pbkdf2` oder `bcrypt`; bei fehlender Angabe nach Gerätetyp gewählt), `token1`, `token2`. +4. Gehashtes Passwort berechnen, `token3` erzeugen, Antwort-Hash senden (SHA-256 über `token3 + token2 + SHA256(token1 + Passwort-Hash)`). +5. Ergebnis innerhalb von 10 Sekunden abwarten. Bei Erfolg `ws_connected = True`. +6. Nachrichtenschleife: `fullStatus` (voll oder teilweise), `deltaStatus` (ohne die Schlüssel in `API_KEYS_TO_IGNORE_FROM_WS`), `response` (Antwort auf Schreibbefehl). +7. Jede neue Nachricht stößt eine entprellte Aktualisierung des Koordinators an. + +Schreibbefehle gehen als `setValue` mit fortlaufender `requestId`. Im lokalen gesicherten Modus wird die Nachricht als `securedMsg` mit HMAC verpackt. + +### 6.3 Polling-Zyklus (ohne WebSocket) + +- Intervall: Scan-Intervall des Config-Entry (LAN Standard 30 s im Dialog, Minimum 5 s; Cloud mindestens 30 s, vorgeschlagen 120 s). +- Ist ein Fahrzeug nicht angeschlossen (`car == 1`), werden nur die Schlüssel `car, modelStatus, err, nrg, tma, trx` (plus Zeitwerte und, nach PV-Daten, `pakku, ppv, pgrid`) gelesen, ein Vollabzug alle 5 Minuten. +- Sobald `car` vom Leerlauf abweicht, folgt sofort ein Vollabzug. +- Konfiguration: höchstens stündlich. Misslingt das Lesen, wird nach 5 Minuten neu versucht. + +### 6.4 Schreiben einer Einstellung + +1. Entität ruft `coordinator.async_write_key(key, value, entity)`. +2. Der Koordinator prüft Sperre nach Fehlern (2 Minuten) und Neustartzustand und wirft sonst `ValueError`. +3. Bridge: WebSocket → senden und sofort `{key: value}` zurückgeben (Mehrfachschreiben: `{}`); die Bestätigung des Geräts kommt später als `response`. HTTP → `GET /api/set`, Ergebnis `{key: value}` oder `{"err": …}`. +4. `handle_write_result()` übernimmt den **gewünschten** Wert sofort in die Daten (auch im WebSocket-Modus) und stößt eine Entitätsaktualisierung an. Lehnt der Charger den Befehl ab, bleibt der Wert, bis das Gerät den Schlüssel erneut meldet (R26). + +### 6.5 PV-Überschuss (`set_pv_data`) + +1. Blueprint ruft alle 5 Sekunden den Dienst mit `pgrid`, `ppv`, `pakku` auf (und `configid`; bei mehreren Chargern ist sie seit `4702213` Pflicht, siehe E10). +2. Cloud-Betrieb: Ist kein Fahrzeug verbunden (`car < 2`), wird **nichts** gesendet (Last-Schutz). +3. Der Dienst schreibt den Schlüssel `ids` mit `{"pGrid", "pPv", "pAkku"}`. +4. Im HTTP-Betrieb setzt die Bridge danach ein Flag, damit im Leerlauf zusätzlich `pakku, ppv, pgrid` gelesen werden. +5. Sendet die Automation nicht mehr, hält die Wallbox 5 Sekunden lang alte Werte [Doku]. Dass danach die gleitenden Mittelwerte der Wallbox (`pvopt_average…`) stehen bleiben, war unsere Annahme bei der Blueprint-Entwicklung und ist am Gerät nicht geprüft. Das Risiko (ungewolltes Netzladen) deckt das Blueprint durch sein Verhalten bei ungültigen Messwerten ab (Standard: Nullwerte senden). + +### 6.6 Zwangs-Stopp (`stop_charging`) + +`frc=1` (Laden aus), 300 s warten, `frc=0`. Eine zweite Anfrage während der Wartezeit wird abgewiesen (`_stop_in_progress`). Seit Version 2026.10.8 läuft der Ablauf auch im WebSocket-Modus durch (der frühere `TypeError` ist behoben). Das Flag wird allerdings bei einem Schreibfehler nicht zurückgesetzt: Schlägt `frc=1` oder nach der Wartezeit `frc=0` fehl, sind weitere Stopp-Anfragen bis zum Neuanlegen des Dienstes gesperrt (R1, Rest). + +### 6.7 Fehlerbehandlung und Neustart + +- `ClientConnectionError`: Zeitstempel und Zähler setzen, 2 Minuten lang keine Anfragen, Daten bleiben stehen. +- Mehr als 5 Fehler in Folge: nach 1 bis 5 Minuten Zufallswartezeit wird der Config-Entry neu geladen. +- WebSocket-Watchdog (alle 5 min 1 s): verbindet neu, wenn nicht verbunden; bricht ab und baut neu auf, wenn seit mehr als 50 Sekunden keine Daten kamen. +- `force_async_update_now()`: erzwingt WebSocket-Neustart, aber nicht in den ersten 5 Minuten nach Start und nicht in den ersten 2 Minuten nach einer frischen Verbindung. + +--- + +## 7. Verteilungssicht + +```mermaid +flowchart LR + subgraph GH[GitHub] + Repo[Repo main] + CI["Workflows:
HACS Action, hassfest,
Tests, Zip Release"] + Rel[Release: goecharger_api2.zip] + end + HACS[HACS im HA] + subgraph Host["Home-Assistant-Host"] + CC["config/custom_components/goecharger_api2"] + Store[".storage/core.config_entries
(Zugangsdaten im Klartext)"] + end + Dev["go-e Wallbox im LAN"] + Repo --> CI --> Rel --> HACS --> CC + CC --- Store + CC --> Dev +``` + +- **Build:** Beim Veröffentlichen eines Releases zippt der Workflow den Ordner `custom_components/goecharger_api2` [Code, `release.yaml`]. +- **CI:** HACS Action und hassfest laufen bei Push, Pull Request und täglich. Seit PR #179 führt `tests.yaml` die Tests bei Push auf `main` und bei Pull Requests aus [Code]. +- **Betrieb:** Läuft im HA-Prozess, keine eigenen Dienste. Zugangsdaten (Passwort, Cloud-Token) liegen im Config-Entry von HA. + +--- + +## 8. Querschnittliche Konzepte + +### 8.1 Konfiguration und Migration +- Config-Flow-Version 2.1. Daten in `config_entry.data`, `options` bleiben leer. +- Migration von Version 1 nach 2 führt `options` in `data` zusammen. Migration von 2.0 löscht alle Geräte des Entries, damit sie mit neuen Identifiern neu entstehen [Code, `async_migrate_entry`]. +- Gerät-Identifier sind `(DOMAIN, Typ, Modus, Seriennummer)`. Seit `8cb62b2` enthält die Geräteinfo keinen Token mehr. + +### 8.2 Entitäten und Namen +- Übersetzungsschlüssel aus API-Schlüssel plus Index oder Zusatz, `unique_id` = `goecharger_api2.`. +- Übersetzungen in `translations/{de,en,fr}.json`; zusätzlich Wert-Texte (zum Beispiel `car`) in `pygoecharger_ha/const.py` für Deutsch und Englisch. + +### 8.3 Geräte- und Firmware-Varianten +- Core/Phoenix/Pro gegenüber klassisch: unterschiedliche Temperatursensoren (`tma` Index 2 bis 5 gegenüber 0 und 1), einzelne nicht unterstützte Entitäten, Core-exklusives `iso2ena`, anderer Hash-Typ (bcrypt). +- 16-A-Begrenzung: aus Kabelgrenze (`cll.cableCurrentLimit`), Adapter (`adi`), Variante (`var == 11`) oder Option; setzt `native_max_value` auf 16 und schreibt beim Start zu hohe Werte zurück. Seit `98af3e0` gilt: Hat `cll` Inhalt, aber keinen `cableCurrentLimit`, wird **keine** Grenze gesetzt und `adi` nicht herangezogen (Annahme des Autors: nur Flex-Geräte liefern diesen Schlüssel; Anlass war Issue #176). Davor galt bei Core-Geräten ohne diesen Schlüssel vorsichtshalber 16 A (R29). +- `cards` als Objekt oder als 30 Einzelschlüssel (Firmware 60.0). + +### 8.4 Fehler- und Logstrategie +- Fehler in Bridge-Methoden werden meist als Warnung geloggt und als leeres Ergebnis zurückgegeben; Schreibfehler als `ValueError` an die aufrufende Entität. +- Viele breite `except BaseException`: 13 Stellen in `custom_components/` [Code, gezählt]. Sie fangen auch `CancelledError` und `KeyboardInterrupt`. + +### 8.5 Sicherheit +- **Zugangsdaten:** Passwort und Cloud-Token liegen im HA-Config-Entry im Klartext (HA stellt keinen Tresor bereit). Weitere Beobachtungen zu Zugangsdaten wurden dem Maintainer direkt mitgeteilt und sind hier bewusst nicht aufgeführt. +- **Vertrauensgrenze:** Das LAN-HTTP-API hat keine Authentifizierung; Schutz liegt im Netz. + +### 8.6 Nebenläufigkeit +- Ein Event-Loop; Schreibbefehle über WebSocket warten je 0,75 s nacheinander bei Mehrfachschreiben. +- Globale Variable `SCAN_INTERVAL` wird im Koordinator-Konstruktor überschrieben (gilt prozessweit, also für alle Config-Entries) [Code, `__init__.py:331`]. + +### 8.7 Test und Qualitätssicherung +- **Im Repo (Upstream):** HACS-Validierung und hassfest. Die früheren Unit-, Integrations- und Blueprint-Tests wurden in `60d9a09` gelöscht; seit PR #178 (`738c3c6`) liegen die Regressionstests in `tests/integration`. +- **Regressionstests (`tests/integration`, entstanden im Fork, Branch `tests/regression-suite`):** 989 Tests (420 Vertragsprüfungen je Schlüssel, 569 Verhaltens- und Fehlertests; 972 blockierend, 17 beabsichtigt rot, mit PR #180), Zeilenabdeckung 93 %, Zweigabdeckung 85 %, Pfadabdeckung (obere Schranke) 61 %. Ein Messbericht entsteht von Hand über `test-report.yaml` (siehe Testkonzept). +- **Testkonzept:** `docs/quality/testkonzept-ha-goecharger-api2.md` beschreibt Umfang, Stufen, Marker, Stand und Lücken. +- **Vertrag zur offiziellen API:** Im Repository prüft `test_api_contract.py`, ob jeder dokumentierte Schlüssel unterstützt oder erklärt ist (92 dokumentierte Schlüssel ohne Tag, 38 genutzte Schlüssel ohne Doku, 14 Abweichungen bei Schreibrecht und Kategorie). Die Listen stehen in `tests/integration/contract/` (`classification.json`, `deviations.json`). +- **Lokal (nicht im Repo):** Unser Fork enthält Blueprint-Tests (240 Fälle) und Integrationstests (12 Fälle) als Arbeitsmittel (`OliEder/ha-goecharger-api2`, Branches `proposal/invalid-data-policy` und `fix/small-bugs`). + +--- + +## 9. Architekturentscheidungen + +Der Autor führt keine Entscheidungsprotokolle. Die folgenden Einträge sind aus Code und Kommentaren rekonstruiert [Schluss]. + +| # | Entscheidung | Begründung (abgeleitet) | Konsequenz | +|---|---|---|---| +| E1 | WebSocket bevorzugen, Polling als Rückfall | Effizienz; einige Geräte (Wattpilot) bieten kein HTTP-API [Doku] | Zwei Datenpfade mit unterschiedlichem Schreibergebnis (`None` gegenüber Dictionary) | +| E2 | Betriebsart = Passwort gesetzt | Einfache Einrichtung | Kein expliziter Schalter; Umschalten nur über Rekonfiguration | +| E3 | Eine Bridge-Teilbibliothek im selben Ordner | Trennung von Protokoll und HA | Nicht separat veröffentlicht; Abhängigkeiten nicht im Manifest vollständig | +| E4 | Deklarative Entity-Beschreibungen | Hunderte Entitäten mit wenig Code | Sehr große `const.py` (3300 Zeilen); Logik versteckt in Optionen (`idx`, `tuple_idx`) | +| E5 | Eigene `ChainMap`-Datenhaltung statt Typmodell | Schnelle Anpassung an neue Schlüssel | Kein Schema; Fehler zeigen sich erst zur Laufzeit (`KeyError` werden abgefangen) | +| E6 | Hibernation im Polling | Geräte- und Cloud-Last senken | Im Leerlauf nur wenige Werte aktuell | +| E7 | PV-Daten über HA-Automation statt Zusatzhardware | Kein Zusatzgerät nötig | Qualität liegt im Blueprint und in den Messwerten des Nutzers | +| E8 | Neustart per Konfigurations-Reload bei dauerhaften Fehlern | Hängende Verbindung selbst heilen | Reload aller Entitäten für ein Verbindungsproblem | +| E9 | Keine automatisierten Tests im Repo (überholt: seit PR #178, `738c3c6`, liegen die Tests in `tests/integration`) | Aufwand/Haltung des Autors | Vorher fielen Regressionen erst bei Nutzern auf; jetzt prüft die Suite jede Änderung gegen den Vertrag zur offiziellen API | +| E10 | Dienste (`set_pv_data`, `stop_charging`) ohne `configid`: nur bei genau einem registrierten Charger wird dieser verwendet, sonst `ServiceValidationError` (Commit `4702213`, 2026-10-29; Entscheidung des Maintainers im Code, im Issue #172 nicht begründet) | Vorher wurde der zuerst registrierte Charger genommen. Nach dem Neuladen eines Entries rückte dieser ans Ende der Liste, der Standard wechselte dann stillschweigend auf einen anderen Charger. Ein falscher Charger bei Schreibzugriffen (Strom, Stopp) wird vermieden [Schluss, aus Diff und Testfund]. | **Verhaltensänderung:** Wer mehrere Charger hat und `configid` bisher wegließ (zum Beispiel ein Blueprint oder eine Automation), bekommt jetzt einen Fehler statt eines Zugriffs auf den ersten Charger. Mit einem Charger oder mit `configid` ändert sich nichts. Alternativen, die nicht gewählt wurden: festen Standard-Charger bestimmen oder die Reihenfolge stabil halten. Tests: `test_without_configid_and_with_two_chargers_no_charger_is_guessed`, `test_with_an_empty_registry_no_charger_can_be_chosen`. `docs/PVSURPLUS.md` verlangt `configid` bei mehreren Chargern bereits; das Blueprint selbst enthält keine `configid` (per `grep` geprüft), ein Aufruf aus dem Blueprint mit mehreren Chargern scheitert deshalb jetzt, sofern der Nutzer sie nicht ergänzt. Am Gerät nicht nachgespielt. | + +--- + +## 10. Qualitätsanforderungen + +### 10.1 Qualitätsbaum + +``` +Qualität +├── Zuverlässigkeit +│ ├── Wiederverbinden (Watchdog, Reload) +│ ├── Firmware-Toleranz (Weichen nach Gerätetyp/Version) +│ └── Verhalten bei Teilausfall (Daten stehen lassen) +├── Effizienz +│ ├── Push statt Poll +│ └── Hibernation, gestaffelte Lesezyklen +├── Wartbarkeit +│ ├── Deklarative Entitäten +│ └── (offen) Tests, Typisierung +├── Sicherheit +│ ├── Zugangsdaten +│ └── Sichere Schreibpfade (Stop-Funktion) +└── Bedienbarkeit + └── Einrichtung über Dialog, Übersetzungen +``` + +### 10.2 Qualitätsszenarien + +| ID | Szenario | Erwartung | Heutiger Stand | +|---|---|---|---| +| Q1 | Wallbox ist 10 Minuten im WLAN weg | Keine Dauerlast; automatische Erholung | Fehlerzähler, 2 min Sperre, nach über 5 Fehlern Reload [Code] | +| Q2 | Firmware-Update ändert Schlüssel (60.0) | Integration läuft weiter | Weichen für `cards` und `cll` vorhanden [Code] | +| Q3 | PV-Automation fällt aus | Kein ungewolltes Netzladen | Liegt im Blueprint; die Integration prüft Eingaben nur auf Zahl und Endlichkeit (`ppv`, `pakku`) | +| Q4 | `stop_charging` im WebSocket-Modus | Laden aus für 5 min, dann wieder frei | **Nicht erfüllt:** `TypeError`, `frc=0` wird nie gesendet (siehe 11) | +| Q5 | Zwei Charger in einer HA-Instanz | Dienst wirkt auf den gewählten Charger | **Fraglich** (siehe 11, R2) | +| Q6 | Nutzer ohne Admin-Rechte | Keine Zugangsdaten lesbar | Der Token steht seit `8cb62b2` nicht mehr in der Geräteinfo. Passwort und Token liegen weiter im Config-Entry (HA-Eigenschaft). | + +--- + +## 11. Risiken und technische Schulden + +Nummerierung nur für diese Liste. Status in der Spalte „Beleg“. + +| # | Befund | Wirkung | Beleg | +|---|---|---|---| +| R1 | `stop_charging` im WebSocket-Modus: `write_value_to_key` liefert `None`, danach `Tag.FRC.key in resp` wirft `TypeError`; abgefangen wird nur `ValueError`. `frc=1` ist gesetzt, `frc=0` wird nie gesendet, `_stop_in_progress` bleibt `True`. | Charger bleibt gesperrt, bis jemand `frc` von Hand zurücksetzt. | Im Test nachgespielt. **Nicht** an echtem Gerät geprüft. Noch in `main`. | +| R2 | Multi-Charger: Das Register `_COORDINATOR_PER_CONFIGENTRYID_MAP` wird nur im Konstruktor von `GoeChargerApiV2Service` befüllt. Der Konstruktor läuft nur, wenn der Dienst noch nicht angemeldet ist, also nur für den ersten Charger. Ein zweiter Charger fehlt im Register, `configid=` führt zu `KeyError`. Wird der erste Entry entladen, bleibt dessen Koordinator im Register. | Der dokumentierte Multi-Charger-Betrieb über `configid` funktioniert für den zweiten Charger nicht. | Aus Code gelesen (`service.py:17,33,41`, `__init__.py:153`ff). **Nicht nachgespielt.** | +| R3 | `async_unload_entry`: `ACTIVE_CHARGER_COUNTER` wird nur für Charger erhöht, aber für jeden Entry verringert und danach ohne Absicherung gelesen. Bei einem reinen Controller-Entry fehlt der Schlüssel. | Mögliche `KeyError` beim Entladen oder falscher Zählerstand bei gemischten Entries. | Aus Code gelesen (`__init__.py:148`ff, `194`ff). **Nicht nachgespielt.** | +| R4 | `async_write_multiple_keys`: `_handle_client_connection_error` wird mit einem Argument statt zwei aufgerufen (`__init__.py:674`). | `TypeError` statt sauberer Fehlerbehandlung bei Verbindungsabbruch während des Schreibens (betrifft die Fahrzeugauswahl `ct`). | Im Test nachgespielt. Noch in `main`. | +| R5 | `set_pv_data` prüft `pgrid` nur mit `isinstance(Number)` (`service.py:59`), `ppv` und `pakku` jedoch auf Endlichkeit. | `NaN` oder `inf` als `pgrid` kann als ungültiges JSON zum Charger gehen. | Aus Code gelesen. Geringe Praxisrelevanz, da das Blueprint prüft. | +| R6 | `_abort_if_unique_id_configured()` wird aufgerufen, aber im Config-Flow wird nirgends eine `unique_id` gesetzt (`async_set_unique_id` kommt nicht vor). | Kein Schutz gegen doppelt angelegte Geräte. | Aus Code gelesen (grep). Wirkung nicht vorgeführt. | +| R7 | Dokumentation und Code weichen ab: README „Konfiguration alle 24 Stunden“, Code stündlich (`+ 3600`). | Falsche Erwartung bei Nutzern. | Aus Code und README gelesen. | +| R8 | Zugangsdaten liegen im Klartext im Config-Entry (Eigenschaft von Home Assistant). Weitere Einzelheiten sind bewusst nicht aufgeführt. | – | – | +| R9 | `SCAN_INTERVAL` ist eine globale Variable, die im Konstruktor überschrieben wird. | Bei mehreren Entries gilt der zuletzt gesetzte Wert für den Standard. Der Koordinator nutzt das Intervall allerdings über `update_interval=SCAN_INTERVAL` im Aufruf direkt danach, daher meist harmlos. | Aus Code gelesen. | +| R10 | `check_and_write_to_16a`: Funktion mit `@staticmethod` auf Modulebene, außerdem privater Zugriff auf `bridge._read_filtered_data`. | Wartbarkeit. | Aus Code gelesen. | +| R11 | Fehlende Abhängigkeit im Manifest (`bcrypt`), Mindestversion von HA unklar (`hacs.json` 2023.8.0 gegenüber genutzten neueren APIs). | Installationsfehler auf älteren Systemen möglich. | Aus Code und Manifest gelesen. Ob `bcrypt` über HA mitgeliefert wird, habe ich nicht geprüft. | +| R12 | `CustomFriendlyNameEntity` überschreibt eine private HA-Methode. | Bricht bei HA-Änderungen. | Aus Code gelesen. | +| R13 | Keine Tests im Repo (überholt: seit PR #178 liegen sie in `tests/integration`). | Regressionen werden erst von Nutzern gefunden. | Repo-Inhalt. | +| R14 | Bei Teilausfall des Geräts: Daten bleiben stehen, Entitäten bleiben `available`, solange der letzte Update-Erfolg anhält. | Nutzer sieht alte Werte als aktuell. | Aus Code gelesen (`available` = `last_update_success`). Wie sich ein WebSocket-Verlust auf die Verfügbarkeit auswirkt, habe ich nicht geprüft. | + + +**Stand der Risiken nach Version 2026.10.8 und `98af3e0` (2026-10-29, durch Tests geprüft, `main` bei `9e7d62a`)** + +| # | Stand | Anmerkung | +|---|---|---| +| R1 | **behoben** | Der `TypeError` im WebSocket-Modus ist weg (`write_value_to_key` liefert `{key: value}`), `_stop_in_progress` wird seit `9e7d62a` in einem `finally` zurückgesetzt. Beide Tests sind grün. | +| R2 | **behoben** | Register wird für jeden Charger gepflegt, Neuladen ersetzt den alten Koordinator. Der wechselnde Standard-Charger ist seit `4702213` über E10 entschieden (kein Standard bei mehreren Chargern). | +| R3 | **behoben** | Seit `eb5da46` werden Zähler und Dienste nur für Charger angepasst. | +| R4 | **behoben** | Aufruf hat jetzt zwei Argumente. | +| R5 | **behoben** | Seit `eb5da46` prüft `set_pv_data` auch `pgrid` auf Endlichkeit. | +| R25 | **behoben** | Seit `eb5da46` bleibt die konkrete `ConfigEntryNotReady`-Meldung erhalten. | +| R24 | **behoben** | Der Test `test_write_rejected_by_charger_returns_err` ist auf `9e7d62a` grün; wann genau die Antwort mit Fehlertext ausgewertet wird, habe ich nicht nachvollzogen. | +| R27 | **behoben** | Seit `9e7d62a` überspringt `handle_write_result` im WebSocket-Modus auch ein leeres Ergebnis. | + +Neue Risiken aus dem Abgleich mit `main` und den Tests (2026-10-29): + +| # | Befund | Wirkung | Beleg | +|---|---|---|---| +| R24 | `_write_values_int` prüft die Antwort des Chargers auf den Schlüssel nur auf „wahr“. Laut `http-en.md` kann der Wert `true` oder ein **Fehlertext** sein. Ein Text gilt dem Code als Erfolg. | Ablehnungen des Chargers (auch bei `ids`) würden als Erfolg gemeldet. | Aus Code und Doku abgeleitet, im Test mit Attrappe gezeigt (`known_bug`). Am Gerät **nicht** geprüft. | +| R25 | `async_setup_entry`: Die Meldung „could not fetch essential information“ wird innerhalb eines `try` erzeugt und von `except BaseException` sofort durch die allgemeine WebSocket-Meldung ersetzt. | Nutzer sehen eine unpassende Fehlermeldung. | Im Test gezeigt (`known_bug`). | +| R26 | Nach `03b0fc3` speichert das Schreiben im WebSocket-Modus den gewünschten Wert sofort in `coordinator.data`. | Lehnt der Charger ab (nur eine Warnung im Log), zeigt HA den abgelehnten Wert, bis das Gerät den Schlüssel erneut meldet. | Aus Code abgeleitet, Verhalten im Test festgehalten. | +| R27 | `write_multiple_values_to_keys` liefert im WebSocket-Modus `{}`; `handle_write_result` überspringt nur `None`. | Nach jeder erfolgreichen Fahrzeugauswahl steht ein falscher `ERROR` im Log („could not write multiple value“). | Im Test gezeigt (`known_bug`). | +| R29 | `98af3e0`: Fehlt `cableCurrentLimit` in `cll`, wird nicht mehr auf 16 A begrenzt und `adi` nicht mehr geprüft; Core-Geräte ohne den Schlüssel waren vorher vorsichtshalber begrenzt. | Sliders erlauben höhere Werte; der Charger begrenzt einen Adapter selbst. Die Annahme „nur Flex hat den Schlüssel“ ist nicht belegt. | Aus Diff gelesen, Verhalten im Test festgehalten. | +| R30 | Laut Maintainer (Issue #176) setzt die Integration das Maximum der Stromstärke (Max Ampere) bei jedem Neustart zurück. | Ein zuvor eingestellter Wert geht verloren; die Grenze gilt dann nicht mehr, bis sie neu gesetzt wird. | Aussage des Maintainers, **nicht** von uns nachgespielt und kein Test dazu. Ob das vom 16-A-Begrenzer (R29) oder von der Wertübernahme beim Start kommt, ist offen. | + +Die Zahl R28 wurde bewusst ausgelassen; die Nummerierung folgt der Reihenfolge der Funde. + +**Nachtrag zu R2:** Das ist eine **Regression** aus Commit `5328e74` („housekeeping of tasks“). Vorher wurde pro Charger-Entry immer ein `GoeChargerApiV2Service` angelegt und trug seinen Koordinator ins Register ein, `configid` funktionierte also für jeden Charger. Seit `5328e74` entsteht der Dienst nur noch beim ersten Charger. Zusätzlich bleibt nach dem Neuladen eines Entries (zum Beispiel durch die automatische Neustart-Logik nach mehr als 5 Verbindungsfehlern) bei mehreren Chargern der **alte** Koordinator im Register; der Dienst schreibt dann über eine getrennte Bridge. Aus dem Diff und dem Code gelesen, nicht nachgespielt. + +Ergänzungen aus dem Abgleich von `main` bei `a9f9350` mit dem Stand, auf dem unser PR #173 aufsetzte (`76b8dd8`): + +| # | Befund | Wirkung | Beleg | +|---|---|---|---| +| R20 | Seit `5328e74` überschreibt `read_all_states`, `read_all_config` und `read_versions` den gespeicherten Zustand nur noch, wenn die Antwort nicht leer ist (`if ret is not None and len(ret) > 0 …`). Davor wurde bei einem Fehler der Zustand geleert. `_read_filtered_data` gibt bei jedem Status außer 200/400 (zum Beispiel Cloud 403 oder 404 bei offline) `{}` zurück. | Der Koordinator behält die letzten Werte und meldet Erfolg. Ein offline gegangener Charger zeigt im Cloud-Betrieb die letzten Werte weiter an. Zusätzlich setzt `_async_update_data` den Fehlerzähler zurück, weil die `ChainMap` der alten Werte nicht leer ist. | Aus Diff und Code abgeleitet, **nicht getestet**. Ob die Anzeige wirklich „eingefroren“ bleibt, hängt davon ab, was HA mit unverändertem `last_update_success` macht. | +| R21 | Die Migration von 2.0 auf 2.1 löscht **alle Geräte** des Entries (`async_remove_device`). HA entfernt damit nach meinem Wissen auch die zugehörigen Einträge der Entity-Registry. | Von Nutzern angepasste Namen, Bereiche, Beschriftungen und manuell aktivierte Entitäten (viele Sensoren sind standardmäßig aus) gehen vermutlich verloren und müssen neu gesetzt werden. Die Entity-IDs bleiben gleich, da sie im Code erzeugt werden. | HA-Verhalten aus dem Gedächtnis, im Code nur der Aufruf belegt. **Nicht nachgespielt.** Ob deine Instanz betroffen war, ist nicht geprüft. | +| R22 | Entries mit Version 1 werden direkt auf 2.1 migriert und überspringen das Löschen der Geräte. Die alte Aufräumfunktion `check_device_registry` wurde entfernt. | Alte Geräte mit dem früheren Identifier (der bei Cloud-Entries den Token enthielt) bleiben dort in der Registry stehen. Sehr seltener Randfall. | Aus Diff gelesen. | +| R23 | Die in `main` gemergte Blueprint-Version (PR #171 plus #174, von marq24 manuell eingebaut) enthält **nicht** unsere Erweiterungen: Bei ungültigen Messwerten wird die Tageslieferung **übersprungen** (kein Nullwert), bei falscher Zielauswahl `stop` mit `error: true` im 5-Sekunden-Takt, `mode: single` ohne `max_exceeded: silent`, kein Akku-Schalter, keine Karenzzeit. Das Verhalten steht im Blueprint-Text als bewusste Entscheidung („does not clear its previous averages“). | Fällt ein Sensor am Tag aus, bleiben die gleitenden Mittelwerte der Wallbox stehen. Das ist das Risiko, das wir mit „Nullwerte senden“ vermeiden wollten. | Quelltext gelesen. Deine Instanz nutzt weiter unsere eigene Version (`OliEder/go-echarger-pv-surplus-data.yaml`). | + +Ergänzungen aus dem Abgleich mit der offiziellen Dokumentation (Abschnitt 3.3): + +| # | Befund | Wirkung | Beleg | +|---|---|---|---| +| R15 | Laut Cloud-Doku antwortet `/api/set` bei Erfolg mit **HTTP 202**. `_write_values_int` wertet nur `200` (und `500` mit Inhalt) aus, alles andere gilt als Fehler (`failed with http-status …`, Rückgabe `{}`). | Wenn die Cloud tatsächlich 202 sendet, würden Schreibbefehle im Cloud-Polling trotz Erfolg als Fehler geloggt, und `handle_write_result` meldet „could not write“. Im WebSocket-Betrieb ist der Pfad nicht betroffen. | Doku und Code gelesen. **Geklärt am 2026-10-29 durch den Maintainer (Issue #172): Die Cloud antwortet mit HTTP 200, die Doku ist falsch. Kein Fehler im Code.** Von uns nicht gegen die echte Cloud geprüft. | +| R16 | Der bevorzugte Datenpfad (WebSocket) ist nicht offiziell dokumentiert; Anmeldeverfahren und Nachrichten sind nachgebaut. | Eine Firmware- oder Cloud-Änderung von go-e kann ihn ohne Vorwarnung brechen. Das README erwähnt das schon für Firmware 60.2 und 60.4. | Doku und README gelesen. | +| R17 | Cloud-`/api/status` liefert standardmäßig Daten bis 30 Sekunden Alter (`age=30`); die Integration setzt `age` nicht. | Im Cloud-Polling können Werte bis zu 30 Sekunden alt sein, ohne dass die Integration das erkennt. | Doku und Code gelesen (kein `age` in den Aufrufen). | +| R18 | Offiziell bedeutet `pPv < 0` „Standby“, der Dienst `set_pv_data` begrenzt `ppv` in der Oberfläche auf Werte ab 0. | Standby kann über die Oberfläche nicht gesetzt werden. Ob das gewollt ist, ist unklar. | Doku und `services.yaml` gelesen. | +| R19 | Offiziell ist der Cloud-Token über den Schlüssel `cak` lesbar (also auch über die lokale API, soweit der Schlüssel dort verfügbar ist). | Eine Maßnahme der Integration, den Token nur im Config-Entry zu schützen, wäre dann nur begrenzt wirksam. Ob `cak` tatsächlich lokal ohne Anmeldung lesbar ist, habe ich nicht geprüft. | Nur Doku gelesen. | + +**Offene Annahmen (nicht geprüft):** +- Verhalten des Chargers nach einem Neustart mit gesetztem `frc=1`. +- Ob der Charger den PV-Datenschlüssel `ids` per WebSocket genauso annimmt wie per HTTP. +- Tatsächliche Mindestversion von Home Assistant. + +--- + +## 12. Glossar + +| Begriff | Bedeutung | +|---|---| +| APIv2 | Zweite Generation der go-e-Geräteschnittstelle (HTTP und WebSocket) | +| `frc` | `forceState`: 0 neutral, 1 aus, 2 an („Manueller Lademodus“) | +| `ids` | Schlüssel für PV-Daten (`pGrid`, `pPv`, `pAkku`) | +| `pgrid` | Leistung aus dem Netz (positiv) oder ins Netz (negativ) in Watt | +| `ppv` | PV-Erzeugung in Watt | +| `pakku` | Batterie: negativ lädt, positiv entlädt | +| `car` | Fahrzeugstatus: 1 frei, 2 lädt, 3 wartet, 4 fertig, 5 Fehler | +| `fup` / `lmo` | PV-Überschuss nutzen / Logikmodus (ECO) | +| `hai` / `cae` | HTTP-API lokal erlaubt / Cloud-API erlaubt | +| Core/Phoenix | Neuere Wallbox-Hardwaregeneration mit teils anderen Schlüsseln und bcrypt-Anmeldung | +| LAN / WAN | Lokaler Zugriff / Zugriff über die go-e-Cloud (Seriennummer plus Token) | +| Hibernation | Polling-Sparmodus ohne angeschlossenes Fahrzeug | +| Coordinator | HA-Hilfsklasse, die Daten holt und an Entitäten verteilt | +| Blueprint | Vorlage für HA-Automationen; hier für PV-Überschuss | + +--- + +## Anhang: Quellen + +**Offizielle go-e-Dokumentation** (Stand Commit `b4d7f85`, 2026-08-12), Repository `goecharger/go-eCharger-API-v2`: +- `README.md`, `introduction-en.md` (Einstieg und Verweise) +- `API_KEYS_FIRMWARE/apikeys-en.md` (alle Schlüssel) und die Listen je Firmware 59.4 bis 60.4 +- `http-en.md` (lokale HTTP-API), `cloudapi-en.md` (Cloud-HTTP-API) +- `mqtt-en.md`, `modbus-en.md`, `ocpp-en.md`, `gridapi-en.md` (nicht genutzt) +- `Changelogs_Gemini/`, `Changelogs_PRO/` (Firmware 60.3 bis 60.5) +- Statusseite: https://status.go-e.com/status/go-e + +**Im Repository der Integration:** + +- `custom_components/goecharger_api2/__init__.py`: Setup, Koordinator, Basisentität +- `custom_components/goecharger_api2/pygoecharger_ha/__init__.py`: Bridge, WebSocket-Anmeldung +- `custom_components/goecharger_api2/service.py`, `services.yaml`: Dienste +- `custom_components/goecharger_api2/config_flow.py`: Einrichtung +- `custom_components/goecharger_api2/const.py`, `pygoecharger_ha/const.py`, `keys.py`: Beschreibungen, Filter, Schlüssel +- `README.md`, `docs/PVSURPLUS.md`, `example/blueprint/automation/readme.md` +- `.github/workflows/*.yaml`, `hacs.json`, `manifest.json` diff --git a/docs/quality/testkonzept-ha-goecharger-api2.md b/docs/quality/testkonzept-ha-goecharger-api2.md new file mode 100644 index 0000000..e087b55 --- /dev/null +++ b/docs/quality/testkonzept-ha-goecharger-api2.md @@ -0,0 +1,250 @@ +# Testkonzept: `ha-goecharger-api2` + +| | | +|---|---| +| **Gegenstand** | Automatisierte Tests der Home-Assistant-Integration `goecharger_api2` (go-eCharger, go-eController) | +| **Stand** | 2026-10-29, `main` bei `9e7d62a` (die Tests entstanden im Fork `OliEder/ha-goecharger-api2`, Branch `tests/regression-suite`) | +| **Bezug** | Architektur und Risiken: `arc42-ha-goecharger-api2.md` (Risiken R1 bis R30). Regeln: `CLAUDE.md` / `AGENTS.md` der Arbeitsumgebung. | +| **Status der Tests** | Die Tests liegen seit PR #178 (`738c3c6`) in diesem Repository, `.github/workflows/tests.yaml` führt sie seit PR #179 bei Push auf `main` und bei Pull Requests aus. Das Dokument wurde mit KI-Unterstützung (Claude Code) erstellt und von Oliver geprüft. | + +**Lesehinweis.** Alle Tests laufen mit Attrappen. Sie belegen, was der Code tut und was die Doku sagt, **nicht**, was ein echter Charger tut. Wo das für eine Aussage wichtig ist, steht es dabei. + +--- + +## 1. Zweck und Geltungsbereich + +**Zweck.** Die Tests sollen Regressionen in der Integration früh melden, bekannte Fehler sichtbar halten und die Übereinstimmung mit der offiziellen go-e-API-Dokumentation prüfen. Sie sind auch die Grundlage für spätere Änderungen und für Gespräche mit dem Maintainer. + +**Im Geltungsbereich** +- Bridge (`pygoecharger_ha`): HTTP lokal und Cloud, WebSocket (Anmeldung, Nachrichten, Befehle). +- Koordinator: Lesen, Schreiben, Fehlerzähler, Pause, Neustart, Start (`ConfigEntryNotReady`). +- Dienste (`set_pv_data`, `stop_charging`), Multi-Charger-Register, Entladen von Entries. +- Config-Flow (Anlegen, Fehlerfälle), Migration der Config-Entries. +- Stabilität der Entity-IDs. +- Vertrag zwischen offizieller Schlüsseltabelle und `keys.py`. +- Das PV-Blueprint hat eigene Tests (240 Fälle, Branch `proposal/invalid-data-policy`). Sie sind **nicht** Teil dieses Konzepts. + +**Nicht im Geltungsbereich (bewusst)** +- Prüfung gegen einen echten Charger im Dauerbetrieb, Last- und Leistungstests. +- Oberfläche von Home Assistant (Dashboards, Übersetzungen). +- MQTT, Modbus, OCPP-Protokolle und Grid-API (nutzt die Integration nicht). +- go-eController: Seine Schlüssel stehen nicht in der Charger-Doku, es fehlt eine Quelle für einen Vertrag. + +--- + +## 2. Qualitätsziele, die geprüft werden + +Aus den Qualitätsszenarien des arc42-Dokuments (Abschnitt 10.2): + +| arc42 | Szenario | Wie geprüft | +|---|---|---| +| Q1 | Charger ist nicht erreichbar: keine Dauerlast, Erholung | `test_coordinator_failures.py` (Fehlerzähler, Pause, einmaliger Neustart) | +| Q2 | Firmware ändert Schlüssel | `test_versions_16a.py`, `test_api_contract.py` (nur Namen und Flags, nicht Inhalte) | +| Q3 | PV-Automation fällt aus: kein ungewolltes Netzladen | **nicht Teil dieser Suite** (liegt im Blueprint), nur `set_pv_data`-Eingaben in `test_service.py` | +| Q4 | `stop_charging` im WebSocket-Modus | `test_services_and_unload.py` | +| Q5 | Zwei Charger in einer Instanz | `test_services_and_unload.py` | +| Q6 | Zugangsdaten nicht lesbar | **nicht geprüft** | + +--- + +## 3. Risikobasierte Priorisierung + +Reihenfolge der Testtiefe nach Auswirkung auf den Betrieb (steuert ein physisches Gerät) und nach Eintrittswahrscheinlichkeit. + +| Priorität | Bereich | Begründung | +|---|---|---| +| 1 | Steuerpfade: `stop_charging`, `set_pv_data`, Schreiben von Einstellungen | Wirken auf Stromfluss und Fahrzeug | +| 2 | Verbindungsverlust und Erholung (HTTP und WebSocket) | Häufigster Fehler im Alltag (WLAN, Router, Firmware-Update) | +| 3 | Anmeldung per WebSocket | Nachgebaut, nicht offiziell dokumentiert, bricht bei Firmware-Änderungen | +| 4 | Stabilität der Entity-IDs und Migration | Ein Fehler trifft alle Bestandsnutzer auf einmal | +| 5 | Vertrag zur offiziellen API | Früherkennung von Doku- und Code-Drift | +| 6 | Eingabevalidierung der Entitäten | Geringe Wirkung, Gerät begrenzt selbst | + +**Abdeckung der Risiken aus arc42 (Abschnitt 11)** + +| Risiko | Test | Stand | +|---|---|---| +| R1 `stop_charging` WebSocket (`TypeError`) | `test_stop_charging_in_websocket_mode_sends_lock_and_unlock` | behoben, Test grün | +| R1 Flag `_stop_in_progress` bleibt hängen | `test_failed_lock_write_...`, `test_failed_unlock_write_...` | `known_bug`, rot | +| R2 Multi-Charger-Register | `test_second_charger_is_reachable_...`, `test_reloaded_charger_...` | behoben, grün | +| R2 Standard-Charger wechselt nach Reload | `test_default_charger_does_not_change_after_reload` | `known_bug`, rot | +| R3 Zähler `ACTIVE_CHARGER_COUNTER` | zwei Tests in `test_services_and_unload.py` | `known_bug`, rot | +| R4 `__init__.py:674` | `test_write_multiple_keys_connection_error_is_counted` | behoben, grün | +| R5 `pgrid` ohne Endlichkeitsprüfung | `test_non_finite_grid_value_is_rejected` | `known_bug`, rot | +| R6 keine `unique_id` im Config-Flow (kein Schutz vor Doppelanlage) | – | **nicht abgedeckt** | +| R7 README „24 h“, Code „1 h“ | `test_read_all_reads_the_configuration_only_every_hour` | Code-Verhalten festgehalten (Abweichung zum README bleibt) | +| R8 Zugangsdaten | – | bewusst nicht Teil der Tests | +| R15 Cloud-Antwort | `test_cloud_write_succeeds_with_http_200` | grün; Doku nennt 202, Maintainer bestätigt 200 | +| R16 WebSocket nicht dokumentiert | `test_ws_protocol.py`, `test_ws_login_failures.py` | Verhalten festgehalten | +| R17 Cloud `age` nicht gesetzt | – | **nicht abgedeckt** | +| R18 `ppv` negativ (Standby) | – | **nicht abgedeckt** | +| R20 alte Werte nach fehlgeschlagenem Lesen | `test_failed_full_read_keeps_the_previous_states` | Verhalten festgehalten, Absicht ungeklärt | +| R21 Migration löscht Geräte | `test_version_2_0_deletes_the_devices_and_moves_to_2_1` | Verhalten festgehalten | +| Fehlermeldung bei `ConfigEntryNotReady` wird überdeckt (neu) | `test_missing_device_data_is_reported_with_its_own_message` | `known_bug`, rot | +| Fehlertext des Chargers gilt als Erfolg (neu) | `test_write_rejected_by_charger_returns_err` | `known_bug`, rot | + +--- + +## 4. Teststufen und Testarten + +| Stufe / Art | Zweck | Dateien | +|---|---|---| +| **Vertrag (Schema)** | Offizielle Schlüsseltabelle gegen `keys.py`, Filter und Entitäten | `test_api_contract.py`, `contract/` | +| **Komponente** | Bridge gegen gemockte HTTP-Schicht oder Attrappe der WebSocket-Verbindung | `test_bridge_http.py`, `test_ws_protocol.py`, `test_bridge.py` | +| **Nahtstelle (Seam)** | Koordinator, Bridge und Dienste im Zusammenspiel; Dienste gegen Register | `test_coordinator_failures.py`, `test_services_and_unload.py`, `test_coordinator_errors.py` | +| **Ablauf** | Config-Flow über die Home-Assistant-Flow-Verwaltung; Migration | `test_config_flow_failures.py`, `test_migration.py` | +| **Regression (Snapshot)** | Entity-IDs bleiben stabil | `test_entity_ids.py`, `snapshots/entity_ids.json` | +| **Entscheidungstabelle** | 16-A-Limit | `test_versions_16a.py` | +| **Negativ / Fehlerpfade** | Fehlschläge enden sauber | in allen Dateien, Schwerpunkt `test_ws_login_failures.py`, `test_coordinator_failures.py`, `test_config_flow_failures.py` | + +Nicht vorhanden: Ende-zu-Ende gegen ein echtes Gerät, Komponententests der Entitäten (Wertdarstellung), Last- und Dauertests. + +--- + +## 5. Teststrategie und Regeln + +Verbindlich nach `CLAUDE.md` / `AGENTS.md`. Der Stand wird hier ehrlich festgehalten: + +| Regel | Stand | Maßnahme | +|---|---|---| +| **Arrange-Act-Assert** für jeden Funktionstest | **Teilweise.** Der Aufbau ist meist erkennbar, aber nicht überall mit Leerzeilen oder Kommentaren getrennt. Es gibt keine Prüfung. | Neue Tests strikt so schreiben; vorhandene bei Gelegenheit umstellen | +| **Mindestens zwei Fehlerfälle** je Netzwerk- oder Dateizugriff | **Erfüllt** für HTTP-Lesen (3 Status plus ungültiges JSON), WebSocket-Anmeldung (über 10 Fälle), Config-Flow (6). **Knapp** für HTTP-Schreiben (Fehlertext, 500 ohne Inhalt, Zeitüberschreitung fehlen teilweise). Dateizugriffe gibt es nicht. | HTTP-Schreiben und Cloud-Statuscodes ergänzen | +| **Grenzwerte** (Null, negativ, maximale Länge) | **Nicht erfüllt.** Nur NaN/inf bei `set_pv_data`. | Siehe Abschnitt 10, soweit die Doku Bereiche nennt | +| **Seam-Tests** an neuen Nahtstellen | **Erfüllt** für Koordinator mit Bridge und Dienste mit Register. **Offen:** Entität mit Koordinator (Schreibpfad). | Entitäten-Tests ergänzen | +| **Formale API-Schemas** einbetten | **Teilweise.** Schlüsseltabelle als `api_keys.json`. **Offen:** Schema der HTTP-Endpunkte (lokal, Cloud). | Endpunkt-Schema aus `http-en.md` und `cloudapi-en.md` ableiten | + +**Weitere Regeln dieses Konzepts** +1. Ein bekannter Fehler bekommt einen Test, der **rot** läuft (Marker `known_bug`). Kein `xfail`, damit der Fehler im Lauf sichtbar bleibt. +2. Jede Aussage über das Verhalten des Chargers braucht eine Quelle: offizielle Doku, Beobachtung am Gerät oder ausdrücklich „Vermutung“. Tests, die eine Vermutung festhalten, sagen das im Docstring. +3. Positivkontrolle: Jede Attrappe, die Fehler simuliert, hat einen Test, der zeigt, dass gültige Eingaben durchgehen. +4. Keine Tests gegen das Netzwerk. Das Test-Framework blockiert echte Sockets. + +--- + +## 6. Testumgebung + +- **Software:** Python 3.14.3, Home Assistant 2026.10.4 (festgelegt durch `pytest-homeassistant-custom-component`), `pytest`, `pytest-asyncio` (Modus `auto`). `msgpack` muss zusätzlich installiert werden, weil das Test-Framework die Anforderungen aus dem Manifest nicht installiert. +- **Ort:** lokale virtuelle Umgebung (Python 3.14, `pip install -r requirements_dev.txt`); Werkzeuge: `tests/tools/`; der Messbericht entsteht von Hand über `.github/workflows/test-report.yaml` (die abgelegten Berichte unter `tests/reports/` wurden entfernt). +- **Aufruf:** + - alles: `python -m pytest tests/integration` + - blockierende Menge: `python -m pytest tests/integration -m "not known_bug and not needs_decision"` +- **Dauer:** rund 10 Sekunden für alle Tests. +- **Voraussetzungen:** kein Charger, kein Netzwerk, keine Zugangsdaten. + +--- + +## 7. Testdaten und Attrappen + +| Attrappe | Wofür | Grenze | +|---|---|---| +| `aioclient_mock` (Home Assistant) | HTTP-Antworten lokal und Cloud | Die Attrappe liefert kein `content_length`; Fälle mit `500` ohne/mit Inhalt stehen in `test_bridge.py` mit einer eigenen Antwort-Klasse | +| `FakeSocket`, `FakeSession` (`test_ws_login_failures.py`) | Skript der Anmeldung und der Nachrichten | Das Skript bildet die **nachgebaute** Anmeldung ab. Eine Änderung durch go-e erkennt sie nicht. | +| Fake-WebSocket-Objekt (`send_json`) | Ausgehende Befehle | wie oben | +| `MockConfigEntry` | Config-Entries für Koordinator, Migration, Flow | – | +| `api_keys.json` | Offizielle Schlüsseltabelle (Namen, R/W, Typ, Kategorie) | Bei 98 von 272 Zeilen steht als Typ nur `TYPE`; keine Beschreibungen gespeichert (kein Lizenztext im offiziellen Repo) | +| Beispieldaten | `123456`, `192.0.2.10` (reservierter Bereich), erfundene Tokens | enthalten keine echten Daten | + +**Grenze aller Attrappen:** Sie kodieren unsere Annahmen. Dass der Charger sich so verhält, belegt erst der lesende Abgleich am echten Gerät (Abschnitt 10, Punkt 4). + +--- + +## 8. Marker und Blockier-Regeln + +| Marker | Bedeutung | Blockiert? | +|---|---|---| +| `known_bug` | Der Code hat einen nicht behobenen Fehler; der Test beschreibt das richtige Verhalten | Ja: der Gesamtlauf ist rot, bis der Fehler behoben ist | +| `needs_decision` | Doku und Code weichen ab; unklar, ob gewollt oder Doku-Lücke | Nein: rot, aber nicht blockierend, bis entschieden ist | +| (kein Marker) | Verhalten, das funktionieren muss | Ja | + +- Eine **neue** Abweichung zur Doku hat keinen Marker und blockiert sofort. +- Wird ein Fehler behoben, schlägt das Entfernen des Markers nicht automatisch an. Der Test wird **grün**, der Marker bleibt stehen und muss von Hand entfernt werden. Das ist ein Nachteil gegenüber `xfail(strict)`. Mitigation: nach jedem Update von `main` die Marker prüfen (`-m known_bug -q`). +- Blockierende Menge heute: grün (972 bestanden, mit PR #180). Offene Funde: 17, alle `needs_decision` (14 Abweichungen in `test_api_contract`, 3 Controller-Schlüssel). Alle `known_bug` sind behoben und ihre Marker entfernt. + +--- + +## 9. Aktueller Stand + +Stand 2026-10-29 (Messung nach dem Ausbau um Controller-Vertrag, Wertebereiche und Transportvergleich), `main` bei `9e7d62a`. Die Abdeckung wurde vor den letzten Fehlerbehebungen gemessen; einen neuen Bericht liefert `test-report.yaml`. + +| Kennzahl | Wert | +|---|---| +| Tests gesammelt | 989, davon 420 parametrisierte Vertragsprüfungen je Schlüssel; **567 Verhaltens- und Fehlertests** | +| Ergebnis | 972 bestanden, 17 beabsichtigt rot (alle `needs_decision`); blockierende Menge 972 von 972 grün (mit PR #180, sonst 961 blockierend und 28 rot) | +| Zeilenabdeckung | 92,9 % (vorher 55,2 %) | +| Zweigabdeckung | 84,9 % (vorher 31,8 %) | +| **Pfadabdeckung** (obere Schranke der Basispfade) | **61 %** alle Funktionen, **62 %** Kernmodule (vorher 20 % und 27 %) | +| Pfadabdeckung nach Kritikalität | K1 kritisch 66 %, K2 hoch 60 %, K3 mittel 64 %, K4 niedrig 48 % | + +**Hinweis zur Pfadabdeckung:** `coverage.py` misst keine Pfade. Die Zahl stammt aus einem eigenen Plugin (`tests/tools/pathtracer.py`) und ist eine obere Schranke: Je Funktion zählt `min(beobachtete verschiedene Pfade, zyklomatische Komplexität)`. Sie lässt sich durch viele leicht verschiedene Eingaben steigern, ohne dass die Prüfung besser wird. Als Gegenprobe dienen die Mutationsprüfungen (Abschnitt 11). + +| Datei | Inhalt | +|---|---| +| `test_api_contract.py` | Schlüsseltabelle gegen Code (420 Prüfungen) | +| `test_platform_values.py`, `test_platform_actions.py` | Wertdarstellung und Schreiben der Entitäten | +| `test_bridge_http.py`, `test_bridge_write_paths.py`, `test_bridge_polling.py` | HTTP-Anfragen, Schreibpfade, Lesezyklus | +| `test_ws_protocol.py`, `test_ws_login_failures.py`, `test_ws_connect_paths.py` | WebSocket: Nachrichten, Anmeldefehler, erfolgreiche Anmeldung und Schleife | +| `test_coordinator_failures.py`, `test_coordinator_runtime.py`, `test_coordinator_errors.py` | Fehlerzähler und Pause, Laufzeitpfade, Schreibfehler | +| `test_services_and_unload.py`, `test_services_and_setup.py`, `test_service.py` | Dienste, Register, Start und Ende eines Entries | +| `test_config_flow_failures.py`, `test_config_flow_paths.py`, `test_migration.py` | Config-Flow, Migration | +| `test_versions_16a.py`, `test_versions_transport_parity.py`, `test_entity_ids.py`, `test_bridge.py` | 16-A-Entscheidung (je nach Code-Stand), gleiches Ergebnis über HTTP und WebSocket, Entity-IDs, Schreibfehler der Bridge | +| `test_controller_contract.py` | Controller-Schlüssel gegen die Tabelle der go-eController-API (59 Prüfungen, 3 davon beabsichtigt rot) | +| `test_value_ranges.py` | Wertebereiche und Optionslisten aus dem Modbus-Profil (`amp`, `ama`, `lbr`, `frc`, `psm` u. a.) | + +**Kritikalität der Pfade.** Jede Funktion ist einer Klasse zugeordnet (K1 ändert den Charger oder die Konfiguration, K2 Verbindung, Anmeldung, Start, K3 Anzeige, K4 Hilfsfunktionen). Die Zuordnung mit Begründung steht in `tests/tools/criticality.py`; der Report listet je Klasse die offenen Lücken (Abschnitt 5.1). + +--- + +## 10. Lücken und Reihenfolge der nächsten Schritte + +Erledigt seit der ersten Fassung: Plattformen, Lesezyklus im Polling, Schreibpfade, Koordinator-Laufzeit (16-A-Begrenzer, Wiederverbindung, Watchdog), Start und Ende eines Entries, erfolgreiche Anmeldung per WebSocket, Config-Flow-Pfade. + +1. **K1 und K2 zuerst:** Der Report (Abschnitt 5.1) nennt je Funktion, wo sicher noch Basispfade fehlen, zum Beispiel in `check_and_write_to_16a`, `async_migrate_entry`, `ws_connect`, `read_versions` des Koordinators und `read_all_states`. +2. **R20 (alte Werte nach fehlgeschlagenem Lesen, Cloud) und R7 (Konfigurationsintervall):** Das Verhalten wird jetzt als Charakterisierung festgehalten (`test_failed_full_read_keeps_the_previous_states`); ein Test auf das gewünschte Verhalten fehlt, weil der Maintainer die Absicht nicht geklärt hat. +3. **Endpunkt-Verträge:** Cloud 403, 404, 422, 202, `age`, `last-modified`, `token` als GET-Parameter, mehrere Schlüssel in einem `set`; dazu ein Schema der Endpunkte aus `http-en.md` und `cloudapi-en.md`. +4. **Schreib-Kodierung je Schlüsseltyp** und **Grenzwerte** (Regel „explizite Grenzen“): nur dort, wo die Doku Typ oder Bereich nennt. Bei 98 Schlüsseln mit Platzhalter-Typ geht das nicht. +5. **Lesender Abgleich am echten Charger:** die Vertragsdaten (Statuscodes, Schlüssel, Typen) gegen `GET /api/status` prüfen. Nur lesend, kein `frc`, kein `ids`. Erst das belegt, dass die Attrappen dem Gerät entsprechen. +6. **Lese-Seite je Firmware:** Beispiel-Antworten aus den Listen für 59.4, 60.2, 60.3 und 60.4. +7. **Entity-Anlage:** `async_setup_entry` je Plattform und `entity.py` (Anzeigename) sind noch nicht ausgeführt. +8. **Verbindungsverlust im laufenden WebSocket-Betrieb:** Verfügbarkeit der Entitäten. +9. **R6 (`unique_id`):** Doppelanlage verhindern. +10. **Struktur:** Arrange-Act-Assert konsequent anwenden (die neuen Tests folgen dem Muster weitgehend, ohne dass es geprüft wird); Abdeckungs-Untergrenze und Marker-Warnung automatisieren. + +--- + +## 11. Qualität der Tests selbst + +- **Mutationsprüfung von Hand:** An verschiedenen Stellen wurden gezielt Fehler in den Code eingebaut und geprüft, ob ein Test rot wird. Bisher **33 Änderungen, alle erkannt**: 19 für die erste Suite (Wertkodierung, Präfixe, HMAC, Löschen der Geräte, 16-A-Grenze, Token, Anmeldeprüfungen, Neustart-Schwelle, Pausen, Flow-Abbruchgrund, Start-Prüfung, Schlüsselzuordnung) und 14 für die erweiterte Suite (Sensorfaktor, Fahrzeugprofil, Schalter-Regeln, Hibernation-Grenze, Konfigurations-Wiederholung, 16-A-Grenze im Begrenzer, Cloud-Fahrzeugprüfung, Zähler, Mindestintervall, Standard-Hash, Temperatur-Sensoren des Core, `ids`-Flag, Null-als-Null). **Eine** Änderung (Temperatur-Sensoren des Core) wurde zuerst nicht erkannt; ein zusätzlicher Testfall schloss die Lücke. Die Prüfung ist **nicht automatisiert** und deckt nicht den gesamten Code ab. +- **Positivkontrollen** für die Attrappen (Anmeldung, Config-Flow). +- **Stabilität:** Die blockierende Menge lief dreimal hintereinander grün (kein flackerndes Verhalten festgestellt). +- **Unabhängige Zweitprüfung:** für die Tests selbst nicht durchgeführt. +- **Bekannte Schwächen:** Die Zahl 420 in der Vertragsdatei verzerrt die Gesamtzahl; die Pfadzahl ist eine obere Schranke und steigerbar; die Attrappen kodieren unsere Annahmen. + +--- + +## 12. Pflege und Änderungsprozess + +| Anlass | Vorgehen | +|---|---| +| go-e veröffentlicht neue Doku | `generate_api_keys.py` mit neuem Commit ausführen, Diff prüfen, `classification.json` und `deviations.json` anpassen | +| Upstream behebt einen Fehler | Marker `known_bug` entfernen, Test muss grün sein; ggf. Eintrag in `deviations.json` oder `classification.json` streichen | +| Upstream ändert Entity-IDs absichtlich | Snapshot mit `UPDATE_SNAPSHOT=1` neu erzeugen und den Diff prüfen; ein Änderungsvorschlag an Nutzer wäre ein Bruch für Bestandsnutzer | +| Neue Entität oder neuer Schlüssel | Vertragstest schlägt an; Schlüssel zuordnen oder in die Klassifikation aufnehmen | +| Abweichung `needs_decision` geklärt | Eintrag entfernen oder Doku-Lücke vermerken und Marker entfernen | + +**Zuständig:** Oliver (Tests und Doku). Änderungen am Code macht der Maintainer; Oliver schlägt sie höchstens vor. + +--- + +## 13. Eintritts- und Abschlusskriterien + +- **Vor jedem Push und jedem Pull Request:** blockierende Menge grün (`-m "not known_bug and not needs_decision"`). +- **Vor jedem Update auf einen neuen Upstream-Stand:** `main` holen, Gesamtlauf starten, neue Rotfälle einordnen (neuer Fehler oder behobener bekannter Fehler). +- **Abschluss des Testkonzepts „offizielle API“:** Punkte 1 bis 3 aus Abschnitt 10 umgesetzt, Punkt 4 mindestens einmal durchgeführt und dokumentiert. + +--- + +## 14. Annahmen, Grenzen, offene Entscheidungen + +- Die Tests prüfen den Code gegen Doku und gegen unsere Annahmen, nicht gegen das Gerät. +- Ob die 11 `known_bug`- und 14 `needs_decision`-Fälle Fehler sind, ist teils offen (siehe `tests/integration/contract/deviations.json`). +- Offen: Soll es Mutationstests automatisiert geben (zum Beispiel `mutmut`)? Das wäre ein Aufwand von einem halben bis einem Tag, ohne Gewähr für Nutzen bei einer Codebasis dieser Struktur. diff --git a/hacs.json b/hacs.json index 5561997..bcd8d32 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,8 @@ { "name": "go-e APIv2 Connect", + "zip_release": true, + "filename": "goecharger_api2.zip", + "content_in_root": false, "homeassistant": "2023.8.0", "hacs": "1.18.0", "render_readme": true diff --git a/requirements_dev.txt b/requirements_dev.txt index 2e0ce26..43e00ba 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1 +1,3 @@ -pytest-homeassistant-custom-component>=0.13.154 +# pinned: 0.13.375 brings Home Assistant 2026.10.4. Raise it on purpose (see tests/tools/README.md). +pytest-homeassistant-custom-component==0.13.375 +msgpack>=1.1.2 diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..fb4030e --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,71 @@ +# Regression tests + +Tests for the integration without a charger, without network access and without credentials: the test +framework blocks real sockets, HTTP is mocked, the WebSocket connection is a fake object. Bridge and +coordinator are the real code. + +```sh +python3.14 -m venv .venv && . .venv/bin/activate +python -m pip install -r requirements_dev.txt +python -m pytest tests/integration -q +``` + +Python 3.14 is required by the current `pytest-homeassistant-custom-component`, which also pins the +Home Assistant release it was built for. `msgpack` is listed in the manifest, but the test framework +does not install manifest requirements, so it is part of `requirements_dev.txt`. + +## What is covered + +| File | Area | Basis | +|---|---|---| +| `test_bridge_http.py` | HTTP requests and answers: filter, value encoding, cloud URL and token, error statuses | official go-e docs (`http-en.md`, `cloudapi-en.md`) | +| `test_ws_protocol.py` | WebSocket messages, password hashing, message signing, start condition | current implementation (protocol is not officially documented) | +| `test_versions_16a.py` | When a charger is limited to 16 A (cable limit, adapter, variant, option) | decision table; rows after 98af3e0 pin the current behaviour | +| `test_entity_ids.py` | Entity IDs and unique IDs stay stable (snapshot, uniqueness, LAN/WAN prefix) | snapshot in `snapshots/entity_ids.json` | +| `test_ws_login_failures.py` | WebSocket login failures: no serial, device offline, unknown hash, missing tokens, wrong password, timeout, unreachable device, dropped connection (plus a positive control) | current implementation | +| `test_coordinator_failures.py` | Error counter, 2 minute pause, restart after more than 5 errors, writes during an outage, `ConfigEntryNotReady` at start | code | +| `test_config_flow_failures.py` | Wrong, silent or unreachable devices and wrong cloud token end the flow with `auth_lan` / `auth_wan` and create no entry (plus a positive control) | code | +| `test_api_contract.py` | Official key table against the code: every documented key supported or classified, undocumented keys explained, read/write flag and category equal to the docs | `contract/api_keys.json` (generated from the official table) | +| `test_platform_values.py`, `test_platform_actions.py` | Entities: how raw values become states (sensor, number, select, switch, binary sensor) and what a write sends (number, select incl. car profiles, switch, button) | code | +| `test_bridge_polling.py` | Read cycle: hibernation, configuration refresh, versions, state housekeeping | code, README (hibernation) | +| `test_bridge_write_paths.py` | Every path from a value to the charger and back (HTTP, WebSocket, answers) | official docs, code | +| `test_ws_connect_paths.py` | Successful WebSocket logins and the message loop; address building | current implementation | +| `test_coordinator_runtime.py` | Write results, 16 A limiter, device detection, WebSocket start, reconnect, watchdog, housekeeping | code | +| `test_services_and_setup.py` | `set_pv_data`, `stop_charging`, charger lookup, setup and unload of entries | code | +| `test_config_flow_paths.py` | Successful setups, interval rules, reconfiguration, WebSocket check | code | +| `test_migration.py` | Config entry migration 1 -> 2.1 and 2.0 -> 2.1 | code | +| `test_services_and_unload.py` | `stop_charging`, multi-charger registry, unloading entries | reported bugs | +| `test_service.py` | `set_pv_data` input validation | reported bugs | +| `test_bridge.py`, `test_coordinator_errors.py` | write errors, connection error counting | reported bugs | + +## Known bugs fail on purpose + +A test marked `@pytest.mark.known_bug("...")` describes behaviour that is wrong today, so the run is +red until the bug is fixed. To get a green run without them: `python -m pytest tests/integration -m "not known_bug"`. +List them with `python -m pytest tests/integration -m known_bug -q`. When a bug is fixed, remove the marker. + +## Markers + +| Marker | Meaning | Blocking? | +|---|---|---| +| `known_bug` | the code has a bug that is not fixed yet | yes: the run is red until it is fixed | +| `needs_decision` | documentation and code differ; not yet decided whether this is intended or a documentation gap | no: fails on purpose, but is not blocking | + +Blocking set only: `python -m pytest tests/integration -m "not known_bug and not needs_decision"`. +A new difference to the documentation has no marker, so it is blocking. + +## Contract data (`contract/`) + +- `api_keys.json`: key names, R/W, type and category from the official key table (no descriptions). Regenerate with `generate_api_keys.py` when go-e publishes a new version, and review the diff. +- `classification.json`: documented keys without a `Tag` (unsupported, decision open) and keys the code uses that the official table does not list, each with a reason. +- `deviations.json`: known differences between documentation and code (status `needs_decision`). + +## Entity ID snapshot + +If an entity ID changes on purpose, regenerate the file and review the diff: +`UPDATE_SNAPSHOT=1 python -m pytest tests/integration/test_entity_ids.py -k snapshot` +Changing an ID breaks dashboards, automations and statistics of existing users. + +## Helpers + +`factories.py` builds config entries, real coordinators and bridges (with an optional fake WebSocket connection) for the tests. diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..70c164a --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures for the integration tests. + +The tests need the packages from requirements_dev.txt and run with Python 3.14 (the version +required by pytest-homeassistant-custom-component for the current Home Assistant release). +""" +import pytest + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """Allow Home Assistant to load the integration from custom_components/.""" + yield diff --git a/tests/integration/contract/api_keys.json b/tests/integration/contract/api_keys.json new file mode 100644 index 0000000..4f9d7ad --- /dev/null +++ b/tests/integration/contract/api_keys.json @@ -0,0 +1,1505 @@ +{ + "source": { + "repository": "goecharger/go-eCharger-API-v2", + "file": "API_KEYS_FIRMWARE/apikeys-en.md", + "commit": "b4d7f85f", + "date": "2026-08-12" + }, + "keys": { + "acp": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "acs": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "acu": { + "rw": "R", + "type": "int", + "category": "Status" + }, + "adi": { + "rw": "R", + "type": "bool", + "category": "Status" + }, + "alw": { + "rw": "R", + "type": "bool", + "category": "Status" + }, + "ama": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "amp": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "amt": { + "rw": "R", + "type": "int", + "category": "Status" + }, + "ara": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "ate": { + "rw": "R/W", + "type": "double", + "category": "Config" + }, + "atp": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "att": { + "rw": "R/W", + "type": "seconds", + "category": "Config" + }, + "avgfhz": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "awc": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "awcp": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "awe": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "awp": { + "rw": "R/W", + "type": "float", + "category": "Config" + }, + "awpl": { + "rw": "W", + "type": "TYPE", + "category": "Status" + }, + "bac": { + "rw": "R/W", + "type": "uint8_t", + "category": "Config" + }, + "bar": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "c0e": { + "rw": "R/W", + "type": "uint64_t", + "category": "Config" + }, + "c0i": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "c0n": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "c1e": { + "rw": "R/W", + "type": "uint64_t", + "category": "Config" + }, + "c1i": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "c1n": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "c2e": { + "rw": "R/W", + "type": "uint64_t", + "category": "Config" + }, + "c2i": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "c2n": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "c3e": { + "rw": "R/W", + "type": "uint64_t", + "category": "Config" + }, + "c3i": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "c3n": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "c4e": { + "rw": "R/W", + "type": "uint64_t", + "category": "Config" + }, + "c4i": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "c4n": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "c5e": { + "rw": "R/W", + "type": "uint64_t", + "category": "Config" + }, + "c5i": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "c5n": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "c6e": { + "rw": "R/W", + "type": "uint64_t", + "category": "Config" + }, + "c6i": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "c6n": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "c7e": { + "rw": "R/W", + "type": "uint64_t", + "category": "Config" + }, + "c7i": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "c7n": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "c8e": { + "rw": "R/W", + "type": "uint64_t", + "category": "Config" + }, + "c8i": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "c8n": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "c9e": { + "rw": "R/W", + "type": "uint64_t", + "category": "Config" + }, + "c9i": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "c9n": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "car": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "cards": { + "rw": "R/W", + "type": "array", + "category": "Config" + }, + "cbl": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "ccd": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "cch": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "cco": { + "rw": "R/W", + "type": "double", + "category": "Config" + }, + "ccrv": { + "rw": "R", + "type": "string", + "category": "Constant" + }, + "ccu": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "ccw": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "cdi": { + "rw": "R", + "type": "object", + "category": "Status" + }, + "cfi": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "cid": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "cle": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "clea": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "cll": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "clp": { + "rw": "R/W", + "type": "array", + "category": "Config" + }, + "cmmr": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "cmp": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "cms": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "cmse": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "csa": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "ct": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "ctrls": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "cus": { + "rw": "R", + "type": "uint8", + "category": "Status" + }, + "cwc": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "cwe": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "data": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "del": { + "rw": "W", + "type": "uint8", + "category": "Other" + }, + "deltaa": { + "rw": "R", + "type": "float", + "category": "Other" + }, + "deltap": { + "rw": "R", + "type": "float", + "category": "Status" + }, + "delw": { + "rw": "W", + "type": "uint8", + "category": "Other" + }, + "di1": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "die": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "dii": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "dll": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "dns": { + "rw": "R", + "type": "object", + "category": "Status" + }, + "dsrc": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "dwo": { + "rw": "R/W", + "type": "optional<...>", + "category": "Config" + }, + "err": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "esk": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "eto": { + "rw": "R", + "type": "uint64", + "category": "Status" + }, + "ferm": { + "rw": "R", + "type": "uint8", + "category": "Status" + }, + "ffb": { + "rw": "R", + "type": "uint8", + "category": "Status" + }, + "fhz": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "fmt": { + "rw": "R/W", + "type": "milliseconds", + "category": "Config" + }, + "fna": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "frc": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "frm": { + "rw": "R", + "type": "uint8", + "category": "Config" + }, + "fsp": { + "rw": "R", + "type": "bool", + "category": "Status" + }, + "fsptws": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "fst": { + "rw": "R/W", + "type": "float", + "category": "Config" + }, + "fup": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "fwc": { + "rw": "R", + "type": "string", + "category": "Constant" + }, + "fwv": { + "rw": "R", + "type": "string", + "category": "Constant" + }, + "fzf": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "gmtr": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "gsa": { + "rw": "R/W", + "type": "TYPE", + "category": "Status" + }, + "hai": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "hla": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "host": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "hsa": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "ido": { + "rw": "R", + "type": "optional<...>", + "category": "Config" + }, + "ids": { + "rw": "R/W", + "type": "bool", + "category": "Other" + }, + "inva": { + "rw": "R", + "type": "milliseconds", + "category": "Status" + }, + "la1": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "la3": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "lbl": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "lbp": { + "rw": "R", + "type": "milliseconds", + "category": "Status" + }, + "lbr": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "lccfc": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "lccfi": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "lcctc": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "lck": { + "rw": "R", + "type": "uint8", + "category": "Status" + }, + "lcs": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "led": { + "rw": "R", + "type": "object", + "category": "Status" + }, + "lfspt": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "lmo": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "lmsc": { + "rw": "R", + "type": "milliseconds", + "category": "Status" + }, + "loa": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "loc": { + "rw": "R", + "type": "string", + "category": "Status" + }, + "loe": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "lof": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "log": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "lop": { + "rw": "R/W", + "type": "uint16", + "category": "Config" + }, + "lopr": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "lot": { + "rw": "R/W", + "type": "uint32", + "category": "Config" + }, + "loty": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "lpsc": { + "rw": "R", + "type": "milliseconds", + "category": "Status" + }, + "lrc": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "lri": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "lrn": { + "rw": "W", + "type": "uint8", + "category": "Other" + }, + "lrr": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "lse": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "lto": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "lwf": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "map": { + "rw": "R/W", + "type": "array", + "category": "Config" + }, + "mca": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "mcc": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mcca": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mce": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mci": { + "rw": "R/W", + "type": "milliseconds", + "category": "Config" + }, + "mcpd": { + "rw": "R/W", + "type": "milliseconds", + "category": "Config" + }, + "mcpea": { + "rw": "R/W", + "type": "optional<...>", + "category": "Status" + }, + "mcr": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mcs": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mcu": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "men": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mhe": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mht": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mlr": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mlra": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mmp": { + "rw": "R", + "type": "float", + "category": "Status" + }, + "modelStatus": { + "rw": "R", + "type": "uint8", + "category": "Status" + }, + "mptwt": { + "rw": "R/W", + "type": "milliseconds", + "category": "Config" + }, + "mpwst": { + "rw": "R/W", + "type": "milliseconds", + "category": "Config" + }, + "mqcn": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mqg": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mqss": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "msb": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "msp": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "msr": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mtp": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "nif": { + "rw": "R", + "type": "string", + "category": "Status" + }, + "nmo": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "nrg": { + "rw": "R", + "type": "array", + "category": "Status" + }, + "ocppa": { + "rw": "R", + "type": "bool", + "category": "Status" + }, + "ocppaa": { + "rw": "R", + "type": "null or milliseconds", + "category": "Status" + }, + "ocppai": { + "rw": "R/W", + "type": "seconds", + "category": "Config" + }, + "ocppao": { + "rw": "R/W", + "type": "TYPE", + "category": "Status" + }, + "ocppc": { + "rw": "R", + "type": "bool", + "category": "Status" + }, + "ocppca": { + "rw": "R", + "type": "null or milliseconds", + "category": "Status" + }, + "ocppcc": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "ocppck": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "ocppcm": { + "rw": "R/W", + "type": "TYPE", + "category": "Status" + }, + "ocppcn": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "ocppcs": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "ocppd": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "ocppe": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "ocppf": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "ocppg": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "ocpph": { + "rw": "R/W", + "type": "seconds", + "category": "Config" + }, + "ocppi": { + "rw": "R/W", + "type": "seconds", + "category": "Config" + }, + "ocppla": { + "rw": "R/W", + "type": "TYPE", + "category": "Status" + }, + "ocpple": { + "rw": "R", + "type": "string or null", + "category": "Status" + }, + "ocpplea": { + "rw": "R", + "type": "null or milliseconds", + "category": "Status" + }, + "ocpplo": { + "rw": "R/W", + "type": "TYPE", + "category": "Status" + }, + "ocppr": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "ocpprl": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "ocpps": { + "rw": "R", + "type": "bool", + "category": "Status" + }, + "ocppsc": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "ocppss": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "ocppti": { + "rw": "R/W", + "type": "TYPE", + "category": "Status" + }, + "ocppu": { + "rw": "R/W", + "type": "string", + "category": "Config" + }, + "oct": { + "rw": "W", + "type": "string", + "category": "Other" + }, + "ocu": { + "rw": "R", + "type": "array", + "category": "Status" + }, + "oem": { + "rw": "R", + "type": "string", + "category": "Constant" + }, + "pakku": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "pco": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "pdi": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "pgr": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "pgrid": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "pgt": { + "rw": "R/W", + "type": "float", + "category": "Config" + }, + "pha": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "pnp": { + "rw": "R", + "type": "uint8", + "category": "Status" + }, + "po": { + "rw": "R/W", + "type": "float", + "category": "Config" + }, + "ppv": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "psh": { + "rw": "R/W", + "type": "float", + "category": "Config" + }, + "psmd": { + "rw": "R/W", + "type": "milliseconds", + "category": "Config" + }, + "pvopt_averagePAkku": { + "rw": "R", + "type": "float", + "category": "Status" + }, + "pvopt_averagePGrid": { + "rw": "R", + "type": "float", + "category": "Status" + }, + "pvopt_averagePPv": { + "rw": "R", + "type": "float", + "category": "Status" + }, + "pwm": { + "rw": "R", + "type": "uint8", + "category": "Status" + }, + "rbc": { + "rw": "R", + "type": "uint32", + "category": "Status" + }, + "rbt": { + "rw": "R", + "type": "milliseconds", + "category": "Status" + }, + "rdbf": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdbfe": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdbs": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdbse": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rde": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdef": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdefe": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdes": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdese": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdpl": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdple": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdre": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rdree": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rfb": { + "rw": "R", + "type": "int", + "category": "Status" + }, + "rmaf": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rmav": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rmif": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rmiv": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rsa": { + "rw": "R/W", + "type": "TYPE", + "category": "Status" + }, + "rsre": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rsrr": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "rssi": { + "rw": "R", + "type": "optional<...>", + "category": "Status" + }, + "rst": { + "rw": "W", + "type": "any", + "category": "Other" + }, + "scaa": { + "rw": "R", + "type": "milliseconds", + "category": "Status" + }, + "scan": { + "rw": "R", + "type": "array", + "category": "Status" + }, + "sch_satur": { + "rw": "R/W", + "type": "object", + "category": "Config" + }, + "sch_sund": { + "rw": "R/W", + "type": "object", + "category": "Config" + }, + "sch_week": { + "rw": "R/W", + "type": "object", + "category": "Config" + }, + "sdp": { + "rw": "R/W", + "type": "uint8_t", + "category": "Config" + }, + "sh": { + "rw": "R/W", + "type": "float", + "category": "Config" + }, + "smd": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "spl3": { + "rw": "R/W", + "type": "float", + "category": "Config" + }, + "sse": { + "rw": "R", + "type": "string", + "category": "Constant" + }, + "su": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "sua": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "sumd": { + "rw": "R/W", + "type": "milliseconds", + "category": "Config" + }, + "t0h": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "t0l": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "t1h": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "t1l": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "tab": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "tcl": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "tds": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "tlf": { + "rw": "R", + "type": "bool", + "category": "Status" + }, + "tls": { + "rw": "R", + "type": "bool", + "category": "Status" + }, + "tma": { + "rw": "R", + "type": "array", + "category": "Status" + }, + "tof": { + "rw": "R/W", + "type": "minutes", + "category": "Config" + }, + "tpa": { + "rw": "R", + "type": "float", + "category": "Status" + }, + "trx": { + "rw": "R/W", + "type": "optional<...>", + "category": "Status" + }, + "tse": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "tsi": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "tsss": { + "rw": "R", + "type": "uint8", + "category": "Config" + }, + "typ": { + "rw": "R", + "type": "string", + "category": "Constant" + }, + "tzt": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "ufa": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "ufe": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "ufm": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "ufs": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "upo": { + "rw": "R/W", + "type": "bool", + "category": "Config" + }, + "ust": { + "rw": "R/W", + "type": "uint8", + "category": "Config" + }, + "utc": { + "rw": "R/W", + "type": "string", + "category": "Status" + }, + "var": { + "rw": "R", + "type": "uint8", + "category": "Constant" + }, + "wbw": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "wcb": { + "rw": "R", + "type": "string", + "category": "Status" + }, + "wda": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "wfb": { + "rw": "R", + "type": "array", + "category": "Status" + }, + "wh": { + "rw": "R", + "type": "double", + "category": "Status" + }, + "wifis": { + "rw": "R/W", + "type": "array", + "category": "Config" + }, + "wpb": { + "rw": "R", + "type": "array", + "category": "Status" + }, + "wsc": { + "rw": "R", + "type": "uint8", + "category": "Status" + }, + "wsl": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "wsm": { + "rw": "R", + "type": "string", + "category": "Status" + }, + "wsms": { + "rw": "R", + "type": "uint8", + "category": "Status" + }, + "wst": { + "rw": "R", + "type": "uint8", + "category": "Status" + }, + "zfo": { + "rw": "R/W", + "type": "float", + "category": "Config" + } + } +} diff --git a/tests/integration/contract/classification.json b/tests/integration/contract/classification.json new file mode 100644 index 0000000..672619e --- /dev/null +++ b/tests/integration/contract/classification.json @@ -0,0 +1,397 @@ +{ + "_comment": "Keys of the official table that the integration does not map to a Tag. 'decision': open = nobody has decided yet whether the gap is intended. Remove an entry when the key gets supported.", + "handled_outside_tag": { + "c0e": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c0i": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c0n": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c1e": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c1i": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c1n": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c2e": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c2i": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c2n": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c3e": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c3i": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c3n": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c4e": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c4i": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c4n": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c5e": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c5i": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c5n": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c6e": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c6i": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c6n": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c7e": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c7i": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c7n": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c8e": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c8i": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c8n": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c9e": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c9i": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag", + "c9n": "RFID card keys of firmware 60.0+ are requested through the FILTER_CARDS_* lists and read in sensor.py, not through Tag" + }, + "not_supported": { + "avgfhz": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "gmtr": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "gsa": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "pgr": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "rmaf": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "rmav": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "rmif": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "rmiv": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "rsa": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "rsre": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "rsrr": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "ufa": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "ufe": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "ufm": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "ufs": { + "group": "grid frequency, grid protection and ramp-up", + "decision": "open" + }, + "rdbfe": { + "group": "random start/stop delays (end timestamps)", + "decision": "open" + }, + "rdbse": { + "group": "random start/stop delays (end timestamps)", + "decision": "open" + }, + "rdefe": { + "group": "random start/stop delays (end timestamps)", + "decision": "open" + }, + "rdese": { + "group": "random start/stop delays (end timestamps)", + "decision": "open" + }, + "rdple": { + "group": "random start/stop delays (end timestamps)", + "decision": "open" + }, + "rdree": { + "group": "random start/stop delays (end timestamps)", + "decision": "open" + }, + "ocppao": { + "group": "OCPP", + "decision": "open" + }, + "ocppcm": { + "group": "OCPP", + "decision": "open" + }, + "ocppcs": { + "group": "OCPP", + "decision": "open" + }, + "ocppf": { + "group": "OCPP", + "decision": "open" + }, + "ocppla": { + "group": "OCPP", + "decision": "open" + }, + "ocpplo": { + "group": "OCPP", + "decision": "open" + }, + "ocppti": { + "group": "OCPP", + "decision": "open" + }, + "lrc": { + "group": "RFID reading", + "decision": "open" + }, + "lri": { + "group": "RFID reading", + "decision": "open" + }, + "lrr": { + "group": "RFID reading", + "decision": "open" + }, + "rde": { + "group": "RFID reading", + "decision": "open" + }, + "tsi": { + "group": "RFID reading", + "decision": "open" + }, + "ccd": { + "group": "controller scan and cloud key", + "decision": "open" + }, + "cmmr": { + "group": "controller scan and cloud key", + "decision": "open" + }, + "cmp": { + "group": "controller scan and cloud key", + "decision": "open" + }, + "cms": { + "group": "controller scan and cloud key", + "decision": "open" + }, + "csa": { + "group": "controller scan and cloud key", + "decision": "open" + }, + "pco": { + "group": "controller scan and cloud key", + "decision": "open" + }, + "led": { + "group": "LED strip and animation", + "decision": "open" + }, + "t0h": { + "group": "LED strip and animation", + "decision": "open" + }, + "t0l": { + "group": "LED strip and animation", + "decision": "open" + }, + "t1h": { + "group": "LED strip and animation", + "decision": "open" + }, + "t1l": { + "group": "LED strip and animation", + "decision": "open" + }, + "die": { + "group": "digital input", + "decision": "open" + }, + "dii": { + "group": "digital input", + "decision": "open" + }, + "pdi": { + "group": "digital input", + "decision": "open" + }, + "mhe": { + "group": "MQTT Home Assistant discovery", + "decision": "open" + }, + "mht": { + "group": "MQTT Home Assistant discovery", + "decision": "open" + }, + "lto": { + "group": "time and timezone", + "decision": "open" + }, + "tab": { + "group": "time and timezone", + "decision": "open" + }, + "tzt": { + "group": "time and timezone", + "decision": "open" + }, + "bar": { + "group": "network and buttons", + "decision": "open" + }, + "hla": { + "group": "network and buttons", + "decision": "open" + }, + "lbl": { + "group": "network and buttons", + "decision": "open" + }, + "wbw": { + "group": "network and buttons", + "decision": "open" + }, + "la1": { + "group": "current limits and load balancing", + "decision": "open" + }, + "la3": { + "group": "current limits and load balancing", + "decision": "open" + }, + "lopr": { + "group": "current limits and load balancing", + "decision": "open" + }, + "dsrc": { + "group": "data sources", + "decision": "open" + }, + "smd": { + "group": "data sources", + "decision": "open" + } + }, + "undocumented_in_code": { + "aus": { + "reason": "listed only in the per-firmware key lists, missing from the main key table" + }, + "bri": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "cae": { + "reason": "documented in cloudapi-en.md (text), missing from the key table" + }, + "cce": { + "reason": "listed only in the per-firmware key lists, missing from the main key table" + }, + "ccf": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "ccn": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "ccp": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "cec": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "cpc": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "ecp": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "evt": { + "reason": "listed only in the per-firmware key lists, missing from the main key table" + }, + "iim": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "ips": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "isgo": { + "reason": "listed only in the per-firmware key lists, missing from the main key table" + }, + "isn": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "iso2ena": { + "reason": "used by the integration (selects), no row in any official key list that was checked" + }, + "isv": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "mck": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "mecd": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "mece": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "mecf": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "mecu": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "mme": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "mmh": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "mqcc": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "mro": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "msc": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "mwo": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "ocppdp": { + "reason": "listed only in the per-firmware key lists, missing from the main key table" + }, + "ocppmp": { + "reason": "listed only in the per-firmware key lists, missing from the main key table" + }, + "ocpptp": { + "reason": "listed only in the per-firmware key lists, missing from the main key table" + }, + "orsch": { + "reason": "listed only in the per-firmware key lists, missing from the main key table" + }, + "psm": { + "reason": "used by the integration (selects), no row in any official key list that was checked" + }, + "simo": { + "reason": "listed only in the per-firmware key lists, missing from the main key table" + }, + "usn": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "usv": { + "reason": "not in the charger key table; the integration uses them for the go-eController or for MQTT/modbus settings (source not verified)" + }, + "zfocore": { + "reason": "internal pseudo keys of the integration (buttons), not part of the go-e API" + }, + "zforeal": { + "reason": "internal pseudo keys of the integration (buttons), not part of the go-e API" + } + } +} diff --git a/tests/integration/contract/controller_keys.json b/tests/integration/contract/controller_keys.json new file mode 100644 index 0000000..2411874 --- /dev/null +++ b/tests/integration/contract/controller_keys.json @@ -0,0 +1,420 @@ +{ + "source": { + "repository": "goecharger/go-eController-API", + "file": "apikeys-en.md", + "commit": "094d08d", + "date": "2026-04-20" + }, + "keys": { + "awc": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "awcp": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "awp": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "awpl": { + "rw": "W", + "type": "TYPE", + "category": "Status" + }, + "bri": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "cce": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "ccf": { + "rw": "R/W", + "type": "TYPE", + "category": "Status" + }, + "ccn": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "ccp": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "ccw": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "cec": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "cle": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "clea": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "cpc": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "cwe": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "data": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "delw": { + "rw": "W", + "type": "TYPE", + "category": "Unknown" + }, + "dll": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "dns": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "fna": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "fwv": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "hai": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "host": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "hsa": { + "rw": "W", + "type": "TYPE", + "category": "Config" + }, + "iim": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "ips": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "isn": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "isv": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "loc": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "lwf": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mcc": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mcca": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mce": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mcr": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mcs": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mcu": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mecd": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mece": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mecf": { + "rw": "R/W", + "type": "TYPE", + "category": "Status" + }, + "mecu": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "men": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mlr": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mlra": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "mme": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mmh": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mmp": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mqcn": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mqg": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mqss": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "msb": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "msp": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "msr": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "mtp": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "nif": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "oct": { + "rw": "W", + "type": "TYPE", + "category": "Config" + }, + "ocu": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "oem": { + "rw": "R", + "type": "TYPE", + "category": "Constant" + }, + "rbc": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "rbt": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "rssi": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "rst": { + "rw": "W", + "type": "TYPE", + "category": "Unknown" + }, + "scaa": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "scan": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "sse": { + "rw": "R", + "type": "TYPE", + "category": "Constant" + }, + "tds": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "tof": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "tse": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "tsss": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "typ": { + "rw": "R", + "type": "TYPE", + "category": "Constant" + }, + "usn": { + "rw": "R", + "type": "TYPE", + "category": "Config" + }, + "usv": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "utc": { + "rw": "R/W", + "type": "TYPE", + "category": "Status" + }, + "wcb": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "wda": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "wfb": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "wifis": { + "rw": "R/W", + "type": "TYPE", + "category": "Config" + }, + "wpb": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "wsc": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "wsl": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "wsm": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "wsms": { + "rw": "R", + "type": "TYPE", + "category": "Status" + }, + "wst": { + "rw": "R", + "type": "TYPE", + "category": "Status" + } + } +} diff --git a/tests/integration/contract/deviations.json b/tests/integration/contract/deviations.json new file mode 100644 index 0000000..911d22f --- /dev/null +++ b/tests/integration/contract/deviations.json @@ -0,0 +1,201 @@ +{ + "_comment": "Differences between the official key table and keys.py. status needs_decision: not yet clear whether the code is wrong, the difference is intended, or the documentation has a gap. Remove an entry when it is resolved. per_firmware_lists: values of the same key in the per-firmware key lists (59.4 to 60.4); they often differ from the main table.", + "deviations": [ + { + "key": "cco", + "aspect": "writeable", + "documented": "R/W", + "code": "read-only", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "di1", + "aspect": "writeable", + "documented": "R/W", + "code": "read-only", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "esk", + "aspect": "writeable", + "documented": "R/W", + "code": "read-only", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "frm", + "aspect": "writeable", + "documented": "R", + "code": "writeable", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "code (main table is the odd one out)" + }, + { + "key": "rdbf", + "aspect": "writeable", + "documented": "R/W", + "code": "read-only", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "rdbs", + "aspect": "writeable", + "documented": "R/W", + "code": "read-only", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "rdef", + "aspect": "writeable", + "documented": "R/W", + "code": "read-only", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "rdes", + "aspect": "writeable", + "documented": "R/W", + "code": "read-only", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "rdpl", + "aspect": "writeable", + "documented": "R/W", + "code": "read-only", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "rdre", + "aspect": "writeable", + "documented": "R/W", + "code": "read-only", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "deltaa", + "aspect": "category", + "documented": "Other", + "code": "Status", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/Unknown", + "60.2": "R/Unknown", + "60.3": "R/Unknown", + "60.4": "R/Unknown" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "di1", + "aspect": "category", + "documented": "Config", + "code": "Status", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/W/Config", + "60.2": "R/W/Config", + "60.3": "R/W/Config", + "60.4": "R/W/Config" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + }, + { + "key": "ids", + "aspect": "category", + "documented": "Other", + "code": "Status", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "W/Status", + "60.2": "W/Status", + "60.3": "W/Status", + "60.4": "W/Status" + }, + "per_firmware_lists_agree_with": "code (main table is the odd one out)" + }, + { + "key": "lcs", + "aspect": "category", + "documented": "Status", + "code": "Config", + "status": "needs_decision", + "per_firmware_lists": { + "59.4": "R/Status", + "60.2": "R/Status", + "60.3": "R/Status", + "60.4": "R/Status" + }, + "per_firmware_lists_agree_with": "main table (code differs from both)" + } + ] +} diff --git a/tests/integration/contract/generate_api_keys.py b/tests/integration/contract/generate_api_keys.py new file mode 100644 index 0000000..271eaf6 --- /dev/null +++ b/tests/integration/contract/generate_api_keys.py @@ -0,0 +1,37 @@ +"""Builds api_keys.json from the official key table (go-eCharger-API-v2, API_KEYS_FIRMWARE/apikeys-en.md). + +Only key names and flags are stored (R/W, type, category), not the descriptions. +Usage: python generate_api_keys.py [controller] +With the last argument the key table of the go-eController API (goecharger/go-eController-API) is read +and controller_keys.json is written instead. +""" +import json +import re +import sys +from pathlib import Path + + +def parse(path): + keys = {} + for line in Path(path).read_text().splitlines(): + if line.startswith("|--") or line.startswith("| Key") or "|" not in line: + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 5: + continue + names = re.sub(r"\(.*?\)", "", cells[0]).split() + for name in (n for n in names if re.fullmatch(r"[A-Za-z0-9_]+", n)): + keys[name] = {"rw": cells[1], "type": re.sub(r"<.*", "<...>", cells[2]), "category": cells[3]} + return dict(sorted(keys.items())) + + +if __name__ == "__main__": + source, commit, date = sys.argv[1:4] + controller = len(sys.argv) > 4 and sys.argv[4] == "controller" + origin = ("goecharger/go-eController-API", "apikeys-en.md") if controller else \ + ("goecharger/go-eCharger-API-v2", "API_KEYS_FIRMWARE/apikeys-en.md") + out = {"source": {"repository": origin[0], "file": origin[1], "commit": commit, "date": date}, + "keys": parse(source)} + target = Path(__file__).with_name("controller_keys.json" if controller else "api_keys.json") + target.write_text(json.dumps(out, indent=1) + "\n") + print(f"{len(out['keys'])} keys written to {target}") diff --git a/tests/integration/factories.py b/tests/integration/factories.py new file mode 100644 index 0000000..b44c138 --- /dev/null +++ b/tests/integration/factories.py @@ -0,0 +1,45 @@ +"""Shared builders for the tests: config entries, coordinators and fake WebSocket connections.""" +from collections import ChainMap +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.goecharger_api2 import GoeChargerDataUpdateCoordinator +from custom_components.goecharger_api2.const import DOMAIN + + +def make_entry(hass, serial="123456", intg_type="charger", mode="lan", password=None, **extra): + data = {"mode": mode, "id": serial, "integration_type": intg_type, "scan_interval": 30, **extra} + if mode == "lan": + data.setdefault("host", "192.0.2.10") + else: + data.setdefault("token", "token") + if password: + data["password"] = password + entry = MockConfigEntry(domain=DOMAIN, title=f"go-e {serial}", data=data) + entry.add_to_hass(hass) + return entry + + +def make_coordinator(hass, data=None, websocket=False, **entry_kwargs): + """Real coordinator and bridge. `data` becomes coordinator.data (a plain dict, as the entities only read it).""" + coordinator = GoeChargerDataUpdateCoordinator(hass, make_entry(hass, **entry_kwargs)) + bridge = coordinator.bridge + coordinator.data = data if data is not None else ChainMap( + bridge._ws_states, bridge._config, bridge._states, bridge._versions) + if websocket: + bridge.ws_connected = True + bridge._ws_connection = SimpleNamespace(send_json=AsyncMock()) + return coordinator + + +def make_bridge(hass, intg_type="charger", password=None, websocket=False): + from homeassistant.helpers.aiohttp_client import async_get_clientsession + from custom_components.goecharger_api2.pygoecharger_ha import GoeChargerApiV2Bridge + bridge = GoeChargerApiV2Bridge(web_session=async_get_clientsession(hass), intg_type=intg_type, + host="192.0.2.10", access_password=password) + if websocket: + bridge.ws_connected = True + bridge._ws_connection = SimpleNamespace(send_json=AsyncMock()) + return bridge diff --git a/tests/integration/pytest.ini b/tests/integration/pytest.ini new file mode 100644 index 0000000..19bb58a --- /dev/null +++ b/tests/integration/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +asyncio_mode = auto +pythonpath = ../.. +markers = + known_bug(description): the test describes a bug that is not fixed yet; it fails on purpose. Deselect with -m "not known_bug". + needs_decision(description): documentation and code differ; it is not yet decided whether this is intended or a documentation gap. Fails on purpose, not blocking. Deselect with -m "not needs_decision". diff --git a/tests/integration/snapshots/entity_ids.json b/tests/integration/snapshots/entity_ids.json new file mode 100644 index 0000000..3bca5a9 --- /dev/null +++ b/tests/integration/snapshots/entity_ids.json @@ -0,0 +1,477 @@ +{ + "charger_lan": [ + "binary_sensor.goe_123456_adi", + "binary_sensor.goe_123456_alw", + "binary_sensor.goe_123456_car_0", + "binary_sensor.goe_123456_ctrls_0_connected", + "binary_sensor.goe_123456_ctrls_0_paired", + "binary_sensor.goe_123456_di1", + "binary_sensor.goe_123456_esk", + "binary_sensor.goe_123456_fsp", + "binary_sensor.goe_123456_pha_0", + "binary_sensor.goe_123456_pha_1", + "binary_sensor.goe_123456_pha_2", + "binary_sensor.goe_123456_pha_3", + "binary_sensor.goe_123456_pha_4", + "binary_sensor.goe_123456_pha_5", + "binary_sensor.goe_123456_tlf", + "binary_sensor.goe_123456_tls", + "button.goe_123456_rst", + "button.goe_123456_zfocore", + "button.goe_123456_zforeal", + "number.goe_123456_ama", + "number.goe_123456_amp", + "number.goe_123456_ate", + "number.goe_123456_att", + "number.goe_123456_awp", + "number.goe_123456_cco", + "number.goe_123456_dwo", + "number.goe_123456_fmt", + "number.goe_123456_fst", + "number.goe_123456_lbr", + "number.goe_123456_lof", + "number.goe_123456_lop", + "number.goe_123456_lot_amp", + "number.goe_123456_lot_dyn", + "number.goe_123456_lot_sta", + "number.goe_123456_lot_ts", + "number.goe_123456_mca", + "number.goe_123456_mci", + "number.goe_123456_mcpd", + "number.goe_123456_mcpea", + "number.goe_123456_mptwt", + "number.goe_123456_mpwst", + "number.goe_123456_pgt", + "number.goe_123456_po", + "number.goe_123456_psh", + "number.goe_123456_psmd", + "number.goe_123456_rdbf", + "number.goe_123456_rdbs", + "number.goe_123456_rdef", + "number.goe_123456_rdes", + "number.goe_123456_rdpl", + "number.goe_123456_rdre", + "number.goe_123456_sh", + "number.goe_123456_spl3", + "number.goe_123456_sumd", + "number.goe_123456_tcl", + "number.goe_123456_zfo", + "select.goe_123456_bac", + "select.goe_123456_ct", + "select.goe_123456_frc", + "select.goe_123456_frm", + "select.goe_123456_iso2ena", + "select.goe_123456_lmo", + "select.goe_123456_loty", + "select.goe_123456_psm", + "select.goe_123456_sch_satur_control", + "select.goe_123456_sch_sund_control", + "select.goe_123456_sch_week_control", + "select.goe_123456_sdp", + "select.goe_123456_tds", + "select.goe_123456_trx", + "select.goe_123456_ust", + "sensor.goe_123456_acu", + "sensor.goe_123456_amt", + "sensor.goe_123456_awcp_marketprice", + "sensor.goe_123456_car", + "sensor.goe_123456_car_value", + "sensor.goe_123456_cards_0_energy", + "sensor.goe_123456_cards_1_energy", + "sensor.goe_123456_cards_2_energy", + "sensor.goe_123456_cards_3_energy", + "sensor.goe_123456_cards_4_energy", + "sensor.goe_123456_cards_5_energy", + "sensor.goe_123456_cards_6_energy", + "sensor.goe_123456_cards_7_energy", + "sensor.goe_123456_cards_8_energy", + "sensor.goe_123456_cards_9_energy", + "sensor.goe_123456_cbl", + "sensor.goe_123456_ccw_ssid", + "sensor.goe_123456_cdi_type", + "sensor.goe_123456_cdi_value", + "sensor.goe_123456_cll", + "sensor.goe_123456_cus", + "sensor.goe_123456_cus_value", + "sensor.goe_123456_deltaa", + "sensor.goe_123456_deltap", + "sensor.goe_123456_err", + "sensor.goe_123456_err_value", + "sensor.goe_123456_eto", + "sensor.goe_123456_ffb", + "sensor.goe_123456_ffb_value", + "sensor.goe_123456_fhz", + "sensor.goe_123456_frm", + "sensor.goe_123456_frm_value", + "sensor.goe_123456_fsptws", + "sensor.goe_123456_fsptws_delta", + "sensor.goe_123456_inva", + "sensor.goe_123456_inva_delta", + "sensor.goe_123456_lbp", + "sensor.goe_123456_lbp_delta", + "sensor.goe_123456_lccfc", + "sensor.goe_123456_lccfc_delta", + "sensor.goe_123456_lccfi", + "sensor.goe_123456_lccfi_delta", + "sensor.goe_123456_lcctc", + "sensor.goe_123456_lcctc_delta", + "sensor.goe_123456_lck", + "sensor.goe_123456_lck_value", + "sensor.goe_123456_lcs", + "sensor.goe_123456_lcs_delta", + "sensor.goe_123456_lfspt", + "sensor.goe_123456_lfspt_delta", + "sensor.goe_123456_lmsc", + "sensor.goe_123456_lmsc_delta", + "sensor.goe_123456_loa", + "sensor.goe_123456_lpsc", + "sensor.goe_123456_lpsc_delta", + "sensor.goe_123456_map", + "sensor.goe_123456_mmp", + "sensor.goe_123456_modelstatus", + "sensor.goe_123456_modelstatus_value", + "sensor.goe_123456_nif", + "sensor.goe_123456_nrg_0", + "sensor.goe_123456_nrg_1", + "sensor.goe_123456_nrg_10", + "sensor.goe_123456_nrg_11", + "sensor.goe_123456_nrg_12", + "sensor.goe_123456_nrg_13", + "sensor.goe_123456_nrg_14", + "sensor.goe_123456_nrg_15", + "sensor.goe_123456_nrg_2", + "sensor.goe_123456_nrg_3", + "sensor.goe_123456_nrg_4", + "sensor.goe_123456_nrg_5", + "sensor.goe_123456_nrg_6", + "sensor.goe_123456_nrg_7", + "sensor.goe_123456_nrg_8", + "sensor.goe_123456_nrg_9", + "sensor.goe_123456_pakku", + "sensor.goe_123456_pgrid", + "sensor.goe_123456_pnp", + "sensor.goe_123456_ppv", + "sensor.goe_123456_pvopt_averagepakku", + "sensor.goe_123456_pvopt_averagepgrid", + "sensor.goe_123456_pvopt_averageppv", + "sensor.goe_123456_pwm", + "sensor.goe_123456_pwm_value", + "sensor.goe_123456_rbc", + "sensor.goe_123456_rbt", + "sensor.goe_123456_rfb", + "sensor.goe_123456_rssi", + "sensor.goe_123456_tma_0", + "sensor.goe_123456_tma_1", + "sensor.goe_123456_tma_2", + "sensor.goe_123456_tma_3", + "sensor.goe_123456_tma_4", + "sensor.goe_123456_tma_5", + "sensor.goe_123456_tpa", + "sensor.goe_123456_wh", + "sensor.goe_123456_wsms", + "sensor.goe_123456_wsms_value", + "sensor.goe_123456_wst", + "sensor.goe_123456_wst_value", + "switch.goe_123456_acp", + "switch.goe_123456_acs", + "switch.goe_123456_ara", + "switch.goe_123456_awe", + "switch.goe_123456_cae", + "switch.goe_123456_cmse", + "switch.goe_123456_cwe", + "switch.goe_123456_fup", + "switch.goe_123456_fzf", + "switch.goe_123456_hai", + "switch.goe_123456_loe", + "switch.goe_123456_lse", + "switch.goe_123456_nmo", + "switch.goe_123456_ocppe", + "switch.goe_123456_su", + "switch.goe_123456_sua", + "switch.goe_123456_upo" + ], + "charger_wan": [ + "binary_sensor.goe_wan_123456_adi", + "binary_sensor.goe_wan_123456_alw", + "binary_sensor.goe_wan_123456_car_0", + "binary_sensor.goe_wan_123456_ctrls_0_connected", + "binary_sensor.goe_wan_123456_ctrls_0_paired", + "binary_sensor.goe_wan_123456_di1", + "binary_sensor.goe_wan_123456_esk", + "binary_sensor.goe_wan_123456_fsp", + "binary_sensor.goe_wan_123456_pha_0", + "binary_sensor.goe_wan_123456_pha_1", + "binary_sensor.goe_wan_123456_pha_2", + "binary_sensor.goe_wan_123456_pha_3", + "binary_sensor.goe_wan_123456_pha_4", + "binary_sensor.goe_wan_123456_pha_5", + "binary_sensor.goe_wan_123456_tlf", + "binary_sensor.goe_wan_123456_tls", + "button.goe_wan_123456_rst", + "button.goe_wan_123456_zfocore", + "button.goe_wan_123456_zforeal", + "number.goe_wan_123456_ama", + "number.goe_wan_123456_amp", + "number.goe_wan_123456_ate", + "number.goe_wan_123456_att", + "number.goe_wan_123456_awp", + "number.goe_wan_123456_cco", + "number.goe_wan_123456_dwo", + "number.goe_wan_123456_fmt", + "number.goe_wan_123456_fst", + "number.goe_wan_123456_lbr", + "number.goe_wan_123456_lof", + "number.goe_wan_123456_lop", + "number.goe_wan_123456_lot_amp", + "number.goe_wan_123456_lot_dyn", + "number.goe_wan_123456_lot_sta", + "number.goe_wan_123456_lot_ts", + "number.goe_wan_123456_mca", + "number.goe_wan_123456_mci", + "number.goe_wan_123456_mcpd", + "number.goe_wan_123456_mcpea", + "number.goe_wan_123456_mptwt", + "number.goe_wan_123456_mpwst", + "number.goe_wan_123456_pgt", + "number.goe_wan_123456_po", + "number.goe_wan_123456_psh", + "number.goe_wan_123456_psmd", + "number.goe_wan_123456_rdbf", + "number.goe_wan_123456_rdbs", + "number.goe_wan_123456_rdef", + "number.goe_wan_123456_rdes", + "number.goe_wan_123456_rdpl", + "number.goe_wan_123456_rdre", + "number.goe_wan_123456_sh", + "number.goe_wan_123456_spl3", + "number.goe_wan_123456_sumd", + "number.goe_wan_123456_tcl", + "number.goe_wan_123456_zfo", + "select.goe_wan_123456_bac", + "select.goe_wan_123456_ct", + "select.goe_wan_123456_frc", + "select.goe_wan_123456_frm", + "select.goe_wan_123456_iso2ena", + "select.goe_wan_123456_lmo", + "select.goe_wan_123456_loty", + "select.goe_wan_123456_psm", + "select.goe_wan_123456_sch_satur_control", + "select.goe_wan_123456_sch_sund_control", + "select.goe_wan_123456_sch_week_control", + "select.goe_wan_123456_sdp", + "select.goe_wan_123456_tds", + "select.goe_wan_123456_trx", + "select.goe_wan_123456_ust", + "sensor.goe_wan_123456_acu", + "sensor.goe_wan_123456_amt", + "sensor.goe_wan_123456_awcp_marketprice", + "sensor.goe_wan_123456_car", + "sensor.goe_wan_123456_car_value", + "sensor.goe_wan_123456_cards_0_energy", + "sensor.goe_wan_123456_cards_1_energy", + "sensor.goe_wan_123456_cards_2_energy", + "sensor.goe_wan_123456_cards_3_energy", + "sensor.goe_wan_123456_cards_4_energy", + "sensor.goe_wan_123456_cards_5_energy", + "sensor.goe_wan_123456_cards_6_energy", + "sensor.goe_wan_123456_cards_7_energy", + "sensor.goe_wan_123456_cards_8_energy", + "sensor.goe_wan_123456_cards_9_energy", + "sensor.goe_wan_123456_cbl", + "sensor.goe_wan_123456_ccw_ssid", + "sensor.goe_wan_123456_cdi_type", + "sensor.goe_wan_123456_cdi_value", + "sensor.goe_wan_123456_cll", + "sensor.goe_wan_123456_cus", + "sensor.goe_wan_123456_cus_value", + "sensor.goe_wan_123456_deltaa", + "sensor.goe_wan_123456_deltap", + "sensor.goe_wan_123456_err", + "sensor.goe_wan_123456_err_value", + "sensor.goe_wan_123456_eto", + "sensor.goe_wan_123456_ffb", + "sensor.goe_wan_123456_ffb_value", + "sensor.goe_wan_123456_fhz", + "sensor.goe_wan_123456_frm", + "sensor.goe_wan_123456_frm_value", + "sensor.goe_wan_123456_fsptws", + "sensor.goe_wan_123456_fsptws_delta", + "sensor.goe_wan_123456_inva", + "sensor.goe_wan_123456_inva_delta", + "sensor.goe_wan_123456_lbp", + "sensor.goe_wan_123456_lbp_delta", + "sensor.goe_wan_123456_lccfc", + "sensor.goe_wan_123456_lccfc_delta", + "sensor.goe_wan_123456_lccfi", + "sensor.goe_wan_123456_lccfi_delta", + "sensor.goe_wan_123456_lcctc", + "sensor.goe_wan_123456_lcctc_delta", + "sensor.goe_wan_123456_lck", + "sensor.goe_wan_123456_lck_value", + "sensor.goe_wan_123456_lcs", + "sensor.goe_wan_123456_lcs_delta", + "sensor.goe_wan_123456_lfspt", + "sensor.goe_wan_123456_lfspt_delta", + "sensor.goe_wan_123456_lmsc", + "sensor.goe_wan_123456_lmsc_delta", + "sensor.goe_wan_123456_loa", + "sensor.goe_wan_123456_lpsc", + "sensor.goe_wan_123456_lpsc_delta", + "sensor.goe_wan_123456_map", + "sensor.goe_wan_123456_mmp", + "sensor.goe_wan_123456_modelstatus", + "sensor.goe_wan_123456_modelstatus_value", + "sensor.goe_wan_123456_nif", + "sensor.goe_wan_123456_nrg_0", + "sensor.goe_wan_123456_nrg_1", + "sensor.goe_wan_123456_nrg_10", + "sensor.goe_wan_123456_nrg_11", + "sensor.goe_wan_123456_nrg_12", + "sensor.goe_wan_123456_nrg_13", + "sensor.goe_wan_123456_nrg_14", + "sensor.goe_wan_123456_nrg_15", + "sensor.goe_wan_123456_nrg_2", + "sensor.goe_wan_123456_nrg_3", + "sensor.goe_wan_123456_nrg_4", + "sensor.goe_wan_123456_nrg_5", + "sensor.goe_wan_123456_nrg_6", + "sensor.goe_wan_123456_nrg_7", + "sensor.goe_wan_123456_nrg_8", + "sensor.goe_wan_123456_nrg_9", + "sensor.goe_wan_123456_pakku", + "sensor.goe_wan_123456_pgrid", + "sensor.goe_wan_123456_pnp", + "sensor.goe_wan_123456_ppv", + "sensor.goe_wan_123456_pvopt_averagepakku", + "sensor.goe_wan_123456_pvopt_averagepgrid", + "sensor.goe_wan_123456_pvopt_averageppv", + "sensor.goe_wan_123456_pwm", + "sensor.goe_wan_123456_pwm_value", + "sensor.goe_wan_123456_rbc", + "sensor.goe_wan_123456_rbt", + "sensor.goe_wan_123456_rfb", + "sensor.goe_wan_123456_rssi", + "sensor.goe_wan_123456_tma_0", + "sensor.goe_wan_123456_tma_1", + "sensor.goe_wan_123456_tma_2", + "sensor.goe_wan_123456_tma_3", + "sensor.goe_wan_123456_tma_4", + "sensor.goe_wan_123456_tma_5", + "sensor.goe_wan_123456_tpa", + "sensor.goe_wan_123456_wh", + "sensor.goe_wan_123456_wsms", + "sensor.goe_wan_123456_wsms_value", + "sensor.goe_wan_123456_wst", + "sensor.goe_wan_123456_wst_value", + "switch.goe_wan_123456_acp", + "switch.goe_wan_123456_acs", + "switch.goe_wan_123456_ara", + "switch.goe_wan_123456_awe", + "switch.goe_wan_123456_cae", + "switch.goe_wan_123456_cmse", + "switch.goe_wan_123456_cwe", + "switch.goe_wan_123456_fup", + "switch.goe_wan_123456_fzf", + "switch.goe_wan_123456_hai", + "switch.goe_wan_123456_loe", + "switch.goe_wan_123456_lse", + "switch.goe_wan_123456_nmo", + "switch.goe_wan_123456_ocppe", + "switch.goe_wan_123456_su", + "switch.goe_wan_123456_sua", + "switch.goe_wan_123456_upo" + ], + "controller_lan": [ + "button.goe_123456_rst", + "select.goe_123456_ips_0", + "select.goe_123456_ips_1", + "select.goe_123456_ips_2", + "select.goe_123456_ips_3", + "select.goe_123456_ips_4", + "select.goe_123456_ips_5", + "sensor.goe_123456_ccp_0", + "sensor.goe_123456_ccp_1", + "sensor.goe_123456_ccp_10", + "sensor.goe_123456_ccp_11", + "sensor.goe_123456_ccp_12", + "sensor.goe_123456_ccp_13", + "sensor.goe_123456_ccp_14", + "sensor.goe_123456_ccp_15", + "sensor.goe_123456_ccp_2", + "sensor.goe_123456_ccp_3", + "sensor.goe_123456_ccp_4", + "sensor.goe_123456_ccp_5", + "sensor.goe_123456_ccp_6", + "sensor.goe_123456_ccp_7", + "sensor.goe_123456_ccp_8", + "sensor.goe_123456_ccp_9", + "sensor.goe_123456_ccw_ssid", + "sensor.goe_123456_cec_0_0", + "sensor.goe_123456_cec_0_1", + "sensor.goe_123456_cec_10_0", + "sensor.goe_123456_cec_10_1", + "sensor.goe_123456_cec_11_0", + "sensor.goe_123456_cec_11_1", + "sensor.goe_123456_cec_12_0", + "sensor.goe_123456_cec_12_1", + "sensor.goe_123456_cec_13_0", + "sensor.goe_123456_cec_13_1", + "sensor.goe_123456_cec_14_0", + "sensor.goe_123456_cec_14_1", + "sensor.goe_123456_cec_15_0", + "sensor.goe_123456_cec_15_1", + "sensor.goe_123456_cec_1_0", + "sensor.goe_123456_cec_1_1", + "sensor.goe_123456_cec_2_0", + "sensor.goe_123456_cec_2_1", + "sensor.goe_123456_cec_3_0", + "sensor.goe_123456_cec_3_1", + "sensor.goe_123456_cec_4_0", + "sensor.goe_123456_cec_4_1", + "sensor.goe_123456_cec_5_0", + "sensor.goe_123456_cec_5_1", + "sensor.goe_123456_cec_6_0", + "sensor.goe_123456_cec_6_1", + "sensor.goe_123456_cec_7_0", + "sensor.goe_123456_cec_7_1", + "sensor.goe_123456_cec_8_0", + "sensor.goe_123456_cec_8_1", + "sensor.goe_123456_cec_9_0", + "sensor.goe_123456_cec_9_1", + "sensor.goe_123456_isv_0_f", + "sensor.goe_123456_isv_0_i", + "sensor.goe_123456_isv_0_p", + "sensor.goe_123456_isv_1_f", + "sensor.goe_123456_isv_1_i", + "sensor.goe_123456_isv_1_p", + "sensor.goe_123456_isv_2_f", + "sensor.goe_123456_isv_2_i", + "sensor.goe_123456_isv_2_p", + "sensor.goe_123456_isv_3_f", + "sensor.goe_123456_isv_3_i", + "sensor.goe_123456_isv_3_p", + "sensor.goe_123456_isv_4_f", + "sensor.goe_123456_isv_4_i", + "sensor.goe_123456_isv_4_p", + "sensor.goe_123456_isv_5_f", + "sensor.goe_123456_isv_5_i", + "sensor.goe_123456_isv_5_p", + "sensor.goe_123456_mecu", + "sensor.goe_123456_mmh", + "sensor.goe_123456_usv_0_u1", + "sensor.goe_123456_usv_0_u2", + "sensor.goe_123456_usv_0_u3", + "sensor.goe_123456_usv_0_un", + "sensor.goe_123456_wst_value", + "switch.goe_123456_hai", + "switch.goe_123456_iim_0", + "switch.goe_123456_iim_1", + "switch.goe_123456_iim_2", + "switch.goe_123456_iim_3", + "switch.goe_123456_iim_4", + "switch.goe_123456_iim_5", + "switch.goe_123456_mece", + "switch.goe_123456_mme", + "switch.goe_123456_wda" + ] +} diff --git a/tests/integration/test_api_contract.py b/tests/integration/test_api_contract.py new file mode 100644 index 0000000..27b16b5 --- /dev/null +++ b/tests/integration/test_api_contract.py @@ -0,0 +1,112 @@ +"""Contract between the official go-e key table and the integration. + +Source of truth: `contract/api_keys.json`, generated from the official key table (see +`contract/generate_api_keys.py` for the commit it comes from). The tests check three things: + +1. Every documented key is either mapped to a `Tag` or listed in `contract/classification.json` + (unsupported keys are documented there with a group, decision "open"). +2. Every key the code uses that the official table does not list is explained in the same file. +3. Read/write flag and category of every mapped key match the documentation. Known differences are + listed in `contract/deviations.json`; their tests carry the marker `needs_decision` (they fail on + purpose, but are not blocking until someone has decided whether the difference is intended or the + documentation has a gap). A NEW difference has no marker and is blocking. +""" +import json +import re +from pathlib import Path + +import pytest + +from custom_components.goecharger_api2 import const as entity_const +from custom_components.goecharger_api2.pygoecharger_ha import const as bridge_const +from custom_components.goecharger_api2.pygoecharger_ha.keys import CAT, Tag + +CONTRACT = Path(__file__).parent / "contract" +DOC = json.loads((CONTRACT / "api_keys.json").read_text())["keys"] +CLASSIFICATION = json.loads((CONTRACT / "classification.json").read_text()) +DEVIATIONS = json.loads((CONTRACT / "deviations.json").read_text())["deviations"] + +TAGS = {tag.key: tag for tag in Tag} +CARD_KEY = re.compile(r"c\d[nei]") +CATEGORY = {"Config": CAT.CONFIG, "Status": CAT.STATUS, "Constant": CAT.CONSTANT, "Other": CAT.OTHER} + + +def _filter_keys(): + keys = set() + for name in dir(bridge_const): + value = getattr(bridge_const, name) + if name.startswith("FILTER_") and isinstance(value, str): + keys |= {k for k in re.split(r"[,\s]+", re.sub(r"\{.*?\}", "", value)) if k} + return keys + + +def _entity_keys(): + return {d.key for name in dir(entity_const) if isinstance(getattr(entity_const, name), list) + for d in getattr(entity_const, name) if hasattr(d, "key")} + + +FILTER_KEYS = _filter_keys() +CODE_KEYS = set(TAGS) | FILTER_KEYS | _entity_keys() + + +def _known(aspect): + return {d["key"] for d in DEVIATIONS if d["aspect"] == aspect} + + +def _cases(aspect): + known = _known(aspect) + return [pytest.param(key, id=key, marks=[pytest.mark.needs_decision] if key in known else []) + for key in sorted(set(DOC) & set(TAGS))] + + +# ----------------------------------------------------------------------------- documented keys + +def test_every_documented_key_is_supported_or_classified(): + classified = set(CLASSIFICATION["handled_outside_tag"]) | set(CLASSIFICATION["not_supported"]) + unclassified = sorted(set(DOC) - set(TAGS) - classified) + assert unclassified == [], "new documented keys: map them to a Tag or add them to classification.json" + + +def test_classification_has_no_stale_entries(): + listed = set(CLASSIFICATION["handled_outside_tag"]) | set(CLASSIFICATION["not_supported"]) + stale = sorted(k for k in listed if k not in DOC or k in TAGS) + assert stale == [], "supported or no longer documented: remove them from classification.json" + + +def test_card_keys_are_really_requested_through_the_card_filters(): + requested = {k for name in ("FILTER_CARDS_ID_FWV60", "FILTER_CARDS_ENGY_FWV60", "FILTER_CARDS_NAME_FWV60") + for k in getattr(bridge_const, name).split(",")} + missing = sorted(k for k in CLASSIFICATION["handled_outside_tag"] if k not in requested) + assert missing == [] + + +# ----------------------------------------------------------------------------- keys used but not documented + +def test_every_undocumented_key_used_by_the_code_is_explained(): + explained = set(CLASSIFICATION["undocumented_in_code"]) | set(CLASSIFICATION["handled_outside_tag"]) + unexplained = sorted(k for k in CODE_KEYS - set(DOC) if k not in explained) + assert unexplained == [], "not in the official table: add them to classification.json with a reason" + + +def test_undocumented_classification_has_no_stale_entries(): + stale = sorted(k for k in CLASSIFICATION["undocumented_in_code"] if k in DOC or k not in CODE_KEYS) + assert stale == [], "documented now, or no longer used: remove them from classification.json" + + +# ----------------------------------------------------------------------------- flags of mapped keys + +@pytest.mark.parametrize("key", _cases("writeable")) +def test_write_flag_matches_documentation(key): + documented = DOC[key]["rw"] in ("R/W", "W") + assert TAGS[key].writeable is documented, f"documented {DOC[key]['rw']!r}, keys.py writeable={TAGS[key].writeable}" + + +@pytest.mark.parametrize("key", _cases("category")) +def test_category_matches_documentation(key): + assert TAGS[key].cat == CATEGORY[DOC[key]["category"]], \ + f"documented {DOC[key]['category']!r}, keys.py {TAGS[key].cat.name}" + + +def test_deviation_list_has_no_stale_entries(): + stale = sorted(f"{d['key']}:{d['aspect']}" for d in DEVIATIONS if d["key"] not in TAGS or d["key"] not in DOC) + assert stale == [] diff --git a/tests/integration/test_bridge.py b/tests/integration/test_bridge.py new file mode 100644 index 0000000..c641933 --- /dev/null +++ b/tests/integration/test_bridge.py @@ -0,0 +1,45 @@ +"""Bridge: constructor validation and error handling of the HTTP write.""" +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from custom_components.goecharger_api2.pygoecharger_ha import GoeChargerApiV2Bridge + + +def test_constructor_needs_host_or_serial_and_token(): + with pytest.raises(ValueError, match="host, or a serial"): + GoeChargerApiV2Bridge(web_session=None, intg_type="charger") + + +class _Response: + def __init__(self, status, content_length, json_body=None): + self.status = status + self.content_length = content_length + self.headers = {} if content_length is None else {"Content-Length": str(content_length)} + self.json = AsyncMock(return_value=json_body) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +def _bridge(response): + session = SimpleNamespace(get=lambda *args, **kwargs: response) + return GoeChargerApiV2Bridge(web_session=session, intg_type="charger", host="192.0.2.10") + + +async def test_error_response_with_body_is_returned(): + bridge = _bridge(_Response(500, content_length=20, json_body={"amp": "error"})) + assert await bridge._write_values_int({"amp": "16"}, "amp", 16) == {"err": {"amp": "error"}} + + +async def test_error_response_without_content_length_does_not_raise_keyerror(caplog): + """Chunked responses have no Content-Length header: this used to raise a KeyError.""" + bridge = _bridge(_Response(500, content_length=None)) + with caplog.at_level(logging.WARNING): + assert await bridge._write_values_int({"amp": "16"}, "amp", 16) == {} + assert "KeyError" not in caplog.text diff --git a/tests/integration/test_bridge_http.py b/tests/integration/test_bridge_http.py new file mode 100644 index 0000000..f89c77a --- /dev/null +++ b/tests/integration/test_bridge_http.py @@ -0,0 +1,123 @@ +"""Bridge, HTTP path: what is requested from the charger and how answers are interpreted. + +The HTTP layer is mocked with Home Assistant's `aioclient_mock`; the bridge is the real code. +The expected requests follow the official go-e HTTP API v2 documentation (http-en.md, cloudapi-en.md). +""" +import pytest +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from custom_components.goecharger_api2.pygoecharger_ha import GoeChargerApiV2Bridge, NonNullDict + +LAN_STATUS = "http://192.0.2.10/api/status" +LAN_SET = "http://192.0.2.10/api/set" + + +def _lan(hass): + return GoeChargerApiV2Bridge(web_session=async_get_clientsession(hass), intg_type="charger", + host="192.0.2.10") + + +def _cloud(hass, serial="1234"): + return GoeChargerApiV2Bridge(web_session=async_get_clientsession(hass), intg_type="charger", + serial=serial, token="secret-token") + + +def _query(aioclient_mock, index=-1): + return dict(aioclient_mock.mock_calls[index][1].query) + + +# ----------------------------------------------------------------------------- reading + +async def test_read_filtered_sends_comma_separated_filter(hass, aioclient_mock): + aioclient_mock.get(LAN_STATUS, json={"car": 1, "amp": 16}) + result = await _lan(hass)._read_filtered_data("car,amp", "test") + assert _query(aioclient_mock) == {"filter": "car,amp"} + assert result == {"car": 1, "amp": 16} + assert isinstance(result, NonNullDict) + + +@pytest.mark.parametrize("status", [403, 404, 500]) +async def test_read_filtered_returns_empty_for_error_status(hass, aioclient_mock, status): + aioclient_mock.get(LAN_STATUS, status=status, json={"error": "x"}) + assert await _lan(hass)._read_filtered_data("car", "test") == {} + + +async def test_read_filtered_returns_empty_for_invalid_json(hass, aioclient_mock): + aioclient_mock.get(LAN_STATUS, text="not json") + assert await _lan(hass)._read_filtered_data("car", "test") == {} + + +async def test_none_null_get_treats_json_null_as_missing(hass, aioclient_mock): + aioclient_mock.get(LAN_STATUS, json={"car": None}) + result = await _lan(hass)._read_filtered_data("car", "test") + assert result.none_null_get("car", 7) == 7 + + +# ----------------------------------------------------------------------------- cloud addressing + +async def test_cloud_url_pads_serial_and_sends_bearer_token(hass, aioclient_mock): + aioclient_mock.get("https://001234.api.v3.go-e.io/api/status", json={"car": 1}) + await _cloud(hass)._read_filtered_data("car", "test") + headers = aioclient_mock.mock_calls[0][3] + assert headers == {"Authorization": "Bearer secret-token"} + + +async def test_lan_requests_send_no_authorization_header(hass, aioclient_mock): + aioclient_mock.get(LAN_STATUS, json={"car": 1}) + await _lan(hass)._read_filtered_data("car", "test") + assert aioclient_mock.mock_calls[0][3] is None + + +# ----------------------------------------------------------------------------- writing + +@pytest.mark.parametrize("value, expected", [ + (16, "16"), + (True, "true"), + (False, "false"), + (3.5, "3.5"), + ("my charger", '"my charger"'), + ({"pGrid": -500.0, "pPv": 1500.0}, '{"pGrid":-500.0,"pPv":1500.0}'), +]) +async def test_write_encodes_value_as_json_query_parameter(hass, aioclient_mock, value, expected): + aioclient_mock.get(LAN_SET, json={"x": True}) + await _lan(hass).write_value_to_key("x", value) + assert _query(aioclient_mock) == {"x": expected} + + +async def test_write_none_is_sent_as_null(hass, aioclient_mock): + aioclient_mock.get(LAN_SET, json={"dwo": True}) + await _lan(hass).write_value_to_key("dwo", None) + assert str(aioclient_mock.mock_calls[0][1]).endswith("/api/set?dwo=null") + + +async def test_write_success_returns_key_and_value(hass, aioclient_mock): + aioclient_mock.get(LAN_SET, json={"amp": True}) + assert await _lan(hass).write_value_to_key("amp", 10) == {"amp": 10} + + +async def test_write_rejected_by_charger_returns_err(hass, aioclient_mock): + aioclient_mock.get(LAN_SET, json={"amp": "value out of range"}) + result = await _lan(hass).write_value_to_key("amp", 99) + assert result == {"err": {"amp": "value out of range"}} + + +async def test_write_ids_sets_flag_to_read_pv_values_in_hibernation(hass, aioclient_mock): + aioclient_mock.get(LAN_SET, json={"ids": True}) + bridge = _lan(hass) + await bridge.write_value_to_key("ids", {"pGrid": 0, "pPv": 0, "pAkku": 0}) + assert bridge._REQUEST_IDS_DATA is True + + +async def test_write_other_key_forces_config_and_state_refresh(hass, aioclient_mock): + aioclient_mock.get(LAN_SET, json={"amp": True}) + bridge = _lan(hass) + bridge._LAST_CONFIG_UPDATE_TS = bridge._LAST_FULL_STATE_UPDATE_TS = 123.0 + await bridge.write_value_to_key("amp", 10) + assert bridge._LAST_CONFIG_UPDATE_TS == 0 and bridge._LAST_FULL_STATE_UPDATE_TS == 0 + + +async def test_cloud_write_succeeds_with_http_200(hass, aioclient_mock): + """The official cloud docs name HTTP 202 for /api/set, the maintainer states that the real cloud answers 200 + (the docs are wrong there, as they are for the controller cloud endpoint).""" + aioclient_mock.get("https://001234.api.v3.go-e.io/api/set", status=200, json={"amp": True}) + assert await _cloud(hass).write_value_to_key("amp", 10) == {"amp": 10} diff --git a/tests/integration/test_bridge_polling.py b/tests/integration/test_bridge_polling.py new file mode 100644 index 0000000..951f0f9 --- /dev/null +++ b/tests/integration/test_bridge_polling.py @@ -0,0 +1,424 @@ +"""Bridge, polling: the read cycle (hibernation, configuration refresh, versions) and the state housekeeping. + +Behaviour is derived from the code and the README (hibernation mode). The HTTP layer is mocked; time +is controlled by setting the bridge's "last update" timestamps. +""" +import asyncio +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from custom_components.goecharger_api2.pygoecharger_ha import NonNullDict +from custom_components.goecharger_api2.pygoecharger_ha.const import ( + FILTER_CARDS_ID_CLASSIC, + FILTER_CARDS_ID_FWV60, + FILTER_CONTROLER_ALL_STATES, + FILTER_CONTROLER_MIN_STATES, + FILTER_IDS_ADDON, + FILTER_MIN_STATES, + FILTER_TIMES_ADDON, +) + +from .factories import make_bridge, make_coordinator + +STATUS = "http://192.0.2.10/api/status" +SLEEP = "custom_components.goecharger_api2.pygoecharger_ha.asyncio.sleep" + + +def _filters(aioclient_mock): + return [dict(c[1].query).get("filter") for c in aioclient_mock.mock_calls] + + +# ----------------------------------------------------------------------------- states: hibernation + +async def test_first_read_requests_all_states_and_stores_the_time(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"car": 1, "amp": 6}) + bridge = make_bridge(hass) + await bridge.read_all_states() + assert bridge._states == {"car": 1, "amp": 6} + assert bridge._LAST_FULL_STATE_UPDATE_TS > 0 + assert len(_filters(aioclient_mock)) == 1 and "alw" in _filters(aioclient_mock)[0] + + +async def test_idle_charger_reads_only_the_minimal_states(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"car": 1, "nrg": [1]}) + bridge = make_bridge(hass) + bridge._states = {"car": 1} + bridge._LAST_FULL_STATE_UPDATE_TS = time.time() + await bridge.read_all_states() + expected = FILTER_MIN_STATES + FILTER_TIMES_ADDON + assert _filters(aioclient_mock) == [expected] + assert bridge._states["nrg"] == [1] + + +async def test_idle_charger_also_reads_pv_values_after_pv_data_was_sent(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"car": 1}) + bridge = make_bridge(hass) + bridge._states = {"car": 1} + bridge._LAST_FULL_STATE_UPDATE_TS = time.time() + bridge._REQUEST_IDS_DATA = True + await bridge.read_all_states() + assert _filters(aioclient_mock) == [FILTER_MIN_STATES + FILTER_IDS_ADDON + FILTER_TIMES_ADDON] + assert bridge._REQUEST_IDS_DATA is False + + +async def test_idle_charger_reads_everything_after_five_minutes(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"car": 1, "amp": 6}) + bridge = make_bridge(hass) + bridge._states = {"car": 1} + bridge._LAST_FULL_STATE_UPDATE_TS = time.time() - 301 + await bridge.read_all_states() + assert "alw" in _filters(aioclient_mock)[0] + + +async def test_charger_that_starts_charging_triggers_a_full_read(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"car": 2, "amp": 6}) + bridge = make_bridge(hass) + bridge._states = {"car": 1} + bridge._LAST_FULL_STATE_UPDATE_TS = time.time() + await bridge.read_all_states() + filters = _filters(aioclient_mock) + assert len(filters) == 2 and filters[0].startswith("car,modelStatus") and "alw" in filters[1] + + +async def test_charger_that_is_not_idle_reads_everything_every_time(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"car": 2}) + bridge = make_bridge(hass) + bridge._states = {"car": 2} + bridge._LAST_FULL_STATE_UPDATE_TS = time.time() + await bridge.read_all_states() + assert "alw" in _filters(aioclient_mock)[0] + + +async def test_empty_minimal_answer_changes_nothing(hass, aioclient_mock): + aioclient_mock.get(STATUS, status=404) + bridge = make_bridge(hass) + bridge._states = {"car": 1, "nrg": [5]} + bridge._LAST_FULL_STATE_UPDATE_TS = time.time() + await bridge.read_all_states() + assert bridge._states == {"car": 1, "nrg": [5]} + + +async def test_failed_full_read_keeps_the_previous_states(hass, aioclient_mock): + """Characterisation (arc42 R20): an empty answer does not clear the old values, and the 'last full + update' time is renewed although nothing was read.""" + aioclient_mock.get(STATUS, status=403) + bridge = make_bridge(hass) + bridge._states = {"car": 2, "amp": 6} + await bridge.read_all_states() + assert bridge._states == {"car": 2, "amp": 6} + assert bridge._LAST_FULL_STATE_UPDATE_TS > 0 + + +async def test_controller_reads_minimal_states_within_five_minutes(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"usv": [1]}) + bridge = make_bridge(hass, intg_type="controller") + bridge._LAST_FULL_STATE_UPDATE_TS = time.time() + await bridge.read_all_states() + assert _filters(aioclient_mock)[0].startswith(FILTER_CONTROLER_MIN_STATES) + + +async def test_controller_reads_everything_after_five_minutes(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"usv": [1]}) + bridge = make_bridge(hass, intg_type="controller") + await bridge.read_all_states() + assert _filters(aioclient_mock) == [FILTER_CONTROLER_ALL_STATES] + + +async def test_websocket_connection_reads_states_from_memory_not_from_http(hass, aioclient_mock): + bridge = make_bridge(hass, websocket=True) + bridge._ws_states = {"car": 2, "alw": True, "amp": 16, "zzz": 1} + await bridge.read_all_states() + assert aioclient_mock.call_count == 0 + assert bridge._states == {"car": 2, "alw": True} # amp is configuration, zzz is not requested + + +async def test_websocket_idle_charger_uses_the_minimal_filter_from_memory(hass, aioclient_mock): + bridge = make_bridge(hass, websocket=True) + bridge._ws_states = {"car": 1, "nrg": [1], "amp": 6} + bridge._states = {"car": 1} + bridge._LAST_FULL_STATE_UPDATE_TS = time.time() + await bridge.read_all_states() + assert aioclient_mock.call_count == 0 + assert bridge._states == {"car": 1, "nrg": [1]} + + +# ----------------------------------------------------------------------------- configuration + +async def test_config_read_stores_values_and_time(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"amp": 10, "frc": 0}) + bridge = make_bridge(hass) + await bridge.read_all_config() + assert bridge._config == {"amp": 10, "frc": 0} and bridge._LAST_CONFIG_UPDATE_TS > 0 + + +async def test_failed_config_read_waits_five_minutes_before_the_next_try(hass, aioclient_mock): + aioclient_mock.get(STATUS, status=500) + bridge = make_bridge(hass) + before = time.time() + await bridge.read_all_config() + # next regular refresh (one hour after the stored time) is due in about 300 seconds + assert before + 280 < bridge._LAST_CONFIG_UPDATE_TS + 3600 < before + 320 + + +async def test_config_read_replaces_websocket_values_that_differ(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"frc": 1, "amp": 10}) + bridge = make_bridge(hass) + bridge._ws_states = {"frc": 0, "amp": 10, "car": 1} + await bridge.read_all_config() + assert "frc" not in bridge._ws_states and bridge._ws_states["amp"] == 10 and bridge._ws_states["car"] == 1 + + +async def test_config_read_over_websocket_uses_memory(hass, aioclient_mock): + bridge = make_bridge(hass, websocket=True) + bridge._ws_states = {"amp": 16, "unrelated": 1} + await bridge.read_all_config() + assert aioclient_mock.call_count == 0 and bridge._config == {"amp": 16} + + +async def test_config_read_without_filter_does_nothing(hass, aioclient_mock): + bridge = make_bridge(hass) + bridge._FILTER_ALL_CONFIG = "" + await bridge.read_all_config() + assert aioclient_mock.call_count == 0 and bridge._LAST_CONFIG_UPDATE_TS == 0 + + +async def test_force_config_update_resets_the_timers_and_reads_again(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"amp": 10}) + bridge = make_bridge(hass) + bridge._LAST_FULL_STATE_UPDATE_TS = 123.0 + await bridge.force_config_update() + assert bridge._LAST_FULL_STATE_UPDATE_TS == 0 and bridge._config == {"amp": 10} + + +async def test_force_config_update_over_websocket_still_works(hass, aioclient_mock): + bridge = make_bridge(hass, websocket=True) + bridge._ws_states = {"amp": 16} + await bridge.force_config_update() + assert bridge._config == {"amp": 16} + + +# ----------------------------------------------------------------------------- read_all + +async def test_read_all_returns_a_merged_view_with_websocket_values_first(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"car": 1, "amp": 6}) + bridge = make_bridge(hass) + bridge._LAST_CONFIG_UPDATE_TS = time.time() + bridge._ws_states = {"amp": 99} + bridge._versions = {"fwv": "60.3"} + merged = await bridge.read_all() + assert merged["amp"] == 99 and merged["car"] == 1 and merged["fwv"] == "60.3" + + +async def test_read_all_reads_the_configuration_only_every_hour(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"car": 1}) + bridge = make_bridge(hass) + bridge._LAST_CONFIG_UPDATE_TS = time.time() + await bridge.read_all() + assert len(_filters(aioclient_mock)) == 1 # states only + aioclient_mock.clear_requests() + aioclient_mock.get(STATUS, json={"car": 1}) + bridge._LAST_CONFIG_UPDATE_TS = time.time() - 3601 + await bridge.read_all() + assert len(_filters(aioclient_mock)) == 2 # states and configuration + + +async def test_read_all_over_websocket_returns_the_websocket_dictionary(hass, aioclient_mock): + bridge = make_bridge(hass, websocket=True) + bridge._ws_states = {"car": 1} + bridge._LAST_CONFIG_UPDATE_TS = time.time() + assert await bridge.read_all() is bridge._ws_states + + +# ----------------------------------------------------------------------------- versions and system + +async def test_read_versions_over_websocket_uses_memory(hass, aioclient_mock): + bridge = make_bridge(hass, websocket=True) + bridge._ws_states = {"fwv": "60.3", "typ": "x", "cards": [{"cardId": 1}], "amp": 6} + assert await bridge.read_versions() is True + assert "amp" not in bridge._versions and bridge._versions["fwv"] == "60.3" + assert aioclient_mock.call_count == 0 + + +async def test_read_versions_without_any_data_fails(hass): + bridge = make_bridge(hass, websocket=True) + assert await bridge.read_versions() is False + + +async def test_read_versions_retries_until_data_arrives(hass): + bridge = make_bridge(hass) + bridge._read_filtered_data = AsyncMock(side_effect=[{}, NonNullDict({"fwv": "59.4", "cards": [1]})]) + with patch(SLEEP, AsyncMock()) as sleep: + assert await bridge.read_versions() is True + assert bridge._versions["fwv"] == "59.4" and bridge._read_filtered_data.await_count == 2 and sleep.await_count == 1 + + +async def test_read_versions_gives_up_after_five_attempts(hass, aioclient_mock): + aioclient_mock.get(STATUS, status=500) + bridge = make_bridge(hass) + with patch(SLEEP, AsyncMock()) as sleep: + assert await bridge.read_versions() is False + assert aioclient_mock.call_count == 5 and sleep.await_count == 4 + + +async def test_firmware_with_cards_object_keeps_the_classic_card_filter(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"fwv": "60.3", "cards": [{"cardId": 1}]}) + bridge = make_bridge(hass) + await bridge.read_versions() + assert FILTER_CARDS_ID_CLASSIC in bridge._FILTER_ALL_CONFIG and "c0i" not in bridge._FILTER_ALL_CONFIG + + +async def test_firmware_without_cards_object_switches_to_single_card_keys(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"fwv": "60.0-beta"}) + bridge = make_bridge(hass) + await bridge.read_versions() + assert FILTER_CARDS_ID_FWV60 in bridge._FILTER_ALL_CONFIG + + +async def test_controller_versions_do_not_touch_the_card_filters(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"fwv": "1.1.1"}) + bridge = make_bridge(hass, intg_type="controller") + before = bridge._FILTER_ALL_CONFIG + assert await bridge.read_versions() is True and bridge._FILTER_ALL_CONFIG == before + + +async def test_read_system_over_http_and_websocket(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"sse": "123456", "typ": "x"}) + assert await make_bridge(hass).read_system() == {"sse": "123456", "typ": "x"} + ws_bridge = make_bridge(hass, websocket=True) + ws_bridge._ws_states = {"sse": "1", "other": 2} + assert await ws_bridge.read_system() == {"sse": "1"} + + +# ----------------------------------------------------------------------------- read everything at once + +async def test_read_all_data_returns_the_whole_status(hass, aioclient_mock): + aioclient_mock.get(STATUS, json={"a": 1}) + assert await make_bridge(hass)._read_all_data() == {"a": 1} + + +@pytest.mark.parametrize("response", [dict(status=403), dict(status=404), dict(text="no json"), dict(json={})]) +async def test_read_all_data_gives_nothing_for_errors(hass, aioclient_mock, response): + aioclient_mock.get(STATUS, **response) + assert await make_bridge(hass)._read_all_data() == {} + + +async def test_read_all_data_sends_the_cloud_token(hass, aioclient_mock): + from homeassistant.helpers.aiohttp_client import async_get_clientsession + from custom_components.goecharger_api2.pygoecharger_ha import GoeChargerApiV2Bridge + aioclient_mock.get("https://001234.api.v3.go-e.io/api/status", json={"a": 1}) + bridge = GoeChargerApiV2Bridge(web_session=async_get_clientsession(hass), intg_type="charger", + serial="1234", token="tok") + await bridge._read_all_data() + assert aioclient_mock.mock_calls[0][3] == {"Authorization": "Bearer tok"} + + +# ----------------------------------------------------------------------------- housekeeping + +async def test_clear_data_resets_everything_and_cancels_the_pending_update(hass): + bridge = make_bridge(hass) + bridge._states, bridge._config, bridge._versions, bridge._ws_states = {"a": 1}, {"b": 2}, {"c": 3}, {"d": 4} + bridge._LAST_CONFIG_UPDATE_TS = bridge._LAST_FULL_STATE_UPDATE_TS = 5 + task = asyncio.create_task(asyncio.sleep(3600)) + bridge._ws_debounced_update_task = task + bridge.clear_data() + await asyncio.sleep(0) + assert task.cancelled() + assert (bridge._states, bridge._config, bridge._versions, bridge._ws_states) == ({}, {}, {}, {}) + assert bridge._LAST_CONFIG_UPDATE_TS == 0 and bridge.available_fields() == 0 + + +async def test_reset_timestamps_over_http_only_resets(hass): + bridge = make_bridge(hass) + bridge._LAST_CONFIG_UPDATE_TS = bridge._LAST_FULL_STATE_UPDATE_TS = 5 + await bridge.reset_stored_update_ts() + assert bridge._LAST_CONFIG_UPDATE_TS == bridge._LAST_FULL_STATE_UPDATE_TS == 0 + + +async def test_reset_timestamps_over_websocket_asks_the_coordinator_to_reconnect(hass): + bridge = make_bridge(hass, websocket=True) + bridge.coordinator = SimpleNamespace(force_async_update_now=AsyncMock()) + await bridge.reset_stored_update_ts() + bridge.coordinator.force_async_update_now.assert_awaited_once() + + +async def test_reset_timestamps_over_websocket_without_coordinator_does_not_fail(hass): + bridge = make_bridge(hass, websocket=True) + await bridge.reset_stored_update_ts() + + +async def test_websocket_update_reaches_the_coordinator_after_the_debounce(hass): + bridge = make_bridge(hass) + bridge.coordinator = SimpleNamespace(_ws_data_update_notify_interval_in_seconds=1, async_set_updated_data=lambda d: received.append(d)) + received = [] + bridge._ws_states = {"a": 1} + bridge._ws_LAST_NEW_DATA_NOTIFY = time.time() + with patch(SLEEP, AsyncMock()): + bridge._ws_notify_for_new_data() + await bridge._ws_debounced_update_task + assert received and received[0]["a"] == 1 and bridge._ws_LAST_NEW_DATA_NOTIFY > 0 + + +async def test_websocket_update_without_wait_when_the_last_one_is_old(hass): + bridge = make_bridge(hass) + received = [] + bridge.coordinator = SimpleNamespace(_ws_data_update_notify_interval_in_seconds=1, async_set_updated_data=received.append) + bridge._ws_LAST_NEW_DATA_NOTIFY = 0 + with patch(SLEEP, AsyncMock()) as sleep: + bridge._ws_notify_for_new_data() + await bridge._ws_debounced_update_task + sleep.assert_not_awaited() + assert len(received) == 1 + + +async def test_a_second_websocket_update_cancels_the_first_pending_one(hass): + bridge = make_bridge(hass) + bridge.coordinator = SimpleNamespace(_ws_data_update_notify_interval_in_seconds=60, async_set_updated_data=lambda d: None) + bridge._ws_LAST_NEW_DATA_NOTIFY = time.time() + bridge._ws_notify_for_new_data() + first = bridge._ws_debounced_update_task + bridge._ws_notify_for_new_data() + await asyncio.sleep(0) + assert first.cancelled() and bridge._ws_debounced_update_task is not first + bridge._ws_debounced_update_task.cancel() + + +async def test_websocket_silence_is_detected_after_fifty_seconds(hass): + bridge = make_bridge(hass) + bridge._ws_LAST_UPDATE = time.time() - 10 + assert bridge.ws_check_last_update() is True + bridge._ws_LAST_UPDATE = time.time() - 60 + assert bridge.ws_check_last_update() is False + + +async def test_ws_close_handles_missing_and_failing_connections(hass): + bridge = make_bridge(hass) + bridge.ws_connected = True + await bridge.ws_close(None) + assert bridge.ws_connected is False + failing = SimpleNamespace(close=AsyncMock(side_effect=RuntimeError("boom"))) + await bridge.ws_close(failing) + bridge._ws_serial = "123456" + ok = SimpleNamespace(close=AsyncMock()) + await bridge.ws_close(ok) + ok.close.assert_awaited_once() + + +async def test_expiring_store_forgets_old_entries(): + from custom_components.goecharger_api2.pygoecharger_ha import ExpiringStore + store = ExpiringStore(ttl=0) + store.set("1", {"a": 1}) + time.sleep(0.01) + assert store.get("1") is None and len(store) == 0 + store = ExpiringStore(ttl=60) + store.set("1", {"a": 1}) + assert store.get("1") == {"a": 1} and store.get("2") is None and len(store) == 1 + store._store["1"]["expires_at"] = 0 + assert store.get("1") is None + + +async def test_nonnull_dict_returns_default_for_null(hass): + data = NonNullDict({"a": None, "b": 1}) + assert data.none_null_get("a", 7) == 7 and data.none_null_get("b", 7) == 1 and data.none_null_get("c") is None diff --git a/tests/integration/test_bridge_write_paths.py b/tests/integration/test_bridge_write_paths.py new file mode 100644 index 0000000..f8d8356 --- /dev/null +++ b/tests/integration/test_bridge_write_paths.py @@ -0,0 +1,151 @@ +"""Bridge, writing: every path from a value to the charger and back (HTTP and WebSocket). + +HTTP is mocked with `aioclient_mock`, the WebSocket connection is a fake object. A write must either +return the written value, an error object, or an empty result, never raise for a bad answer. +""" +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import aiohttp +import pytest + +from custom_components.goecharger_api2.pygoecharger_ha import GoeChargerApiV2Bridge +from custom_components.goecharger_api2.pygoecharger_ha.keys import IS_TRIGGER, Tag + +from .factories import make_bridge + +SET = "http://192.0.2.10/api/set" +SLEEP = "custom_components.goecharger_api2.pygoecharger_ha.asyncio.sleep" + + +# ----------------------------------------------------------------------------- one key, over HTTP + +async def test_force_config_read_button_rereads_the_configuration(hass, aioclient_mock): + bridge = make_bridge(hass) + bridge.force_config_update = AsyncMock() + key = Tag.INTERNAL_FORCE_CONFIG_READ.key + assert await bridge.write_value_to_key(key, IS_TRIGGER) == {key: IS_TRIGGER} + bridge.force_config_update.assert_awaited_once() + assert aioclient_mock.call_count == 0 + + +async def test_force_refresh_button_resets_the_timers(hass, aioclient_mock): + bridge = make_bridge(hass) + bridge.reset_stored_update_ts = AsyncMock() + key = Tag.INTERNAL_FORCE_REFRESH_ALL.key + assert await bridge.write_value_to_key(key, IS_TRIGGER) == {key: IS_TRIGGER} + bridge.reset_stored_update_ts.assert_awaited_once() + + +async def test_any_other_trigger_does_nothing_and_sends_nothing(hass, aioclient_mock): + bridge = make_bridge(hass) + assert await bridge.write_value_to_key("other", IS_TRIGGER) == {"other": IS_TRIGGER} + assert aioclient_mock.call_count == 0 + + +async def test_websocket_flag_without_open_connection_falls_back_to_http(hass, aioclient_mock): + aioclient_mock.get(SET, json={"amp": True}) + bridge = make_bridge(hass) + bridge.ws_connected = True + bridge._ws_connection = None + assert await bridge.write_value_to_key("amp", 10) == {"amp": 10} + assert aioclient_mock.call_count == 1 + + +async def test_websocket_send_that_reports_failure_gives_an_empty_result(hass): + bridge = make_bridge(hass, websocket=True) + bridge._ws_send_command = AsyncMock(return_value=False) + assert await bridge.write_value_to_key("amp", 10) == {} + + +# ----------------------------------------------------------------------------- several keys + +async def test_multiple_keys_over_websocket_are_sent_one_after_the_other(hass): + bridge = make_bridge(hass, websocket=True) + bridge._ws_LAST_NEW_DATA_NOTIFY = 5 + with patch(SLEEP, AsyncMock()) as sleep: + result = await bridge.write_multiple_values_to_keys({"ct": "x", "mca": 6}, "ct", "default") + sent = [(c.args[0]["key"], c.args[0]["value"]) for c in bridge._ws_connection.send_json.await_args_list] + assert sent == [("ct", "x"), ("mca", 6)] and sleep.await_count == 2 and result == {} + assert bridge._ws_LAST_NEW_DATA_NOTIFY == 0 + + +async def test_multiple_keys_over_http_are_encoded_by_type(hass, aioclient_mock): + aioclient_mock.get(SET, json={"ct": True}) + bridge = make_bridge(hass) + args = {"acp": True, "mca": 6, "x": 1.5, "name": "abc", "obj": {"a": 1}, "ignored": [1, 2]} + assert await bridge.write_multiple_values_to_keys(args, "ct", "default") == {"ct": "default"} + query = dict(aioclient_mock.mock_calls[0][1].query) + assert query == {"acp": "true", "mca": "6", "x": "1.5", "name": '"abc"', "obj": '{"a":1}'} + + +# ----------------------------------------------------------------------------- the answer of the charger + +async def test_charger_answer_with_the_key_resets_the_read_timers(hass, aioclient_mock): + aioclient_mock.get(SET, json={"amp": True}) + bridge = make_bridge(hass) + bridge._LAST_CONFIG_UPDATE_TS = bridge._LAST_FULL_STATE_UPDATE_TS = 9 + await bridge.write_value_to_key("amp", 6) + assert bridge._LAST_CONFIG_UPDATE_TS == 0 and bridge._REQUEST_IDS_DATA is False + + +async def test_pv_data_answer_keeps_the_timers_and_asks_for_pv_values_for_a_charger(hass, aioclient_mock): + aioclient_mock.get(SET, json={"ids": True}) + bridge = make_bridge(hass) + bridge._LAST_CONFIG_UPDATE_TS = 9 + await bridge.write_value_to_key("ids", {"pGrid": 0}) + assert bridge._LAST_CONFIG_UPDATE_TS == 9 and bridge._REQUEST_IDS_DATA is True + + +async def test_pv_data_answer_for_a_controller_does_not_set_the_flag(hass, aioclient_mock): + aioclient_mock.get(SET, json={"ids": True}) + bridge = make_bridge(hass, intg_type="controller") + await bridge.write_value_to_key("ids", {"pGrid": 0}) + assert bridge._REQUEST_IDS_DATA is False + + +async def test_answer_without_the_key_is_an_error_object(hass, aioclient_mock): + aioclient_mock.get(SET, json={"other": True}) + assert await make_bridge(hass).write_value_to_key("amp", 6) == {"err": {"other": True}} + + +async def test_answer_that_denies_the_key_with_false_is_an_error_object(hass, aioclient_mock): + aioclient_mock.get(SET, json={"amp": False}) + assert await make_bridge(hass).write_value_to_key("amp", 6) == {"err": {"amp": False}} + + +async def test_empty_answer_gives_an_empty_result(hass, aioclient_mock): + aioclient_mock.get(SET, json={}) + assert await make_bridge(hass).write_value_to_key("amp", 6) == {} + + +async def test_answer_that_is_not_json_gives_an_empty_result(hass, aioclient_mock): + aioclient_mock.get(SET, text="not json", headers={"Content-Type": "application/json"}) + assert await make_bridge(hass).write_value_to_key("amp", 6) == {} + + +async def test_answer_with_the_wrong_content_type_gives_an_empty_result(hass, aioclient_mock): + aioclient_mock.get(SET, text="", headers={"Content-Type": "text/html"}) + assert await make_bridge(hass).write_value_to_key("amp", 6) == {} + + +@pytest.mark.parametrize("status", [403, 404, 422, 503]) +async def test_other_statuses_give_an_empty_result(hass, aioclient_mock, status): + aioclient_mock.get(SET, status=status, json={"amp": True}) + assert await make_bridge(hass).write_value_to_key("amp", 6) == {} + + +async def test_a_lost_connection_while_writing_is_raised_for_the_coordinator_to_handle(hass, aioclient_mock): + aioclient_mock.get(SET, exc=aiohttp.ClientConnectionError("down")) + with pytest.raises(aiohttp.ClientConnectionError): + await make_bridge(hass).write_value_to_key("amp", 6) + + +async def test_cloud_write_sends_the_bearer_token(hass, aioclient_mock): + from homeassistant.helpers.aiohttp_client import async_get_clientsession + aioclient_mock.get("https://001234.api.v3.go-e.io/api/set", json={"amp": True}) + bridge = GoeChargerApiV2Bridge(web_session=async_get_clientsession(hass), intg_type="charger", + serial="1234", token="tok") + assert await bridge.write_value_to_key("amp", 6) == {"amp": 6} + assert aioclient_mock.mock_calls[0][3] == {"Authorization": "Bearer tok"} diff --git a/tests/integration/test_config_flow_failures.py b/tests/integration/test_config_flow_failures.py new file mode 100644 index 0000000..0d6e364 --- /dev/null +++ b/tests/integration/test_config_flow_failures.py @@ -0,0 +1,83 @@ +"""Config flow: wrong or unreachable devices must end the flow with a clear reason and create no entry.""" +from unittest.mock import AsyncMock, patch + +import pytest +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResultType + +from custom_components.goecharger_api2 import config_flow +from custom_components.goecharger_api2.const import DOMAIN + +LAN_STATUS = "http://192.0.2.10/api/status" +CLOUD_STATUS = "https://123456.api.v3.go-e.io/api/status" +GOOD_STATUS = {"sse": "123456", "typ": "go-eCharger_V4", "var": 11, "fwv": "59.4"} + +LAN_INPUT = {"integration_type": "charger", "host": "192.0.2.10", "scan_interval": 30, + "delay": False, "limit_to_11kw": False} +WAN_INPUT = {"integration_type": "charger", "id": "123456", "token": "token", "scan_interval": 120, + "delay": False} + + +async def _start(hass, system): + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + assert result["type"] == FlowResultType.FORM and result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {"stype": system}) + assert result["type"] == FlowResultType.FORM + return result + + +# ----------------------------------------------------------------------------- positive control + +async def test_valid_lan_device_creates_an_entry(hass, aioclient_mock): + aioclient_mock.get(LAN_STATUS, json=GOOD_STATUS) + result = await _start(hass, "lan") + with patch("custom_components.goecharger_api2.async_setup_entry", AsyncMock(return_value=True)): + result = await hass.config_entries.flow.async_configure(result["flow_id"], LAN_INPUT) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "go-eCharger API v2 [123456] Local" + assert result["data"]["id"] == "123456" and result["data"]["mode"] == "lan" + + +# ----------------------------------------------------------------------------- failures + +@pytest.mark.parametrize("status", [403, 404, 500]) +async def test_lan_device_that_answers_with_an_error_is_rejected(hass, aioclient_mock, status): + aioclient_mock.get(LAN_STATUS, status=status) + result = await _start(hass, "lan") + result = await hass.config_entries.flow.async_configure(result["flow_id"], LAN_INPUT) + assert result["type"] == FlowResultType.ABORT and result["reason"] == "auth_lan" + assert hass.config_entries.async_entries(DOMAIN) == [] + + +async def test_lan_device_with_an_empty_answer_is_rejected(hass, aioclient_mock): + aioclient_mock.get(LAN_STATUS, json={}) + result = await _start(hass, "lan") + result = await hass.config_entries.flow.async_configure(result["flow_id"], LAN_INPUT) + assert result["type"] == FlowResultType.ABORT and result["reason"] == "auth_lan" + + +async def test_unreachable_lan_device_is_rejected(hass, aioclient_mock): + from aiohttp import ClientConnectionError + aioclient_mock.get(LAN_STATUS, exc=ClientConnectionError("no route")) + result = await _start(hass, "lan") + result = await hass.config_entries.flow.async_configure(result["flow_id"], LAN_INPUT) + assert result["type"] == FlowResultType.ABORT and result["reason"] == "auth_lan" + + +async def test_failed_websocket_check_falls_back_to_http_and_then_rejects(hass, aioclient_mock): + aioclient_mock.get(LAN_STATUS, status=403) + result = await _start(hass, "lan") + with patch.object(config_flow.GoeChargerApiV2FlowHandler, "_check_ws_connection_and_flags", + AsyncMock(return_value=False)) as ws_check: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {**LAN_INPUT, "password": "wrong"}) + ws_check.assert_awaited_once() + assert result["type"] == FlowResultType.ABORT and result["reason"] == "auth_lan" + + +async def test_cloud_device_with_a_wrong_token_is_rejected(hass, aioclient_mock): + aioclient_mock.get(CLOUD_STATUS, status=403) + result = await _start(hass, "wan") + result = await hass.config_entries.flow.async_configure(result["flow_id"], WAN_INPUT) + assert result["type"] == FlowResultType.ABORT and result["reason"] == "auth_wan" + assert hass.config_entries.async_entries(DOMAIN) == [] diff --git a/tests/integration/test_config_flow_paths.py b/tests/integration/test_config_flow_paths.py new file mode 100644 index 0000000..76d7c58 --- /dev/null +++ b/tests/integration/test_config_flow_paths.py @@ -0,0 +1,191 @@ +"""Config flow: successful setups, the value rules, reconfiguration and the WebSocket check. + +The charger is mocked: HTTP with `aioclient_mock`, the WebSocket check with a fake connection. +""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResultType +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.goecharger_api2 import config_flow +from custom_components.goecharger_api2.const import DOMAIN +from custom_components.goecharger_api2.pygoecharger_ha import GoeChargerApiV2Bridge, TargetedEvent + +LAN_STATUS = "http://192.0.2.10/api/status" +CLOUD_STATUS = "https://123456.api.v3.go-e.io/api/status" +CHARGER = {"sse": "123456", "typ": "go-eCharger_V4", "var": 22, "fwv": "59.4"} +CONTROLLER = {"sse": "654321", "typ": "go-eController", "fwv": "1.1.1"} +SETUP = "custom_components.goecharger_api2.async_setup_entry" + + +async def _start(hass, system): + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + return await hass.config_entries.flow.async_configure(result["flow_id"], {"stype": system}) + + +def _lan(**extra): + return {"integration_type": "charger", "host": "192.0.2.10", "scan_interval": 30, "delay": False, + "limit_to_11kw": False, **extra} + + +def _wan(**extra): + return {"integration_type": "charger", "id": "123456", "token": "tok", "scan_interval": 120, "delay": False, **extra} + + +async def _create(hass, system, data): + result = await _start(hass, system) + with patch(SETUP, AsyncMock(return_value=True)): + return await hass.config_entries.flow.async_configure(result["flow_id"], data) + + +# ----------------------------------------------------------------------------- creating entries + +async def test_cloud_charger_is_created_with_its_title_and_a_minimum_interval(hass, aioclient_mock): + aioclient_mock.get(CLOUD_STATUS, json=CHARGER) + result = await _create(hass, "wan", _wan(scan_interval=10)) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "go-eCharger API v2 [123456] Cloud" + assert result["data"]["mode"] == "wan" and result["data"]["scan_interval"] == 30 + assert result["data"]["type"].endswith("Cloud") and "password" not in result["data"] + + +async def test_local_charger_gets_at_least_five_seconds_and_keeps_its_model(hass, aioclient_mock): + aioclient_mock.get(LAN_STATUS, json=CHARGER) + result = await _create(hass, "lan", _lan(scan_interval=1)) + assert result["data"]["scan_interval"] == 5 and result["data"]["model"] == "22" + assert result["data"]["type"] == "go-eCharger V4 [22 kW] Local" + + +async def test_local_controller_is_created_with_its_own_title(hass, aioclient_mock): + aioclient_mock.get(LAN_STATUS, json=CONTROLLER) + result = await _create(hass, "lan", _lan(integration_type="controller")) + assert result["title"] == "go-eController API v2 [654321] Local" + assert result["data"]["type"] == "go-eController Local" and result["data"]["model"] == "eControl" + + +async def test_cloud_controller_is_created_with_its_own_title(hass, aioclient_mock): + aioclient_mock.get(CLOUD_STATUS, json={**CONTROLLER, "sse": "123456"}) + result = await _create(hass, "wan", _wan(integration_type="controller")) + assert result["title"] == "go-eController API v2 [123456] Cloud" + + +def _ws_found(states): + async def check(self, client): + client._ws_states = dict(states) + client.ws_connected = True + return True + return check + + +@pytest.mark.parametrize("flags", [ + {"hai": True, "cae": True}, {"hai": False, "cae": False}, {}]) +async def test_websocket_login_creates_the_entry_with_the_password(hass, flags): + states = {**CHARGER, **flags} + with patch.object(config_flow.GoeChargerApiV2FlowHandler, "_check_ws_connection_and_flags", _ws_found(states)): + result = await _create(hass, "lan", _lan(password=" secret ")) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"]["password"] == " secret " and result["data"]["id"] == "123456" + + +async def test_websocket_check_for_the_cloud_also_keeps_the_password(hass): + with patch.object(config_flow.GoeChargerApiV2FlowHandler, "_check_ws_connection_and_flags", _ws_found(CHARGER)): + result = await _create(hass, "wan", _wan(password="secret")) + assert result["data"]["password"] == "secret" and result["data"]["mode"] == "wan" + + +# ----------------------------------------------------------------------------- reconfiguring + +async def _reconfigure(hass, data): + entry = MockConfigEntry(domain=DOMAIN, title="t", data=data, version=2, minor_version=1) + entry.add_to_hass(hass) + return entry, await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_RECONFIGURE, "entry_id": entry.entry_id}) + + +async def test_reconfigure_a_local_charger_shows_the_stored_values(hass): + entry, result = await _reconfigure(hass, {"mode": "lan", "host": "192.0.2.10", "id": "123456", + "integration_type": "charger", "scan_interval": 45, "limit_to_11kw": True}) + assert result["type"] == FlowResultType.FORM and result["step_id"] == "user_lan" + defaults = {str(k): k.default() if callable(k.default) else None for k in result["data_schema"].schema} + assert defaults["host"] == "192.0.2.10" and defaults["scan_interval"] == 45 and defaults["limit_to_11kw"] is True + + +async def test_reconfigure_a_cloud_charger_shows_the_cloud_form(hass): + entry, result = await _reconfigure(hass, {"mode": "wan", "id": "123456", "token": "tok", + "integration_type": "charger", "scan_interval": 90}) + assert result["type"] == FlowResultType.FORM and result["step_id"] == "user_wan" + + +async def test_reconfigure_with_a_working_device_updates_and_reloads_the_entry(hass, aioclient_mock): + aioclient_mock.get(LAN_STATUS, json=CHARGER) + entry, result = await _reconfigure(hass, {"mode": "lan", "host": "192.0.2.10", "id": "123456", + "integration_type": "charger", "scan_interval": 45}) + with patch.object(hass.config_entries, "async_reload", AsyncMock()): + result = await hass.config_entries.flow.async_configure(result["flow_id"], _lan(scan_interval=60)) + assert result["type"] == FlowResultType.ABORT and result["reason"] == "reconfigure_successful" + assert entry.data["scan_interval"] == 60 + + +async def test_reconfigure_with_a_working_cloud_device_updates_the_entry(hass, aioclient_mock): + aioclient_mock.get(CLOUD_STATUS, json=CHARGER) + entry, result = await _reconfigure(hass, {"mode": "wan", "id": "123456", "token": "tok", + "integration_type": "charger", "scan_interval": 90}) + with patch.object(hass.config_entries, "async_reload", AsyncMock()): + result = await hass.config_entries.flow.async_configure(result["flow_id"], _wan(scan_interval=200)) + assert result["reason"] == "reconfigure_successful" and entry.data["scan_interval"] == 200 + + +async def test_the_first_step_offers_local_and_cloud(hass): + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + assert result["type"] == FlowResultType.FORM and result["step_id"] == "user" + + +# ----------------------------------------------------------------------------- the WebSocket check + +def _handler(hass): + handler = config_flow.GoeChargerApiV2FlowHandler() + handler.hass = hass + return handler + + +def _client(connect): + client = GoeChargerApiV2Bridge(web_session=None, intg_type="charger", host="192.0.2.10", access_password="secret") + client.ws_connect = connect + return client + + +async def test_check_reports_success_when_the_first_data_arrives(hass): + async def connect(a_event=None, keep_ws_states_after_close=False): + a_event.set() + client = _client(connect) + with patch.object(config_flow.asyncio, "sleep", AsyncMock()): + assert await _handler(hass)._check_ws_connection_and_flags(client) is True + + +async def test_check_reports_failure_for_errors_from_the_connection(hass): + async def connect(a_event=None, keep_ws_states_after_close=False): + a_event.set_error(Exception("Authentication failed")) + with patch.object(config_flow.asyncio, "sleep", AsyncMock()): + assert await _handler(hass)._check_ws_connection_and_flags(_client(connect)) is False + + +@pytest.mark.parametrize("error", [asyncio.TimeoutError(), asyncio.CancelledError()]) +async def test_check_reports_failure_when_the_wait_ends_without_data(hass, error): + async def connect(a_event=None, keep_ws_states_after_close=False): + await asyncio.sleep(3600) + real_wait_for = asyncio.wait_for + calls = [] + + async def wait_for(awaitable, timeout): + calls.append(timeout) + if len(calls) == 1: + awaitable.close() if hasattr(awaitable, "close") else None + raise error + return await real_wait_for(awaitable, 0.5) + + with patch.object(config_flow.asyncio, "wait_for", wait_for), patch.object(config_flow.asyncio, "sleep", AsyncMock()): + assert await _handler(hass)._check_ws_connection_and_flags(_client(connect)) is False diff --git a/tests/integration/test_controller_contract.py b/tests/integration/test_controller_contract.py new file mode 100644 index 0000000..4cd10d4 --- /dev/null +++ b/tests/integration/test_controller_contract.py @@ -0,0 +1,93 @@ +"""Contract between the official go-eController key table and the integration. + +Source of truth: `contract/controller_keys.json`, generated from `go-eController-API/apikeys-en.md` (see +`contract/generate_api_keys.py`). The charger contract in test_api_contract.py is built from the charger +table only, so before these tests the controller paths were not checked against any documentation. + +Checks: +1. Every key a controller entity uses is documented for controllers. +2. Controller entities that write (switch, select, button) use keys the documentation marks as writable, + and keys.py marks them as writeable as well. +3. Every key the code requests from a controller is documented. `cae`, `ecp` and `lcs` are requested but + not documented; they are marked `needs_decision` (fails on purpose, not blocking) until someone has + checked on a device whether they are leftovers or a documentation gap. +""" +import json +import re +from pathlib import Path + +import pytest + +from custom_components.goecharger_api2 import const as entity_const +from custom_components.goecharger_api2.pygoecharger_ha import const as bridge_const +from custom_components.goecharger_api2.pygoecharger_ha.keys import Tag + +CONTROLLER_DOC = json.loads((Path(__file__).parent / "contract" / "controller_keys.json").read_text())["keys"] +TAGS = {tag.key: tag for tag in Tag} + +# requested from a controller although the controller documentation does not list them +UNDOCUMENTED_REQUESTS = {"cae", "ecp", "lcs"} + + +def _keys(list_name): + return sorted({d.key for d in getattr(entity_const, list_name)}) + + +def _requested_keys(): + keys = set() + for name in dir(bridge_const): + value = getattr(bridge_const, name) + if name.startswith("FILTER_CONTROLER_") and isinstance(value, str): + keys |= {k for k in re.split(r"[,\s]+", value) if k} + return keys + + +ENTITY_LISTS = ["CONTROLLER_BINARY_SENSORS", "CONTROLLER_BUTTONS", "CONTROLLER_NUMBER_SENSORS", + "CONTROLLER_SELECT_SENSORS", "CONTROLLER_SENSOR_SENSORS", "CONTROLLER_SWITCH_SENSORS"] +WRITING_LISTS = ["CONTROLLER_BUTTONS", "CONTROLLER_NUMBER_SENSORS", "CONTROLLER_SELECT_SENSORS", + "CONTROLLER_SWITCH_SENSORS"] + +ENTITY_KEYS = sorted({k for name in ENTITY_LISTS for k in _keys(name)}) +WRITING_KEYS = sorted({k for name in WRITING_LISTS for k in _keys(name)}) +REQUESTED = sorted(_requested_keys()) + + +def test_contract_file_is_the_controller_table(): + assert len(CONTROLLER_DOC) == 82 + assert all(v["rw"] in ("R", "W", "R/W") for v in CONTROLLER_DOC.values()) + + +def test_there_are_controller_entities_to_check(): + assert ENTITY_KEYS and WRITING_KEYS + + +@pytest.mark.parametrize("key", ENTITY_KEYS) +def test_controller_entity_key_is_documented(key): + assert key in CONTROLLER_DOC, f"{key!r} is used by a controller entity but not in the controller key table" + + +@pytest.mark.parametrize("key", WRITING_KEYS) +def test_writing_controller_entity_uses_a_writable_key(key): + assert "W" in CONTROLLER_DOC[key]["rw"], f"{key!r} is documented as {CONTROLLER_DOC[key]['rw']!r}" + + +@pytest.mark.parametrize("key", WRITING_KEYS) +def test_writing_controller_entity_key_is_writeable_in_keys_py(key): + assert TAGS[key].writeable, f"{key!r} is written by a controller entity but keys.py says writeable=False" + + +@pytest.mark.parametrize("key", [pytest.param(k, id=k, marks=[pytest.mark.needs_decision] if k in UNDOCUMENTED_REQUESTS else []) + for k in REQUESTED]) +def test_key_requested_from_a_controller_is_documented(key): + assert key in CONTROLLER_DOC, f"{key!r} is requested from controllers but not in the controller key table" + + +def test_undocumented_request_list_has_no_stale_entries(): + stale = sorted(k for k in UNDOCUMENTED_REQUESTS if k in CONTROLLER_DOC or k not in REQUESTED) + assert stale == [], "documented now, or no longer requested: remove them from UNDOCUMENTED_REQUESTS" + + +def test_controller_does_not_get_the_temporary_current_limit(): + # answers the question in PR #177: neither `tcl` nor `cll` exist in the controller API + assert "tcl" not in CONTROLLER_DOC and "cll" not in CONTROLLER_DOC + assert "tcl" not in REQUESTED and "cll" not in REQUESTED diff --git a/tests/integration/test_coordinator_errors.py b/tests/integration/test_coordinator_errors.py new file mode 100644 index 0000000..db2c57d --- /dev/null +++ b/tests/integration/test_coordinator_errors.py @@ -0,0 +1,33 @@ +"""Coordinator: connection errors while writing must be counted and reported as ValueError.""" +from unittest.mock import AsyncMock + +import pytest +from aiohttp import ClientConnectionError +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.goecharger_api2 import GoeChargerDataUpdateCoordinator + +DOMAIN = "goecharger_api2" + + +@pytest.fixture +async def coordinator(hass): + entry = MockConfigEntry(domain=DOMAIN, title="test", data={ + "mode": "lan", "host": "192.0.2.10", "id": "123456", "integration_type": "charger", + "scan_interval": 30}) + entry.add_to_hass(hass) + return GoeChargerDataUpdateCoordinator(hass, entry) + + +async def test_write_key_connection_error_is_counted(coordinator): + coordinator.bridge.write_value_to_key = AsyncMock(side_effect=ClientConnectionError("down")) + with pytest.raises(ValueError, match="ClientConnectionError while writing amp"): + await coordinator.async_write_key("amp", 16) + assert coordinator._CLIENT_COMMUNICATION_ERROR_COUNT == 1 + + +async def test_write_multiple_keys_connection_error_is_counted(coordinator): + coordinator.bridge.write_multiple_values_to_keys = AsyncMock(side_effect=ClientConnectionError("down")) + with pytest.raises(ValueError, match="ClientConnectionError while writing multiple ct"): + await coordinator.async_write_multiple_keys({"ct": "1"}, "ct", "default") + assert coordinator._CLIENT_COMMUNICATION_ERROR_COUNT == 1 diff --git a/tests/integration/test_coordinator_failures.py b/tests/integration/test_coordinator_failures.py new file mode 100644 index 0000000..94d5379 --- /dev/null +++ b/tests/integration/test_coordinator_failures.py @@ -0,0 +1,165 @@ +"""Coordinator and setup: what happens when the charger cannot be reached or answers badly. + +Covers the error counter, the 2 minute pause after a connection error, the delayed restart after more +than 5 errors, writes during that pause, and the start of the config entry (ConfigEntryNotReady). +""" +import time +from collections import ChainMap +from unittest.mock import AsyncMock, patch + +import pytest +from aiohttp import ClientConnectionError +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import UpdateFailed +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.goecharger_api2 import GoeChargerDataUpdateCoordinator, async_setup_entry +from custom_components.goecharger_api2.const import DOMAIN + + +def _entry(hass, password=None): + data = {"mode": "lan", "host": "192.0.2.10", "id": "123456", "integration_type": "charger", + "scan_interval": 30} + if password: + data["password"] = password + entry = MockConfigEntry(domain=DOMAIN, title="t", data=data) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def coordinator(hass): + coordinator = GoeChargerDataUpdateCoordinator(hass, _entry(hass)) + bridge = coordinator.bridge + coordinator.data = ChainMap(bridge._ws_states, bridge._config, bridge._states, bridge._versions) + coordinator.trigger_restart_delayed = AsyncMock() + return coordinator + + +# ----------------------------------------------------------------------------- reading + +async def test_connection_error_while_reading_raises_update_failed_and_counts(coordinator): + coordinator.bridge.read_all = AsyncMock(side_effect=ClientConnectionError("down")) + with pytest.raises(UpdateFailed, match="ClientConnectionError"): + await coordinator._async_update_data() + assert coordinator._CLIENT_COMMUNICATION_ERROR_COUNT == 1 + + +async def test_unexpected_error_while_reading_raises_update_failed(coordinator): + coordinator.bridge.read_all = AsyncMock(side_effect=RuntimeError("boom")) + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + +async def test_no_request_is_made_during_the_pause_after_a_connection_error(coordinator): + coordinator.bridge.read_all = AsyncMock(side_effect=ClientConnectionError("down")) + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + coordinator.bridge.read_all.reset_mock() + + assert await coordinator._async_update_data() is coordinator.data # pause: old data, no request + coordinator.bridge.read_all.assert_not_awaited() + + +async def test_more_than_five_errors_trigger_exactly_one_restart(hass, coordinator): + coordinator.bridge.read_all = AsyncMock(side_effect=ClientConnectionError("down")) + for _ in range(6): + coordinator._CLIENT_COMMUNICATION_ERROR_TS = 0 # skip the pause between the attempts + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + await hass.async_block_till_done() + assert coordinator._RESTART_TRIGGERED is True + coordinator.trigger_restart_delayed.assert_awaited_once() + + # while the restart is pending, further polls neither fail nor trigger a second restart + for _ in range(2): + coordinator._CLIENT_COMMUNICATION_ERROR_TS = 0 + assert await coordinator._async_update_data() is coordinator.data + await hass.async_block_till_done() + coordinator.trigger_restart_delayed.assert_awaited_once() + + +async def test_five_errors_do_not_trigger_a_restart_yet(hass, coordinator): + coordinator.bridge.read_all = AsyncMock(side_effect=ClientConnectionError("down")) + for _ in range(5): + coordinator._CLIENT_COMMUNICATION_ERROR_TS = 0 + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + await hass.async_block_till_done() + assert coordinator._RESTART_TRIGGERED is False + coordinator.trigger_restart_delayed.assert_not_awaited() + + +async def test_no_request_is_made_while_a_restart_is_pending(coordinator): + coordinator._RESTART_TRIGGERED = True + coordinator.bridge.read_all = AsyncMock() + assert await coordinator._async_update_data() is coordinator.data + coordinator.bridge.read_all.assert_not_awaited() + + +async def test_a_successful_read_clears_the_error_state(coordinator): + coordinator._CLIENT_COMMUNICATION_ERROR_COUNT = 3 + coordinator._CLIENT_COMMUNICATION_ERROR_TS = 0 + coordinator.bridge.read_all = AsyncMock(return_value={"car": 1}) + assert await coordinator._async_update_data() == {"car": 1} + assert coordinator._CLIENT_COMMUNICATION_ERROR_COUNT == 0 + + +# ----------------------------------------------------------------------------- writing during an outage + +@pytest.mark.parametrize("pause", ["error_pause", "restart_pending"]) +async def test_writes_are_refused_without_touching_the_charger(coordinator, pause): + if pause == "error_pause": + coordinator._CLIENT_COMMUNICATION_ERROR_TS = time.time() + else: + coordinator._RESTART_TRIGGERED = True + coordinator.bridge.write_value_to_key = AsyncMock() + coordinator.bridge.write_multiple_values_to_keys = AsyncMock() + + with pytest.raises(ValueError): + await coordinator.async_write_key("amp", 10) + with pytest.raises(ValueError): + await coordinator.async_write_multiple_keys({"ct": "1"}, "ct", "default") + coordinator.bridge.write_value_to_key.assert_not_awaited() + coordinator.bridge.write_multiple_values_to_keys.assert_not_awaited() + + +async def test_unexpected_write_error_becomes_value_error(coordinator): + coordinator.bridge.write_value_to_key = AsyncMock(side_effect=RuntimeError("boom")) + with pytest.raises(ValueError, match="RuntimeError"): + await coordinator.async_write_key("amp", 10) + + +# ----------------------------------------------------------------------------- start of the config entry + +async def test_setup_fails_when_the_first_poll_fails(hass): + entry = _entry(hass) + with patch.object(GoeChargerDataUpdateCoordinator, "_async_update_data", + AsyncMock(side_effect=UpdateFailed("down"))): + with pytest.raises(ConfigEntryNotReady): + await async_setup_entry(hass, entry) + + +async def test_setup_fails_when_versions_cannot_be_read(hass): + entry = _entry(hass) + with patch.object(GoeChargerDataUpdateCoordinator, "_async_update_data", AsyncMock(return_value={"car": 1})), \ + patch.object(GoeChargerDataUpdateCoordinator, "read_versions", AsyncMock(return_value=False)): + with pytest.raises(ConfigEntryNotReady): + await async_setup_entry(hass, entry) + + +async def test_setup_fails_when_the_websocket_delivers_no_data(hass): + entry = _entry(hass, password="secret") + with patch.object(GoeChargerDataUpdateCoordinator, "start_websocket_and_wait_for_first_data", + AsyncMock(return_value=False)): + with pytest.raises(ConfigEntryNotReady): + await async_setup_entry(hass, entry) + + +async def test_setup_fails_when_the_websocket_start_raises(hass): + entry = _entry(hass, password="secret") + with patch.object(GoeChargerDataUpdateCoordinator, "start_websocket_and_wait_for_first_data", + AsyncMock(side_effect=ConfigEntryNotReady("timeout"))): + with pytest.raises(ConfigEntryNotReady): + await async_setup_entry(hass, entry) + assert entry.entry_id not in hass.data.get(DOMAIN, {}) diff --git a/tests/integration/test_coordinator_runtime.py b/tests/integration/test_coordinator_runtime.py new file mode 100644 index 0000000..4389dcd --- /dev/null +++ b/tests/integration/test_coordinator_runtime.py @@ -0,0 +1,462 @@ +"""Coordinator at runtime: write results, the 16 A limiter, device detection, WebSocket start, reconnect and housekeeping. + +The coordinator and the bridge are the real code. Timers (`async_call_later`, `async_track_time_interval`) +and sleeps are replaced so that no test waits or leaves a timer behind. +""" +import asyncio +import logging +import time +from collections import ChainMap +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.components.number import NumberDeviceClass +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from custom_components.goecharger_api2.const import DOMAIN +from custom_components.goecharger_api2.pygoecharger_ha import NonNullDict +from custom_components.goecharger_api2.pygoecharger_ha.keys import Tag + +from .factories import make_coordinator + +PKG = "custom_components.goecharger_api2" + + +# ----------------------------------------------------------------------------- handle_write_result + +async def test_websocket_write_result_is_left_to_the_websocket_processor(hass): + coordinator = make_coordinator(hass, data={"amp": 6}, websocket=True) + coordinator.handle_write_result("single", 10, "amp", None, None) + assert coordinator.data == {"amp": 6} + + +async def test_http_write_result_updates_the_data_and_refreshes_the_entity(hass): + coordinator = make_coordinator(hass, data={"amp": 6}) + entity = MagicMock() + coordinator.handle_write_result("single", 10, "amp", {"amp": 10}, entity) + assert coordinator.data["amp"] == 10 + entity.async_schedule_update_ha_state.assert_called_once_with(force_refresh=True) + + +async def test_write_result_without_the_key_is_logged_as_error(hass, caplog): + coordinator = make_coordinator(hass, data={"amp": 6}) + with caplog.at_level(logging.ERROR): + coordinator.handle_write_result("single", 10, "amp", {"err": {"amp": "denied"}}, None) + assert coordinator.data["amp"] == 6 and "could not write" in caplog.text + + +async def test_pv_data_result_rebuilds_the_data_and_notifies_listeners_without_refresh(hass): + coordinator = make_coordinator(hass, data={}) + coordinator.async_update_listeners = MagicMock() + entity = MagicMock() + coordinator.handle_write_result("single", {}, Tag.IDS.key, {Tag.IDS.key: {}}, entity) + assert isinstance(coordinator.data, ChainMap) + coordinator.async_update_listeners.assert_called_once() + entity.async_schedule_update_ha_state.assert_not_called() + + +async def test_refresh_button_result_schedules_a_delayed_update(hass): + coordinator = make_coordinator(hass, data={}) + coordinator.request_update_in_sec = MagicMock() + entity = MagicMock() + key = Tag.INTERNAL_FORCE_REFRESH_ALL.key + coordinator.handle_write_result("single", "TRIGGER", key, {key: "TRIGGER"}, entity) + coordinator.request_update_in_sec.assert_called_once_with(2.5) + entity.async_schedule_update_ha_state.assert_not_called() + + +async def test_write_result_without_entity_does_not_fail(hass): + coordinator = make_coordinator(hass, data={}) + coordinator.handle_write_result("single", 1, "amp", {"amp": 1}, None) + assert coordinator.data["amp"] == 1 + + +async def test_controller_treats_pv_key_like_any_other(hass): + coordinator = make_coordinator(hass, data={}, intg_type="controller") + coordinator.async_update_listeners = MagicMock() + entity = MagicMock() + coordinator.handle_write_result("single", 1, Tag.IDS.key, {Tag.IDS.key: 1}, entity) + coordinator.async_update_listeners.assert_not_called() + entity.async_schedule_update_ha_state.assert_called_once() + + +# ----------------------------------------------------------------------------- 16 A limiter + +def _current_entity(key, max_value=16, **kwargs): + values = dict(original_device_class=NumberDeviceClass.CURRENT, capabilities={"max": max_value}, + disabled=False, orphaned_timestamp=None, translation_key=key) + values.update(kwargs) + return SimpleNamespace(**values) + + +# ----------------------------------------------------------------------------- device detection (read_versions) + +def _versions_coordinator(hass, versions, **kwargs): + coordinator = make_coordinator(hass, data={}, **kwargs) + coordinator.bridge.read_versions = AsyncMock(return_value=True) + coordinator.bridge._versions = NonNullDict(versions) + return coordinator + + +async def test_read_versions_fails_when_the_bridge_has_no_versions(hass): + coordinator = make_coordinator(hass, data={}) + coordinator.bridge.read_versions = AsyncMock(return_value=False) + assert await coordinator.read_versions() is False + + +async def test_read_versions_fails_for_a_plain_dictionary(hass): + coordinator = make_coordinator(hass, data={}) + coordinator.bridge.read_versions = AsyncMock(return_value=True) + coordinator.bridge._versions = {"typ": "x"} + assert await coordinator.read_versions() is False + + +async def test_cards_object_lists_only_activated_cards(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eCharger_V4", "cards": [{"cardId": 1}, {"cardId": 0}, {"cardId": 7}]}) + await coordinator.read_versions() + assert coordinator._cards_as_object_present is True and coordinator.available_cards_idx == ["1", "3"] + + +async def test_single_card_keys_are_used_without_a_cards_object(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eCharger_V4", "c0i": "A", "c2i": "B"}) + await coordinator.read_versions() + assert coordinator._cards_as_object_present is False and coordinator.available_cards_idx == ["1", "3"] + + +async def test_no_cards_at_all_gives_an_empty_list(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eCharger_V4"}) + await coordinator.read_versions() + assert coordinator.available_cards_idx == [] + + +@pytest.mark.parametrize("typ, expected", [ + ("go-eCharger_Phoenix", True), ("go-eCharger_Core", True), ("go-eCharger Gemini Pro", True), + ("go-eCharger_V4", False)]) +async def test_core_wallbox_is_recognised_by_its_type(hass, typ, expected): + coordinator = _versions_coordinator(hass, {"typ": typ}) + await coordinator.read_versions() + assert coordinator._is_core_wallbox is expected + + +async def test_type_falls_back_to_the_type_stored_at_setup(hass): + coordinator = _versions_coordinator(hass, {}, type="go-eCharger_Phoenix [11 kW] Local") + await coordinator.read_versions() + assert coordinator._is_core_wallbox is True + + +async def test_unreadable_cable_limit_on_a_core_wallbox_limits_to_16_a(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eCharger_Phoenix", "cll": {"cableCurrentLimit": "n/a"}}) + await coordinator.read_versions() + assert coordinator.limit_to16a is False + + +async def test_unreadable_cable_limit_on_another_wallbox_uses_the_adapter_flag(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eCharger_V4", "cll": {"cableCurrentLimit": "n/a"}, "adi": True}) + await coordinator.read_versions() + assert coordinator.limit_to16a is False + + +async def test_empty_cll_object_falls_back_to_the_adapter_flag(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eCharger_V4", "cll": {}, "adi": False}) + await coordinator.read_versions() + assert coordinator.limit_to16a is False + + +async def test_load_balancer_group_is_detected(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eCharger_V4", "log": "group-1"}) + await coordinator.read_versions() + assert coordinator.is_loadbalancer_group_id_set is True + + +async def test_blank_load_balancer_group_is_not_a_group(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eCharger_V4", "log": " "}) + await coordinator.read_versions() + assert coordinator.is_loadbalancer_group_id_set is False + + +async def test_device_info_uses_type_mode_serial_and_cleaned_firmware(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eCharger_V4", "fwv": "60.3-beta", "var": 11}, type="V4 [11 kW] Local") + await coordinator.read_versions() + info = coordinator._device_info_dict + assert info["identifiers"] == {(DOMAIN, "charger", "lan", "123456")} + assert info["sw_version"] == "60.3" and "[16A limited]" in info["model"] and "HTTP v2 API" in info["model"] + + +async def test_device_info_without_firmware_and_with_websocket_label(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eCharger_V4", "adi": False}, password="secret") + await coordinator.read_versions() + info = coordinator._device_info_dict + assert info["sw_version"] == "UNKNOWN" and info["model"].endswith("WebSocket") + + +async def test_controller_has_no_charger_features(hass): + coordinator = _versions_coordinator(hass, {"typ": "go-eController", "fwv": "1.1.1"}, intg_type="controller") + await coordinator.read_versions() + assert coordinator.limit_to16a is False + assert coordinator._device_info_dict["identifiers"] == {(DOMAIN, "controller", "lan", "123456")} + + +# ----------------------------------------------------------------------------- which entities exist + +@pytest.mark.parametrize("core, key, idx, expected", [ + (True, "tma", 0, False), (True, "tma", 1, False), (True, "tma", 2, True), (True, "tma", 5, True), + (False, "tma", 0, True), (False, "tma", 1, True), (False, "tma", 2, False), + (True, "cus", None, False), (True, "bac", None, False), (False, "cus", None, True), + (False, "iso2ena", None, False), (True, "iso2ena", None, True), (True, "amp", None, True), +]) +async def test_entity_filter_depends_on_the_wallbox_generation(hass, core, key, idx, expected): + coordinator = make_coordinator(hass, data={}) + coordinator._is_core_wallbox = core + description = SimpleNamespace(key=key) if idx is None else SimpleNamespace(key=key, idx=idx) + assert coordinator.is_valid_charger_entity(description) is expected + + +async def test_temperature_entity_without_index_is_always_valid(hass): + coordinator = make_coordinator(hass, data={}) + coordinator._is_core_wallbox = True + assert coordinator.is_valid_charger_entity(SimpleNamespace(key="tma")) is True + + +# ----------------------------------------------------------------------------- WebSocket start + +def _fake_connect(coordinator, *, set_event=True, error=None): + async def connect(a_event=None, keep_ws_states_after_close=False): + if error is not None: + a_event.set_error(error) + elif set_event: + a_event.set() + coordinator.bridge.ws_connect = connect + + +async def test_websocket_start_succeeds_when_the_first_data_arrives(hass): + coordinator = make_coordinator(hass, password="secret") + _fake_connect(coordinator) + with patch(f"{PKG}.async_call_later", MagicMock()) as later: + assert await coordinator.start_websocket_and_wait_for_first_data() is True + later.assert_called_once() + + +async def test_websocket_start_reports_errors_from_the_connection(hass): + coordinator = make_coordinator(hass, password="secret") + _fake_connect(coordinator, error=Exception("Authentication failed")) + with patch(f"{PKG}.async_call_later", MagicMock()): + assert await coordinator.start_websocket_and_wait_for_first_data() is False + + +async def test_websocket_start_times_out_without_data(hass): + coordinator = make_coordinator(hass, password="secret") + _fake_connect(coordinator, set_event=False) + real_timeout = asyncio.timeout + with patch(f"{PKG}.asyncio.timeout", lambda _: real_timeout(0.01)): + with pytest.raises(ConfigEntryNotReady): + await coordinator.start_websocket_and_wait_for_first_data() + + +@pytest.mark.parametrize("versions, expected", [ + (AsyncMock(return_value=True), True), + (AsyncMock(return_value=False), False), + (AsyncMock(side_effect=RuntimeError("boom")), False), +]) +async def test_websocket_start_at_setup_also_needs_the_versions(hass, versions, expected): + coordinator = make_coordinator(hass, password="secret") + _fake_connect(coordinator) + coordinator.read_versions = versions + with patch(f"{PKG}.async_call_later", MagicMock()): + assert await coordinator.start_websocket_and_wait_for_first_data(is_integration_init=True) is expected + + +# ----------------------------------------------------------------------------- forced reconnect + +async def test_forced_reconnect_is_ignored_shortly_after_integration_start(hass): + coordinator = make_coordinator(hass, password="secret") + coordinator.start_websocket_and_wait_for_first_data = AsyncMock() + await coordinator.force_async_update_now() + coordinator.start_websocket_and_wait_for_first_data.assert_not_awaited() + + +async def test_forced_reconnect_is_ignored_shortly_after_a_fresh_connection(hass): + coordinator = make_coordinator(hass, password="secret") + coordinator._integration_start = time.time() - 1000 + coordinator.bridge.ws_connection_start = time.time() + coordinator.start_websocket_and_wait_for_first_data = AsyncMock() + await coordinator.force_async_update_now() + coordinator.start_websocket_and_wait_for_first_data.assert_not_awaited() + + +@pytest.mark.parametrize("reconnect_ok", [True, False]) +async def test_forced_reconnect_restarts_the_websocket(hass, reconnect_ok): + coordinator = make_coordinator(hass, password="secret") + coordinator._integration_start = time.time() - 1000 + coordinator.start_websocket_and_wait_for_first_data = AsyncMock(return_value=reconnect_ok) + with patch(f"{PKG}.asyncio.sleep", AsyncMock()) as sleep: + await coordinator.force_async_update_now() + sleep.assert_awaited_once_with(2) + coordinator.start_websocket_and_wait_for_first_data.assert_awaited_once_with(is_integration_init=False) + + +# ----------------------------------------------------------------------------- watchdog + +async def test_watchdog_starts_a_connection_when_there_is_none(hass): + coordinator = make_coordinator(hass, password="secret") + _fake_connect(coordinator) + with patch(f"{PKG}.async_call_later", MagicMock()) as later: + await coordinator._async_watchdog_check() + await asyncio.sleep(0) + assert coordinator._ws_start_task is not None and later.called + + +async def test_watchdog_leaves_a_healthy_connection_alone(hass): + coordinator = make_coordinator(hass, password="secret", websocket=True) + coordinator.bridge._ws_LAST_UPDATE = time.time() + coordinator._ws_start_task = None + await coordinator._async_watchdog_check() + assert coordinator._ws_start_task is None + + +async def test_watchdog_cancels_a_connection_without_data(hass): + coordinator = make_coordinator(hass, password="secret", websocket=True) + coordinator.bridge._ws_LAST_UPDATE = time.time() - 120 + task = asyncio.create_task(asyncio.sleep(3600)) + coordinator._ws_start_task = task + with patch(f"{PKG}.async_call_later", MagicMock()): + await coordinator._async_watchdog_check() + await asyncio.sleep(0) + assert task.cancelled() and coordinator._ws_start_task is None + + +async def test_start_watchdog_checks_once_and_registers_the_timer(hass): + coordinator = make_coordinator(hass, password="secret", websocket=True) + coordinator.bridge._ws_LAST_UPDATE = time.time() + unsubscribe = MagicMock() + with patch(f"{PKG}.async_track_time_interval", MagicMock(return_value=unsubscribe)) as track, \ + patch(f"{PKG}.async_call_later", MagicMock()): + await coordinator.start_watchdog() + coordinator.stop_watchdog() + track.assert_called_once() + unsubscribe.assert_called_once() + + +async def test_stop_watchdog_without_a_timer_does_nothing(hass): + coordinator = make_coordinator(hass, password="secret") + coordinator._watchdog = None + coordinator.stop_watchdog() + + +async def test_running_connection_task_is_cancelled_and_forgotten(hass): + coordinator = make_coordinator(hass, password="secret") + task = asyncio.create_task(asyncio.sleep(3600)) + coordinator._ws_start_task = task + coordinator._check_for_ws_task_and_cancel_if_running() + await asyncio.sleep(0) + assert task.cancelled() and coordinator._ws_start_task is None + + +async def test_finished_connection_task_is_left_alone(hass): + coordinator = make_coordinator(hass, password="secret") + task = asyncio.create_task(asyncio.sleep(0)) + await task + coordinator._ws_start_task = task + coordinator._check_for_ws_task_and_cancel_if_running() + assert coordinator._ws_start_task is task + + +async def test_failing_cancel_is_logged_not_raised(hass): + coordinator = make_coordinator(hass, password="secret") + task = MagicMock() + task.done.return_value = False + task.cancel.side_effect = RuntimeError("boom") + coordinator._ws_start_task = task + coordinator._check_for_ws_task_and_cancel_if_running() + assert coordinator._ws_start_task is None + + +# ----------------------------------------------------------------------------- device registry label + +async def _device_for(hass, coordinator): + entry = coordinator._config_entry + coordinator.config_entry = entry + coordinator._device_info_dict = {"identifiers": {(DOMAIN, "charger", "lan", "123456")}} + coordinator._device_info_model_raw = "model" + return dr.async_get(hass).async_get_or_create(config_entry_id=entry.entry_id, identifiers={(DOMAIN, "charger", "lan", "123456")}) + + +async def test_device_label_shows_a_working_websocket(hass): + coordinator = make_coordinator(hass, password="secret", websocket=True) + device = await _device_for(hass, coordinator) + coordinator.bridge._ws_LAST_UPDATE = time.time() + await coordinator.call_later_update_device_registry(None) + assert dr.async_get(hass).async_get(device.id).model == "model ✅" + + +async def test_device_label_shows_a_broken_websocket(hass): + coordinator = make_coordinator(hass, password="secret") + device = await _device_for(hass, coordinator) + await coordinator.call_later_update_device_registry(None) + assert dr.async_get(hass).async_get(device.id).model == "model ⛔" + + +async def test_device_label_is_skipped_without_identifiers(hass): + coordinator = make_coordinator(hass, password="secret") + coordinator._device_info_dict = {} + await coordinator.call_later_update_device_registry(None) + + +async def test_device_label_is_skipped_in_polling_mode(hass): + coordinator = make_coordinator(hass) + device = await _device_for(hass, coordinator) + await coordinator.call_later_update_device_registry(None) + assert dr.async_get(hass).async_get(device.id).model is None + + +async def test_device_label_ignores_a_device_that_is_not_registered(hass): + coordinator = make_coordinator(hass, password="secret") + coordinator.config_entry = coordinator._config_entry + coordinator._device_info_dict = {"identifiers": {(DOMAIN, "charger", "lan", "000000")}} + await coordinator.call_later_update_device_registry(None) + + +# ----------------------------------------------------------------------------- housekeeping + +async def test_delayed_update_refreshes_the_coordinator(hass): + coordinator = make_coordinator(hass, data={}) + coordinator.async_refresh = AsyncMock() + coordinator.request_update_in_sec(0) + await coordinator._debounced_update_task + coordinator.async_refresh.assert_awaited_once() + + +async def test_a_new_delayed_update_replaces_the_pending_one(hass): + coordinator = make_coordinator(hass, data={}) + coordinator.async_refresh = AsyncMock() + coordinator.request_update_in_sec(3600) + first = coordinator._debounced_update_task + coordinator.request_update_in_sec(3600) + await asyncio.sleep(0) + assert first.cancelled() + coordinator.cancel_existing_debouncer() + + +async def test_clear_data_resets_errors_and_empties_the_data(hass): + coordinator = make_coordinator(hass, data={"a": 1}) + coordinator._CLIENT_COMMUNICATION_ERROR_COUNT = 3 + coordinator._CLIENT_COMMUNICATION_ERROR_TS = 5 + coordinator._RESTART_TRIGGERED = True + coordinator.clear_data() + assert coordinator.data == {} and coordinator._CLIENT_COMMUNICATION_ERROR_COUNT == 0 + assert coordinator._CLIENT_COMMUNICATION_ERROR_TS == 0 and coordinator._RESTART_TRIGGERED is False + + +async def test_delayed_restart_reloads_the_config_entry(hass): + coordinator = make_coordinator(hass, data={}) + with patch(f"{PKG}.asyncio.sleep", AsyncMock()), \ + patch.object(hass.config_entries, "async_reload", AsyncMock()) as reload: + await coordinator.trigger_restart_delayed() + reload.assert_awaited_once_with(coordinator._config_entry.entry_id) + + +async def test_event_callback_accepts_any_event(hass): + coordinator = make_coordinator(hass, data={}) + assert coordinator(SimpleNamespace()) is True + assert coordinator.cards_as_object_present() is False diff --git a/tests/integration/test_entity_ids.py b/tests/integration/test_entity_ids.py new file mode 100644 index 0000000..39ac38f --- /dev/null +++ b/tests/integration/test_entity_ids.py @@ -0,0 +1,77 @@ +"""Entity IDs must not change by accident. + +Users have dashboards, automations, statistics and renamed entities that reference these IDs. +The expected IDs are stored in `snapshots/entity_ids.json`. If an ID changes on purpose, regenerate +the file with `UPDATE_SNAPSHOT=1 python -m pytest tests/integration/test_entity_ids.py` and review the diff. +""" +import json +import os +from pathlib import Path + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.goecharger_api2 import GoeChargerBaseEntity, GoeChargerDataUpdateCoordinator +from custom_components.goecharger_api2 import const + +SNAPSHOT = Path(__file__).parent / "snapshots" / "entity_ids.json" + +CHARGER_LISTS = { + "binary_sensor": const.BINARY_SENSORS, "button": const.BUTTONS, "number": const.NUMBER_SENSORS, + "select": const.SELECT_SENSORS, "sensor": const.SENSOR_SENSORS, "switch": const.SWITCH_SENSORS, +} +CONTROLLER_LISTS = { + "binary_sensor": const.CONTROLLER_BINARY_SENSORS, "button": const.CONTROLLER_BUTTONS, + "number": const.CONTROLLER_NUMBER_SENSORS, "select": const.CONTROLLER_SELECT_SENSORS, + "sensor": const.CONTROLLER_SENSOR_SENSORS, "switch": const.CONTROLLER_SWITCH_SENSORS, +} + +VARIANTS = { + "charger_lan": ({"mode": "lan", "host": "192.0.2.10"}, "charger", CHARGER_LISTS), + "charger_wan": ({"mode": "wan", "token": "t"}, "charger", CHARGER_LISTS), + "controller_lan": ({"mode": "lan", "host": "192.0.2.10"}, "controller", CONTROLLER_LISTS), +} + + +def _entities(hass, variant): + data, intg_type, lists = VARIANTS[variant] + entry = MockConfigEntry(domain="goecharger_api2", title="t", + data={**data, "id": "123456", "integration_type": intg_type}) + entry.add_to_hass(hass) + coordinator = GoeChargerDataUpdateCoordinator(hass, entry) + return [GoeChargerBaseEntity(platform, coordinator, description) + for platform, descriptions in lists.items() for description in descriptions] + + +@pytest.mark.parametrize("variant", VARIANTS) +async def test_entity_ids_match_snapshot(hass, variant): + actual = sorted(entity.entity_id for entity in _entities(hass, variant)) + if os.environ.get("UPDATE_SNAPSHOT"): + stored = json.loads(SNAPSHOT.read_text()) if SNAPSHOT.exists() else {} + stored[variant] = actual + SNAPSHOT.parent.mkdir(exist_ok=True) + SNAPSHOT.write_text(json.dumps(dict(sorted(stored.items())), indent=1) + "\n") + pytest.skip("snapshot updated") + expected = json.loads(SNAPSHOT.read_text())[variant] + assert {"added": sorted(set(actual) - set(expected)), "removed": sorted(set(expected) - set(actual))} == \ + {"added": [], "removed": []} + + +@pytest.mark.parametrize("variant", VARIANTS) +async def test_entity_ids_are_unique(hass, variant): + ids = [entity.entity_id for entity in _entities(hass, variant)] + duplicates = sorted({i for i in ids if ids.count(i) > 1}) + assert duplicates == [] + + +@pytest.mark.parametrize("variant", VARIANTS) +async def test_unique_id_is_derived_from_the_entity_id(hass, variant): + for entity in _entities(hass, variant): + assert entity.unique_id == f"goecharger_api2.{entity.entity_id.split('.')[1]}" + + +async def test_wan_entities_are_prefixed_differently_from_lan(hass): + lan = {e.entity_id for e in _entities(hass, "charger_lan")} + wan = {e.entity_id for e in _entities(hass, "charger_wan")} + assert lan.isdisjoint(wan) + assert all(".goe_wan_123456_" in i for i in wan) and all(".goe_123456_" in i for i in lan) diff --git a/tests/integration/test_migration.py b/tests/integration/test_migration.py new file mode 100644 index 0000000..54a7fe9 --- /dev/null +++ b/tests/integration/test_migration.py @@ -0,0 +1,47 @@ +"""Config entry migration (async_migrate_entry). + +Version 2.0 -> 2.1 deletes all devices of the entry. Home Assistant removes the entities of a deleted +device as well (assumed, not tested here), so user settings on those entities are lost once. The +test pins that the purge happens exactly once. +""" +from homeassistant.helpers import device_registry as dr +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.goecharger_api2 import async_migrate_entry +from custom_components.goecharger_api2.const import DOMAIN + + +def _entry(hass, version, minor, data=None, options=None): + entry = MockConfigEntry(domain=DOMAIN, title="t", version=version, minor_version=minor, + data=data or {"host": "192.0.2.10", "id": "123456"}, options=options or {}) + entry.add_to_hass(hass) + return entry + + +def _device(hass, entry): + return dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, identifiers={(DOMAIN, "old-identifier")}) + + +async def test_version_1_merges_options_into_data(hass): + entry = _entry(hass, 1, 0, options={"scan_interval": 60}) + assert await async_migrate_entry(hass, entry) is True + assert entry.data["scan_interval"] == 60 and entry.data["host"] == "192.0.2.10" + assert dict(entry.options) == {} + assert (entry.version, entry.minor_version) == (2, 1) + + +async def test_version_2_0_deletes_the_devices_and_moves_to_2_1(hass): + entry = _entry(hass, 2, 0) + device = _device(hass, entry) + assert await async_migrate_entry(hass, entry) is True + assert dr.async_get(hass).async_get(device.id) is None + assert (entry.version, entry.minor_version) == (2, 1) + + +async def test_version_2_1_is_left_alone(hass): + entry = _entry(hass, 2, 1) + device = _device(hass, entry) + assert await async_migrate_entry(hass, entry) is True + assert dr.async_get(hass).async_get(device.id) is not None + assert (entry.version, entry.minor_version) == (2, 1) diff --git a/tests/integration/test_platform_actions.py b/tests/integration/test_platform_actions.py new file mode 100644 index 0000000..4dc0145 --- /dev/null +++ b/tests/integration/test_platform_actions.py @@ -0,0 +1,230 @@ +"""Platform entities, writing and switch/binary-sensor states. + +Writing: what an entity sends to the coordinator when a user changes it, and that a failing write never +raises into Home Assistant. Reading: how switches and binary sensors turn raw values into on/off. +""" +from unittest.mock import AsyncMock + +import pytest + +from custom_components.goecharger_api2.binary_sensor import GoeChargerApiV2BinarySensor +from custom_components.goecharger_api2.button import GoeChargerApiV2Button +from custom_components.goecharger_api2.const import ( + ExtBinarySensorEntityDescription as BinaryDesc, + ExtButtonEntityDescription as ButtonDesc, + ExtSelectEntityDescription as SelectDesc, + ExtSwitchEntityDescription as SwitchDesc, +) +from custom_components.goecharger_api2.select import GoeChargerSelect +from custom_components.goecharger_api2.switch import GoeChargerSwitch + +from .factories import make_coordinator + + +def _select(hass, data, **description): + coordinator = make_coordinator(hass, data=data) + coordinator.available_cards_idx = [] + coordinator.async_write_key = AsyncMock() + coordinator.async_write_multiple_keys = AsyncMock() + return GoeChargerSelect(coordinator, SelectDesc(options=["0", "1"], **description)), coordinator + + +# ----------------------------------------------------------------------------- select: writing + +async def test_select_writes_an_integer(hass): + select, coordinator = _select(hass, {"lmo": "3"}, key="lmo") + await select.async_select_option("4") + coordinator.async_write_key.assert_awaited_once_with("lmo", 4, select) + + +async def test_select_null_option_writes_none(hass): + select, coordinator = _select(hass, {"trx": 1}, key="trx") + await select.async_select_option("null") + coordinator.async_write_key.assert_awaited_once_with("trx", None, select) + + +async def test_select_with_idx_writes_the_whole_object(hass): + select, coordinator = _select(hass, {"x": {"control": "a", "other": 1}}, key="x", idx="control") + await select.async_select_option("b") + coordinator.async_write_key.assert_awaited_once_with("x", {"control": "b", "other": 1}, select) + + +async def test_select_with_idx_and_null_writes_none_into_the_object(hass): + select, coordinator = _select(hass, {"x": {"control": "a"}}, key="x", idx="control") + await select.async_select_option("null") + coordinator.async_write_key.assert_awaited_once_with("x", {"control": None}, select) + + +async def test_select_with_idx_and_missing_key_writes_nothing(hass): + select, coordinator = _select(hass, {}, key="x", idx="control") + assert await select.async_select_option("b") == "unavailable" + coordinator.async_write_key.assert_not_awaited() + + +async def test_select_schedule_writes_an_integer_and_removes_the_seconds(hass): + obj = {"control": 0, "ranges": [{"begin": {"hour": 1, "second": 0}, "end": {"hour": 2, "second": 0}}, + {"begin": {"hour": 3}, "end": {"hour": 4}}]} + select, coordinator = _select(hass, {"sch_week": obj}, key="sch_week", idx="control") + await select.async_select_option("2") + written = coordinator.async_write_key.await_args.args[1] + assert written["control"] == 2 + assert all("second" not in r["begin"] and "second" not in r["end"] for r in written["ranges"]) + + +@pytest.mark.parametrize("option, expected", [ + ("default", {"ct": "default", "mca": 6, "acp": True, "su": False, "psm": 0, "mcpd": 0}), + ("renaultzoe", {"ct": "renaultZoe", "mca": 10, "acp": True, "su": True, "psm": 0, "mcpd": 0}), + ("vwid3_2", {"ct": "vwID3_2", "mca": 6, "acp": False, "su": False, "psm": 0, "mcpd": 0}), + ("daciaspring", {"ct": "daciaSpring", "mca": 6, "acp": True, "su": False, "psm": 1, "mcpd": 0}), + ("ssangyong", {"ct": "ssangyong", "mca": 6, "acp": True, "su": False, "psm": 0, "mcpd": 0}), + ("fordexplorer", {"ct": "fordExplorer", "mca": 6, "acp": True, "su": True, "psm": 0, "mcpd": 120000}), + ("cuprabornalternative", {"ct": "cupraBornAlternative", "mca": 6, "acp": True, "su": True, "psm": 0, "mcpd": 120000}), + ("kiasoul", {"ct": "kiaSoul", "mca": 6, "acp": True, "su": True, "psm": 0, "mcpd": 0}), +]) +async def test_select_car_type_writes_the_whole_profile(hass, option, expected): + select, coordinator = _select(hass, {"ct": "default"}, key="ct") + await select.async_select_option(option) + coordinator.async_write_multiple_keys.assert_awaited_once() + attr = coordinator.async_write_multiple_keys.await_args.kwargs["attr"] + assert attr == expected + + +async def test_select_swallows_write_errors(hass): + select, coordinator = _select(hass, {"lmo": "3"}, key="lmo") + coordinator.async_write_key.side_effect = ValueError("down") + assert await select.async_select_option("4") == "unavailable" + + +# ----------------------------------------------------------------------------- switch + +def _switch(hass, data, **description): + coordinator = make_coordinator(hass, data=data) + coordinator.async_write_key = AsyncMock() + return GoeChargerSwitch(coordinator, SwitchDesc(**description)), coordinator + + +@pytest.mark.parametrize("description, turn_on, expected", [ + (dict(key="bac"), True, True), + (dict(key="bac"), False, False), + (dict(key="bac", is_zero_or_one=True), True, 1), + (dict(key="bac", is_zero_or_one=True), False, 0), +]) +async def test_switch_writes_boolean_or_number(hass, description, turn_on, expected): + switch, coordinator = _switch(hass, {"bac": 1}, **description) + await (switch.async_turn_on() if turn_on else switch.async_turn_off()) + coordinator.async_write_key.assert_awaited_once_with("bac", expected, switch) + assert type(coordinator.async_write_key.await_args.args[1]) is type(expected) + + +@pytest.mark.parametrize("turn_on, flag, expected", [ + (True, None, True), (False, None, False), (True, True, 1), (False, True, 0)]) +async def test_switch_with_idx_writes_the_whole_object(hass, turn_on, flag, expected): + switch, coordinator = _switch(hass, {"x": {"a": 5, "b": None}}, key="x", idx="b", is_zero_or_one=flag) + await (switch.async_turn_on() if turn_on else switch.async_turn_off()) + coordinator.async_write_key.assert_awaited_once_with("x", {"a": 5, "b": expected}, switch) + + +@pytest.mark.parametrize("turn_on", [True, False]) +async def test_switch_with_idx_and_missing_key_writes_nothing(hass, turn_on): + switch, coordinator = _switch(hass, {}, key="x", idx="b") + assert await (switch.async_turn_on() if turn_on else switch.async_turn_off()) == "unavailable" + coordinator.async_write_key.assert_not_awaited() + + +@pytest.mark.parametrize("turn_on", [True, False]) +async def test_switch_swallows_write_errors(hass, turn_on): + switch, coordinator = _switch(hass, {"bac": 1}, key="bac") + coordinator.async_write_key.side_effect = ValueError("down") + assert await (switch.async_turn_on() if turn_on else switch.async_turn_off()) == "unavailable" + + +STATE_CASES = [ + ("bool true", {"x": True}, dict(key="x"), True), + ("bool false", {"x": False}, dict(key="x"), False), + ("positive integer", {"x": 3}, dict(key="x"), True), + ("zero", {"x": 0}, dict(key="x"), False), + ("text on", {"x": "ON"}, dict(key="x"), True), + ("text other", {"x": "off"}, dict(key="x"), False), + ("null", {"x": None}, dict(key="x"), False), + ("empty text", {"x": ""}, dict(key="x"), False), + ("missing key", {"y": 1}, dict(key="x"), False), + ("idx", {"x": [0, 1]}, dict(key="x", idx=1), True), + ("wrong idx", {"x": {}}, dict(key="x", idx=1), False), + ("list value", {"x": [1]}, dict(key="x"), False), +] + + +@pytest.mark.parametrize("data, description, expected", [c[1:] for c in STATE_CASES], ids=[c[0] for c in STATE_CASES]) +async def test_switch_is_on(hass, data, description, expected): + switch, _ = _switch(hass, data, **description) + assert switch.is_on is expected + + +async def test_switch_without_data_and_with_idx_on_null_have_no_state(hass): + switch, coordinator = _switch(hass, {"x": None}, key="x", idx=0) + assert switch.is_on is None and switch.state is None + + +async def test_switch_state_and_icon(hass): + switch, _ = _switch(hass, {"x": False}, key="x", icon="mdi:on", icon_off="mdi:off") + assert switch.state == "off" and switch.icon == "mdi:off" + switch.coordinator.data["x"] = True + assert switch.state == "on" and switch.icon == "mdi:on" + + +# ----------------------------------------------------------------------------- binary sensor + +def _binary(hass, data, **description): + return GoeChargerApiV2BinarySensor(make_coordinator(hass, data=data), BinaryDesc(**description)) + + +BINARY_CASES = [ + ("bool", {"x": True}, dict(key="x"), True), + ("text on", {"x": "on"}, dict(key="x"), True), + ("text other", {"x": "no"}, dict(key="x"), False), + ("integer is not on", {"x": 5}, dict(key="x"), False), + ("null", {"x": None}, dict(key="x"), False), + ("empty", {"x": ""}, dict(key="x"), False), + ("missing key", {"y": 1}, dict(key="x"), False), + ("idx", {"x": [False, True]}, dict(key="x", idx=1), True), + ("car connected above idle", {"car": 2}, dict(key="car", idx=0), True), + ("car idle is not connected", {"car": 1}, dict(key="car", idx=0), False), + ("tuple idx", {"x": {"a": {"b": True}}}, dict(key="x", tuple_idx=["a", "b"]), True), + ("idx out of range", {"x": [True]}, dict(key="x", idx=4), False), + ("tuple idx out of range", {"x": [[True]]}, dict(key="x", tuple_idx=[0, 3]), False), + ("wrong key in tuple idx", {"x": {"a": {}}}, dict(key="x", tuple_idx=["a", "b"]), False), + ("single tuple idx is ignored", {"x": 1}, dict(key="x", tuple_idx=["a"]), False), +] + + +@pytest.mark.parametrize("data, description, expected", [c[1:] for c in BINARY_CASES], ids=[c[0] for c in BINARY_CASES]) +async def test_binary_sensor_is_on(hass, data, description, expected): + assert _binary(hass, data, **description).is_on is expected + + +async def test_binary_sensor_with_wrong_type_has_no_state(hass): + assert _binary(hass, {"x": None}, key="x", idx=0).is_on is None + + +async def test_binary_sensor_icon_follows_the_state(hass): + sensor = _binary(hass, {"x": False}, key="x", icon="mdi:on", icon_off="mdi:off") + assert sensor.icon == "mdi:off" + sensor.coordinator.data["x"] = True + assert sensor.icon == "mdi:on" + + +# ----------------------------------------------------------------------------- button + +async def test_button_press_sends_its_payload(hass): + coordinator = make_coordinator(hass, data={}) + coordinator.async_write_key = AsyncMock() + button = GoeChargerApiV2Button(coordinator, ButtonDesc(key="zfocore", payload="TRIGGER")) + await button.async_press() + coordinator.async_write_key.assert_awaited_once_with("zfocore", "TRIGGER", button) + + +async def test_button_press_swallows_write_errors(hass): + coordinator = make_coordinator(hass, data={}) + coordinator.async_write_key = AsyncMock(side_effect=ValueError("down")) + button = GoeChargerApiV2Button(coordinator, ButtonDesc(key="zfocore", payload="TRIGGER")) + assert await button.async_press() == "unavailable" diff --git a/tests/integration/test_platform_values.py b/tests/integration/test_platform_values.py new file mode 100644 index 0000000..2ab2e64 --- /dev/null +++ b/tests/integration/test_platform_values.py @@ -0,0 +1,264 @@ +"""Platform entities, reading: how raw charger values become sensor, number, select, switch and binary sensor states. + +Each case builds an entity description by hand (so exactly one feature is exercised), puts a raw value +into the coordinator data and checks the state. Failure cases (missing key, wrong type, wrong index) +must give an empty state and no exception. +""" +from datetime import datetime, time + +import pytest +from homeassistant.components.sensor import SensorDeviceClass + +from custom_components.goecharger_api2.binary_sensor import GoeChargerApiV2BinarySensor +from custom_components.goecharger_api2.const import ( + ExtBinarySensorEntityDescription as BinaryDesc, + ExtNumberEntityDescription as NumberDesc, + ExtSelectEntityDescription as SelectDesc, + ExtSensorEntityDescription as SensorDesc, + ExtSwitchEntityDescription as SwitchDesc, +) +from custom_components.goecharger_api2.number import GoeChargerNumber +from custom_components.goecharger_api2.select import GoeChargerSelect +from custom_components.goecharger_api2.sensor import GoeChargerSensor +from custom_components.goecharger_api2.switch import GoeChargerSwitch + +from .factories import make_coordinator + + +def _sensor(hass, data, cards_as_object=True, **description): + coordinator = make_coordinator(hass, data=data) + coordinator._cards_as_object_present = cards_as_object + return GoeChargerSensor(coordinator, SensorDesc(**description)), coordinator + + +# ----------------------------------------------------------------------------- sensor + +SENSOR_CASES = [ + ("plain", {"amp": 16}, dict(key="amp"), 16), + ("empty string", {"amp": ""}, dict(key="amp"), None), + ("null", {"amp": None}, dict(key="amp"), None), + ("missing key", {}, dict(key="amp"), None), + ("idx", {"nrg": [230, 231, 232]}, dict(key="nrg", idx=1), 231), + ("idx out of range", {"nrg": [230]}, dict(key="nrg", idx=5), None), + ("idx on null", {"nrg": None}, dict(key="nrg", idx=0), None), + ("tuple idx", {"x": {"a": {"b": 3}}}, dict(key="x", tuple_idx=["a", "b"]), 3), + ("tuple idx missing", {"x": {"a": {}}}, dict(key="x", tuple_idx=["a", "b"]), None), + ("cards as object", {"cards": [{"name": "A", "energy": 5}]}, dict(key="cards", tuple_idx=[0, "energy"]), 5), + ("factor", {"x": 1000}, dict(key="x", factor=10), 100), + ("factor zero ignored", {"x": 1000}, dict(key="x", factor=0), 1000), + ("differential base", {"x": 100, "rbt": 150}, dict(key="x", differential_base_key="rbt"), 50), + ("differential base zero", {"x": 100, "rbt": 0}, dict(key="x", differential_base_key="rbt"), 100), + ("differential and factor", {"x": 100, "rbt": 1150}, dict(key="x", differential_base_key="rbt", factor=10), 105), + ("differential base missing", {"x": 100}, dict(key="x", differential_base_key="rbt"), None), + ("datetime", {"x": datetime(2026, 10, 29, 8, 30, 45)}, dict(key="x"), "2026-10-29 08:30"), + ("time", {"x": time(7, 5, 9)}, dict(key="x"), "07:05"), + ("bool true", {"x": True}, dict(key="x"), "on"), + ("bool false", {"x": False}, dict(key="x"), "off"), + ("string unchanged", {"x": "text"}, dict(key="x"), "text"), +] + + +@pytest.mark.parametrize("data, description, expected", [c[1:] for c in SENSOR_CASES], ids=[c[0] for c in SENSOR_CASES]) +async def test_sensor_native_value(hass, data, description, expected): + sensor, _ = _sensor(hass, data, **description) + assert sensor.native_value == expected + + +async def test_sensor_without_data_has_no_value(hass): + sensor, coordinator = _sensor(hass, {}, key="amp") + coordinator.data = None + assert sensor.native_value is None + + +@pytest.mark.parametrize("sub_key, key, expected", [ + ("energy", "c1e", 77), ("name", "c1n", "Anna"), ("cardId", "c1i", "A1B2")]) +async def test_sensor_card_values_of_firmware_60_use_single_keys(hass, sub_key, key, expected): + data = {"c1e": 77, "c1n": "Anna", "c1i": "A1B2"} + sensor, _ = _sensor(hass, data, cards_as_object=False, key="cards", tuple_idx=[1, sub_key]) + assert sensor.native_value == expected + + +async def test_sensor_unknown_card_sub_key_gives_no_value(hass): + sensor, _ = _sensor(hass, {"c1e": 77}, cards_as_object=False, key="cards", tuple_idx=[1, "colour"]) + assert sensor.native_value is None + + +async def test_sensor_card_key_missing_gives_no_value(hass): + sensor, _ = _sensor(hass, {}, cards_as_object=False, key="cards", tuple_idx=[1, "energy"]) + assert sensor.native_value is None + + +async def test_sensor_lookup_translates_the_value(hass): + sensor, coordinator = _sensor(hass, {"car": 2}, key="car", lookup=True) + assert sensor.native_value == coordinator.lang_map["car"][2] + + +async def test_sensor_lookup_without_translation_keeps_the_raw_value(hass, caplog): + sensor, _ = _sensor(hass, {"zzz": 5}, key="zzz", lookup=True) + assert sensor.native_value == 5 + assert "not found in translations" in caplog.text + + +async def test_sensor_lookup_with_unknown_code_gives_no_value(hass): + sensor, _ = _sensor(hass, {"car": 99}, key="car", lookup=True) + assert sensor.native_value is None + + +async def test_sensor_cll_object_becomes_readable_text(hass): + data = {"cll": {"cableCurrentLimit": 32, "unknownLimit": 1}} + sensor, coordinator = _sensor(hass, data, key="cll") + value = sensor.native_value + first = coordinator.lang_map.get("cll_cablecurrentlimit", "cable_current_limit") + assert value.startswith(f"{first}: 32") and value.endswith("unknown_limit: 1") + + +async def test_sensor_camel_case_is_converted_to_snake_case(): + assert GoeChargerSensor._camel_to_snake("cableCurrentLimit") == "cable_current_limit" + assert GoeChargerSensor._camel_to_snake("Max-Value 2") == "max_value_2" + + +async def test_sensor_date_state_is_unknown_without_value(hass): + sensor, _ = _sensor(hass, {"x": None}, key="x", device_class=SensorDeviceClass.DATE) + assert sensor.state == "unknown" + + +async def test_sensor_date_state_passes_the_value_through(hass): + sensor, _ = _sensor(hass, {"x": "2026-10-29"}, key="x", device_class=SensorDeviceClass.DATE) + assert sensor.state == "2026-10-29" + + +# ----------------------------------------------------------------------------- number + +def _number(hass, data, **description): + coordinator = make_coordinator(hass, data=data) + return GoeChargerNumber(coordinator, NumberDesc(**description)), coordinator + + +NUMBER_CASES = [ + ("plain", {"amp": 16}, dict(key="amp"), 16), + ("null is unknown", {"amp": None}, dict(key="amp"), "unknown"), + ("empty is unknown", {"amp": ""}, dict(key="amp"), "unknown"), + ("missing key is unknown", {}, dict(key="amp"), "unknown"), + ("idx", {"lop": [1, 2, 3]}, dict(key="lop", idx=2), 3), + ("idx on null gives none", {"lop": None}, dict(key="lop", idx=0), None), + ("float", {"x": 5}, dict(key="x", handle_as_float=True), 5.0), + ("float with factor", {"x": 150}, dict(key="x", handle_as_float=True, factor=100), 1.5), + ("integer with factor", {"x": 150}, dict(key="x", factor=10), 15), +] + + +@pytest.mark.parametrize("data, description, expected", [c[1:] for c in NUMBER_CASES], ids=[c[0] for c in NUMBER_CASES]) +async def test_number_native_value(hass, data, description, expected): + number, _ = _number(hass, data, **description) + assert number.native_value == expected + + +WRITE_CASES = [ + ("integer", {"amp": 6}, dict(key="amp"), 10, ("amp", 10)), + ("float value is cut to integer", {"amp": 6}, dict(key="amp"), 10.9, ("amp", 10)), + ("zero as null", {"x": 5}, dict(key="x", write_zero_as_null=True), 0, ("x", None)), + ("zero stays zero", {"x": 5}, dict(key="x"), 0, ("x", 0)), + ("factor", {"x": 5}, dict(key="x", factor=100), 1.5, ("x", 150)), + ("float", {"x": 5}, dict(key="x", handle_as_float=True), 2, ("x", 2.0)), +] + + +@pytest.mark.parametrize("data, description, value, expected", [c[1:] for c in WRITE_CASES], ids=[c[0] for c in WRITE_CASES]) +async def test_number_set_value_writes_the_converted_value(hass, data, description, value, expected): + from unittest.mock import AsyncMock + number, coordinator = _number(hass, data, **description) + coordinator.async_write_key = AsyncMock() + await number.async_set_native_value(value) + coordinator.async_write_key.assert_awaited_once_with(expected[0], expected[1], number, require_ack=False) + + +IDX_WRITE_CASES = [ + ("integer", dict(idx=1), 9, [1, 9, 3]), + ("zero as null", dict(idx=1, write_zero_as_null=True), 0, [1, None, 3]), + ("factor", dict(idx=1, factor=10), 2, [1, 20, 3]), + ("float", dict(idx=1, handle_as_float=True), 4, [1, 4.0, 3]), +] + + +@pytest.mark.parametrize("description, value, expected", [c[1:] for c in IDX_WRITE_CASES], ids=[c[0] for c in IDX_WRITE_CASES]) +async def test_number_set_value_writes_the_whole_list(hass, description, value, expected): + from unittest.mock import AsyncMock + number, coordinator = _number(hass, {"lop": [1, 2, 3]}, key="lop", **description) + coordinator.async_write_key = AsyncMock() + await number.async_set_native_value(value) + coordinator.async_write_key.assert_awaited_once_with("lop", expected, number, require_ack=False) + + +async def test_number_set_value_swallows_write_errors(hass): + from unittest.mock import AsyncMock + number, coordinator = _number(hass, {"amp": 6}, key="amp") + coordinator.async_write_key = AsyncMock(side_effect=ValueError("down")) + assert await number.async_set_native_value(10) == "unavailable" + + +# ----------------------------------------------------------------------------- number: limits of amp + +def _amp(value): + from types import SimpleNamespace + return SimpleNamespace(_attr_native_max_value=32, _attr_native_min_value=6, native_value=value, + entity_id="number.goe_123456_amp") + + +LIMIT_CASES = [ + ("max below amp lowers amp", "ama", 10, 20, 10), + ("max above amp changes nothing", "ama", 32, 20, None), + ("min above amp raises amp", "mca", 25, 20, 25), + ("min below amp changes nothing", "mca", 6, 20, None), + ("max equal to stored max changes nothing", "ama", 32, 40, None), +] + + +@pytest.mark.parametrize("key, value, amp_value, expected", [c[1:] for c in LIMIT_CASES], ids=[c[0] for c in LIMIT_CASES]) +async def test_number_amp_limits_adjust_amp_entity(hass, key, value, amp_value, expected): + from pytest_homeassistant_custom_component.common import async_mock_service + calls = async_mock_service(hass, "number", "set_value") + number, coordinator = _number(hass, {key: value}, key=key) + number.hass = hass + coordinator.entities["amp"] = _amp(amp_value) + number.check_amp_min_max(value) + await hass.async_block_till_done() + assert [c.data["value"] for c in calls] == ([] if expected is None else [expected]) + + +async def test_number_without_amp_entity_ignores_limits(hass): + number, _ = _number(hass, {"ama": 10}, key="ama") + number.check_amp_min_max(10) # no amp entity registered: must not raise + + +# ----------------------------------------------------------------------------- select + +def _select(hass, data, **description): + coordinator = make_coordinator(hass, data=data) + coordinator.available_cards_idx = ["1", "2"] + return GoeChargerSelect(coordinator, SelectDesc(options=["0", "1", "2"], **description)), coordinator + + +SELECT_CASES = [ + ("string", {"lmo": "3"}, dict(key="lmo"), "3"), + ("integer becomes text", {"lmo": 3}, dict(key="lmo"), "3"), + ("idx", {"x": ["a", "b"]}, dict(key="x", idx=1), "b"), + ("idx with missing key", {}, dict(key="x", idx=0), "unavailable"), + ("missing key is unknown", {}, dict(key="lmo"), "unknown"), + ("empty is unknown", {"lmo": ""}, dict(key="lmo"), "unknown"), + ("transaction without card is null", {"trx": None}, dict(key="trx"), "null"), + ("car type without value is default", {"ct": None}, dict(key="ct"), "default"), + ("car type is lower case", {"ct": "VWID5"}, dict(key="ct"), "vwid5"), + ("wrong index is unknown", {"x": {}}, dict(key="x", idx=1), "unknown"), + ("index on null gives none", {"x": None}, dict(key="x", idx=0), None), +] + + +@pytest.mark.parametrize("data, description, expected", [c[1:] for c in SELECT_CASES], ids=[c[0] for c in SELECT_CASES]) +async def test_select_current_option(hass, data, description, expected): + select, _ = _select(hass, data, **description) + assert select.current_option == expected + + +async def test_select_transaction_offers_the_active_cards(hass): + select, _ = _select(hass, {}, key="trx") + assert select.options == ["null", "0", "1", "2"] diff --git a/tests/integration/test_service.py b/tests/integration/test_service.py new file mode 100644 index 0000000..3dbd837 --- /dev/null +++ b/tests/integration/test_service.py @@ -0,0 +1,49 @@ +"""set_pv_data: non-finite numbers must never reach the charger.""" +import math +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from custom_components.goecharger_api2 import service as service_module +from custom_components.goecharger_api2.service import GoeChargerApiV2Service +from custom_components.goecharger_api2.pygoecharger_ha.keys import Tag + + +@pytest.fixture +def svc(hass): + service_module._COORDINATOR_PER_CONFIG_ENTRY_ID_MAP.clear() + coordinator = SimpleNamespace( + intg_type="charger", mode="lan", name="test charger", _serial="123456", data={}, + async_write_key=AsyncMock(return_value={Tag.IDS.key: True})) + service = GoeChargerApiV2Service(hass, SimpleNamespace(entry_id="entry"), coordinator) + yield service, coordinator + service_module._COORDINATOR_PER_CONFIG_ENTRY_ID_MAP.clear() + + +def _call(**data): + return SimpleNamespace(data=data, return_response=True) + + +async def test_valid_values_are_written(svc): + service, coordinator = svc + result = await service.set_pv_data(_call(pgrid=-500, ppv=1500, pakku=200)) + assert result["success"] == "true" + coordinator.async_write_key.assert_awaited_once_with( + Tag.IDS.key, {"pGrid": -500.0, "pPv": 1500.0, "pAkku": 200.0}) + + +@pytest.mark.parametrize("bad", [math.nan, math.inf, -math.inf]) +async def test_non_finite_grid_value_is_rejected(svc, bad): + service, coordinator = svc + result = await service.set_pv_data(_call(pgrid=bad, ppv=1500)) + assert "error" in result + coordinator.async_write_key.assert_not_awaited() + + +@pytest.mark.parametrize("bad", [math.nan, math.inf, -math.inf]) +async def test_non_finite_pv_and_battery_value_become_zero(svc, bad): + service, coordinator = svc + await service.set_pv_data(_call(pgrid=100, ppv=bad, pakku=bad)) + coordinator.async_write_key.assert_awaited_once_with( + Tag.IDS.key, {"pGrid": 100.0, "pPv": 0.0, "pAkku": 0.0}) diff --git a/tests/integration/test_services_and_setup.py b/tests/integration/test_services_and_setup.py new file mode 100644 index 0000000..e5b3552 --- /dev/null +++ b/tests/integration/test_services_and_setup.py @@ -0,0 +1,345 @@ +"""Services (PV data, stop charging, charger lookup) and the start and end of a config entry. + +Setup and unload run the real functions of the integration; only the platform loading, the data +polling and the WebSocket start are replaced. +""" +import inspect +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.config_entries import ConfigEntryDisabler +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState +from homeassistant.exceptions import ServiceValidationError + +from custom_components.goecharger_api2 import ( + ACTIVE_CHARGER_COUNTER, + GoeChargerDataUpdateCoordinator, + async_migrate_entry, + async_setup, + async_setup_entry, + async_unload_entry, + entry_update_listener, +) +from custom_components.goecharger_api2 import service as service_module +from custom_components.goecharger_api2.const import DOMAIN, SERVICE_SET_PV_DATA, SERVICE_STOP_CHARGING +from custom_components.goecharger_api2.service import GoeChargerApiV2Service, add_config_entry_to_service +from custom_components.goecharger_api2.pygoecharger_ha import NonNullDict +from custom_components.goecharger_api2.pygoecharger_ha.keys import Tag + +from .factories import make_coordinator, make_entry + +PKG = "custom_components.goecharger_api2" + + +@pytest.fixture(autouse=True) +def clean_registry(): + service_module._COORDINATOR_PER_CONFIG_ENTRY_ID_MAP = {} + yield + service_module._COORDINATOR_PER_CONFIG_ENTRY_ID_MAP = {} + + +def _call(return_response=True, **data): + return MagicMock(data=data, return_response=return_response) + + +def _service(hass, mode="lan", car=2, **kwargs): + coordinator = make_coordinator(hass, data={Tag.CAR_CONNECTED.key: car}, mode=mode, **kwargs) + coordinator.async_write_key = AsyncMock(return_value={Tag.IDS.key: True}) + return GoeChargerApiV2Service(hass, coordinator._config_entry, coordinator), coordinator + + +# ----------------------------------------------------------------------------- set_pv_data + +async def test_cloud_charger_without_a_car_gets_no_pv_data(hass): + service, coordinator = _service(hass, mode="wan", car=1) + result = await service.set_pv_data(_call(pgrid=-500)) + assert "No CAR CONNECTED" in result["error"] + coordinator.async_write_key.assert_not_awaited() + + +async def test_cloud_charger_without_a_car_returns_nothing_when_no_answer_is_wanted(hass): + service, coordinator = _service(hass, mode="wan", car=0) + assert await service.set_pv_data(_call(return_response=False, pgrid=-500)) is None + coordinator.async_write_key.assert_not_awaited() + + +async def test_cloud_charger_with_a_car_gets_pv_data(hass): + service, coordinator = _service(hass, mode="wan", car=2) + result = await service.set_pv_data(_call(pgrid=-500)) + assert result["success"] == "true" + coordinator.async_write_key.assert_awaited_once() + + +async def test_cloud_charger_with_unreadable_car_state_still_gets_pv_data(hass): + service, coordinator = _service(hass, mode="wan", car="n/a") + await service.set_pv_data(_call(pgrid=0)) + coordinator.async_write_key.assert_awaited_once() + + +async def test_cloud_charger_without_car_information_gets_no_pv_data(hass): + coordinator = make_coordinator(hass, data={}, mode="wan") + coordinator.async_write_key = AsyncMock() + service = GoeChargerApiV2Service(hass, coordinator._config_entry, coordinator) + result = await service.set_pv_data(_call(pgrid=0)) + assert "error" in result and coordinator.async_write_key.await_count == 0 + + +async def test_missing_grid_power_is_an_error(hass): + service, coordinator = _service(hass) + result = await service.set_pv_data(_call(ppv=100)) + assert "No Grid Power" in result["error"] + coordinator.async_write_key.assert_not_awaited() + + +async def test_missing_grid_power_without_wanted_answer_returns_nothing(hass): + service, _ = _service(hass) + assert await service.set_pv_data(_call(return_response=False, ppv=100)) is None + + +async def test_successful_pv_data_returns_nothing_when_no_answer_is_wanted(hass): + service, coordinator = _service(hass) + assert await service.set_pv_data(_call(return_response=False, pgrid=-1, ppv=2, pakku=3)) is None + coordinator.async_write_key.assert_awaited_once_with(Tag.IDS.key, {"pGrid": -1.0, "pPv": 2.0, "pAkku": 3.0}) + + +@pytest.mark.parametrize("error", [ValueError("down"), TypeError("odd")]) +@pytest.mark.parametrize("wanted", [True, False]) +async def test_failed_pv_write_is_reported_not_raised(hass, error, wanted): + service, coordinator = _service(hass) + coordinator.async_write_key.side_effect = error + result = await service.set_pv_data(_call(return_response=wanted, pgrid=0)) + assert (result is None) if not wanted else ("error" in result and str(error) in result["error"]) + + +# ----------------------------------------------------------------------------- stop_charging + +async def test_stop_charging_reports_when_the_lock_was_not_confirmed(hass): + service, coordinator = _service(hass) + coordinator.async_write_key.return_value = {} + result = await service.stop_charging(_call()) + assert "could not be send" in result["error"] and service._stop_in_progress is False + + +async def test_stop_charging_without_wanted_answer_returns_nothing(hass): + service, coordinator = _service(hass) + coordinator.async_write_key.return_value = {} + assert await service.stop_charging(_call(return_response=False)) is None + + +async def test_stop_charging_locks_waits_and_unlocks(hass): + service, coordinator = _service(hass) + coordinator.async_write_key.side_effect = [{Tag.FRC.key: 1}, {Tag.FRC.key: 0}] + with patch(f"{PKG}.service.asyncio.sleep", AsyncMock()) as sleep: + result = await service.stop_charging(_call()) + sleep.assert_awaited_once_with(300) + assert result["success"] == "true" + assert [c.args for c in coordinator.async_write_key.await_args_list] == [(Tag.FRC.key, 1), (Tag.FRC.key, 0)] + + +async def test_stop_charging_without_wanted_answer_still_unlocks(hass): + service, coordinator = _service(hass) + coordinator.async_write_key.side_effect = [{Tag.FRC.key: 1}, {Tag.FRC.key: 0}] + with patch(f"{PKG}.service.asyncio.sleep", AsyncMock()): + assert await service.stop_charging(_call(return_response=False)) is None + assert coordinator.async_write_key.await_count == 2 + + +async def test_second_stop_request_is_refused_while_one_runs(hass): + service, _ = _service(hass) + service._stop_in_progress = True + assert "already in progress" in (await service.stop_charging(_call()))["error"] + assert await service.stop_charging(_call(return_response=False)) is None + + +@pytest.mark.parametrize("wanted", [True, False]) +async def test_stop_charging_reports_write_errors(hass, wanted): + service, coordinator = _service(hass) + coordinator.async_write_key.side_effect = ValueError("down") + result = await service.stop_charging(_call(return_response=wanted)) + assert (result is None) if not wanted else ("down" in result["error"]) + + +# ----------------------------------------------------------------------------- choosing the charger + +async def test_without_configid_the_first_registered_charger_is_used(hass): + service, coordinator = _service(hass) + assert service.get_coordinator(_call())[1] is coordinator + + +# since 4702213 a call without configid is only answered when exactly one charger is registered +REFUSES_GUESSING = "zero (or multiple)" in inspect.getsource(GoeChargerApiV2Service.get_coordinator) + + +async def test_with_an_empty_registry_no_charger_can_be_chosen(hass): + service, _ = _service(hass) + service_module._COORDINATOR_PER_CONFIG_ENTRY_ID_MAP.clear() + expected = ServiceValidationError if REFUSES_GUESSING else (StopIteration, RuntimeError) + with pytest.raises(expected): + service.get_coordinator(_call()) + + +@pytest.mark.skipif(not REFUSES_GUESSING, reason="this version still picks the first registered charger") +async def test_without_configid_and_with_two_chargers_no_charger_is_guessed(hass): + service, _ = _service(hass) + other = make_coordinator(hass, serial="222222") + add_config_entry_to_service(other._config_entry, other) + with pytest.raises(ServiceValidationError, match="zero \\(or multiple\\)"): + service.get_coordinator(_call()) + + +async def test_configid_of_a_registered_enabled_charger_is_accepted(hass): + service, coordinator = _service(hass) + config_id = coordinator._config_entry.entry_id + assert service.get_coordinator(_call(configid=config_id)) == (config_id, coordinator) + + +async def test_unknown_configid_is_refused(hass): + service, _ = _service(hass) + with pytest.raises(ServiceValidationError, match="disabled or invalid"): + service.get_coordinator(_call(configid="does-not-exist")) + + +async def test_configid_of_a_disabled_entry_is_refused(hass): + service, coordinator = _service(hass) + entry = coordinator._config_entry + await hass.config_entries.async_set_disabled_by(entry.entry_id, ConfigEntryDisabler.USER) + with pytest.raises(ServiceValidationError, match="disabled or invalid"): + service.get_coordinator(_call(configid=entry.entry_id)) + + +async def test_configid_of_an_entry_missing_in_the_registry_is_refused(hass): + service, _ = _service(hass) + other = make_entry(hass, serial="999999") + with pytest.raises(ServiceValidationError, match="not in the internal"): + service.get_coordinator(_call(configid=other.entry_id)) + + +async def test_registry_helpers_ignore_unknown_entries(hass): + entry = make_entry(hass, serial="777777") + service_module.remove_config_entry_from_service(entry) + assert entry.entry_id not in service_module._COORDINATOR_PER_CONFIG_ENTRY_ID_MAP + + +# ----------------------------------------------------------------------------- start of an entry + +@pytest.fixture +def setup_patches(): + with patch.object(GoeChargerDataUpdateCoordinator, "_async_update_data", AsyncMock(return_value={"car": 1})), \ + patch.object(GoeChargerDataUpdateCoordinator, "read_versions", AsyncMock(return_value=True)), \ + patch.object(GoeChargerDataUpdateCoordinator, "start_websocket_and_wait_for_first_data", AsyncMock(return_value=True)) as ws_start, \ + patch.object(GoeChargerDataUpdateCoordinator, "start_watchdog", AsyncMock()) as watchdog: + yield SimpleNamespace_(ws_start=ws_start, watchdog=watchdog) + + +class SimpleNamespace_: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +async def _setup(hass, entry, limit=False): + with patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()) as forward: + result = await async_setup_entry(hass, entry) + return result, forward + + +async def test_polling_charger_starts_registers_services_and_counts(hass, setup_patches): + entry = make_entry(hass) + result, forward = await _setup(hass, entry) + assert result is True + assert isinstance(hass.data[DOMAIN][entry.entry_id], GoeChargerDataUpdateCoordinator) + assert hass.data[DOMAIN][ACTIVE_CHARGER_COUNTER] == 1 + assert hass.services.has_service(DOMAIN, SERVICE_SET_PV_DATA) and hass.services.has_service(DOMAIN, SERVICE_STOP_CHARGING) + forward.assert_awaited_once() + setup_patches.ws_start.assert_not_awaited() + setup_patches.watchdog.assert_not_awaited() + + +async def test_second_charger_shares_the_services_and_joins_the_registry(hass, setup_patches): + first, second = make_entry(hass, serial="111111"), make_entry(hass, serial="222222") + await _setup(hass, first) + await _setup(hass, second) + assert hass.data[DOMAIN][ACTIVE_CHARGER_COUNTER] == 2 + assert set(service_module._COORDINATOR_PER_CONFIG_ENTRY_ID_MAP) == {first.entry_id, second.entry_id} + + +async def test_controller_does_not_register_charger_services(hass, setup_patches): + entry = make_entry(hass, intg_type="controller") + await _setup(hass, entry) + assert not hass.services.has_service(DOMAIN, SERVICE_SET_PV_DATA) + assert ACTIVE_CHARGER_COUNTER not in hass.data[DOMAIN] + + +async def test_websocket_charger_starts_the_connection_and_the_watchdog(hass, setup_patches): + entry = make_entry(hass, password="secret") + await _setup(hass, entry) + setup_patches.ws_start.assert_awaited_once_with(is_integration_init=True) + setup_patches.watchdog.assert_awaited_once() + + +async def test_watchdog_waits_for_home_assistant_to_finish_starting(hass, setup_patches): + entry = make_entry(hass, password="secret") + hass.set_state(CoreState.starting) + await _setup(hass, entry) + setup_patches.watchdog.assert_not_awaited() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + setup_patches.watchdog.assert_awaited_once() + + +async def test_unknown_language_falls_back_to_english_texts(hass, setup_patches): + from homeassistant.exceptions import ConfigEntryNotReady + hass.config.language = "xx" + entry = make_entry(hass, password="secret") + setup_patches.ws_start.side_effect = RuntimeError("connection broke") + with pytest.raises(ConfigEntryNotReady, match="The Websocket could not be started"): + await _setup(hass, entry) + + +async def test_missing_device_data_is_reported_with_its_own_message(hass, setup_patches): + from homeassistant.exceptions import ConfigEntryNotReady + entry = make_entry(hass, password="secret") + setup_patches.ws_start.return_value = False + with pytest.raises(ConfigEntryNotReady, match="could not fetch essential"): + await _setup(hass, entry) + + +# ----------------------------------------------------------------------------- end of an entry + +async def test_unloading_the_last_charger_removes_the_services(hass, setup_patches): + entry = make_entry(hass) + await _setup(hass, entry) + with patch.object(hass.config_entries, "async_unload_platforms", AsyncMock(return_value=True)): + assert await async_unload_entry(hass, entry) is True + assert entry.entry_id not in hass.data[DOMAIN] + assert not hass.services.has_service(DOMAIN, SERVICE_SET_PV_DATA) + assert entry.entry_id not in service_module._COORDINATOR_PER_CONFIG_ENTRY_ID_MAP + + +async def test_unloading_one_of_two_chargers_keeps_the_services(hass, setup_patches): + first, second = make_entry(hass, serial="111111"), make_entry(hass, serial="222222") + await _setup(hass, first) + await _setup(hass, second) + with patch.object(hass.config_entries, "async_unload_platforms", AsyncMock(return_value=True)): + await async_unload_entry(hass, first) + assert hass.services.has_service(DOMAIN, SERVICE_SET_PV_DATA) + assert hass.data[DOMAIN][ACTIVE_CHARGER_COUNTER] == 1 + + +async def test_failed_platform_unload_keeps_everything(hass, setup_patches): + entry = make_entry(hass) + await _setup(hass, entry) + with patch.object(hass.config_entries, "async_unload_platforms", AsyncMock(return_value=False)): + assert await async_unload_entry(hass, entry) is False + assert entry.entry_id in hass.data[DOMAIN] and hass.services.has_service(DOMAIN, SERVICE_SET_PV_DATA) + + +async def test_changed_entry_is_reloaded(hass): + entry = make_entry(hass) + with patch.object(hass.config_entries, "async_reload", AsyncMock()) as reload: + await entry_update_listener(hass, entry) + reload.assert_awaited_once_with(entry.entry_id) + + +async def test_yaml_setup_is_accepted_and_ignored(hass): + assert await async_setup(hass, {}) is True diff --git a/tests/integration/test_services_and_unload.py b/tests/integration/test_services_and_unload.py new file mode 100644 index 0000000..50129db --- /dev/null +++ b/tests/integration/test_services_and_unload.py @@ -0,0 +1,175 @@ +"""Services (set_pv_data, stop_charging), the multi-charger registry and unloading of entries. + +Tests marked `xfail(strict=True)` describe behaviour that is wrong today. They turn the run red +(XPASS) as soon as the bug is fixed, so the marker must then be removed. All other tests pin +behaviour that works. The bridge is real; only the WebSocket connection object is faked. +""" +import logging +from collections import ChainMap +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.goecharger_api2 import ( + ACTIVE_CHARGER_COUNTER, + GoeChargerDataUpdateCoordinator, + async_unload_entry, +) +from custom_components.goecharger_api2 import service as service_module +from custom_components.goecharger_api2.const import DOMAIN, SERVICE_SET_PV_DATA, SERVICE_STOP_CHARGING +from custom_components.goecharger_api2.service import ( + GoeChargerApiV2Service, + add_config_entry_to_service, + remove_config_entry_from_service, +) +from custom_components.goecharger_api2.pygoecharger_ha.keys import Tag + +BRIDGE_SLEEP = "custom_components.goecharger_api2.pygoecharger_ha.asyncio.sleep" +SERVICE_SLEEP = "custom_components.goecharger_api2.service.asyncio.sleep" + + +def _entry(hass, serial="123456", intg_type="charger"): + entry = MockConfigEntry(domain=DOMAIN, title=f"go-e {serial}", data={ + "mode": "lan", "host": "192.0.2.10", "id": serial, "integration_type": intg_type, + "scan_interval": 30}) + entry.add_to_hass(hass) + return entry + + +def _coordinator(hass, entry, websocket): + coordinator = GoeChargerDataUpdateCoordinator(hass, entry) + bridge = coordinator.bridge + coordinator.data = ChainMap(bridge._ws_states, bridge._config, bridge._states, bridge._versions) + if websocket: + bridge.ws_connected = True + bridge._ws_connection = SimpleNamespace(send_json=AsyncMock()) + return coordinator + + +def _sent(coordinator): + """(key, value) of every setValue message that was sent over the fake WebSocket.""" + calls = coordinator.bridge._ws_connection.send_json.await_args_list + return [(c.args[0]["key"], c.args[0]["value"]) for c in calls] + + +def _call(**data): + return SimpleNamespace(data=data, return_response=True) + + +@pytest.fixture(autouse=True) +def clean_registry(): + service_module._COORDINATOR_PER_CONFIG_ENTRY_ID_MAP = {} + yield + service_module._COORDINATOR_PER_CONFIG_ENTRY_ID_MAP = {} + + +# --------------------------------------------------------------------------- stop_charging + +async def test_stop_charging_in_websocket_mode_sends_lock_and_unlock(hass): + """The reported TypeError is gone: frc=1, wait, frc=0.""" + coordinator = _coordinator(hass, _entry(hass), websocket=True) + service = GoeChargerApiV2Service(hass, coordinator._config_entry, coordinator) + async def mock_async_write_key(key, value, require_ack=False): + await coordinator.bridge._ws_connection.send_json({"key": key, "value": value}) + return {key: value} + coordinator.async_write_key = AsyncMock(side_effect=mock_async_write_key) + with patch(SERVICE_SLEEP, AsyncMock()): + result = await service.stop_charging(_call()) + assert result["success"] == "true" + assert _sent(coordinator) == [(Tag.FRC.key, 1), (Tag.FRC.key, 0)] + assert service._stop_in_progress is False + + +async def test_failed_lock_write_blocks_later_stop_requests(hass): + """a ValueError on the first write leaves _stop_in_progress True forever.""" + coordinator = _coordinator(hass, _entry(hass), websocket=True) + service = GoeChargerApiV2Service(hass, coordinator._config_entry, coordinator) + coordinator.async_write_key = AsyncMock(side_effect=ValueError("charger unreachable")) + await service.stop_charging(_call()) + assert service._stop_in_progress is False + + +async def test_failed_unlock_write_leaves_flag_stuck(hass): + """if frc=0 cannot be written after the wait, the charger stays locked + and the flag blocks every later stop_charging call.""" + coordinator = _coordinator(hass, _entry(hass), websocket=True) + service = GoeChargerApiV2Service(hass, coordinator._config_entry, coordinator) + coordinator.async_write_key = AsyncMock(side_effect=[{Tag.FRC.key: 1}, ValueError("down")]) + with patch(SERVICE_SLEEP, AsyncMock()): + await service.stop_charging(_call()) + assert service._stop_in_progress is False + + +# --------------------------------------------------------------------------- WebSocket write results + +async def test_websocket_multi_write_logs_no_error_on_success(hass, caplog): + """a successful multi-key write in WebSocket mode is logged as an ERROR.""" + coordinator = _coordinator(hass, _entry(hass), websocket=True) + with patch(BRIDGE_SLEEP, AsyncMock()), caplog.at_level(logging.ERROR): + await coordinator.async_write_multiple_keys({"ct": "1", "mca": 6}, "ct", "default") + assert [k for k, _ in _sent(coordinator)] == ["ct", "mca"] + errors = [r.message for r in caplog.records if r.levelno >= logging.ERROR] + assert errors == [] + + +async def test_websocket_single_write_stores_requested_value_before_confirmation(hass): + """Characterisation: the requested value is stored at once, not after the device's answer. + If the charger rejects the command, this value stays until the device reports the key again.""" + coordinator = _coordinator(hass, _entry(hass), websocket=True) + await coordinator.async_write_key("amp", 10) + assert coordinator.data["amp"] == 10 + + +# --------------------------------------------------------------------------- multi-charger registry + +async def test_second_charger_is_reachable_by_configid(hass): + a = _coordinator(hass, _entry(hass, "111111"), websocket=False) + b = _coordinator(hass, _entry(hass, "222222"), websocket=False) + service = GoeChargerApiV2Service(hass, a._config_entry, a) + add_config_entry_to_service(b._config_entry, b) + _, found = service.get_coordinator(_call(configid=b._config_entry.entry_id)) + assert found is b + + +async def test_reloaded_charger_replaces_stale_coordinator(hass): + a = _coordinator(hass, _entry(hass, "111111"), websocket=False) + b = _coordinator(hass, _entry(hass, "222222"), websocket=False) + service = GoeChargerApiV2Service(hass, a._config_entry, a) + add_config_entry_to_service(b._config_entry, b) + + remove_config_entry_from_service(a._config_entry) # unload of A + new_a = _coordinator(hass, a._config_entry, websocket=False) # reload of A + add_config_entry_to_service(a._config_entry, new_a) + + _, found = service.get_coordinator(_call(configid=a._config_entry.entry_id)) + assert found is new_a + + +# --------------------------------------------------------------------------- unload counting + +def _register_services(hass): + async def _noop(call): + return None + hass.services.async_register(DOMAIN, SERVICE_SET_PV_DATA, _noop) + hass.services.async_register(DOMAIN, SERVICE_STOP_CHARGING, _noop) + + +async def test_unloading_a_controller_keeps_the_charger_services(hass): + """the active-charger counter is only raised for chargers but lowered for every + entry, so unloading a controller removes the services of a still-running charger.""" + controller = _entry(hass, "999999", intg_type="controller") + hass.data[DOMAIN] = {controller.entry_id: MagicMock(), ACTIVE_CHARGER_COUNTER: 1} # one charger + _register_services(hass) + with patch.object(hass.config_entries, "async_unload_platforms", AsyncMock(return_value=True)): + await async_unload_entry(hass, controller) + assert hass.services.has_service(DOMAIN, SERVICE_SET_PV_DATA) + + +async def test_unloading_a_controller_without_any_charger_does_not_raise(hass): + """with only controllers configured the counter key does not exist.""" + controller = _entry(hass, "999999", intg_type="controller") + hass.data[DOMAIN] = {controller.entry_id: MagicMock()} + with patch.object(hass.config_entries, "async_unload_platforms", AsyncMock(return_value=True)): + assert await async_unload_entry(hass, controller) is True diff --git a/tests/integration/test_value_ranges.py b/tests/integration/test_value_ranges.py new file mode 100644 index 0000000..233022a --- /dev/null +++ b/tests/integration/test_value_ranges.py @@ -0,0 +1,56 @@ +"""Value ranges and option sets of the entities against the documentation. + +Sources (go-eCharger-API-v2, official repository): +- key table (`API_KEYS_FIRMWARE/apikeys-en.md`): option sets with their meaning and `lbr` 0-255 +- Modbus profile (`modbus-en.md`): amp/ama as whole amps 6 to 32 ("AMPERE_VOLATILE", "AMPERE_EEPROM"), + `frc` and `psm` "only accepts values 0, 1, 2", `dwo` any number of Wh or "null" for no limit + +Only numbers and option values are stored here, not the descriptions. The comparison is against what +the entity description offers; Home Assistant itself rejects values outside min/max before the +integration is called, so these limits are what a user can actually send. +""" +import pytest + +from custom_components.goecharger_api2 import const as entity_const + +# key -> (min, max) documented +NUMBER_RANGES = {"amp": (6, 32), "ama": (6, 32), "lbr": (0, 255)} +# key -> the set of values the charger accepts according to the documentation +OPTION_SETS = { + "frc": {"0", "1", "2"}, # Neutral, Off, On + "psm": {"0", "1", "2"}, # Modbus: "only accepts values 0, 1, 2" + "ust": {"0", "1", "2"}, # Normal, AutoUnlock, AlwaysLock + "tds": {"0", "1", "2"}, # None, EuropeanSummerTime, UsDaylightTime + "lmo": {"3", "4", "5"}, # Default, Awattar, AutomaticStop + "bac": {"0", "1", "2", "3"}, # AlwaysLock .. NeverLock +} + + +def _description(list_name, key): + matches = [d for d in getattr(entity_const, list_name) if d.key == key] + assert len(matches) == 1, f"{key}: expected one description in {list_name}, found {len(matches)}" + return matches[0] + + +@pytest.mark.parametrize("key, bounds", NUMBER_RANGES.items(), ids=NUMBER_RANGES) +def test_number_range_equals_documentation(key, bounds): + d = _description("NUMBER_SENSORS", key) + assert (d.native_min_value, d.native_max_value) == bounds + + +@pytest.mark.parametrize("key", NUMBER_RANGES) +def test_number_step_is_a_whole_unit(key): + assert _description("NUMBER_SENSORS", key).native_step == 1 + + +@pytest.mark.parametrize("key, values", OPTION_SETS.items(), ids=OPTION_SETS) +def test_select_options_equal_documentation(key, values): + d = _description("SELECT_SENSORS", key) + assert set(d.options) == values and len(d.options) == len(values), "no duplicates, no missing or extra option" + + +def test_energy_limit_stays_inside_what_the_api_accepts(): + # documented: any number of Wh (null = no limit); the integration offers 0 to 1,000,000 Wh in steps of 10 + d = _description("NUMBER_SENSORS", "dwo") + assert 0 <= d.native_min_value < d.native_max_value and d.native_step > 0 + assert d.native_min_value >= 0, "a negative energy limit is not meaningful" diff --git a/tests/integration/test_versions_16a.py b/tests/integration/test_versions_16a.py new file mode 100644 index 0000000..3cbc2ab --- /dev/null +++ b/tests/integration/test_versions_16a.py @@ -0,0 +1,60 @@ +"""Coordinator.read_versions: when is a charger treated as limited to 16 A? + +This is a decision table over cable limit (`cll`), 16 A adapter (`adi`), variant (`var`), device type +and the user option. The rows marked "current behaviour" pin what the code does today (since 98af3e0) +without claiming that it is the right choice for every device. +""" +import inspect +from unittest.mock import AsyncMock + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.goecharger_api2 import GoeChargerDataUpdateCoordinator +from custom_components.goecharger_api2.pygoecharger_ha import NonNullDict + +NON_CORE = "go-eCharger_V4" +CORE = "go-eCharger_Phoenix" + + +# The rule "cll without cableCurrentLimit -> no 16 A limit" exists since 98af3e0. Older releases limit such +# chargers, so that row is skipped there (the skip is visible in the report, it is not a silent pass). +RULE_SINCE_98AF3E0 = "will not enforce the 16A CABLE limit" in inspect.getsource( + GoeChargerDataUpdateCoordinator.read_versions) + + +def _cll(**fields): + return {"cll": fields} + + +CASES = [ + # id, device, extra versions, option, expected + ("cable_limit_32", NON_CORE, _cll(cableCurrentLimit=32), False, False), + ("cable_limit_16", NON_CORE, _cll(cableCurrentLimit=16), False, False), + ("cable_limit_6", NON_CORE, _cll(cableCurrentLimit=6), False, False), + ("variant_11kw_always_limits", NON_CORE, {"var": 11, **_cll(cableCurrentLimit=32)}, False, True), + ("user_option_forces_limit", NON_CORE, _cll(cableCurrentLimit=32), True, True), + ("no_cll_adapter_in_use", NON_CORE, {"adi": True}, False, False), + ("no_cll_no_adapter", NON_CORE, {"adi": False}, False, False), + ("core_without_cll_is_limited", CORE, {}, False, False), + # current behaviour since 98af3e0 (issue #176): cll present but no cableCurrentLimit -> no limit + ("cll_without_cable_limit_non_core", NON_CORE, _cll(currentLimitMax=32), False, False), + ("cll_without_cable_limit_core", CORE, _cll(currentLimitMax=32), False, False), +] + + +@pytest.mark.parametrize("device, extra, option, expected", + [c[1:] for c in CASES], ids=[c[0] for c in CASES]) +async def test_limit_to_16a_decision(request, hass, device, extra, option, expected): + if request.node.callspec.id.startswith("cll_without_cable_limit") and not RULE_SINCE_98AF3E0: + pytest.skip("this release predates the 98af3e0 rule for cll without cableCurrentLimit") + entry = MockConfigEntry(domain="goecharger_api2", title="t", data={ + "mode": "lan", "host": "192.0.2.10", "id": "123456", "integration_type": "charger", + "limit_to_11kw": option}) + entry.add_to_hass(hass) + coordinator = GoeChargerDataUpdateCoordinator(hass, entry) + coordinator.bridge.read_versions = AsyncMock(return_value=True) + coordinator.bridge._versions = NonNullDict({"typ": device, "fwv": "60.3", "var": 22, **extra}) + + assert await coordinator.read_versions() is True + assert coordinator.limit_to16a is expected diff --git a/tests/integration/test_ws_connect_paths.py b/tests/integration/test_ws_connect_paths.py new file mode 100644 index 0000000..c9964d2 --- /dev/null +++ b/tests/integration/test_ws_connect_paths.py @@ -0,0 +1,257 @@ +"""WebSocket connection: the paths of a login that succeeds and of the message loop afterwards. + +The fake socket plays a script (see test_ws_login_failures.py for the failing logins). The handshake is +the current implementation, not an official protocol. +""" +import asyncio +import json +import logging +from types import SimpleNamespace + +import aiohttp +import msgpack +import pytest + +from custom_components.goecharger_api2.pygoecharger_ha import GoeChargerApiV2Bridge, TargetedEvent + +from .factories import make_bridge +from .test_ws_login_failures import AUTH_OK, AUTH_REQUIRED, HELLO, FakeSession, FakeSocket, _text + + +class StreamSocket(FakeSocket): + """Like FakeSocket, but `after` holds ready-made message objects.""" + + def __aiter__(self): + async def stream(): + for message in self._after: + yield message + return stream() + + +def _bridge(socket, password="secret", **kwargs): + return GoeChargerApiV2Bridge(web_session=FakeSession(socket), intg_type="charger", host="192.0.2.10", + access_password=password, **kwargs) + + +def _msg(kind, data): + return SimpleNamespace(type=kind, data=data) + + +FULL = {"type": "fullStatus", "status": {"car": 1, "amp": 6}} + + +async def _run(socket, **kwargs): + bridge = _bridge(socket) + await bridge.ws_connect(**kwargs) + return bridge + + +# ----------------------------------------------------------------------------- the handshake + +@pytest.mark.parametrize("hello, expected_secured", [ + ({**HELLO, "version": "60.4", "secured": True}, True), + ({**HELLO, "version": "beta"}, False), + ({k: v for k, v in HELLO.items() if k != "version"}, False), +]) +async def test_hello_variants_are_accepted(hello, expected_secured): + socket = StreamSocket([hello, AUTH_REQUIRED, AUTH_OK], after=[_text(FULL)]) + bridge = _bridge(socket) + seen = {} + original = bridge.extract_ws_message_data + + def spy(data): + seen.update(secured=bridge._ws_secured, serial=bridge._ws_serial, info=dict(bridge._ws_device_info)) + return original(data) + + bridge.extract_ws_message_data = spy + await bridge.ws_connect() + assert seen["secured"] is expected_secured and seen["serial"] == "123456" and "type" not in seen["info"] + + +@pytest.mark.parametrize("auth, device, expected_hash", [ + ({"type": "authRequired", "token1": "a", "token2": "b", "hash": "bcrypt"}, "go-eCharger_V4", "bcrypt"), + ({"type": "authRequired", "token1": "a", "token2": "b"}, "go-eCharger_V4", "pbkdf2"), + ({"type": "authRequired", "token1": "a", "token2": "b"}, "go-eCharger_Phoenix", "bcrypt"), + ({"type": "authRequired", "token1": "a", "token2": "b"}, "go-eCharger_Core", "bcrypt"), + ({"type": "authRequired", "token1": "a", "token2": "b", "hash": "PBKDF2"}, "go-eCharger_Phoenix", "pbkdf2"), +]) +async def test_hash_type_is_taken_from_the_device_or_chosen_by_its_generation(auth, device, expected_hash): + socket = StreamSocket([{**HELLO, "devicetype": device}, auth, AUTH_OK], after=[]) + bridge = _bridge(socket) + used = [] + original = bridge._ws_compute_hashed_password + bridge._ws_compute_hashed_password = lambda hash_type, pwd, serial: (used.append(hash_type), original(hash_type, pwd, serial))[1] + await bridge.ws_connect() + assert used == [expected_hash] + + +async def test_cloud_connection_sends_the_token_and_uses_the_cloud_address(): + captured = {} + socket = StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=[]) + + class Session(FakeSession): + def ws_connect(self, **kwargs): + captured.update(kwargs) + return super().ws_connect(**kwargs) + + bridge = GoeChargerApiV2Bridge(web_session=Session(socket), intg_type="charger", serial="123456", + token="tok", access_password="secret") + await bridge.ws_connect() + assert captured["url"] == "wss://app.v3.go-e.io/123456" and captured["headers"] == {"Authorization": "Bearer tok"} + + +async def test_local_connection_sends_no_headers(): + captured = {} + socket = StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=[]) + + class Session(FakeSession): + def ws_connect(self, **kwargs): + captured.update(kwargs) + return super().ws_connect(**kwargs) + + bridge = GoeChargerApiV2Bridge(web_session=Session(socket), intg_type="charger", host="https://wallbox.local", + access_password="secret") + await bridge.ws_connect() + assert captured["url"] == "wss://wallbox.local/ws" and captured["headers"] is None + + +# ----------------------------------------------------------------------------- the message loop + +async def test_text_and_binary_messages_both_update_the_states(): + after = [_msg(aiohttp.WSMsgType.TEXT, json.dumps(FULL)), + _msg(aiohttp.WSMsgType.BINARY, b"\x00" + msgpack.packb({"type": "deltaStatus", "status": {"amp": 10}}))] + bridge = _bridge(StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=after)) + states = {} + original = bridge.extract_ws_message_data + bridge.extract_ws_message_data = lambda d: (states.update(bridge._ws_states), original(d))[1] + await bridge.ws_connect(keep_ws_states_after_close=True) + assert bridge._ws_states == {"car": 1, "amp": 10} and bridge.ws_connected is True + + +async def test_new_data_marks_the_time_and_notifies_the_coordinator(): + bridge = _bridge(StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=[_text(FULL)])) + notified = [] + bridge._ws_notify_for_new_data = lambda: notified.append(bridge._ws_LAST_UPDATE) + await bridge.ws_connect() + assert len(notified) == 1 and notified[0] > 0 + + +async def test_broken_binary_message_is_logged_and_the_loop_continues(caplog): + after = [_msg(aiohttp.WSMsgType.BINARY, b"\xc1\xc1"), _text(FULL)] + bridge = _bridge(StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=after)) + notified = [] + bridge._ws_notify_for_new_data = lambda: notified.append(1) + with caplog.at_level(logging.WARNING): + await bridge.ws_connect() + assert "Error processing BINARY message" in caplog.text and notified == [1] + + +async def test_broken_text_message_is_logged_and_the_loop_continues(caplog): + after = [_msg(aiohttp.WSMsgType.TEXT, "{not json"), _text(FULL)] + bridge = _bridge(StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=after)) + notified = [] + bridge._ws_notify_for_new_data = lambda: notified.append(1) + with caplog.at_level(logging.WARNING): + await bridge.ws_connect() + assert "Error processing TEXT message" in caplog.text and notified == [1] + + +@pytest.mark.parametrize("kind", [aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR]) +async def test_close_or_error_message_ends_the_loop(kind): + after = [_msg(kind, None), _text(FULL)] # the message after the close must never be read + bridge = _bridge(StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=after)) + bridge._ws_notify_for_new_data = lambda: pytest.fail("data after close was processed") + await bridge.ws_connect() + assert bridge.ws_connected is False + + +async def test_unknown_message_type_is_logged_and_ignored(caplog): + bridge = _bridge(StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=[_msg(aiohttp.WSMsgType.PING, b"")])) + with caplog.at_level(logging.WARNING): + await bridge.ws_connect() + assert "Unknown message type" in caplog.text + + +async def test_start_event_is_set_once_all_required_keys_have_arrived(): + keys = TargetedEvent(is_charger=True).tag_keys + first = {"type": "fullStatus", "partial": True, "status": {keys[0]: 1}} + rest = {"type": "deltaStatus", "status": {k: 1 for k in keys}} + event = TargetedEvent(is_charger=True) + bridge = _bridge(StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=[_text(first), _text(rest)])) + await bridge.ws_connect(a_event=event) + assert event.is_set() and not event.has_errors() + + +# ----------------------------------------------------------------------------- how a connection ends + +async def test_connection_end_clears_the_states_by_default(): + bridge = await _run(StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=[_text(FULL)])) + assert bridge._ws_states == {} and bridge.ws_connected is False and bridge._ws_connection is None + + +async def test_connection_end_can_keep_the_states_for_the_setup_check(): + bridge = await _run(StreamSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=[_text(FULL)]), keep_ws_states_after_close=True) + assert bridge._ws_states == {"car": 1, "amp": 6} and bridge.ws_connected is True + + +@pytest.mark.parametrize("error", [asyncio.TimeoutError(), asyncio.CancelledError()]) +async def test_timeout_and_cancel_are_reported_to_the_waiting_start(error): + bridge = GoeChargerApiV2Bridge(web_session=FakeSession(error=error), intg_type="charger", host="192.0.2.10", + access_password="secret") + event = TargetedEvent(is_charger=True) + await bridge.ws_connect(a_event=event) + assert event.has_errors() and bridge.ws_connected is False + + +async def test_errors_without_a_waiting_event_do_not_raise(): + bridge = GoeChargerApiV2Bridge(web_session=FakeSession(error=aiohttp.ClientConnectionError("x")), + intg_type="charger", host="192.0.2.10", access_password="secret") + assert await bridge.ws_connect() is None + + +# ----------------------------------------------------------------------------- building the addresses + +@pytest.mark.parametrize("host, password, expected_http, expected_ws", [ + ("wallbox.local", None, "http://wallbox.local", None), + ("wallbox.local", " ", "http://wallbox.local", None), + ("wallbox.local", " secret ", "http://wallbox.local", "ws://wallbox.local/ws"), + ("http://wallbox.local", "secret", "http://wallbox.local", "ws://wallbox.local/ws"), + ("https://wallbox.local", "secret", "https://wallbox.local", "wss://wallbox.local/ws"), +]) +async def test_local_addresses(host, password, expected_http, expected_ws): + bridge = GoeChargerApiV2Bridge(web_session=None, intg_type="charger", host=host, access_password=password) + assert bridge.host_url == expected_http and bridge.ws_url == expected_ws + if password and password.strip(): + assert bridge.access_password == "secret" + + +@pytest.mark.parametrize("password, expected_ws", [(None, None), ("secret", "wss://app.v3.go-e.io/1234")]) +async def test_cloud_addresses(password, expected_ws): + bridge = GoeChargerApiV2Bridge(web_session=None, intg_type="charger", serial="1234", token="tok", + access_password=password) + assert bridge.host_url == "https://001234.api.v3.go-e.io" and bridge.token == "Bearer tok" + assert bridge.ws_url == expected_ws + + +async def test_a_serial_without_a_token_is_not_enough(): + with pytest.raises(ValueError): + GoeChargerApiV2Bridge(web_session=None, intg_type="charger", serial="1234") + + +@pytest.mark.parametrize("language, expected", [("de", "de"), ("xx", "en")]) +async def test_bridge_texts_follow_the_language_with_english_fallback(language, expected): + from custom_components.goecharger_api2.pygoecharger_ha.const import TRANSLATIONS + bridge = GoeChargerApiV2Bridge(web_session=None, intg_type="charger", host="h", lang=language) + assert bridge.lang_map is TRANSLATIONS[expected] + + +async def test_normalizing_unreadable_bytes_gives_hex_and_keeps_other_types(): + bridge = make_bridge_no_hass() + assert bridge._ws_normalize_value(b"\xff\xfe") == "fffe" + assert bridge._ws_normalize_value((b"a", 1)) == ("a", 1) + assert bridge._ws_normalize_value(3.5) == 3.5 + assert bridge._ws_normalize_dict({1: b"x"}) == {"1": "x"} + + +def make_bridge_no_hass(): + return GoeChargerApiV2Bridge(web_session=None, intg_type="charger", host="h") diff --git a/tests/integration/test_ws_login_failures.py b/tests/integration/test_ws_login_failures.py new file mode 100644 index 0000000..3b97458 --- /dev/null +++ b/tests/integration/test_ws_login_failures.py @@ -0,0 +1,186 @@ +"""WebSocket login: every way the handshake can fail must end cleanly. + +"Cleanly" means: the waiting start code is told about the error (TargetedEvent), the connection is +reported as not connected, no data is kept and no command is sent. The handshake follows the +current implementation (hello -> auth required -> auth -> auth result); it is not officially documented. +A positive control (`test_successful_login_...`) makes sure the fake server really lets a valid login pass. +""" +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import aiohttp +import pytest + +from custom_components.goecharger_api2.pygoecharger_ha import GoeChargerApiV2Bridge, TargetedEvent + +WAIT_FOR = "custom_components.goecharger_api2.pygoecharger_ha.asyncio.wait_for" +HELLO = {"type": "hello", "serial": "123456", "version": "59.4", "secured": False} +AUTH_REQUIRED = {"type": "authRequired", "token1": "t1", "token2": "t2", "hash": "pbkdf2"} +AUTH_OK = {"type": "authSuccess"} + + +def _text(payload): + return SimpleNamespace(type=aiohttp.WSMsgType.TEXT, data=json.dumps(payload)) + + +class FakeSocket: + """Plays a script: the first receive() calls answer the handshake, `after` is streamed afterwards.""" + + def __init__(self, handshake, after=()): + self._handshake = list(handshake) + self._after = list(after) + self.send_json = AsyncMock() + + async def receive(self): + item = self._handshake.pop(0) + if item == "hang": + await asyncio.sleep(3600) + return _text(item) + + def __aiter__(self): + async def stream(): + for payload in self._after: + yield _text(payload) + return stream() + + async def close(self): + return None + + +class FakeSession: + def __init__(self, socket=None, error=None): + self._socket, self._error = socket, error + + def ws_connect(self, **kwargs): + session = self + + class _Ctx: + async def __aenter__(self): + if session._error: + raise session._error + return session._socket + + async def __aexit__(self, *exc): + return False + return _Ctx() + + +def _bridge(session, password="secret"): + return GoeChargerApiV2Bridge(web_session=session, intg_type="charger", host="192.0.2.10", + access_password=password) + + +async def _login(bridge): + event = TargetedEvent(is_charger=True) + result = await bridge.ws_connect(a_event=event) + return event, result + + +def _assert_clean_failure(bridge, event, socket, message): + assert event.is_set() and event.has_errors() + assert message in str(event._error) + assert bridge.ws_connected is False + assert bridge._ws_states == {} + if socket is not None: + socket.send_json.assert_awaited_once() # at most the auth answer, never a setValue command + assert socket.send_json.await_args.args[0].get("type") != "setValue" + + +async def test_successful_login_reports_data_and_resets_state_afterwards(): + """Positive control for the fake server.""" + status = {"type": "fullStatus", "status": {key: 1 for key in TargetedEvent(is_charger=True).tag_keys}} + socket = FakeSocket([HELLO, AUTH_REQUIRED, AUTH_OK], after=[status]) + bridge = _bridge(FakeSession(socket)) + event, _ = await _login(bridge) + assert event.is_set() and not event.has_errors() + assert socket.send_json.await_args.args[0]["type"] == "auth" + assert bridge.ws_connected is False # the stream ended, so the connection is closed again + + +async def test_no_password_means_no_websocket_url(): + bridge = _bridge(FakeSession(FakeSocket([])), password=None) + event, _ = await _login(bridge) + _assert_clean_failure(bridge, event, None, "WebSocket URL not configured") + + +async def test_hello_without_serial_fails(): + socket = FakeSocket([{"type": "hello"}]) + bridge = _bridge(FakeSession(socket)) + event, _ = await _login(bridge) + assert event.has_errors() and "No serial" in str(event._error) + assert bridge.ws_connected is False + socket.send_json.assert_not_awaited() + + +async def test_device_reports_offline(): + socket = FakeSocket([HELLO, {"type": "offline"}]) + bridge = _bridge(FakeSession(socket)) + event, _ = await _login(bridge) + assert event.has_errors() and "offline" in str(event._error) + assert bridge.ws_connected is False + socket.send_json.assert_not_awaited() + + +async def test_unsupported_hash_type_fails(): + socket = FakeSocket([HELLO, {**AUTH_REQUIRED, "hash": "rot13"}]) + bridge = _bridge(FakeSession(socket)) + event, _ = await _login(bridge) + assert event.has_errors() and "Unsupported authentication hash type" in str(event._error) + socket.send_json.assert_not_awaited() + + +@pytest.mark.parametrize("missing", ["token1", "token2"]) +async def test_missing_challenge_token_fails(missing): + challenge = {k: v for k, v in AUTH_REQUIRED.items() if k != missing} + socket = FakeSocket([HELLO, challenge]) + bridge = _bridge(FakeSession(socket)) + event, _ = await _login(bridge) + assert event.has_errors() and "Missing authentication tokens" in str(event._error) + socket.send_json.assert_not_awaited() + + +@pytest.mark.parametrize("answer", [ + {"type": "authError", "message": "wrong password"}, + {"type": "response", "success": False}, +]) +async def test_wrong_password_fails(answer): + socket = FakeSocket([HELLO, AUTH_REQUIRED, answer]) + bridge = _bridge(FakeSession(socket)) + event, _ = await _login(bridge) + _assert_clean_failure(bridge, event, socket, "Authentication failed") + + +async def test_no_answer_to_the_auth_message_times_out(): + socket = FakeSocket([HELLO, AUTH_REQUIRED, "hang"]) + bridge = _bridge(FakeSession(socket)) + real_wait_for = asyncio.wait_for + with patch(WAIT_FOR, lambda coro, timeout: real_wait_for(coro, 0.01)): + event, _ = await _login(bridge) + _assert_clean_failure(bridge, event, socket, "Authentication TIMEOUT") + + +async def test_unreachable_device_reports_the_connection_error(): + error = aiohttp.ClientConnectionError("no route to host") + bridge = _bridge(FakeSession(error=error)) + event, _ = await _login(bridge) + assert event.has_errors() and event._error is error + assert bridge.ws_connected is False and bridge._ws_states == {} + + +async def test_unexpected_error_during_handshake_is_reported_not_raised(): + error = RuntimeError("boom") + bridge = _bridge(FakeSession(error=error)) + event, result = await _login(bridge) + assert result is None and event._error is error + assert bridge.ws_connected is False + + +async def test_connection_that_drops_after_login_resets_the_state(): + socket = FakeSocket([HELLO, AUTH_REQUIRED, AUTH_OK], + after=[{"type": "fullStatus", "status": {"car": 1}}]) + bridge = _bridge(FakeSession(socket)) + await bridge.ws_connect() + assert bridge.ws_connected is False + assert bridge._ws_states == {} and bridge._ws_connection is None diff --git a/tests/integration/test_ws_protocol.py b/tests/integration/test_ws_protocol.py new file mode 100644 index 0000000..4924191 --- /dev/null +++ b/tests/integration/test_ws_protocol.py @@ -0,0 +1,192 @@ +"""Bridge, WebSocket path: message handling, password hashing and message signing. + +The WebSocket protocol is not part of the official go-e documentation. These tests pin the behaviour +of the current implementation so that refactorings do not change it unnoticed; they are not a +statement about what the charger firmware really does. +""" +import hashlib +import hmac +import json +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import bcrypt +import msgpack +import pytest + +from custom_components.goecharger_api2.pygoecharger_ha import ( + GoeChargerApiV2Bridge, + TargetedEvent, + bcryptjs_base64_encode_serial, +) +from custom_components.goecharger_api2.pygoecharger_ha.const import API_KEYS_TO_IGNORE_FROM_WS + +BCRYPT_ALPHABET = set("./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") + + +@pytest.fixture +def bridge(): + return GoeChargerApiV2Bridge(web_session=None, intg_type="charger", host="192.0.2.10", + access_password="secret") + + +# ----------------------------------------------------------------------------- incoming messages + +def test_full_status_replaces_all_states(bridge): + bridge._ws_states = {"a": 1} + assert bridge.extract_ws_message_data({"type": "fullStatus", "status": {"a": 2, "b": 3}}) is True + assert bridge._ws_states == {"a": 2, "b": 3} + + +def test_full_status_keeps_keys_that_are_missing_in_the_message(bridge): + bridge._ws_states = {"a": 1, "z": 9} + bridge.extract_ws_message_data({"type": "fullStatus", "status": {"a": 2}}) + assert bridge._ws_states == {"a": 2, "z": 9} + + +def test_partial_full_status_is_merged(bridge): + bridge._ws_states = {"a": 1} + bridge.extract_ws_message_data({"type": "fullStatus", "partial": True, "status": {"b": 3}}) + assert bridge._ws_states == {"a": 1, "b": 3} + + +def test_delta_status_updates_values(bridge): + bridge._ws_states = {"a": 1} + assert bridge.extract_ws_message_data({"type": "deltaStatus", "status": {"a": 5}}) is True + assert bridge._ws_states == {"a": 5} + + +def test_delta_status_ignores_configured_keys(bridge): + ignored = API_KEYS_TO_IGNORE_FROM_WS[0] + assert bridge.extract_ws_message_data({"type": "deltaStatus", "status": {ignored: 1}}) is False + assert ignored not in bridge._ws_states + + +def test_successful_response_updates_states(bridge): + assert bridge.extract_ws_message_data( + {"type": "response", "success": True, "requestId": 1, "status": {"frc": 1}}) is True + assert bridge._ws_states == {"frc": 1} + + +def test_failed_response_is_logged_and_changes_nothing(bridge, caplog): + bridge._ws_request_storage.set("7", {"key": "amp", "value": 99}) + with caplog.at_level(logging.WARNING): + result = bridge.extract_ws_message_data( + {"type": "response", "success": False, "requestId": "7", "message": "denied"}) + assert result is False + assert bridge._ws_states == {} + assert "Command failed" in caplog.text and "amp" in caplog.text + + +@pytest.mark.parametrize("message", [ + {"type": "hello"}, + {"type": "fullStatus", "status": {}}, + {"type": "deltaStatus"}, + {}, +]) +def test_other_messages_report_no_new_data(bridge, message): + assert bridge.extract_ws_message_data(message) is False + + +def test_decode_accepts_json_text_and_msgpack_with_or_without_prefix(bridge): + payload = {"type": "hello", "serial": "123456"} + assert bridge._ws_decode_message(json.dumps(payload)) == payload + assert bridge._ws_decode_message(msgpack.packb(payload)) == payload + assert bridge._ws_decode_message(b"\x00" + msgpack.packb(payload)) == payload + + +def test_normalize_turns_bytes_into_strings(bridge): + assert bridge._ws_normalize_dict({b"serial": b"123456", "list": [b"a", {b"k": b"v"}]}) == \ + {"serial": "123456", "list": ["a", {"k": "v"}]} + + +# ----------------------------------------------------------------------------- password hashing + +def test_pbkdf2_hash_is_32_bytes_and_follows_the_documented_steps(bridge): + expected = hashlib.pbkdf2_hmac("sha512", b"secret", b"123456", 100000, 256) + import base64 + result = bridge._ws_compute_hashed_password("pbkdf2", "secret", "123456") + assert len(result) == 32 + assert result == base64.b64encode(expected)[:32] + + +def test_bcrypt_hash_is_deterministic_and_verifiable(bridge): + first = bridge._ws_compute_hashed_password("bcrypt", "secret", "123456") + second = bridge._ws_compute_hashed_password("bcrypt", "secret", "123456") + assert first == second and len(first) == 31 + salt = f"$2a$08${bcryptjs_base64_encode_serial('123456', 16)}" + assert bcrypt.hashpw(hashlib.sha256(b"secret").hexdigest().encode(), salt.encode()).endswith(first) + + +def test_unknown_hash_type_returns_none(bridge): + assert bridge._ws_compute_hashed_password("md5", "secret", "123456") is None + + +def test_bcrypt_salt_encoding_has_22_valid_characters(): + encoded = bcryptjs_base64_encode_serial("123456", 16) + assert len(encoded) == 22 and set(encoded) <= BCRYPT_ALPHABET + + +def test_bcrypt_salt_encoding_rejects_non_numeric_serial(): + with pytest.raises(ValueError): + bcryptjs_base64_encode_serial("12AB56", 16) + + +def test_auth_hash_follows_challenge_response_formula(bridge): + hashed = b"hashed-password" + inner = hashlib.sha256(b"token1" + hashed).hexdigest() + expected = hashlib.sha256(("token3" + "token2" + inner).encode()).hexdigest() + assert bridge._ws_compute_auth_hash("token1", "token2", "token3", hashed) == expected + + +# ----------------------------------------------------------------------------- outgoing commands + +async def test_plain_command_has_documented_shape(bridge): + bridge._ws_connection = SimpleNamespace(send_json=AsyncMock()) + await bridge._ws_send_command("amp", 10) + first = bridge._ws_connection.send_json.await_args.args[0] + assert first == {"type": "setValue", "requestId": 1, "key": "amp", "value": 10} + await bridge._ws_send_command("frc", 1) + assert bridge._ws_connection.send_json.await_args.args[0]["requestId"] == 2 + + +async def test_secured_command_is_signed_with_hmac_sha256(bridge): + bridge._ws_connection = SimpleNamespace(send_json=AsyncMock()) + bridge._ws_secured = True + bridge._ws_hashed_password = b"hashed-password" + await bridge._ws_send_command("frc", 1) + message = bridge._ws_connection.send_json.await_args.args[0] + assert message["type"] == "securedMsg" and message["requestId"] == "1sm" + assert json.loads(message["data"]) == {"type": "setValue", "requestId": 1, "key": "frc", "value": 1} + assert message["hmac"] == hmac.new(b"hashed-password", message["data"].encode(), hashlib.sha256).hexdigest() + + +async def test_command_sets_flag_and_returns_requested_value_over_websocket(bridge): + bridge.ws_connected = True + bridge._ws_connection = SimpleNamespace(send_json=AsyncMock()) + bridge._ws_LAST_NEW_DATA_NOTIFY = 123.0 + assert await bridge.write_value_to_key("frc", 1) == {"frc": 1} + assert bridge._ws_LAST_NEW_DATA_NOTIFY == 0 + + +async def test_button_press_is_ignored_over_websocket(bridge): + bridge.ws_connected = True + bridge._ws_connection = SimpleNamespace(send_json=AsyncMock()) + assert await bridge.write_value_to_key("zfocore", "TRIGGER") == {"zfocore": "TRIGGER"} + bridge._ws_connection.send_json.assert_not_awaited() + + +# ----------------------------------------------------------------------------- start condition + +def test_targeted_event_waits_for_the_required_charger_keys(): + event = TargetedEvent(is_charger=True) + assert "hai" not in event.tag_keys and "cll" not in event.tag_keys + assert event.check_and_set({"typ": 1}) is False and not event.is_set() + assert event.check_and_set({key: 1 for key in event.tag_keys}) is True and event.is_set() + + +def test_targeted_event_for_controller_needs_different_keys(): + charger, controller = TargetedEvent(is_charger=True), TargetedEvent(is_charger=False) + assert set(charger.tag_keys) != set(controller.tag_keys) + assert "fwv" in controller.tag_keys diff --git a/tests/tools/README.md b/tests/tools/README.md new file mode 100644 index 0000000..bd34201 --- /dev/null +++ b/tests/tools/README.md @@ -0,0 +1,26 @@ +# Test tools + +| File | Purpose | +|---|---| +| `pathtracer.py` | pytest plugin (`-p pathtracer`) that records which distinct execution paths each function takes (needs Python 3.12+, uses `sys.monitoring`). Empirical, not a proof of path coverage. | +| `criticality.py` | Criticality class (K1 to K4) of every function, with a reason. A judgement, not a measurement. | +| `pathmetric.py` | Prints the path coverage (upper bound of basis paths) per criticality class from a path-trace file. | +| `build_report.py` | Builds `testreport.md` from coverage, junit and path-trace data. | +| `measure.sh` | Runs everything and builds the report. | +| `release_check.sh` | Tests a release (or branch) of the upstream integration with the tests of this branch. Run by hand, for example to test an older release with the current tests. | + +Needs `pytest-cov` and `radon` in addition to `requirements_dev.txt`. + +## Running the tests automatically + +`.github/workflows/tests.yaml` runs the blocking tests (`-m "not known_bug and not needs_decision"`) on every push to `main` and on every pull request that touches `custom_components/`, `tests/` or `requirements_dev.txt`. A second, non-blocking step runs the tests marked `known_bug` or `needs_decision` and lists them in the job summary; a test that passes there means the finding looks fixed and its marker can go. + +`.github/workflows/test-report.yaml` builds the full report (coverage, paths, criticality) by hand for a release or branch. + +Things to know: +- The tests follow `main`. An older release can fail a test that pins behaviour introduced later (for example the 16 A rule since commit `98af3e0` fails on release 2026.10.8), and a release that lacks functions the tests import cannot be collected. +- Test an older release by hand: `tests/tools/release_check.sh [tag-or-branch]` (needs the test dependencies and the GitHub CLI for the latest release). + +## Home Assistant version of the tests + +`requirements_dev.txt` pins `pytest-homeassistant-custom-component` (currently 0.13.375, which brings Home Assistant 2026.10.4). A red run is therefore a change in the code under test, not a new Home Assistant. To see what a Home Assistant update breaks, start `regression-on-release.yaml` by hand with `home_assistant = latest`. To move the pin on purpose: run the suite with the new version, fix what broke, then raise the number in `requirements_dev.txt`. The tests run against the newest Home Assistant only; the minimum version named in `hacs.json` (2023.8.0) is not tested. diff --git a/tests/tools/build_report.py b/tests/tools/build_report.py new file mode 100644 index 0000000..39fb992 --- /dev/null +++ b/tests/tools/build_report.py @@ -0,0 +1,294 @@ +"""Builds testreport.md from coverage JSON, junit XML, pathtrace JSON and radon complexity. + +Usage: python build_report.py +""" +import json +import os +import subprocess +import sys +import xml.etree.ElementTree as ET +from collections import defaultdict +from datetime import date +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from criticality import classify # noqa: E402 + +report_dir, checkout, src_commit, tests_commit = Path(sys.argv[1]), Path(sys.argv[2]).resolve(), sys.argv[3], sys.argv[4] +PKG = "custom_components/goecharger_api2/" +CORE = ["__init__.py", "pygoecharger_ha/__init__.py", "service.py", "config_flow.py"] +PLATFORMS = ["binary_sensor.py", "button.py", "number.py", "select.py", "sensor.py", "switch.py", "entity.py"] + + +def rel(path): + return path.split(PKG)[-1] + + +cov = json.loads((report_dir / "coverage-all.json").read_text()) +cov_block = json.loads((report_dir / "coverage-blocking.json").read_text())["totals"] +def load_trace(name): + raw = json.loads((report_dir / name).read_text()) + return {(rel(k.split("::")[0]), k.split("::")[1]): v for k, v in raw.items()} + + +trace = load_trace("pathtrace-all.json") +base_trace = load_trace("pathtrace-baseline.json") +base_cov = json.loads((report_dir / "coverage-baseline.json").read_text())["totals"] +sources = {rel(p): (checkout / p).read_text().splitlines() if (checkout / p).exists() else + (checkout / PKG / rel(p)).read_text().splitlines() for p in cov["files"]} + +radon = json.loads(subprocess.run(["radon", "cc", "-s", "-j", str(checkout / PKG)], capture_output=True, text=True).stdout) +cc = {} +for file, blocks in radon.items(): + for block in blocks: + entries = [block] + block.get("methods", []) if block["type"] == "class" else [block] + for e in entries: + if e["type"] in ("function", "method"): + name = f"{e['classname']}.{e['name']}" if e.get("classname") else e["name"] + cc[(rel(file), name)] = e["complexity"] + +funcs = [] +for path, data in cov["files"].items(): + f = rel(path) + for qual, fd in data["functions"].items(): + if not qual or (f, qual) not in cc: + continue + s = fd["summary"] + n_paths = trace.get((f, qual), {}).get("distinct_paths", 0) + calls = trace.get((f, qual), {}).get("calls", 0) + base_paths = base_trace.get((f, qual), {}).get("distinct_paths", 0) + funcs.append(dict(file=f, name=qual, cc=cc[(f, qual)], stm=s["num_statements"], base_paths=base_paths, + klass=classify(f, qual)[0], why=classify(f, qual)[1], covered_lines=s["covered_lines"], + cov_branches=s["covered_branches"], + line=s["percent_statements_covered"], br=s["num_branches"], brc=s["covered_branches"], + paths=n_paths, calls=calls, missing=fd["missing_branches"])) + + +def pct(a, b): + return f"{100 * a / b:.0f} %" if b else "–" + + +def path_bound(rows): + total = sum(r["cc"] for r in rows) + return sum(min(r["paths"], r["cc"]) for r in rows), total + + +# ---------------------------------------------------------------------------------------- results +junit = ET.parse(report_dir / "junit-all.xml").getroot() +suite = junit if junit.tag == "testsuite" else junit.find("testsuite") +by_file = defaultdict(lambda: [0, 0, 0.0]) +for tc in suite.iter("testcase"): + name = tc.get("classname").split(".")[-1] + by_file[name][0] += 1 + by_file[name][2] += float(tc.get("time")) + if tc.find("failure") is not None or tc.find("error") is not None: + by_file[name][1] += 1 + +t = cov["totals"] +core_rows = [r for r in funcs if r["file"] in CORE] +all_cov, all_cc = path_bound(funcs) +core_cov, core_cc = path_bound(core_rows) +never = [r for r in funcs if r["calls"] == 0] +short = [r for r in funcs if r["calls"] > 0 and r["paths"] < r["cc"]] +enough = [r for r in funcs if r["calls"] > 0 and r["paths"] >= r["cc"]] + +failures = int(suite.get("failures")) + int(suite.get("errors")) +passed = int(suite.get("tests")) - failures +NEW_BEHAVIOUR = int(suite.get("tests")) - by_file["test_api_contract"][0] +BASE_ALL = pct(sum(min(r["base_paths"], r["cc"]) for r in funcs), all_cc) +BASE_CORE = pct(sum(min(r["base_paths"], r["cc"]) for r in core_rows), core_cc) +out = [] +w = out.append +ERROR_WORDS = ("except", "raise", "error", "warning", "fail", "invalid", "timeout", "denied", "unavailable") + + +def crit_section(): + names = ["K1 kritisch", "K2 hoch", "K3 mittel", "K4 niedrig"] + w("""## 5. Pfade nach Kritikalität + +**Maßstab.** Kritikalität ist die Auswirkung eines falschen Pfades auf den Nutzer, nicht die Häufigkeit. Die Einstufung gilt für eine ganze Funktion und damit für alle ihre Pfade. Die Zuordnung steht in `tests/tools/criticality.py` (mit Begründung je Funktion) und ist eine Einschätzung, keine Messung. + +| Klasse | Bedeutung | Beispiele | +|---|---|---| +| **K1 kritisch** | Ändert den Charger, seine Grenzen oder die HA-Konfiguration; ein falscher Pfad kann den Charger sperren, falsche Werte senden, Geräte löschen oder Dienste entfernen | `write_value_to_key`, `stop_charging`, `set_pv_data`, `check_and_write_to_16a`, `async_migrate_entry` | +| **K2 hoch** | Verbindung, Anmeldung, Erholung, Start; ein falscher Pfad macht die Integration unverfügbar oder zeigt alte Daten | `ws_connect`, `_async_update_data`, `async_setup_entry`, Config-Flow | +| **K3 mittel** | Korrektheit dessen, was angezeigt oder erkannt wird, ohne Schreiben | Wertdarstellung der Entitäten, `is_valid_charger_entity` | +| **K4 niedrig** | Hilfsfunktionen, Namen, Symbole, Logging | `_ws_normalize_value`, `unique_id`, Anzeigename | + +| Klasse | Funktionen | CC (Basispfade) | Pfade vorher | Pfade jetzt (obere Schranke) | Zeilen | Zweige | nie ausgeführt | +|---|---|---|---|---|---|---|---|""") + for k in names: + rows = [r for r in funcs if r["klass"] == k] + cc_sum = sum(r["cc"] for r in rows) + now = sum(min(r["paths"], r["cc"]) for r in rows) + before = sum(min(r["base_paths"], r["cc"]) for r in rows) + stm = sum(r["stm"] for r in rows) + w(f"| **{k}** | {len(rows)} | {cc_sum} | {pct(before, cc_sum)} | **{pct(now, cc_sum)}** ({now} von {cc_sum}) | " + f"{pct(sum(r['covered_lines'] for r in rows), stm)} | {pct(sum(r['cov_branches'] for r in rows), sum(r['br'] for r in rows))} | " + f"{sum(1 for r in rows if r['calls'] == 0)} |") + w(""" +Lesehilfe: „Pfade jetzt“ ist die obere Schranke der Basispfadabdeckung (Abschnitt 2). Die Spalten „Zeilen“ und „Zweige“ stehen für dieselben Funktionen und liegen viel höher; der Unterschied zeigt, wie viel strenger die Pfadabdeckung ist. + +### 5.1 Offene Lücken in K1 und K2 + +Funktionen, bei denen **sicher** Basispfade fehlen (beobachtete Pfade unter der CC), geordnet nach Klasse und Zahl der fehlenden Pfade. „Fehlerpfad“ markiert nicht genommene Zweige, deren Zeile nach einem Fehlerfall aussieht (Schlüsselwörter wie `except`, `raise`, `error`, `warning`); das ist eine Heuristik. +""") + for k in names[:2]: + rows = sorted((r for r in funcs if r["klass"] == k and r["paths"] < r["cc"]), + key=lambda r: -(r["cc"] - r["paths"])) + w(f"**{k}**\n\n| Funktion | CC | Pfade | fehlen mind. | Aufrufe | Warum kritisch | Nicht genommene Zweige |\n|---|---|---|---|---|---|---|") + for r in rows[:14]: + src = sources[r["file"]] + parts = [] + for frm, to in sorted({(m[0], m[1]) for m in r["missing"] if m[0] > 0})[:4]: + text = src[frm - 1].strip() + target = src[to - 1].strip() if to > 0 else "" + kind = "Fehlerpfad" if any(wd in (text + " " + target).lower() for wd in ERROR_WORDS) else "Normalpfad" + parts.append(f"Z.{frm} `{text[:46]}` ({kind})") + how = "nie ausgeführt" if r["calls"] == 0 else str(r["calls"]) + w(f"| `{r['name']}` | {r['cc']} | {r['paths']} | {r['cc'] - r['paths']} | {how} | {r['why']} | {'
'.join(parts) if parts else '–'} |") + w("") +w(f"""# Testreport und Testabdeckung: `ha-goecharger-api2` + +| | | +|---|---| +| **Datum** | {date.today().isoformat()} | +| **Geprüfter Code** | `marq24/ha-goecharger-api2` `{os.environ.get('SOURCE_REF', 'main')}`, Commit `{src_commit}` | +| **Tests** | Fork `OliEder/ha-goecharger-api2`, Branch `tests/regression-suite`, Commit `{tests_commit}` | +| **Umgebung** | Python 3.14.3, Home Assistant 2026.10.4, coverage {cov['meta']['version']}, radon 6.0.1 | +| **Rohdaten** | `coverage-all.json`, `coverage-blocking.json`, `junit-all.xml`, `pathtrace-all.json`, HTML-Bericht in `html-all/index.html` | + +## 1. Zusammenfassung + +| Kennzahl | Wert | +|---|---| +| Tests gesammelt / bestanden / fehlgeschlagen | {suite.get('tests')} / {passed} / {failures} (alle beabsichtigt: {failures - 14} `known_bug`, 14 `needs_decision`) | +| Blockierende Menge (ohne die beiden Marker) | {passed} von {passed} bestanden | +| **Zeilenabdeckung** (alle Tests) | **{t['percent_statements_covered']:.1f} %** ({t['covered_lines']} von {t['num_statements']} Anweisungen) | +| **Zweigabdeckung** (alle Tests) | **{t['percent_branches_covered']:.1f} %** ({t['covered_branches']} von {t['num_branches']} Zweigen) | +| **Pfadabdeckung** (obere Schranke der Basispfade, alle Funktionen) | **{pct(all_cov, all_cc)}** ({all_cov} von {all_cc}) | +| **Pfadabdeckung** (obere Schranke, nur die vier Kernmodule) | **{pct(core_cov, core_cc)}** ({core_cov} von {core_cc}) | +| Funktionen: nie ausgeführt / mit zu wenigen Pfaden / Pfadzahl nicht widerlegt | {len(never)} / {len(short)} / {len(enough)} (von {len(funcs)}) | + +**Entwicklung gegenüber dem ersten Report (gleicher Tag, Stand vor dem Ausbau der Tests)** + +| Kennzahl | Vorher | Jetzt | +|---|---|---| +| Verhaltens- und Fehlertests (ohne Vertragsprüfungen je Schlüssel) | 125 | {NEW_BEHAVIOUR} | +| Zeilenabdeckung | {base_cov['percent_statements_covered']:.1f} % | {t['percent_statements_covered']:.1f} % | +| Zweigabdeckung | {base_cov['percent_branches_covered']:.1f} % | {t['percent_branches_covered']:.1f} % | +| Pfadabdeckung (obere Schranke), alle Funktionen | {BASE_ALL} | {pct(all_cov, all_cc)} | +| Pfadabdeckung (obere Schranke), Kernmodule | {BASE_CORE} | {pct(core_cov, core_cc)} | + +**Was das bedeutet:** Die Tests prüfen jetzt auch die Plattformen (Wertdarstellung, Schreiben) und die Laufzeitpfade des Koordinators. Zeilen- und Zweigabdeckung sind hoch, die Pfadabdeckung ist deutlich niedriger, weil sie verlangt, dass jede Funktion **viele verschiedene Kombinationen** von Entscheidungen durchläuft. Die Pfadzahl ist eine obere Schranke und kein Beleg für Vollständigkeit (Abschnitt 2). + +## 2. Begriffe und Messverfahren (wichtig für die Einordnung) + +| Kennzahl | Bedeutung | Gemessen mit | +|---|---|---| +| Zeilenabdeckung | Anteil der ausgeführten Anweisungen | `coverage.py` | +| Zweigabdeckung | Anteil der genommenen Verzweigungsausgänge (`if`/`else`, Schleife betreten/übersprungen, `try`/`except`). Entspricht der Entscheidungsabdeckung. | `coverage.py --cov-branch` | +| Pfadabdeckung | Anteil der möglichen Ausführungswege durch eine Funktion. **Wird von `coverage.py` nicht gemessen** und ist bei Schleifen und vielen Entscheidungen praktisch nicht vollständig erreichbar (Zahl der Pfade wächst exponentiell). | siehe unten | + +**Wie die Pfadzahlen entstehen.** Ein eigenes Test-Plugin (`tests/tools/pathtracer.py`, nutzt `sys.monitoring`) zeichnet für **jeden Aufruf** einer Funktion die Folge der ausgeführten Zeilen auf. Schleifen werden verkürzt: eine Zeile zählt je Aufruf höchstens zweimal. Danach zählt es, wie viele **verschiedene** Pfade je Funktion im Testlauf vorkamen. + +Diese Zahl wird mit der **zyklomatischen Komplexität** (CC, `radon`) verglichen. Die CC ist die Zahl der linear unabhängigen Basispfade einer Funktion. Daraus folgt eine **sichere Aussage in eine Richtung**: +- Wurden weniger verschiedene Pfade beobachtet als CC, sind **sicher nicht** alle Basispfade getestet. +- Wurden mindestens CC Pfade beobachtet, ist das **kein Beleg** für vollständige Abdeckung. Die Pfade können sich in denselben Entscheidungen ähneln. +- Die Kennzahl „Pfadabdeckung (obere Schranke)“ summiert je Funktion `min(beobachtete Pfade, CC)` und teilt durch die Summe der CC. Die tatsächliche Basispfadabdeckung ist **höchstens so hoch**. + +Grenzen: Die Messung ist empirisch und heuristisch. Verschachtelte Funktionen werden der umgebenden Funktion zugerechnet oder nicht erfasst. Nur eine Tracing-Variante (Zeilenfolgen) wurde verwendet. + +## 3. Testergebnisse + +| Datei | Tests | fehlgeschlagen | Zeit | +|---|---|---|---|""") +for name, (n, fail, tm) in sorted(by_file.items()): + w(f"| `{name}.py` | {n} | {fail} | {tm:.2f} s |") +w(f""" +Hinweis: `test_api_contract.py` enthält 420 parametrisierte Vertragsprüfungen (je Schlüssel). Verhaltens- und Fehlerpfadtests insgesamt: {int(suite.get('tests')) - by_file['test_api_contract'][0]}. + +**Gesamtdauer** {suite.get('time')} s. + +## 4. Abdeckung je Datei + +| Datei | Anweisungen | Zeilenabdeckung | Zweige | Zweigabdeckung | CC (Summe) | Pfade (obere Schranke) | +|---|---|---|---|---|---|---|""") +for path, data in sorted(cov["files"].items(), key=lambda kv: rel(kv[0])): + f = rel(path) + s = data["summary"] + rows = [r for r in funcs if r["file"] == f] + c, total = path_bound(rows) + w(f"| `{f}` | {s['num_statements']} | {s['percent_statements_covered']:.0f} % | {s['num_branches']} | " + f"{pct(s['covered_branches'], s['num_branches'])} | {total} | {pct(c, total)} |") +w(f""" +**Vergleich:** Nur die blockierenden Tests: Zeilen {cov_block['percent_statements_covered']:.1f} %, Zweige {cov_block['percent_branches_covered']:.1f} %. Die beabsichtigt roten Tests (bekannte Fehler und Abweichungen) verändern die Abdeckung kaum ({t['percent_statements_covered'] - cov_block['percent_statements_covered']:.1f} Prozentpunkte bei den Zeilen). + +""") +crit_section() +w(f""" +## 6. Kritische Funktionen (nach Komplexität) + +Kernmodule (`__init__.py` mit Koordinator, `pygoecharger_ha/__init__.py` mit Bridge, `service.py`, `config_flow.py`), sortiert nach Komplexität, gezeigt ab CC 4. „Pfade“ = beobachtete verschiedene Pfade, „CC“ = Basispfade. + +| Funktion | CC | Aufrufe | Pfade | Zeilen | Zweige | Bewertung | +|---|---|---|---|---|---|---|""") +for r in sorted(core_rows, key=lambda r: -r["cc"]): + if r["cc"] < 4: + continue + verdict = "nie ausgeführt" if r["calls"] == 0 else (f"es fehlen mindestens {r['cc'] - r['paths']} Basispfade" if r["paths"] < r["cc"] else "Pfadzahl nicht widerlegt") + if r["file"] == "__init__.py": + short_name = ("Entität" if r["name"].startswith("GoeChargerBaseEntity") else + "Koordinator" if r["name"].startswith("GoeChargerDataUpdateCoordinator") else "Setup") + else: + short_name = {"pygoecharger_ha/__init__.py": "Bridge", "service.py": "Dienst", "config_flow.py": "Flow"}[r["file"]] + w(f"| `{r['name']}` ({short_name}) | {r['cc']} | {r['calls']} | {r['paths']} | {r['line']:.0f} % | {pct(r['brc'], r['br'])} | {verdict} |") +w(""" +### 6.1 Nicht genommene Zweige in den wichtigsten Pfaden +Je Funktion die Zeilen, an denen mindestens ein Ausgang nie genommen wurde (bis zu sechs, Auswahl der Funktionen mit CC ab 8). +""") +for r in sorted(core_rows, key=lambda r: -r["cc"]): + if r["cc"] < 8 or not r["missing"] or r["calls"] == 0: + continue + lines = sorted({m[0] for m in r["missing"] if m[0] > 0})[:6] + src = sources[r["file"]] + w(f"**`{r['name']}`** ({r['file']}): " + "; ".join(f"Z. {n} `{src[n - 1].strip()[:70]}`" for n in lines) + "\n") + +w("""## 7. Nicht ausgeführte Funktionen im Kern + +Funktionen der Kernmodule, die kein Test erreicht. +""") +rows = [r for r in core_rows if r["calls"] == 0] +w("| Funktion | CC |\n|---|---|") +for r in sorted(rows, key=lambda r: -r["cc"]): + w(f"| `{r['name']}` ({r['file']}) | {r['cc']} |") +WS = ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.ws_connect") +RV = ("__init__.py", "GoeChargerDataUpdateCoordinator.read_versions") +WS_CC, RV_CC = cc[WS], cc[RV] +WS_N, RV_N = trace.get(WS, {}).get("distinct_paths", 0), trace.get(RV, {}).get("distinct_paths", 0) +BIG2 = WS_CC + RV_CC +BIG2_PCT = round(100 * BIG2 / core_cc) +ALL_PCT, CORE_PCT = pct(all_cov, all_cc), pct(core_cov, core_cc) +PLATFORM_GAPS = ", ".join(f"`{r['file']}: {r['name']}`" for r in funcs if r['file'] in PLATFORMS and r['calls'] == 0) or "keine" +w(f""" +## 8. Plattformen und Hilfsdateien + +Die Plattformen sind jetzt getestet (Wertdarstellung und Schreiben der Entitäten). Nicht ausgeführt bleiben das Anlegen der Entitäten (`async_setup_entry` je Plattform) und der Anzeigename in `entity.py`: {PLATFORM_GAPS}. + +## 9. Einordnung + +- **Pfadabdeckung:** Vollständige Pfadabdeckung ist hier weder erreichbar noch ein sinnvolles Ziel. Mit {BIG2} von {core_cc} Basispfaden der Kernmodule ({BIG2_PCT} %) bestimmen `ws_connect` (CC {WS_CC}) und `read_versions` des Koordinators (CC {RV_CC}) die Kennzahl; für sie wurden {WS_N} und {RV_N} verschiedene Pfade beobachtet. Funktionen mit sehr hoher CC sind in dieser Form schwer vollständig zu testen. Das ist ein Hinweis auf die Struktur des Codes, kein Vorwurf an die Tests. +- **Das Ziel von 60 % ist erreicht** (alle Funktionen {ALL_PCT}, Kernmodule {CORE_PCT}), aber die Zahl ist eine **obere Schranke** und lässt sich durch Tests mit nur leicht verschiedenen Eingaben steigern, ohne dass die Prüfung besser wird. Deshalb gelten die Mutationsprüfungen als Gegenprobe: 33 gezielte Code-Änderungen, alle wurden von den Tests erkannt (eine Lücke wurde erst nach einem zusätzlichen Test geschlossen). +- **Zeilen- und Zweigabdeckung sagen nichts über die Qualität der Prüfungen.** Alle Tests laufen mit Attrappen und sagen nichts darüber, ob der echte Charger sich so verhält. + +## 10. Empfehlungen (nach Wirkung) + +1. K1 und K2 zuerst: die Tabellen in Abschnitt 5.1 listen, wo noch Basispfade sicher fehlen. Dort stehen auch nicht genommene Fehlerpfade. +2. Fehlende Tests für bekannte Risiken: R20 (alte Werte nach fehlgeschlagenem Lesen, Cloud-Betrieb) und R7 (Konfigurationsintervall). +3. Lesenden Abgleich am echten Charger durchführen; erst er belegt, dass die Attrappen dem Gerät entsprechen. +4. Eine Untergrenze für die Abdeckung in den Lauf einbauen (zum Beispiel Zweigabdeckung der Kernmodule), damit sie nicht unbemerkt sinkt. +5. `ws_connect` und `read_versions` zerlegen, wenn sie später geändert werden; erst dann lassen sich ihre Pfade sinnvoll einzeln prüfen. +""") +(report_dir / "testreport.md").write_text("\n".join(out)) +print("written", report_dir / "testreport.md", len("\n".join(out).splitlines()), "lines") +print("all:", all_cov, all_cc, " core:", core_cov, core_cc, " never/short/enough:", len(never), len(short), len(enough)) diff --git a/tests/tools/build_site.py b/tests/tools/build_site.py new file mode 100644 index 0000000..6119638 --- /dev/null +++ b/tests/tools/build_site.py @@ -0,0 +1,98 @@ +"""Builds the static site for GitHub Pages from the latest results. + +Usage: python build_site.py --report \ + --testcases [--regression ] [--docs ] +Needs the package `markdown`. Every input is optional except the site directory; a missing part is left out. +""" +import argparse +import html +import re +import shutil +from datetime import datetime, timezone +from pathlib import Path + +import markdown + +CSS = """ +:root{--bg:#fff;--fg:#1f2328;--muted:#59636e;--line:#d1d9e0;--head:#f6f8fa;--link:#0969da} +@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9198a1;--line:#3d444d;--head:#151b23;--link:#4493f8}} +body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.55 system-ui,sans-serif} +main{max-width:60rem;margin:0 auto;padding:1rem 16px 4rem} +nav{border-bottom:1px solid var(--line);background:var(--head)} +nav div{max-width:60rem;margin:0 auto;padding:.7rem 16px;display:flex;gap:1.2rem;flex-wrap:wrap} +a{color:var(--link)}h1,h2,h3{line-height:1.25} +table{border-collapse:collapse;display:block;overflow-x:auto;max-width:100%} +th,td{border:1px solid var(--line);padding:.3rem .6rem;text-align:left;vertical-align:top} +th{background:var(--head)}code{background:var(--head);padding:.1rem .3rem;border-radius:4px} +footer{color:var(--muted);font-size:.9rem;margin-top:3rem} +""" +PAGES = [("index.html", "Übersicht"), ("testreport.html", "Testbericht"), ("testcases.html", "Testfälle"), + ("testkonzept.html", "Testkonzept"), ("arc42.html", "Architektur (arc42)"), + ("coverage/index.html", "Abdeckung im Detail")] +# the diagrams of the arc42 document are Mermaid; the library is loaded from a CDN with a fixed version +MERMAID = ('') +DOC_FILES = {"arc42.html": ("Architektur (arc42)", "arc42-ha-goecharger-api2.md"), + "testkonzept.html": ("Testkonzept", "testkonzept-ha-goecharger-api2.md")} + + +def render(site, name, title, body_md, available, toc_levels=False): + nav = "".join(f'{label}' for href, label in PAGES if href in available) + md = markdown.Markdown(extensions=["tables", "fenced_code", "toc"]) + body = md.convert(body_md) + mermaid = '' in body + if mermaid: + body = re.sub(r'
(.*?)
', r'
\1
', body, flags=re.S) + if toc_levels: + entries = [t for top in md.toc_tokens for t in (top["children"] if top["level"] == 1 else [top])] + items = "".join(f'
  • {html.escape(t["name"])}
  • ' for t in entries) + body = body.replace("", f"
    Inhalt
      {items}
    ", 1) + stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + (site / name).write_text( + f'' + f"{html.escape(title)}" + f"
    {body}{MERMAID if mermaid else ''}
    Erzeugt am {stamp}. Die Tests wurden mit KI-Unterstützung (Claude Code) erstellt und von Oliver geprüft. " + f'Quelle: Fork OliEder/ha-goecharger-api2.' + f"
    ") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("site") + ap.add_argument("--report") + ap.add_argument("--testcases") + ap.add_argument("--regression") + ap.add_argument("--docs") + a = ap.parse_args() + site = Path(a.site) + site.mkdir(parents=True, exist_ok=True) + pages = {} + if a.report and (Path(a.report) / "testreport.md").exists(): + pages["testreport.html"] = ("Testbericht", (Path(a.report) / "testreport.md").read_text()) + if (Path(a.report) / "html-all").exists(): + shutil.copytree(Path(a.report) / "html-all", site / "coverage", dirs_exist_ok=True) + if a.testcases and Path(a.testcases).exists(): + pages["testcases.html"] = ("Testfälle", Path(a.testcases).read_text()) + for name, (title, filename) in DOC_FILES.items(): + if a.docs and (Path(a.docs) / filename).exists(): + pages[name] = (title, (Path(a.docs) / filename).read_text(), True) + index = ["# Regressionstests für ha-goecharger-api2", "", + "Tests und Berichte zur Home-Assistant-Integration [marq24/ha-goecharger-api2](https://github.com/marq24/ha-goecharger-api2) " + "(go-eCharger). Die Tests laufen in einem Fork gegen den Code des Original-Repos und ändern dieses nicht.", ""] + if a.regression and Path(a.regression).exists(): + index += ["## Letzter Lauf gegen eine Version des Original-Repos", "", Path(a.regression).read_text().replace("\n## ", "\n### ") + .replace("## Regression", "### Regression", 1), ""] + index += ["## Seiten", "", "- [Testbericht](testreport.html): Zeilen-, Zweig- und Pfadabdeckung, Kritikalität", + "- [Testfälle](testcases.html): jede Testfunktion in einer Zeile", + "- [Abdeckung im Detail](coverage/index.html): Zeile für Zeile", + "- [Testkonzept](testkonzept.html): Ziele, Methoden, Kennzahlen und ihre Grenzen", + "- [Architektur (arc42)](arc42.html): Aufbau der Integration, Risiken, Entscheidungen"] + available = set(pages) | {"index.html"} | ({"coverage/index.html"} if (site / "coverage").exists() else set()) + for name, (title, body, *toc) in pages.items(): + render(site, name, title, body, available, bool(toc)) + render(site, "index.html", "Regressionstests", "\n".join(index), available) + (site / ".nojekyll").write_text("") + print("site written to", site) + + +main() diff --git a/tests/tools/build_testcases.py b/tests/tools/build_testcases.py new file mode 100644 index 0000000..bccf86a --- /dev/null +++ b/tests/tools/build_testcases.py @@ -0,0 +1,55 @@ +"""Builds testcases.md: every test function on one line, with the number of cases and the result. + +Usage: python build_testcases.py + +The text of a line is the first line of the docstring of the test function; without a docstring it is the +function name written as a sentence. Parametrized tests are one line with the number of cases. A case that +fails on purpose (marker known_bug or needs_decision) is counted as "red on purpose". +""" +import ast +import sys +import xml.etree.ElementTree as ET +from collections import OrderedDict +from pathlib import Path + +junit, tests_dir, output = Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3]) + + +def sentence(name): + text = name.removeprefix("test_").replace("_", " ") + return text[:1].upper() + text[1:] + + +results = OrderedDict() # (module, function) -> [total, red, skipped] +for case in ET.parse(junit).getroot().iter("testcase"): + function = case.get("name").split("[")[0] + entry = results.setdefault((case.get("classname").split(".")[-1], function), [0, 0, 0]) + entry[0] += 1 + if case.find("failure") is not None or case.find("error") is not None: + entry[1] += 1 + elif case.find("skipped") is not None: + entry[2] += 1 + +lines = ["# Testfälle", "", + "Eine Zeile je Testfunktion. Der Text ist die erste Zeile der Beschreibung im Code oder der Funktionsname als Satz. " + "„Rot“ bedeutet: das Ergebnis ist absichtlich rot (bekannter Fehler oder offene Entscheidung), nicht blockierend für die Freigabe.", ""] +total = red = skipped = 0 +for path in sorted(tests_dir.glob("test_*.py")): + tree = ast.parse(path.read_text()) + module_doc = (ast.get_docstring(tree) or path.stem).splitlines()[0] + rows = [] + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"): + n, r, s = results.get((path.stem, node.name), [0, 0, 0]) + if n == 0: + continue + doc = (ast.get_docstring(node) or "").splitlines() + text = (doc[0] if doc else sentence(node.name)).replace("|", "\\|") + status = "rot" if r == n else (f"{r} rot" if r else ("übersprungen" if s == n else "grün")) + rows.append(f"| {text} | {n} | {status} |") + total, red, skipped = total + n, red + r, skipped + s + if rows: + lines += [f"## `{path.name}`", "", module_doc, "", "| Prüft | Fälle | Ergebnis |", "|---|---|---|", *rows, ""] +lines.insert(3, f"**{total} Fälle**, davon {red} absichtlich rot und {skipped} übersprungen.\n") +output.write_text("\n".join(lines) + "\n") +print(f"{total} cases in {output}") diff --git a/tests/tools/criticality.py b/tests/tools/criticality.py new file mode 100644 index 0000000..7fda285 --- /dev/null +++ b/tests/tools/criticality.py @@ -0,0 +1,115 @@ +"""Criticality of functions (and with them of their paths) of the integration. + +Rubric: criticality = impact of a wrong path on the user. Probability is not rated separately; it is +covered by looking at which functions run often (the report shows call counts). + +K1 critical : changes the charger, its limits or the HA configuration; a wrong path can lock the charger, + send wrong values, delete devices/entities or switch off the services. +K2 high : connection, login, recovery and start of the integration; a wrong path makes the integration + unavailable or shows stale data. +K3 medium : correctness of what is shown or detected (values, units, device type), without writing. +K4 low : helpers, naming, icons, logging, constructors of small helper classes. +""" +import re + +K1, K2, K3, K4 = "K1 kritisch", "K2 hoch", "K3 mittel", "K4 niedrig" + +# exact assignments: (file, qualified name without class prefix handling) -> (class, reason) +EXACT = { + # bridge: writing + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.write_value_to_key"): (K1, "schreibt einen Wert an den Charger (HTTP oder WebSocket)"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.write_multiple_values_to_keys"): (K1, "schreibt mehrere Werte (Fahrzeugauswahl)"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge._write_values_int"): (K1, "wertet die Antwort des Chargers auf Schreibbefehle aus"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge._ws_send_command"): (K1, "sendet und signiert Befehle per WebSocket"), + # coordinator: writing and limits + ("__init__.py", "GoeChargerDataUpdateCoordinator.async_write_key"): (K1, "zentraler Schreibpfad mit Fehlerpause"), + ("__init__.py", "GoeChargerDataUpdateCoordinator.async_write_multiple_keys"): (K1, "Mehrfach-Schreibpfad mit Fehlerpause"), + ("__init__.py", "GoeChargerDataUpdateCoordinator.handle_write_result"): (K1, "übernimmt das Schreibergebnis in die Daten"), + ("__init__.py", "check_and_write_to_16a"): (K1, "schreibt beim Start Stromgrenzen an den Charger"), + ("__init__.py", "GoeChargerDataUpdateCoordinator.check_for_16a_limit"): (K1, "startet das Zurückschreiben der 16-A-Grenze"), + ("__init__.py", "async_migrate_entry"): (K1, "löscht Geräte und damit Entitäten"), + ("__init__.py", "async_unload_entry"): (K1, "entfernt die Dienste set_pv_data und stop_charging"), + # services + ("service.py", "GoeChargerApiV2Service.set_pv_data"): (K1, "sendet PV-Daten; steuert das PV-Laden"), + ("service.py", "GoeChargerApiV2Service.stop_charging"): (K1, "sperrt und entsperrt den Charger"), + ("service.py", "GoeChargerApiV2Service.get_coordinator"): (K1, "wählt den Charger für Dienste"), + ("service.py", "_is_finite_number"): (K1, "verhindert ungültige Werte an den Charger"), + ("service.py", "add_config_entry_to_service"): (K1, "Register der Charger für die Dienste"), + ("service.py", "remove_config_entry_from_service"): (K1, "Register der Charger für die Dienste"), + ("service.py", "GoeChargerApiV2Service.__init__"): (K1, "Register der Charger für die Dienste"), + # entities that write + ("number.py", "GoeChargerNumber.async_set_native_value"): (K1, "schreibt Zahlenwerte (Ströme, Grenzen)"), + ("number.py", "GoeChargerNumber.check_amp_min_max"): (K1, "löst automatisch das Setzen von amp aus"), + ("select.py", "GoeChargerSelect.async_select_option"): (K1, "schreibt Auswahl, inklusive Fahrzeugprofil mit mehreren Werten"), + ("switch.py", "GoeChargerSwitch.async_turn_on"): (K1, "schaltet Einstellungen am Charger"), + ("switch.py", "GoeChargerSwitch.async_turn_off"): (K1, "schaltet Einstellungen am Charger"), + ("button.py", "GoeChargerApiV2Button.async_press"): (K1, "löst Aktionen am Charger aus"), + # connection, login, recovery, start + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.ws_connect"): (K2, "Anmeldung und Nachrichtenschleife der WebSocket-Verbindung"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.extract_ws_message_data"): (K2, "verarbeitet alle Daten und Antworten vom WebSocket"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge._read_filtered_data"): (K2, "zentraler Lesepfad im Polling"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge._read_all_data"): (K2, "Lesen aller Daten per HTTP"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.read_all_states"): (K2, "Statuslesen mit Hibernation"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.read_all_config"): (K2, "Konfigurationslesen mit Wiederholungs-Pause"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.read_all"): (K2, "Lesezyklus"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.read_versions"): (K2, "liest Geräte- und Versionsdaten beim Start"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.read_system"): (K2, "liest Systemdaten"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.__init__"): (K2, "bildet Adressen für LAN, Cloud und WebSocket"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge._ws_compute_hashed_password"): (K2, "Passwort-Hash der Anmeldung"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge._ws_compute_auth_hash"): (K2, "Antwort auf die Anmelde-Aufforderung"), + ("pygoecharger_ha/__init__.py", "bcryptjs_base64_encode_serial"): (K2, "Salz für die bcrypt-Anmeldung"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge._ws_decode_message"): (K2, "liest WebSocket-Nachrichten"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.ws_close"): (K2, "schließt die Verbindung sauber"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge.ws_check_last_update"): (K2, "erkennt eine tote Verbindung"), + ("pygoecharger_ha/__init__.py", "GoeChargerApiV2Bridge._ws_notify_for_new_data"): (K2, "gibt neue Daten an HA weiter"), + ("pygoecharger_ha/__init__.py", "TargetedEvent.check_and_set"): (K2, "meldet, dass die Startdaten vollständig sind"), + ("pygoecharger_ha/__init__.py", "TargetedEvent.__init__"): (K2, "legt die Pflichtschlüssel für den Start fest"), + ("__init__.py", "async_setup_entry"): (K2, "Start der Integration"), + ("__init__.py", "GoeChargerDataUpdateCoordinator._async_update_data"): (K2, "Lesezyklus mit Fehlerpause"), + ("__init__.py", "GoeChargerDataUpdateCoordinator._handle_client_connection_error"): (K2, "Fehlerzähler und Neustart"), + ("__init__.py", "GoeChargerDataUpdateCoordinator.start_websocket_and_wait_for_first_data"): (K2, "Start der WebSocket-Verbindung"), + ("__init__.py", "GoeChargerDataUpdateCoordinator.force_async_update_now"): (K2, "erzwingt neue WebSocket-Verbindung"), + ("__init__.py", "GoeChargerDataUpdateCoordinator._async_watchdog_check"): (K2, "Wiederverbindung"), + ("__init__.py", "GoeChargerDataUpdateCoordinator._check_for_ws_task_and_cancel_if_running"): (K2, "Wiederverbindung"), + ("__init__.py", "GoeChargerDataUpdateCoordinator.start_watchdog"): (K2, "Wiederverbindung"), + ("__init__.py", "GoeChargerDataUpdateCoordinator.stop_watchdog"): (K2, "Wiederverbindung"), + ("__init__.py", "GoeChargerDataUpdateCoordinator.trigger_restart_delayed"): (K2, "Neustart nach Dauerfehlern"), + ("__init__.py", "GoeChargerDataUpdateCoordinator.clear_data"): (K2, "Aufräumen beim Entladen"), + ("__init__.py", "GoeChargerDataUpdateCoordinator.read_versions"): (K2, "erkennt Gerätetyp, 16-A-Limit und Karten"), + ("__init__.py", "entry_update_listener"): (K2, "lädt den Entry bei Änderungen neu"), + ("config_flow.py", "GoeChargerApiV2FlowHandler._test_host"): (K2, "prüft Zugang beim Einrichten"), + ("config_flow.py", "GoeChargerApiV2FlowHandler._check_ws_connection_and_flags"): (K2, "prüft WebSocket beim Einrichten"), + ("config_flow.py", "GoeChargerApiV2FlowHandler.async_step_user_lan"): (K2, "Einrichtung lokal"), + ("config_flow.py", "GoeChargerApiV2FlowHandler.async_step_user_wan"): (K2, "Einrichtung Cloud"), + ("config_flow.py", "GoeChargerApiV2FlowHandler.async_step_reconfigure"): (K2, "Umkonfigurieren"), + ("config_flow.py", "GoeChargerApiV2FlowHandler.async_step_user"): (K2, "Einrichtung, Auswahl LAN oder Cloud"), +} + +# defaults by file for everything not listed +FILE_DEFAULT = { + "sensor.py": (K3, "Wertdarstellung"), + "binary_sensor.py": (K3, "Wertdarstellung"), + "number.py": (K3, "Wertdarstellung"), + "select.py": (K3, "Wertdarstellung"), + "switch.py": (K3, "Wertdarstellung"), + "button.py": (K4, "Hilfsfunktion"), + "entity.py": (K4, "Anzeigename"), + "pygoecharger_ha/keys.py": (K4, "Hilfsfunktion"), +} +NAME_DEFAULT = [ + (re.compile(r"is_valid_charger_entity|cards_as_object_present"), (K3, "bestimmt, welche Entitäten es gibt")), + (re.compile(r"_read_filtered_data_from_ws_source|none_null_get|available_fields"), (K3, "liest aus dem Datenspeicher")), + (re.compile(r"clear_data|reset_stored_update_ts|force_config_update|ws_set_coordinator|cancel_existing_debouncer|request_update_in_sec|_debounce"), (K3, "Datenspeicher und Aktualisierung")), + (re.compile(r"_ws_normalize|ExpiringStore|\.get$|\.set$|_cleanup|__len__|__call__|device_info|unique_id|available$|_name_internal|_friendly_name|call_later_update_device_registry|async_setup$|has_errors|set_error|__hash__|__str__"), (K4, "Hilfsfunktion oder Anzeigename")), +] + + +def classify(file: str, qual: str): + if (file, qual) in EXACT: + return EXACT[(file, qual)] + if file in FILE_DEFAULT: + return FILE_DEFAULT[file] + for pattern, value in NAME_DEFAULT: + if pattern.search(qual): + return value + return (K4, "Hilfsfunktion") diff --git a/tests/tools/measure.sh b/tests/tools/measure.sh new file mode 100755 index 0000000..1e5ac5f --- /dev/null +++ b/tests/tools/measure.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Measures coverage and path coverage of the integration with the tests in tests/integration. +# Usage (from the repository root, with the test dependencies installed): +# tests/tools/measure.sh [report-dir] +# Optional environment: TESTS_COMMIT (commit of the tests, default: HEAD), SOURCE_REF (name of the measured version in the report). +# Writes coverage, junit and path-trace data and builds testreport.md in the report directory. +set -e +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" +OUT="${1:-$ROOT/tests/reports/$(date +%Y-%m-%d)}" +mkdir -p "$OUT" +COMMIT="$(git rev-parse --short HEAD)" + +python -m pytest tests/integration -p no:cacheprovider -q \ + --cov=custom_components.goecharger_api2 --cov-branch \ + --cov-report=json:"$OUT/coverage-all.json" --cov-report=html:"$OUT/html-all" \ + --junitxml="$OUT/junit-all.xml" || true +python -m pytest tests/integration -p no:cacheprovider -q -m "not known_bug and not needs_decision" \ + --cov=custom_components.goecharger_api2 --cov-branch \ + --cov-report=json:"$OUT/coverage-blocking.json" --junitxml="$OUT/junit-blocking.xml" +PYTHONPATH="$ROOT/tests/tools" PATHTRACE_OUT="$OUT/pathtrace-all.json" \ + python -m pytest tests/integration -p pathtracer -p no:cacheprovider -q || true + +# the report compares with a baseline; without one, the current data is used as baseline +[ -f "$OUT/coverage-baseline.json" ] || cp "$OUT/coverage-all.json" "$OUT/coverage-baseline.json" +[ -f "$OUT/pathtrace-baseline.json" ] || cp "$OUT/pathtrace-all.json" "$OUT/pathtrace-baseline.json" +python tests/tools/build_report.py "$OUT" "$ROOT" "$COMMIT" "${TESTS_COMMIT:-$COMMIT}" diff --git a/tests/tools/pathmetric.py b/tests/tools/pathmetric.py new file mode 100644 index 0000000..57a9935 --- /dev/null +++ b/tests/tools/pathmetric.py @@ -0,0 +1,40 @@ +"""Quick metric: path coverage (upper bound of basis paths) from a pathtrace JSON, per criticality. + +Usage: python pathmetric.py +""" +import json +import subprocess +import sys +from collections import defaultdict +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from criticality import classify # noqa: E402 + +PKG = "custom_components/goecharger_api2/" +CORE = ["__init__.py", "pygoecharger_ha/__init__.py", "service.py", "config_flow.py"] +trace_file, checkout = sys.argv[1], Path(sys.argv[2]).resolve() + + +def rel(p): + return p.split(PKG)[-1] + + +trace = {(rel(k.split("::")[0]), k.split("::")[1]): v for k, v in json.load(open(trace_file)).items()} +radon = json.loads(subprocess.run(["radon", "cc", "-s", "-j", str(checkout / PKG)], capture_output=True, text=True).stdout) +cc = {} +for file, blocks in radon.items(): + for b in blocks: + for e in ([b] + b.get("methods", []) if b["type"] == "class" else [b]): + if e["type"] in ("function", "method"): + cc[(rel(file), f"{e['classname']}.{e['name']}" if e.get("classname") else e["name"])] = e["complexity"] +tot = defaultdict(lambda: [0, 0]) +for (f, q), c in cc.items(): + n = trace.get((f, q), {}).get("distinct_paths", 0) + k = classify(f, q)[0] + for key in ("ALL", "CORE" if f in CORE else "PLAT", k): + tot[key][0] += min(n, c) + tot[key][1] += c +for key in ("ALL", "CORE", "PLAT", "K1 kritisch", "K2 hoch", "K3 mittel", "K4 niedrig"): + a, b = tot[key] + print(f"{key:12s} {a:4d}/{b:4d} = {100 * a / b:5.1f} %") diff --git a/tests/tools/pathtracer.py b/tests/tools/pathtracer.py new file mode 100644 index 0000000..20320a8 --- /dev/null +++ b/tests/tools/pathtracer.py @@ -0,0 +1,72 @@ +"""pytest plugin: records which distinct execution paths each function of the integration takes. + +A path is the sequence of executed source lines of ONE call of a function (a coroutine's call spans +all its resumptions). Loops are shortened: a line counts at most twice per call. The result is an +empirical number of distinct paths seen during the test run; it is NOT a proof of path coverage. +Use: PYTHONPATH=tools PATHTRACE_OUT=out.json python -m pytest -p pathtracer ... +Needs Python 3.12+ (sys.monitoring). +""" +import json +import os +import sys +from collections import defaultdict + +mon = sys.monitoring +TOOL = 3 +PKG = "/custom_components/goecharger_api2/" +E = mon.events +active = {} # frame -> [code, lines, counts] +paths = defaultdict(set) # (file, qualname) -> {tuple of lines} +calls = defaultdict(int) + + +def _relevant(code): + return PKG in code.co_filename + + +def _start(code, offset): + if not _relevant(code): + return mon.DISABLE + active[sys._getframe(1)] = [code, [], {}] + calls[(code.co_filename, code.co_qualname)] += 1 + + +def _line(code, line): + if not _relevant(code): + return mon.DISABLE + entry = active.get(sys._getframe(1)) + if entry is None: + return + seen = entry[2].get(line, 0) + entry[2][line] = seen + 1 + if seen < 2: + entry[1].append(line) + + +def _finish(marker): + def callback(code, offset, *args): + if not _relevant(code): + return None # PY_UNWIND events cannot be disabled + entry = active.pop(sys._getframe(1), None) + if entry is not None: + paths[(code.co_filename, code.co_qualname)].add(tuple(entry[1]) + (marker,)) + return callback + + +def pytest_configure(config): + mon.use_tool_id(TOOL, "pathtracer") + mon.register_callback(TOOL, E.PY_START, _start) + mon.register_callback(TOOL, E.LINE, _line) + mon.register_callback(TOOL, E.PY_RETURN, _finish("R")) + mon.register_callback(TOOL, E.PY_UNWIND, _finish("X")) + mon.set_events(TOOL, E.PY_START | E.LINE | E.PY_RETURN | E.PY_UNWIND) + + +def pytest_sessionfinish(session, exitstatus): + mon.set_events(TOOL, 0) + out = os.environ.get("PATHTRACE_OUT", "pathtrace.json") + data = {f"{file}::{qual}": {"calls": calls[(file, qual)], "distinct_paths": len(p)} + for (file, qual), p in paths.items()} + for (file, qual), n in calls.items(): + data.setdefault(f"{file}::{qual}", {"calls": n, "distinct_paths": 0}) + json.dump(data, open(out, "w"), indent=1) diff --git a/tests/tools/release_check.sh b/tests/tools/release_check.sh new file mode 100755 index 0000000..8e4cbd5 --- /dev/null +++ b/tests/tools/release_check.sh @@ -0,0 +1,85 @@ +#!/bin/sh +# Tests a version of the upstream integration with the tests of this branch. +# +# Usage: tests/tools/release_check.sh [ref] [work-dir] +# ref tag or branch of marq24/ha-goecharger-api2 (default: the latest release) +# work-dir directory for the clone and the results (default: a temporary directory) +# Environment: PYTHON (default: python), UPSTREAM (default: https://github.com/marq24/ha-goecharger-api2) +# +# The upstream source is cloned into /src and only the directory tests/integration of this +# branch is copied into it (no rebase, no merge, nothing is pushed anywhere). Two runs follow: +# 1. the blocking tests (everything except the markers known_bug and needs_decision) +# 2. the tests marked known_bug or needs_decision. They fail on purpose as long as a bug exists; +# a test that PASSES here means the bug looks fixed in this version (remove its marker). +# Exit code: 0 when the blocking tests pass, 1 otherwise. A summary goes to /summary.md and, +# when running in GitHub Actions, to the job summary. +set -u +HERE="$(cd "$(dirname "$0")/../.." && pwd)" +PYTHON="${PYTHON:-python}" +UPSTREAM="${UPSTREAM:-https://github.com/marq24/ha-goecharger-api2}" +REF="${1:-}" +WORK="${2:-$(mktemp -d)}" + +if [ -z "$REF" ]; then + REF="$(gh api repos/marq24/ha-goecharger-api2/releases/latest --jq .tag_name)" || { echo "cannot determine the latest release"; exit 2; } +fi +mkdir -p "$WORK" +rm -rf "$WORK/src" +git -c advice.detachedHead=false clone --quiet --depth 1 --branch "$REF" "$UPSTREAM" "$WORK/src" || { echo "cannot clone $REF"; exit 2; } +SHA="$(git -C "$WORK/src" rev-parse --short HEAD)" +mkdir -p "$WORK/src/tests" +cp -R "$HERE/tests/integration" "$WORK/src/tests/" +find "$WORK/src/tests" -name __pycache__ -prune -exec rm -rf {} + + +cd "$WORK/src" || exit 2 +"$PYTHON" -m pytest tests/integration -p no:cacheprovider -q -m "not known_bug and not needs_decision" \ + --junitxml="$WORK/blocking.xml" > "$WORK/blocking.txt" 2>&1 +BLOCKING=$? +"$PYTHON" -m pytest tests/integration -p no:cacheprovider -q -m "known_bug or needs_decision" \ + --junitxml="$WORK/known.xml" > "$WORK/known.txt" 2>&1 + +"$PYTHON" - "$WORK" "$REF" "$SHA" <<'PY' +import sys +import xml.etree.ElementTree as ET + +work, ref, sha = sys.argv[1:4] + + +def read(name): + root = ET.parse(f"{work}/{name}.xml").getroot() + suite = root if root.tag == "testsuite" else root.find("testsuite") + cases = [] + for tc in suite.iter("testcase"): + failed = tc.find("failure") is not None or tc.find("error") is not None + cases.append((tc.get("classname").split(".")[-1] + "::" + tc.get("name"), failed)) + return cases + + +blocking, known = read("blocking"), read("known") +raw = open(f"{work}/blocking.txt").read() +if "ERROR collecting" in raw or "ImportError" in raw: + text = "\n".join([f"## Regression tests for upstream `{ref}` (`{sha}`)", "", + "**The tests could not be collected.** This version probably lacks functions the tests import " + "(the tests follow upstream `main`; for example `add_config_entry_to_service` exists since 2026.10.8).", + "", "```", "\n".join(raw.strip().splitlines()[-12:]), "```", ""]) + open(f"{work}/summary.md", "w").write(text + "\n") + print(text) + sys.exit(0) +failing = [n for n, f in blocking if f] +fixed = [n for n, f in known if not f] +still = [n for n, f in known if f] +lines = [f"## Regression tests for upstream `{ref}` (`{sha}`)", "", + f"| | |", "|---|---|", + f"| Blocking tests | **{len(blocking) - len(failing)} of {len(blocking)} passed** |", + f"| Known bugs / open decisions still failing | {len(still)} |", + f"| Known-bug tests that now PASS (bug looks fixed) | **{len(fixed)}** |", ""] +if failing: + lines += ["### Blocking tests that fail (new regression or version-specific behaviour)", ""] + [f"- `{n}`" for n in failing] + [""] +if fixed: + lines += ["### Known-bug tests that pass in this version (remove the marker)", ""] + [f"- `{n}`" for n in fixed] + [""] +text = "\n".join(lines) +open(f"{work}/summary.md", "w").write(text + "\n") +print(text) +PY +[ -n "${GITHUB_STEP_SUMMARY:-}" ] && cat "$WORK/summary.md" >> "$GITHUB_STEP_SUMMARY" +[ "$BLOCKING" -eq 0 ] && exit 0 || exit 1