diff --git a/.coveragerc b/.coveragerc index 5d9a60f6..27ff4dfc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,5 @@ [run] -branch = True +branch = False source = custom_components/ocpp [report] diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml deleted file mode 100644 index 531e7372..00000000 --- a/.devcontainer/configuration.yaml +++ /dev/null @@ -1,25 +0,0 @@ -default_config: - -ocpp: - default_authorization_status: 'Invalid' - authorization_list: - - id_tag: 'pulsar' - name: 'My tag' - authorization_status : Accepted - - id_tag: 'CAFEBABE' - name: 'Some other tag' - authorization_status: Blocked - - id_tag: 'DEADBEEF' - name: 'Old tag' - status: Expired - - id_tag: '12341234' - name: 'Invalid tag' - authorization_status: Invalid - -logger: - default: info - logs: - custom_components.ocpp: debug - -# If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) -# debugpy: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 43095e82..b1fcba37 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,28 +1,80 @@ -// See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "ludeeus/container:integration-debian", - "name": "OCPP integration development", - "context": "..", - "appPort": ["9123:8123", "9000:9000"], - "postCreateCommand": "container install", - "extensions": [ - "ms-python.python", - "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" - ], - "settings": { - "files.eol": "\n", - "editor.tabSize": 4, - "terminal.integrated.shell.linux": "/bin/bash", - "python.pythonPath": "/usr/bin/python3", - "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true - } -} + "name": "Home Assistant Dev", + "context": "..", + "dockerFile": "../Dockerfile.dev", + "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && scripts/setup", + "postStartCommand": "scripts/bootstrap", + "containerEnv": { + "PYTHONASYNCIODEBUG": "1" + }, + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "appPort": [ + "9000:9000", // OCPP + "8123:8123", // Home Assistant + "5683:5683/udp" // Shelly integration + ], + "runArgs": [ + "-e", + "GIT_EDITOR=code --wait", + "--security-opt", + "label=disable" + ], + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "ms-python.pylint", + "ms-python.vscode-pylance", + "visualstudioexptteam.vscodeintellicode", + "redhat.vscode-yaml", + "esbenp.prettier-vscode", + "GitHub.vscode-pull-request-github", + "GitHub.copilot" + ], + // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json + "settings": { + "python.experiments.optOutFrom": [ + "pythonTestAdapter" + ], + "python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python", + "python.pythonPath": "/home/vscode/.local/ha-venv/bin/python", + "python.terminal.activateEnvInCurrentTerminal": true, + "python.testing.pytestArgs": [ + "--no-cov" + ], + "pylint.importStrategy": "fromEnvironment", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/usr/bin/zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", + "yaml.customTags": [ + "!input scalar", + "!secret scalar", + "!include_dir_named scalar", + "!include_dir_list scalar", + "!include_dir_merge_list scalar", + "!include_dir_merge_named scalar" + ], + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "json.schemas": [ + { + "fileMatch": [ + "homeassistant/components/*/manifest.json" + ], + "url": "${containerWorkspaceFolder}/scripts/json_schemas/manifest_schema.json" + } + ] + } + } + } +} \ No newline at end of file diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt deleted file mode 100644 index cfbc912b..00000000 --- a/.github/workflows/constraints.txt +++ /dev/null @@ -1,11 +0,0 @@ -# home assistant -pip>=21.0,<22.4 -pre-commit==3.3.3 -bandit==1.7.5 -black==23.7.0 -flake8==6.1.0 -isort==5.12.0 -pre-comit-hooks==4.1.0 -pyupgrade==3.10.1 -reorder-python-imports==3.10.0 -sqlalchemy>=1.4.23 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 0ead7faf..f443c2c9 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,20 +1,20 @@ -name: Manage labels - -on: - push: - branches: - - main - - master - -jobs: - labeler: - name: Labeler - runs-on: ubuntu-latest - steps: - - name: Check out the repository - uses: actions/checkout@v3 - - - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v4.1.0 - with: - skip-delete: true +name: Manage labels + +on: + push: + branches: + - main + - master + +jobs: + labeler: + name: Labeler + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@v5 + + - name: Run Labeler + uses: crazy-max/ghaction-github-labeler@v6.0.0 + with: + skip-delete: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f829f3c8..3ea67c3e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,52 +1,52 @@ -name: Publish - -on: - release: - types: - - published - push: - branches: - - main - -jobs: - release_zip_file: - name: Publish zip file asset - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: 📥 Checkout the repository - uses: actions/checkout@v3 - - - name: 🛠️ Set up Python - uses: actions/setup-python@v4.7.0 - with: - python-version: "3.x" - - - name: 🔢 Get version - id: version - uses: home-assistant/actions/helpers/version@master - - - name: 🔢 Set version number - run: | - python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} - - - name: 📤 Upload zip to action - uses: actions/upload-artifact@v3.1.2 - if: ${{ github.event_name == 'push' }} - with: - name: ocpp - path: ${{ github.workspace }}/custom_components/ocpp - - # Pack the dir as a zip and upload to the release - - name: 📦 ZIP Dir - if: ${{ github.event_name == 'release' }} - run: | - cd ${{ github.workspace }}/custom_components/ocpp - zip ocpp.zip -r ./ - - - name: 📤 Upload zip to release - uses: softprops/action-gh-release@v1 - if: ${{ github.event_name == 'release' }} - with: +name: Publish + +on: + release: + types: + - published + push: + branches: + - main + +jobs: + release_zip_file: + name: Publish zip file asset + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: 📥 Checkout the repository + uses: actions/checkout@v5 + + - name: 🛠️ Set up Python + uses: actions/setup-python@v6.2.0 + with: + python-version: "3.x" + + - name: 🔢 Get version + id: version + uses: home-assistant/actions/helpers/version@master + + - name: 🔢 Set version number + run: | + python3 ${{ github.workspace }}/manage/update_manifest.py --version ${{ steps.version.outputs.version }} + + - name: 📤 Upload zip to action + uses: actions/upload-artifact@v7.0.1 + if: ${{ github.event_name == 'push' }} + with: + name: ocpp + path: ${{ github.workspace }}/custom_components/ocpp + + # Pack the dir as a zip and upload to the release + - name: 📦 ZIP Dir + if: ${{ github.event_name == 'release' }} + run: | + cd ${{ github.workspace }}/custom_components/ocpp + zip ocpp.zip -r ./ + + - name: 📤 Upload zip to release + uses: softprops/action-gh-release@v3 + if: ${{ github.event_name == 'release' }} + with: files: ${{ github.workspace }}/custom_components/ocpp/ocpp.zip \ No newline at end of file diff --git a/.github/workflows/publish_docs_to_wiki.yml b/.github/workflows/publish_docs_to_wiki.yml index 6aa32035..1dd63552 100644 --- a/.github/workflows/publish_docs_to_wiki.yml +++ b/.github/workflows/publish_docs_to_wiki.yml @@ -1,38 +1,38 @@ -name: Publish docs to Wiki - -on: - push: - paths: - - docs/** - branches: - - main - -env: - USER_TOKEN: ${{ secrets.WIKI_ACTION_TOKEN }} - USER_NAME: ocpp - USER_EMAIL: ocpp@lbbrhzn.nl - OWNER: ${{ github.event.repository.owner.name }} - REPOSITORY_NAME: ${{ github.event.repository.name }} - -jobs: - publish_docs_to_wiki: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Pull wiki - run: | - mkdir tmp_wiki - cd tmp_wiki - git init - git config user.name $USER_NAME - git config user.email $USER_EMAIL - git pull https://$USER_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git - - - name: Push wiki - run: | - rsync -av --delete docs/ tmp_wiki/ --exclude .git - cd tmp_wiki - git add . - git commit -m "Update Wiki content" - git push -f --set-upstream https://$USER_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git master +name: Publish docs to Wiki + +on: + push: + paths: + - docs/** + branches: + - main + +env: + USER_TOKEN: ${{ secrets.WIKI_ACTION_TOKEN }} + USER_NAME: ocpp + USER_EMAIL: ocpp@lbbrhzn.nl + OWNER: ${{ github.event.repository.owner.name }} + REPOSITORY_NAME: ${{ github.event.repository.name }} + +jobs: + publish_docs_to_wiki: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Pull wiki + run: | + mkdir tmp_wiki + cd tmp_wiki + git init + git config user.name $USER_NAME + git config user.email $USER_EMAIL + git pull https://$USER_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git + + - name: Push wiki + run: | + rsync -av --delete docs/ tmp_wiki/ --exclude .git + cd tmp_wiki + git add . + git commit -m "Update Wiki content" + git push -f --set-upstream https://$USER_TOKEN@github.com/$OWNER/$REPOSITORY_NAME.wiki.git master diff --git a/.github/workflows/rebase-pull-requests.txt b/.github/workflows/rebase-pull-requests.txt index 88227977..0a87a861 100644 --- a/.github/workflows/rebase-pull-requests.txt +++ b/.github/workflows/rebase-pull-requests.txt @@ -1,9 +1,9 @@ -name: Rebase Pull Requests -on: - push: - branches: '**' -jobs: - rebase: - runs-on: ubuntu-latest - steps: +name: Rebase Pull Requests +on: + push: + branches: '**' +jobs: + rebase: + runs-on: ubuntu-latest + steps: - uses: linhbn123/rebase-pull-requests@v1.0.1 \ No newline at end of file diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 49f39a97..a7790dbe 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,15 +1,15 @@ -name: Draft a release note -on: - push: - branches: - - main - - master -jobs: - draft_release: - name: Release Drafter - runs-on: ubuntu-latest - steps: - - name: Run release-drafter - uses: release-drafter/release-drafter@v5.24.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +name: Draft a release note +on: + push: + branches: + - main + - master +jobs: + draft_release: + name: Release Drafter + runs-on: ubuntu-latest + steps: + - name: Run release-drafter + uses: release-drafter/release-drafter@v7.2.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sphinx-build.yml b/.github/workflows/sphinx-build.yml index 763e5731..f208a2f5 100644 --- a/.github/workflows/sphinx-build.yml +++ b/.github/workflows/sphinx-build.yml @@ -1,12 +1,12 @@ -name: "Pull Request Docs Check" -on: -- pull_request - -jobs: - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: ammaraskar/sphinx-action@master - with: +name: "Pull Request Docs Check" +on: +- pull_request + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: ammaraskar/sphinx-action@master + with: docs-folder: "docs/" \ No newline at end of file diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 35f3ba46..4fffeb56 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,27 +1,27 @@ -# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. -# -# You can adjust the behavior by modifying this file. -# For more information, see: -# https://github.com/actions/stale -name: Mark stale issues and pull requests - -on: - schedule: - - cron: '41 6 * * *' - -jobs: - stale: - - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - - steps: - - uses: actions/stale@v8 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: 'Stale issue message' - stale-pr-message: 'Stale pull request message' - stale-issue-label: 'no-issue-activity' - stale-pr-label: 'no-pr-activity' +# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '41 6 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v10 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'Stale issue message' + stale-pr-message: 'Stale pull request message' + stale-issue-label: 'no-issue-activity' + stale-pr-label: 'no-pr-activity' diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5e608577..cdb5f09a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,92 +1,100 @@ -name: Linting - -on: - push: - branches: - - main - - master - - dev - pull_request: - -env: - DEFAULT_PYTHON: "3.10" - -jobs: - pre-commit: - runs-on: "ubuntu-latest" - name: Pre-commit - steps: - - name: Check out the repository - uses: actions/checkout@v3 - - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - - name: Upgrade pip - run: | - pip install --constraint=.github/workflows/constraints.txt pip - pip --version - - - name: Install Python modules - run: | - pip install --constraint=.github/workflows/constraints.txt pre-commit black flake8 reorder-python-imports - - - name: Run pre-commit on all files - run: | - pre-commit run --all-files --show-diff-on-failure --color=always - - hacs: - runs-on: "ubuntu-latest" - name: HACS - steps: - - name: Check out the repository - uses: "actions/checkout@v3" - - - name: HACS validation - uses: "hacs/action@main" - with: - category: "integration" - ignore: brands - - hassfest: - runs-on: "ubuntu-latest" - name: Hassfest - steps: - - name: Check out the repository - uses: "actions/checkout@v3" - - - name: Hassfest validation - uses: "home-assistant/actions/hassfest@master" - tests: - runs-on: "ubuntu-latest" - name: Run tests - steps: - - name: Check out code from GitHub - uses: "actions/checkout@v3" - - name: Setup Python ${{ env.DEFAULT_PYTHON }} - uses: "actions/setup-python@v4.7.0" - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Install requirements - run: | - pip install --constraint=.github/workflows/constraints.txt pip - pip install -r requirements_test.txt --constraint=.github/workflows/constraints.txt - - name: Tests suite - run: | - pytest \ - --cov=./ \ - --cov-report=xml \ - --cov-report=term \ - --timeout=30 \ - --durations=10 \ - -n auto \ - -p no:sugar \ - -rA \ - tests - - name: "Upload coverage to Codecov" - uses: codecov/codecov-action@v3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true +name: Linting + +on: + push: + branches: + - main + - master + - dev + pull_request: + +env: + DEFAULT_PYTHON: "3.14" + +jobs: + pre-commit: + runs-on: "ubuntu-latest" + name: Pre-commit + steps: + - name: Check out the repository + uses: actions/checkout@v5 + + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v6.2.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + + - name: Upgrade pip + run: | + python -m venv venv + . venv/bin/activate + pip install "$(grep '^uv' < requirements.txt)" + pip --version + + - name: Install Python modules + run: | + . venv/bin/activate + uv pip install "$(grep '^pre-commit' < requirements.txt)" + + - name: Run pre-commit on all files + run: | + . venv/bin/activate + pre-commit run --all-files --show-diff-on-failure --color=always + + hacs: + runs-on: "ubuntu-latest" + name: HACS + steps: + - name: Check out the repository + uses: "actions/checkout@v5" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + hassfest: + runs-on: "ubuntu-latest" + name: Hassfest + steps: + - name: Check out the repository + uses: "actions/checkout@v5" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + tests: + runs-on: "ubuntu-latest" + name: Run tests + environment: continuous-integration + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v5" + - name: Setup Python ${{ env.DEFAULT_PYTHON }} + uses: "actions/setup-python@v6.2.0" + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Install requirements + run: | + python -m venv venv + . venv/bin/activate + pip install "$(grep '^uv' < requirements.txt)" + uv pip install -r requirements.txt --prerelease=allow + - name: Tests suite + run: | + . venv/bin/activate + pytest \ + --cov=./ \ + --cov-report=xml \ + --cov-report=term \ + --timeout=30 \ + --durations=10 \ + -n auto \ + -p no:sugar \ + -rA \ + tests + - name: "Upload coverage to Codecov" + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true diff --git a/.github/workflows/update-version.yml b/.github/workflows/update-version.yml new file mode 100644 index 00000000..00af34b9 --- /dev/null +++ b/.github/workflows/update-version.yml @@ -0,0 +1,26 @@ +name: Update Version in manifest.json + +on: + release: + types: [published] + +jobs: + update-version: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Update version in manifest.json and commit + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + run: | + VERSION=${GITHUB_REF#refs/tags/} + MANIFEST=custom_components/ocpp/manifest.json + jq --arg version "$VERSION" '.version = $version' $MANIFEST > tmp.json && mv tmp.json $MANIFEST + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git add $MANIFEST + git commit -m "Update version in manifest.json to $VERSION" + git push diff --git a/.gitignore b/.gitignore index b6e47617..d70fba6b 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,7 @@ dmypy.json # Pyre type checker .pyre/ + +# HA Development +/config/ +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 654640f2..261aa8f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,59 +1,28 @@ -repos: - - repo: https://github.com/asottile/pyupgrade - rev: v2.31.1 - hooks: - - id: pyupgrade - args: [--py37-plus] - - repo: https://github.com/psf/black - rev: 22.3.0 - hooks: - - id: black - args: - - --safe - - --quiet - files: ^((custom_components|homeassistant|script|tests)/.+)?[^/]+\.py$ - - repo: https://github.com/codespell-project/codespell - rev: v2.1.0 - hooks: - - id: codespell - args: - - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing - - --skip="./.*,*.csv,*.json" - - --quiet-level=2 - exclude_types: [csv, json] - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: - - flake8-docstrings==1.5.0 - - pydocstyle==5.0.2 - files: ^(custom_components|homeassistant|script|tests)/.+\.py$ - - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 - hooks: - - id: bandit - args: - - --quiet - - --format=custom - - --configfile=bandit.yaml - files: ^(custom_components|homeassistant|script|tests|)/.+\.py$ - - repo: https://github.com/PyCQA/isort - rev: 5.11.5 - hooks: - - id: isort - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 - hooks: - - id: check-executables-have-shebangs - stages: [manual] - - id: check-json - stages: [manual] - - id: requirements-txt-fixer - stages: [manual] - - id: check-ast - stages: [manual] - - id: mixed-line-ending - stages: [manual] - args: - - --fix=lf +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: [--py37-plus] + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.4 + hooks: + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-executables-have-shebangs + stages: [manual] + - id: check-json + stages: [manual] + - id: requirements-txt-fixer + stages: [manual] + - id: check-ast + stages: [manual] + - id: mixed-line-ending + stages: [manual] + args: + - --fix=lf diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 00000000..3f7141ff --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,49 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py312" + +lint.select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T20", # flake8-print + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +lint.ignore = [ + "C901", # function too complex + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +[lint.flake8-pytest-style] +fixture-parentheses = false + +[lint.pyupgrade] +keep-runtime-typing = true + +[lint.mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 531dc634..8cbb6e2d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,21 @@ { - "python.linting.pylintEnabled": true, "python.linting.enabled": true, + "python.linting.enabledWithoutWorkspace": true, + "python.linting.lintOnSave": true, + "python.linting.pylintEnabled": true, + "python.linting.ruffEnabled": true, "python.pythonPath": "/usr/local/bin/python", "python.analysis.extraPaths": [ "/usr/local/lib/python3.9/site-packages" ], "files.associations": { "*.yaml": "home-assistant" - } + }, + "python.testing.pytestArgs": [ + "tests", + "--cov=custom_components", + "--cov-report=term-missing", + "--cov-report=xml:coverage.xml", + "--timeout=30" + ], } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 47f12102..0015dad8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,28 +2,10 @@ "version": "2.0.0", "tasks": [ { - "label": "Run Home Assistant on port 9123", + "label": "Run Home Assistant on port 8123", "type": "shell", - "command": "container start", - "problemMatcher": [] - }, - { - "label": "Run Home Assistant configuration against /config", - "type": "shell", - "command": "container check", - "problemMatcher": [] - }, - { - "label": "Upgrade Home Assistant to latest dev", - "type": "shell", - "command": "container install", - "problemMatcher": [] - }, - { - "label": "Install a specific version of Home Assistant", - "type": "shell", - "command": "container set-version", + "command": "scripts/develop", "problemMatcher": [] } ] -} +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34cbb6a3..bbfe758a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,105 +1,105 @@ -# Contribution guidelines - -Contributing to this project should be as easy and transparent as possible, whether it's: - -- Reporting a bug -- Discussing the current state of the code -- Submitting a fix -- Proposing new features - -## Github is used for everything - -Github is used to host code, to track issues and feature requests, as well as accept pull requests. - -Pull requests are the best way to propose changes to the codebase. - -1. Fork the repo and create your branch from `master`. -2. If you've changed something, update the documentation. -3. Make sure your code lints (using black). -4. Test you contribution. -5. Issue that pull request! - -## Any contributions you make will be under the MIT Software License - -In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. - -## Report bugs using Github's [issues](../../issues) - -GitHub issues are used to track public bugs. -Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! - -## Write bug reports with detail, background, and sample code - -**Great Bug Reports** tend to have: - -- A quick summary and/or background -- Steps to reproduce - - Be specific! - - Give sample code if you can. -- What you expected would happen -- What actually happens -- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) - -People _love_ thorough bug reports. I'm not even kidding. - -## Use a Consistent Coding Style - -Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) -to make sure the code follows the style. - -Or use the `pre-commit` settings implemented in this repository -(see deicated section below). - -## Test your code modification - -This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). - -It comes with development environment in a container, easy to launch -if you use Visual Studio Code. With this container you will have a stand alone -Home Assistant instance running and already configured with the included -[`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) -file. - -You can use the `pre-commit` settings implemented in this repository to have -linting tool checking your contributions (see deicated section below). - -You should also verify that existing [tests](./tests) are still working -and you are encouraged to add new ones. -You can run the tests using the following commands from the root folder: - -```bash -# Create a virtual environment -python3 -m venv venv -source venv/bin/activate -# Install requirements -pip install -r requirements_test.txt -# Run tests and get a summary of successes/failures and code coverage -pytest --durations=10 --cov-report term-missing --cov=custom_components.ocpp tests -``` - -If any of the tests fail, make the necessary changes to the tests as part of -your changes to the integration. - -## Pre-commit - -You can use the [pre-commit](https://pre-commit.com/) settings included in the -repostory to have code style and linting checks. - -With `pre-commit` tool already installed, -activate the settings of the repository: - -```console -$ pre-commit install -``` - -Now the pre-commit tests will be done every time you commit. - -You can run the tests on all repository file with the command: - -```console -$ pre-commit run --all-files -``` - -## License - -By contributing, you agree that your contributions will be licensed under its MIT License. +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `master`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using black). +4. Test you contribution. +5. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People _love_ thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) +to make sure the code follows the style. + +Or use the `pre-commit` settings implemented in this repository +(see deicated section below). + +## Test your code modification + +This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) +file. + +You can use the `pre-commit` settings implemented in this repository to have +linting tool checking your contributions (see deicated section below). + +You should also verify that existing [tests](./tests) are still working +and you are encouraged to add new ones. +You can run the tests using the following commands from the root folder: + +```bash +# Create a virtual environment +python3 -m venv venv +source venv/bin/activate +# Install requirements +pip install -r requirements.txt +# Run tests and get a summary of successes/failures and code coverage +pytest --durations=10 --cov-report term-missing --cov=custom_components.ocpp tests +``` + +If any of the tests fail, make the necessary changes to the tests as part of +your changes to the integration. + +## Pre-commit + +You can use the [pre-commit](https://pre-commit.com/) settings included in the +repository to have code style and linting checks. + +With `pre-commit` tool already installed, +activate the settings of the repository: + +```console +$ pre-commit install +``` + +Now the pre-commit tests will be done every time you commit. + +You can run the tests on all repository file with the command: + +```console +$ pre-commit run --all-files +``` + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..51f9e7a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker +ARG BUILD_FROM +FROM ${BUILD_FROM} + +LABEL \ + io.hass.type="core" \ + org.opencontainers.image.authors="The Home Assistant Authors" \ + org.opencontainers.image.description="Open-source home automation platform running on Python 3" \ + org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.source="https://github.com/home-assistant/core" \ + org.opencontainers.image.title="Home Assistant" \ + org.opencontainers.image.url="https://www.home-assistant.io/" + +# Synchronize with homeassistant/core.py:async_stop +ENV \ + S6_SERVICES_GRACETIME=240000 \ + UV_SYSTEM_PYTHON=true \ + UV_NO_CACHE=true + +# Home Assistant S6-Overlay +COPY rootfs / + +# Add go2rtc binary +COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc + +RUN \ + # Verify go2rtc can be executed + go2rtc --version \ + # Install uv + && pip3 install uv==0.9.26 + +WORKDIR /usr/src + +## Setup Home Assistant Core dependencies +COPY requirements.txt homeassistant/ +COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ +RUN \ + uv pip install \ + --no-build \ + -r homeassistant/requirements.txt + +COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ +RUN \ + if ls homeassistant/home_assistant_*.whl 1> /dev/null 2>&1; then \ + uv pip install homeassistant/home_assistant_*.whl; \ + fi \ + && uv pip install \ + --no-build \ + -r homeassistant/requirements_all.txt + +## Setup Home Assistant Core +COPY . homeassistant/ +RUN \ + uv pip install \ + -e ./homeassistant \ + && python3 -m compileall \ + homeassistant/homeassistant + +WORKDIR /config \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..5acdaf49 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,57 @@ +FROM mcr.microsoft.com/vscode/devcontainers/base:debian + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN \ + apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + # Additional library needed by some tests and accordingly by VScode Tests Discovery + bluez \ + ffmpeg \ + libudev-dev \ + libavformat-dev \ + libavcodec-dev \ + libavdevice-dev \ + libavutil-dev \ + libswscale-dev \ + libswresample-dev \ + libavfilter-dev \ + libpcap-dev \ + libturbojpeg0 \ + libyaml-dev \ + libxml2 \ + git \ + cmake \ + autoconf \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Add go2rtc binary +COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc + +WORKDIR /usr/src + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +USER vscode + +ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" +RUN uv python install \ + && uv venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +# Setup hass-release +RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \ + && uv pip install -e ~/hass-release/ + +# Install Python dependencies from requirements +COPY requirements.txt ./ +RUN uv pip install --prerelease=allow -r requirements.txt + +# Claude Code native install +RUN curl -fsSL https://claude.ai/install.sh | bash + +WORKDIR /workspaces + +# Set the default shell to bash instead of sh +ENV SHELL=/bin/bash \ No newline at end of file diff --git a/README.md b/README.md index 467993fc..42be6094 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![OCPP](https://github.com/home-assistant/brands/raw/master/custom_integrations/ocpp/icon.png) -This is a Home Assistant integration for Electric Vehicle chargers that support the Open Charge Point Protocol. +This is a Home Assistant integration for Electric Vehicle chargers that support the following Open Charge Point Protocols 1.6j, 2.0.1 and 2.1 (experimental). * based on the [Python OCPP Package](https://github.com/mobilityhouse/ocpp). * HACS compliant repository diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..a744f6c9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +This integration is intended for personal use only. +The integration supports secure connections, but does not perform any validation of client connections. + +## Reporting a Vulnerability + +Reporting a vulnerability can be done by creating an issue at: +](https://github.com/lbbrhzn/ocpp/issues)https://github.com/lbbrhzn/ocpp/issues diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 00000000..7a99ba9e --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,71 @@ +# Loads default set of integrations. Do not remove. +#default_config: +#automation: +assist_pipeline: +backup: +bluetooth: +config: +conversation: +counter: +dhcp: +energy: +#frontend: +hardware: +history: +homeassistant_alerts: +image_upload: +input_boolean: +input_button: +input_datetime: +input_number: +input_select: +input_text: +logbook: +#logger: +map: +my: +network: +person: +schedule: +#scene: +#script: +stream: +sun: +system_health: +tag: +timer: +usb: +webhook: +zone: + +# Load frontend themes from the themes folder +frontend: + themes: !include_dir_merge_named themes + +#automation: !include automations.yaml +#script: !include scripts.yaml +#scene: !include scenes.yaml + +ocpp: + default_authorization_status: 'Invalid' + authorization_list: + - id_tag: 'pulsar' + name: 'My tag' + authorization_status : Accepted + - id_tag: 'CAFEBABE' + name: 'Some other tag' + authorization_status: Blocked + - id_tag: 'DEADBEEF' + name: 'Old tag' + status: Expired + - id_tag: '12341234' + name: 'Invalid tag' + authorization_status: Invalid + +logger: + default: info + logs: + custom_components.ocpp: debug + +# If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) +# debugpy: diff --git a/custom_components/ocpp/__init__.py b/custom_components/ocpp/__init__.py index ecdc21ce..f65c61df 100644 --- a/custom_components/ocpp/__init__.py +++ b/custom_components/ocpp/__init__.py @@ -1,10 +1,10 @@ """Custom integration for Chargers that support the Open Charge Point Protocol.""" -import asyncio import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Config, HomeAssistant +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers import device_registry import homeassistant.helpers.config_validation as cv import voluptuous as vol @@ -15,20 +15,54 @@ from .const import ( CONF_AUTH_LIST, CONF_AUTH_STATUS, - CONF_CPID, - CONF_CSID, + CONF_CPIDS, CONF_DEFAULT_AUTH_STATUS, CONF_ID_TAG, CONF_NAME, + CONF_CPID, + CONF_IDLE_INTERVAL, + CONF_MAX_CURRENT, + CONF_METER_INTERVAL, + CONF_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, + CONF_SKIP_SCHEMA_VALIDATION, + CONF_FORCE_SMART_CHARGING, + CONF_HOST, + CONF_PORT, + CONF_CSID, + CONF_SSL, + CONF_SSL_CERTFILE_PATH, + CONF_SSL_KEYFILE_PATH, + CONF_WEBSOCKET_CLOSE_TIMEOUT, + CONF_WEBSOCKET_PING_TRIES, + CONF_WEBSOCKET_PING_INTERVAL, + CONF_WEBSOCKET_PING_TIMEOUT, CONFIG, DEFAULT_CPID, + DEFAULT_IDLE_INTERVAL, + DEFAULT_MAX_CURRENT, + DEFAULT_METER_INTERVAL, + DEFAULT_MONITORED_VARIABLES, + DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_NUM_CONNECTORS, + DEFAULT_SKIP_SCHEMA_VALIDATION, + DEFAULT_FORCE_SMART_CHARGING, + DEFAULT_HOST, + DEFAULT_PORT, DEFAULT_CSID, + DEFAULT_SSL, + DEFAULT_SSL_CERTFILE_PATH, + DEFAULT_SSL_KEYFILE_PATH, + DEFAULT_WEBSOCKET_CLOSE_TIMEOUT, + DEFAULT_WEBSOCKET_PING_TRIES, + DEFAULT_WEBSOCKET_PING_INTERVAL, + DEFAULT_WEBSOCKET_PING_TIMEOUT, DOMAIN, PLATFORMS, ) _LOGGER: logging.Logger = logging.getLogger(__package__) -logging.getLogger(DOMAIN).setLevel(logging.INFO) AUTH_LIST_SCHEMA = vol.Schema( { @@ -51,7 +85,7 @@ ) -async def async_setup(hass: HomeAssistant, config: Config): +async def async_setup(hass: HomeAssistant, config: ConfigType): """Read configuration from yaml.""" ocpp_config = config.get(DOMAIN, {}) @@ -72,55 +106,159 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): dr = device_registry.async_get(hass) - """ Create Central System Device """ + # Create Central System device dr.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.data.get(CONF_CSID, DEFAULT_CSID))}, - name=entry.data.get(CONF_CSID, DEFAULT_CSID), + identifiers={(DOMAIN, central_sys.id)}, + name=central_sys.id, model="OCPP Central System", ) - """ Create Charge Point Device """ - dr.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.data.get(CONF_CPID, DEFAULT_CPID))}, - name=entry.data.get(CONF_CPID, DEFAULT_CPID), - model="Unknown", - via_device=((DOMAIN), central_sys.id), - ) + # Create charger devices + for cp_data in entry.data[CONF_CPIDS]: + for cp_id, cp_settings in cp_data.items(): + cpid = cp_settings[CONF_CPID] + dr.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, cp_id), (DOMAIN, cpid)}, + name=cpid, + suggested_area="Garage", + via_device=(DOMAIN, central_sys.id), + ) hass.data[DOMAIN][entry.entry_id] = central_sys - for platform in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) + if entry.data[CONF_CPIDS]: + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Handle removal of an entry.""" - central_sys = hass.data[DOMAIN][entry.entry_id] +async def async_migrate_entry(hass, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.info( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 2: + # This means the user has downgraded from a future version + return False - central_sys._server.close() - await central_sys._server.wait_closed() + if config_entry.version == 1: + old_data = {**config_entry.data} + csid_data = {} + cpid_data = {} + cpid_keys = { + CONF_CPID: DEFAULT_CPID, + CONF_IDLE_INTERVAL: DEFAULT_IDLE_INTERVAL, + CONF_MAX_CURRENT: DEFAULT_MAX_CURRENT, + CONF_METER_INTERVAL: DEFAULT_METER_INTERVAL, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + CONF_SKIP_SCHEMA_VALIDATION: DEFAULT_SKIP_SCHEMA_VALIDATION, + CONF_FORCE_SMART_CHARGING: DEFAULT_FORCE_SMART_CHARGING, + } + csid_keys = { + CONF_HOST: DEFAULT_HOST, + CONF_PORT: DEFAULT_PORT, + CONF_CSID: DEFAULT_CSID, + CONF_SSL: DEFAULT_SSL, + CONF_SSL_CERTFILE_PATH: DEFAULT_SSL_CERTFILE_PATH, + CONF_SSL_KEYFILE_PATH: DEFAULT_SSL_KEYFILE_PATH, + CONF_WEBSOCKET_CLOSE_TIMEOUT: DEFAULT_WEBSOCKET_CLOSE_TIMEOUT, + CONF_WEBSOCKET_PING_TRIES: DEFAULT_WEBSOCKET_PING_TRIES, + CONF_WEBSOCKET_PING_INTERVAL: DEFAULT_WEBSOCKET_PING_INTERVAL, + CONF_WEBSOCKET_PING_TIMEOUT: DEFAULT_WEBSOCKET_PING_TIMEOUT, + } + for key, value in cpid_keys.items(): + cpid_data.update({key: old_data.get(key, value)}) - unloaded = all( - await asyncio.gather( - *( - hass.config_entries.async_forward_entry_unload(entry, platform) - for platform in PLATFORMS + for key, value in csid_keys.items(): + csid_data.update({key: old_data.get(key, value)}) + + new_data = csid_data + cp_id = hass.states.get(f"sensor.{cpid_data[CONF_CPID].lower()}_id") + if cp_id is None: + _LOGGER.warning( + "Could not find charger id during migration, try a clean install" ) + return False + new_data.update({CONF_CPIDS: [{cp_id: cpid_data}]}) + + hass.config_entries.async_update_entry( + config_entry, data=new_data, minor_version=0, version=2 ) + + if config_entry.version == 2 and config_entry.minor_version == 0: + data = {**config_entry.data} + cpids = data.get(CONF_CPIDS, []) + + changed = False + for idx, cp_map in enumerate(cpids): + if not isinstance(cp_map, dict) or not cp_map: + continue + cp_id, cp_data = next(iter(cp_map.items())) + if CONF_NUM_CONNECTORS not in cp_data: + cp_data = {**cp_data, CONF_NUM_CONNECTORS: DEFAULT_NUM_CONNECTORS} + cpids[idx] = {cp_id: cp_data} + changed = True + + if changed: + data[CONF_CPIDS] = cpids + hass.config_entries.async_update_entry( + config_entry, + data=data, + version=2, + minor_version=1, + ) + else: + hass.config_entries.async_update_entry( + config_entry, + data=data, + version=2, + minor_version=1, + ) + + _LOGGER.info( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, ) - if unloaded: - hass.data[DOMAIN].pop(entry.entry_id) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + unloaded = False + if DOMAIN in hass.data: + if entry.entry_id in hass.data[DOMAIN]: + # Close server + central_sys = hass.data[DOMAIN][entry.entry_id] + central_sys._server.close() + await central_sys._server.wait_closed() + # Unload services + # print(hass.services.async_services_for_domain(DOMAIN)) + for service in hass.services.async_services_for_domain(DOMAIN): + hass.services.async_remove(DOMAIN, service) + # Unload platforms if a charger connected + if central_sys.connections == 0: + unloaded = True + else: + unloaded = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS + ) + # Remove entry + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id) return unloaded async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/ocpp/api.py b/custom_components/ocpp/api.py index 5184b6cc..0dec30dc 100644 --- a/custom_components/ocpp/api.py +++ b/custom_components/ocpp/api.py @@ -1,145 +1,97 @@ -"""Representation of a OCCP Entities.""" +"""Representation of a OCPP Entities.""" + from __future__ import annotations -import asyncio -from collections import defaultdict -from datetime import datetime, timedelta, timezone +import contextlib +import json import logging -from math import sqrt +import re import ssl -import time -from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN, TIME_MINUTES -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_component, entity_registry +from functools import partial +from homeassistant.config_entries import ConfigEntry, SOURCE_INTEGRATION_DISCOVERY +from homeassistant.const import STATE_OK, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv import voluptuous as vol -import websockets.connection +from websockets import Subprotocol, NegotiationError import websockets.server +from websockets.asyncio.server import ServerConnection -from ocpp.exceptions import NotImplementedError -from ocpp.messages import CallError -from ocpp.routing import on -from ocpp.v16 import ChargePoint as cp, call, call_result -from ocpp.v16.enums import ( - Action, - AuthorizationStatus, - AvailabilityStatus, - AvailabilityType, - ChargePointStatus, - ChargingProfileKindType, - ChargingProfilePurposeType, - ChargingProfileStatus, - ChargingRateUnitType, - ClearChargingProfileStatus, - ConfigurationStatus, - DataTransferStatus, - Measurand, - MessageTrigger, - Phase, - RegistrationStatus, - RemoteStartStopStatus, - ResetStatus, - ResetType, - TriggerMessageStatus, - UnitOfMeasure, - UnlockStatus, -) +from .ocppv16 import ChargePoint as ChargePointv16 +from .ocppv201 import ChargePoint as ChargePointv201 from .const import ( - CONF_AUTH_LIST, - CONF_AUTH_STATUS, - CONF_CPID, - CONF_CSID, - CONF_DEFAULT_AUTH_STATUS, - CONF_FORCE_SMART_CHARGING, - CONF_HOST, - CONF_ID_TAG, - CONF_IDLE_INTERVAL, - CONF_METER_INTERVAL, - CONF_MONITORED_VARIABLES, - CONF_PORT, - CONF_SKIP_SCHEMA_VALIDATION, - CONF_SSL, - CONF_SSL_CERTFILE_PATH, - CONF_SSL_KEYFILE_PATH, - CONF_SUBPROTOCOL, - CONF_WEBSOCKET_CLOSE_TIMEOUT, - CONF_WEBSOCKET_PING_INTERVAL, - CONF_WEBSOCKET_PING_TIMEOUT, - CONF_WEBSOCKET_PING_TRIES, - CONFIG, - DEFAULT_CPID, - DEFAULT_CSID, - DEFAULT_ENERGY_UNIT, - DEFAULT_FORCE_SMART_CHARGING, - DEFAULT_HOST, - DEFAULT_IDLE_INTERVAL, - DEFAULT_MEASURAND, - DEFAULT_METER_INTERVAL, - DEFAULT_PORT, - DEFAULT_POWER_UNIT, - DEFAULT_SKIP_SCHEMA_VALIDATION, - DEFAULT_SSL, - DEFAULT_SSL_CERTFILE_PATH, - DEFAULT_SSL_KEYFILE_PATH, - DEFAULT_SUBPROTOCOL, - DEFAULT_WEBSOCKET_CLOSE_TIMEOUT, - DEFAULT_WEBSOCKET_PING_INTERVAL, - DEFAULT_WEBSOCKET_PING_TIMEOUT, - DEFAULT_WEBSOCKET_PING_TRIES, + CentralSystemSettings, DOMAIN, - HA_ENERGY_UNIT, - HA_POWER_UNIT, - UNITS_OCCP_TO_HA, + OCPP_2_0, + ChargerSystemSettings, ) from .enums import ( - ConfigurationKey as ckey, - HAChargerDetails as cdet, HAChargerServices as csvcs, - HAChargerSession as csess, HAChargerStatuses as cstat, - OcppMisc as om, - Profiles as prof, ) +from .chargepoint import SetVariableResult _LOGGER: logging.Logger = logging.getLogger(__package__) -logging.getLogger(DOMAIN).setLevel(logging.INFO) # Uncomment these when Debugging # logging.getLogger("asyncio").setLevel(logging.DEBUG) # logging.getLogger("websockets").setLevel(logging.DEBUG) UFW_SERVICE_DATA_SCHEMA = vol.Schema( { + vol.Optional("devid"): cv.string, vol.Required("firmware_url"): cv.string, vol.Optional("delay_hours"): cv.positive_int, } ) CONF_SERVICE_DATA_SCHEMA = vol.Schema( { + vol.Optional("devid"): cv.string, vol.Required("ocpp_key"): cv.string, vol.Required("value"): cv.string, } ) GCONF_SERVICE_DATA_SCHEMA = vol.Schema( { - vol.Required("ocpp_key"): cv.string, + vol.Optional("devid"): cv.string, + vol.Optional("ocpp_key"): cv.string, } ) GDIAG_SERVICE_DATA_SCHEMA = vol.Schema( { + vol.Optional("devid"): cv.string, vol.Required("upload_url"): cv.string, } ) TRANS_SERVICE_DATA_SCHEMA = vol.Schema( { + vol.Optional("devid"): cv.string, vol.Required("vendor_id"): cv.string, vol.Optional("message_id"): cv.string, vol.Optional("data"): cv.string, } ) +CHRGR_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("devid"): cv.string, + vol.Optional("limit_amps"): cv.positive_float, + vol.Optional("limit_watts"): cv.positive_int, + vol.Optional("conn_id"): cv.positive_int, + vol.Optional("custom_profile"): vol.Any(cv.string, dict), + } +) +CUSTMSG_SERVICE_DATA_SCHEMA = vol.Schema( + { + vol.Optional("devid"): cv.string, + vol.Required("requested_message"): cv.string, + } +) + + +def _norm(s: str) -> str: + return re.sub(r"[^a-z0-9]", "", str(s).lower()) class CentralSystem: @@ -149,1343 +101,582 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry): """Instantiate instance of a CentralSystem.""" self.hass = hass self.entry = entry - self.host = entry.data.get(CONF_HOST, DEFAULT_HOST) - self.port = entry.data.get(CONF_PORT, DEFAULT_PORT) - self.csid = entry.data.get(CONF_CSID, DEFAULT_CSID) - self.cpid = entry.data.get(CONF_CPID, DEFAULT_CPID) - self.websocket_close_timeout = entry.data.get( - CONF_WEBSOCKET_CLOSE_TIMEOUT, DEFAULT_WEBSOCKET_CLOSE_TIMEOUT + self.settings = CentralSystemSettings(**entry.data) + self.subprotocols = self.settings.subprotocols + self._server = None + self.id = self.settings.csid + self.charge_points = {} # uses cp_id as reference to charger instance + self.cpids = {} # dict of {cpid:cp_id} + self.connections = 0 + + # Register custom services with home assistant + self.hass.services.async_register( + DOMAIN, + csvcs.service_configure.value, + self.handle_configure, + CONF_SERVICE_DATA_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_get_configuration.value, + self.handle_get_configuration, + GCONF_SERVICE_DATA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_data_transfer.value, + self.handle_data_transfer, + TRANS_SERVICE_DATA_SCHEMA, ) - self.websocket_ping_tries = entry.data.get( - CONF_WEBSOCKET_PING_TRIES, DEFAULT_WEBSOCKET_PING_TRIES + self.hass.services.async_register( + DOMAIN, + csvcs.service_trigger_custom_message.value, + self.handle_trigger_custom_message, + CUSTMSG_SERVICE_DATA_SCHEMA, ) - self.websocket_ping_interval = entry.data.get( - CONF_WEBSOCKET_PING_INTERVAL, DEFAULT_WEBSOCKET_PING_INTERVAL + self.hass.services.async_register( + DOMAIN, + csvcs.service_clear_profile.value, + self.handle_clear_profile, ) - self.websocket_ping_timeout = entry.data.get( - CONF_WEBSOCKET_PING_TIMEOUT, DEFAULT_WEBSOCKET_PING_TIMEOUT + self.hass.services.async_register( + DOMAIN, + csvcs.service_set_charge_rate.value, + self.handle_set_charge_rate, + CHRGR_SERVICE_DATA_SCHEMA, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_update_firmware.value, + self.handle_update_firmware, + UFW_SERVICE_DATA_SCHEMA, + ) + self.hass.services.async_register( + DOMAIN, + csvcs.service_get_diagnostics.value, + self.handle_get_diagnostics, + GDIAG_SERVICE_DATA_SCHEMA, ) - self.subprotocol = entry.data.get(CONF_SUBPROTOCOL, DEFAULT_SUBPROTOCOL) - self._server = None - self.config = entry.data - self.id = entry.entry_id - self.charge_points = {} - if entry.data.get(CONF_SSL, DEFAULT_SSL): + @staticmethod + async def create(hass: HomeAssistant, entry: ConfigEntry): + """Create instance and start listening for OCPP connections on given port.""" + self = CentralSystem(hass, entry) + + if self.settings.ssl: self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) # see https://community.home-assistant.io/t/certificate-authority-and-self-signed-certificate-for-ssl-tls/196970 - localhost_certfile = entry.data.get( - CONF_SSL_CERTFILE_PATH, DEFAULT_SSL_CERTFILE_PATH - ) - localhost_keyfile = entry.data.get( - CONF_SSL_KEYFILE_PATH, DEFAULT_SSL_KEYFILE_PATH - ) - self.ssl_context.load_cert_chain( - localhost_certfile, keyfile=localhost_keyfile + localhost_certfile = self.settings.ssl_certfile_path + localhost_keyfile = self.settings.ssl_keyfile_path + await self.hass.async_add_executor_job( + partial( + self.ssl_context.load_cert_chain, + localhost_certfile, + keyfile=localhost_keyfile, + ) ) else: self.ssl_context = None - @staticmethod - async def create(hass: HomeAssistant, entry: ConfigEntry): - """Create instance and start listening for OCPP connections on given port.""" - self = CentralSystem(hass, entry) - - server = await websockets.server.serve( + server = await websockets.serve( self.on_connect, - self.host, - self.port, - subprotocols=[self.subprotocol], + self.settings.host, + self.settings.port, + select_subprotocol=self.select_subprotocol, + subprotocols=self.subprotocols, ping_interval=None, # ping interval is not used here, because we send pings mamually in ChargePoint.monitor_connection() ping_timeout=None, - close_timeout=self.websocket_close_timeout, + close_timeout=self.settings.websocket_close_timeout, ssl=self.ssl_context, ) self._server = server return self - async def on_connect( - self, websocket: websockets.server.WebSocketServerProtocol, path: str - ): + @staticmethod + def _norm_conn(connector_id: int | None) -> int: + if connector_id is None: + return 0 + try: + return int(connector_id) + except Exception: + return 0 + + def select_subprotocol( + self, connection: ServerConnection, subprotocols + ) -> Subprotocol | None: + """Override default subprotocol selection.""" + + # Server offers at least one subprotocol but client doesn't offer any. + # Default to None + if not subprotocols: + return None + + # Server and client both offer subprotocols. Look for a shared one. + proposed_subprotocols = set(subprotocols) + for subprotocol in proposed_subprotocols: + if subprotocol in self.subprotocols: + return subprotocol + + # No common subprotocol was found. + raise NegotiationError( + "invalid subprotocol; expected one of " + ", ".join(self.subprotocols) + ) + + async def on_connect(self, websocket: ServerConnection): """Request handler executed for every new OCPP connection.""" - if self.config.get(CONF_SKIP_SCHEMA_VALIDATION, DEFAULT_SKIP_SCHEMA_VALIDATION): - _LOGGER.warning("Skipping websocket subprotocol validation") + if websocket.subprotocol is not None: + _LOGGER.info("Websocket Subprotocol matched: %s", websocket.subprotocol) else: - if websocket.subprotocol is not None: - _LOGGER.info("Websocket Subprotocol matched: %s", websocket.subprotocol) - else: - # In the websockets lib if no subprotocols are supported by the - # client and the server, it proceeds without a subprotocol, - # so we have to manually close the connection. - _LOGGER.warning( - "Protocols mismatched | expected Subprotocols: %s," - " but client supports %s | Closing connection", - websocket.available_subprotocols, - websocket.request_headers.get("Sec-WebSocket-Protocol", ""), - ) - return await websocket.close() + _LOGGER.info( + "Websocket Subprotocol not provided by charger: default to ocpp1.6" + ) - _LOGGER.info(f"Charger websocket path={path}") - cp_id = path.strip("/") + _LOGGER.info(f"Charger websocket path={websocket.request.path}") + cp_id = websocket.request.path.strip("/") cp_id = cp_id[cp_id.rfind("/") + 1 :] - if self.cpid not in self.charge_points: - _LOGGER.info(f"Charger {cp_id} connected to {self.host}:{self.port}.") - charge_point = ChargePoint(cp_id, websocket, self.hass, self.entry, self) - self.charge_points[self.cpid] = charge_point + if cp_id not in self.charge_points: + try: + config_flow = False + for cfg in self.settings.cpids: + if cfg.get(cp_id): + config_flow = True + cp_settings = ChargerSystemSettings(**list(cfg.values())[0]) + _LOGGER.info( + f"Charger match found for {cp_settings.cpid}:{cp_id}" + ) + _LOGGER.debug(f"Central settings: {self.settings}") + + if not config_flow: + # discovery_info for flow + info = {"cp_id": cp_id, "entry": self.entry} + await self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data=info, + ) + # use return to wait for config entry to reload after discovery + return + + self.cpids.update({cp_settings.cpid: cp_id}) + except Exception as e: + _LOGGER.error(f"Failed to setup charger {cp_id}: {str(e)}") + return + + if websocket.subprotocol and websocket.subprotocol.startswith(OCPP_2_0): + charge_point = ChargePointv201( + cp_id, websocket, self.hass, self.entry, self.settings, cp_settings + ) + else: + charge_point = ChargePointv16( + cp_id, websocket, self.hass, self.entry, self.settings, cp_settings + ) + self.charge_points[cp_id] = charge_point + self.connections += 1 + _LOGGER.info( + f"Charger {cp_settings.cpid}:{cp_id} connected to {self.settings.host}:{self.settings.port}." + ) + _LOGGER.info( + f"{self.connections} charger(s): {self.cpids} now connected to central system:{self.settings.csid}." + ) await charge_point.start() else: - _LOGGER.info(f"Charger {cp_id} reconnected to {self.host}:{self.port}.") - charge_point: ChargePoint = self.charge_points[self.cpid] + _LOGGER.info( + f"Charger {cp_id} reconnected to {self.settings.host}:{self.settings.port}." + ) + charge_point = self.charge_points[cp_id] await charge_point.reconnect(websocket) - _LOGGER.info(f"Charger {cp_id} disconnected from {self.host}:{self.port}.") - def get_metric(self, cp_id: str, measurand: str): - """Return last known value for given measurand.""" - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].value - return None + def _get_metrics(self, id: str): + """Return (cp_id, metrics mapping, cp instance, safe int num_connectors).""" + cp_id = self.cpids.get(id, id) + cp = self.charge_points.get(cp_id) - def del_metric(self, cp_id: str, measurand: str): - """Set given measurand to None.""" - if cp_id in self.charge_points: - self.charge_points[cp_id]._metrics[measurand].value = None - return None + def _safe_int(value, default=1): + try: + iv = int(value) + return iv if iv > 0 else default + except Exception: + return default - def get_unit(self, cp_id: str, measurand: str): - """Return unit of given measurand.""" - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].unit - return None + n_connectors = _safe_int(getattr(cp, "num_connectors", 1), default=1) - def get_ha_unit(self, cp_id: str, measurand: str): - """Return home assistant unit of given measurand.""" - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].ha_unit - return None - - def get_extra_attr(self, cp_id: str, measurand: str): - """Return last known extra attributes for given measurand.""" - if cp_id in self.charge_points: - return self.charge_points[cp_id]._metrics[measurand].extra_attr - return None + return ( + (cp_id, cp._metrics, cp, n_connectors) + if cp is not None + else (None, None, None, None) + ) - def get_available(self, cp_id: str): - """Return whether the charger is available.""" - if cp_id in self.charge_points: - return self.charge_points[cp_id].status == STATE_OK - return False + def get_metric(self, id: str, measurand: str, connector_id: int | None = None): + """Return last known value for given measurand.""" + cp_id, m, cp, n_connectors = self._get_metrics(id) + if cp is None: + return None - def get_supported_features(self, cp_id: str): - """Return what profiles the charger supports.""" - if cp_id in self.charge_points: - return self.charge_points[cp_id].supported_features - return 0 + def _try_val(key): + with contextlib.suppress(Exception): + val = m[key].value + return val + return None - async def set_max_charge_rate_amps(self, cp_id: str, value: float): - """Set the maximum charge rate in amps.""" - if cp_id in self.charge_points: - return await self.charge_points[cp_id].set_charge_rate(limit_amps=value) - return False + # 1) Explicit connector_id (including 0): just get it + if connector_id is not None: + conn = self._norm_conn(connector_id) + return _try_val((conn, measurand)) + + # 2) No connector_id: try CHARGER level (conn=0) + val = _try_val((0, measurand)) + if val is not None: + return val + + # 3) Legacy "flat" key (before the connector support) + with contextlib.suppress(Exception): + val = m[measurand].value + if val is not None: + return val + + # 4) Fallback to connector 1 (old tests often expect this) + if n_connectors >= 1: + val = _try_val((1, measurand)) + if val is not None: + return val + + # 5) Last resort: find the first connector 2..N with value + for c in range(2, int(n_connectors) + 1): + val = _try_val((c, measurand)) + if val is not None: + return val - async def set_charger_state( - self, cp_id: str, service_name: str, state: bool = True - ): - """Carry out requested service/state change on connected charger.""" - resp = False - if cp_id in self.charge_points: - if service_name == csvcs.service_availability.name: - resp = await self.charge_points[cp_id].set_availability(state) - if service_name == csvcs.service_charge_start.name: - resp = await self.charge_points[cp_id].start_transaction() - if service_name == csvcs.service_charge_stop.name: - resp = await self.charge_points[cp_id].stop_transaction() - if service_name == csvcs.service_reset.name: - resp = await self.charge_points[cp_id].reset() - if service_name == csvcs.service_unlock.name: - resp = await self.charge_points[cp_id].unlock() - return resp + return None - async def update(self, cp_id: str): - """Update sensors values in HA.""" - er = entity_registry.async_get(self.hass) - dr = device_registry.async_get(self.hass) - identifiers = {(DOMAIN, cp_id)} - dev = dr.async_get_device(identifiers) - # _LOGGER.info("Device id: %s updating", dev.name) - for ent in entity_registry.async_entries_for_device(er, dev.id): - # _LOGGER.info("Entity id: %s updating", ent.entity_id) - self.hass.async_create_task( - entity_component.async_update_entity(self.hass, ent.entity_id) - ) + def del_metric(self, id: str, measurand: str, connector_id: int | None = None): + """Set given measurand to None.""" + cp_id, m, cp, n_connectors = self._get_metrics(id) - def device_info(self): - """Return device information.""" - return { - "identifiers": {(DOMAIN, self.id)}, - } + if m is None: + return None + conn = self._norm_conn(connector_id) + try: + m[(conn, measurand)].value = None + except Exception: + if conn == 0: + with contextlib.suppress(Exception): + m[measurand].value = None + return None -class ChargePoint(cp): - """Server side representation of a charger.""" + def get_unit(self, id: str, measurand: str, connector_id: int | None = None): + """Return unit of given measurand.""" + cp_id, m, cp, n_connectors = self._get_metrics(id) - def __init__( - self, - id: str, - connection: websockets.server.WebSocketServerProtocol, - hass: HomeAssistant, - entry: ConfigEntry, - central: CentralSystem, - interval_meter_metrics: int = 10, - skip_schema_validation: bool = False, - ): - """Instantiate a ChargePoint.""" + if cp is None: + return None - super().__init__(id, connection) + def _try_unit(key): + with contextlib.suppress(Exception): + val = m[key].unit + if isinstance(val, str) and val.strip() == "": + return None + return val + return None - for action in self.route_map: - self.route_map[action]["_skip_schema_validation"] = skip_schema_validation + if connector_id is not None: + conn = self._norm_conn(connector_id) + return _try_unit((conn, measurand)) - self.interval_meter_metrics = interval_meter_metrics - self.hass = hass - self.entry = entry - self.central = central - self.status = "init" - # Indicates if the charger requires a reboot to apply new - # configuration. - self._requires_reboot = False - self.preparing = asyncio.Event() - self.active_transaction_id: int = 0 - self.triggered_boot_notification = False - self.received_boot_notification = False - self.post_connect_success = False - self.tasks = None - self._charger_reports_session_energy = False - self._metrics = defaultdict(lambda: Metric(None, None)) - self._metrics[cdet.identifier.value].value = id - self._metrics[csess.session_time.value].unit = TIME_MINUTES - self._metrics[csess.session_energy.value].unit = UnitOfMeasure.kwh.value - self._metrics[csess.meter_start.value].unit = UnitOfMeasure.kwh.value - self._attr_supported_features: int = 0 - self._metrics[cstat.reconnects.value].value: int = 0 - - async def post_connect(self): - """Logic to be executed right after a charger connects.""" - # Define custom service handles for charge point - async def handle_clear_profile(call): - """Handle the clear profile service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - await self.clear_profile() + val = _try_unit((0, measurand)) + if val is not None: + return val - async def handle_update_firmware(call): - """Handle the firmware update service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - url = call.data.get("firmware_url") - delay = int(call.data.get("delay_hours", 0)) - await self.update_firmware(url, delay) - - async def handle_configure(call): - """Handle the configure service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - key = call.data.get("ocpp_key") - value = call.data.get("value") - await self.configure(key, value) - - async def handle_get_configuration(call): - """Handle the get configuration service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - key = call.data.get("ocpp_key") - await self.get_configuration(key) + with contextlib.suppress(Exception): + val = m[measurand].unit + if isinstance(val, str) and val.strip() == "": + val = None + if val is not None: + return val - async def handle_get_diagnostics(call): - """Handle the get get diagnostics service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - url = call.data.get("upload_url") - await self.get_diagnostics(url) + if n_connectors >= 1: + val = _try_unit((1, measurand)) + if val is not None: + return val - async def handle_data_transfer(call): - """Handle the data transfer service call.""" - if self.status == STATE_UNAVAILABLE: - _LOGGER.warning("%s charger is currently unavailable", self.id) - return - vendor = call.data.get("vendor_id") - message = call.data.get("message_id", "") - data = call.data.get("data", "") - await self.data_transfer(vendor, message, data) + for c in range(2, int(n_connectors) + 1): + val = _try_unit((c, measurand)) + if val is not None: + return val - try: - self.status = STATE_OK - await asyncio.sleep(2) - await self.get_supported_features() - resp = await self.get_configuration(ckey.number_of_connectors.value) - self._metrics[cdet.connectors.value].value = resp - await self.get_configuration(ckey.heartbeat_interval.value) - await self.configure( - ckey.meter_values_sampled_data.value, - self.entry.data.get(CONF_MONITORED_VARIABLES, DEFAULT_MEASURAND), - ) - await self.configure( - ckey.meter_value_sample_interval.value, - str(self.entry.data.get(CONF_METER_INTERVAL, DEFAULT_METER_INTERVAL)), - ) - await self.configure( - ckey.clock_aligned_data_interval.value, - str(self.entry.data.get(CONF_IDLE_INTERVAL, DEFAULT_IDLE_INTERVAL)), - ) - # await self.configure( - # "StopTxnSampledData", ",".join(self.entry.data[CONF_MONITORED_VARIABLES]) - # ) - # await self.start_transaction() - - # Register custom services with home assistant - self.hass.services.async_register( - DOMAIN, - csvcs.service_configure.value, - handle_configure, - CONF_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_get_configuration.value, - handle_get_configuration, - GCONF_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_data_transfer.value, - handle_data_transfer, - TRANS_SERVICE_DATA_SCHEMA, - ) - if prof.SMART in self._attr_supported_features: - self.hass.services.async_register( - DOMAIN, csvcs.service_clear_profile.value, handle_clear_profile - ) - if prof.FW in self._attr_supported_features: - self.hass.services.async_register( - DOMAIN, - csvcs.service_update_firmware.value, - handle_update_firmware, - UFW_SERVICE_DATA_SCHEMA, - ) - self.hass.services.async_register( - DOMAIN, - csvcs.service_get_diagnostics.value, - handle_get_diagnostics, - GDIAG_SERVICE_DATA_SCHEMA, - ) - self.post_connect_success = True - _LOGGER.debug(f"'{self.id}' post connection setup completed successfully") - - # nice to have, but not needed for integration to function - # and can cause issues with some chargers - await self.configure(ckey.web_socket_ping_interval.value, "60") - await self.set_availability() - if prof.REM in self._attr_supported_features: - if self.received_boot_notification is False: - await self.trigger_boot_notification() - await self.trigger_status_notification() - except (NotImplementedError) as e: - _LOGGER.error("Configuration of the charger failed: %s", e) - - async def get_supported_features(self): - """Get supported features.""" - req = call.GetConfigurationPayload(key=[ckey.supported_feature_profiles.value]) - resp = await self.call(req) - feature_list = (resp.configuration_key[0][om.value.value]).split(",") - if feature_list[0] == "": - _LOGGER.warning("No feature profiles detected, defaulting to Core") - await self.notify_ha("No feature profiles detected, defaulting to Core") - feature_list = [om.feature_profile_core.value] - if self.central.config.get( - CONF_FORCE_SMART_CHARGING, DEFAULT_FORCE_SMART_CHARGING - ): - _LOGGER.warning("Force Smart Charging feature profile") - self._attr_supported_features |= prof.SMART - for item in feature_list: - item = item.strip().replace(" ", "") - if item == om.feature_profile_core.value: - self._attr_supported_features |= prof.CORE - elif item == om.feature_profile_firmware.value: - self._attr_supported_features |= prof.FW - elif item == om.feature_profile_smart.value: - self._attr_supported_features |= prof.SMART - elif item == om.feature_profile_reservation.value: - self._attr_supported_features |= prof.RES - elif item == om.feature_profile_remote.value: - self._attr_supported_features |= prof.REM - elif item == om.feature_profile_auth.value: - self._attr_supported_features |= prof.AUTH - else: - _LOGGER.warning("Unknown feature profile detected ignoring: %s", item) - await self.notify_ha( - f"Warning: Unknown feature profile detected ignoring {item}" - ) - self._metrics[cdet.features.value].value = self._attr_supported_features - _LOGGER.debug("Feature profiles returned: %s", self._attr_supported_features) + return None - async def trigger_boot_notification(self): - """Trigger a boot notification.""" - req = call.TriggerMessagePayload( - requested_message=MessageTrigger.boot_notification - ) - resp = await self.call(req) - if resp.status == TriggerMessageStatus.accepted: - self.triggered_boot_notification = True - return True - else: - self.triggered_boot_notification = False - _LOGGER.warning("Failed with response: %s", resp.status) - return False - - async def trigger_status_notification(self): - """Trigger status notifications for all connectors.""" - return_value = True - nof_connectors = int(self._metrics[cdet.connectors.value].value) - for id in range(0, nof_connectors + 1): - _LOGGER.debug(f"trigger status notification for connector={id}") - req = call.TriggerMessagePayload( - requested_message=MessageTrigger.status_notification, - connector_id=int(id), - ) - resp = await self.call(req) - if resp.status != TriggerMessageStatus.accepted: - _LOGGER.warning("Failed with response: %s", resp.status) - return_value = False - return return_value - - async def clear_profile(self): - """Clear all charging profiles.""" - req = call.ClearChargingProfilePayload() - resp = await self.call(req) - if resp.status == ClearChargingProfileStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Clear profile failed with response {resp.status}" - ) - return False + def get_ha_unit(self, id: str, measurand: str, connector_id: int | None = None): + """Return home assistant unit of given measurand.""" + cp_id, m, cp, n_connectors = self._get_metrics(id) - async def set_charge_rate(self, limit_amps: int = 32, limit_watts: int = 22000): - """Set a charging profile with defined limit.""" - if prof.SMART in self._attr_supported_features: - resp = await self.get_configuration( - ckey.charging_schedule_allowed_charging_rate_unit.value - ) - _LOGGER.info( - "Charger supports setting the following units: %s", - resp, - ) - _LOGGER.info("If more than one unit supported default unit is Amps") - if om.current.value in resp: - lim = limit_amps - units = ChargingRateUnitType.amps.value - else: - lim = limit_watts - units = ChargingRateUnitType.watts.value - resp = await self.get_configuration( - ckey.charge_profile_max_stack_level.value - ) - stack_level = int(resp) - req = call.SetChargingProfilePayload( - connector_id=0, - cs_charging_profiles={ - om.charging_profile_id.value: 8, - om.stack_level.value: stack_level, - om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: ChargingProfilePurposeType.charge_point_max_profile.value, - om.charging_schedule.value: { - om.charging_rate_unit.value: units, - om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: lim} - ], - }, - }, - ) - else: - _LOGGER.info("Smart charging is not supported by this charger") - return False - resp = await self.call(req) - if resp.status == ChargingProfileStatus.accepted: - return True - else: - _LOGGER.debug( - "ChargePointMaxProfile is not supported by this charger, trying TxDefaultProfile instead..." - ) - # try a lower stack level for chargers where level < maximum, not <= - req = call.SetChargingProfilePayload( - connector_id=0, - cs_charging_profiles={ - om.charging_profile_id.value: 8, - om.stack_level.value: stack_level - 1, - om.charging_profile_kind.value: ChargingProfileKindType.relative.value, - om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_default_profile.value, - om.charging_schedule.value: { - om.charging_rate_unit.value: units, - om.charging_schedule_period.value: [ - {om.start_period.value: 0, om.limit.value: lim} - ], - }, - }, - ) - resp = await self.call(req) - if resp.status == ChargingProfileStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set charging profile failed with response {resp.status}" - ) - return False + if cp is None: + return None - async def set_availability(self, state: bool = True): - """Change availability.""" - if state is True: - typ = AvailabilityType.operative.value - else: - typ = AvailabilityType.inoperative.value + def _try_ha_unit(key): + with contextlib.suppress(Exception): + val = m[key].ha_unit + if isinstance(val, str) and val.strip() == "": + return None + return val + return None - req = call.ChangeAvailabilityPayload(connector_id=0, type=typ) - resp = await self.call(req) - if resp.status == AvailabilityStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Set availability failed with response {resp.status}" - ) - return False - - async def start_transaction(self): - """ - Remote start a transaction. - - Check if authorisation enabled, if it is disable it before remote start - """ - resp = await self.get_configuration(ckey.authorize_remote_tx_requests.value) - if resp is True: - await self.configure(ckey.authorize_remote_tx_requests.value, "false") - req = call.RemoteStartTransactionPayload( - connector_id=1, id_tag=self._metrics[cdet.identifier.value].value[:20] - ) - resp = await self.call(req) - if resp.status == RemoteStartStopStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Start transaction failed with response {resp.status}" - ) - return False - - async def stop_transaction(self): - """ - Request remote stop of current transaction. - - Leaves charger in finishing state until unplugged. - Use reset() to make the charger available again for remote start - """ - if self.active_transaction_id == 0: - return True - req = call.RemoteStopTransactionPayload( - transaction_id=self.active_transaction_id - ) - resp = await self.call(req) - if resp.status == RemoteStartStopStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Stop transaction failed with response {resp.status}" - ) - return False - - async def reset(self, typ: str = ResetType.hard): - """Hard reset charger unless soft reset requested.""" - self._metrics[cstat.reconnects.value].value = 0 - req = call.ResetPayload(typ) - resp = await self.call(req) - if resp.status == ResetStatus.accepted: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha(f"Warning: Reset failed with response {resp.status}") - return False - - async def unlock(self, connector_id: int = 1): - """Unlock charger if requested.""" - req = call.UnlockConnectorPayload(connector_id) - resp = await self.call(req) - if resp.status == UnlockStatus.unlocked: - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha(f"Warning: Unlock failed with response {resp.status}") - return False - - async def update_firmware(self, firmware_url: str, wait_time: int = 0): - """Update charger with new firmware if available.""" - """where firmware_url is the http or https url of the new firmware""" - """and wait_time is hours from now to wait before install""" - if prof.FW in self._attr_supported_features: - schema = vol.Schema(vol.Url()) - try: - url = schema(firmware_url) - except vol.MultipleInvalid as e: - _LOGGER.debug("Failed to parse url: %s", e) - update_time = ( - datetime.now(tz=timezone.utc) + timedelta(hours=wait_time) - ).strftime("%Y-%m-%dT%H:%M:%SZ") - req = call.UpdateFirmwarePayload(location=url, retrieve_date=update_time) - resp = await self.call(req) - _LOGGER.info("Response: %s", resp) - return True - else: - _LOGGER.warning("Charger does not support ocpp firmware updating") - return False + if connector_id is not None: + conn = self._norm_conn(connector_id) + return _try_ha_unit((conn, measurand)) - async def get_diagnostics(self, upload_url: str): - """Upload diagnostic data to server from charger.""" - if prof.FW in self._attr_supported_features: - schema = vol.Schema(vol.Url()) - try: - url = schema(upload_url) - except vol.MultipleInvalid as e: - _LOGGER.warning("Failed to parse url: %s", e) - req = call.GetDiagnosticsPayload(location=url) - resp = await self.call(req) - _LOGGER.info("Response: %s", resp) - return True - else: - _LOGGER.warning("Charger does not support ocpp diagnostics uploading") - return False + val = _try_ha_unit((0, measurand)) + if val is not None: + return val - async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): - """Request vendor specific data transfer from charger.""" - req = call.DataTransferPayload( - vendor_id=vendor_id, message_id=message_id, data=data - ) - resp = await self.call(req) - if resp.status == DataTransferStatus.accepted: - _LOGGER.info( - "Data transfer [vendorId(%s), messageId(%s), data(%s)] response: %s", - vendor_id, - message_id, - data, - resp.data, - ) - self._metrics[cdet.data_response.value].value = datetime.now( - tz=timezone.utc - ) - self._metrics[cdet.data_response.value].extra_attr = {message_id: resp.data} - return True - else: - _LOGGER.warning("Failed with response: %s", resp.status) - await self.notify_ha( - f"Warning: Data transfer failed with response {resp.status}" - ) - return False + with contextlib.suppress(Exception): + val = m[measurand].ha_unit + if isinstance(val, str) and val.strip() == "": + val = None + if val is not None: + return val - async def get_configuration(self, key: str = ""): - """Get Configuration of charger for supported keys else return None.""" - if key == "": - req = call.GetConfigurationPayload() - else: - req = call.GetConfigurationPayload(key=[key]) - resp = await self.call(req) - if resp.configuration_key is not None: - value = resp.configuration_key[0][om.value.value] - _LOGGER.debug("Get Configuration for %s: %s", key, value) - self._metrics[cdet.config_response.value].value = datetime.now( - tz=timezone.utc - ) - self._metrics[cdet.config_response.value].extra_attr = {key: value} - return value - if resp.unknown_key is not None: - _LOGGER.warning("Get Configuration returned unknown key for: %s", key) - await self.notify_ha(f"Warning: charger reports {key} is unknown") - return None + if n_connectors >= 1: + val = _try_ha_unit((1, measurand)) + if val is not None: + return val - async def configure(self, key: str, value: str): - """Configure charger by setting the key to target value. + for c in range(2, int(n_connectors) + 1): + val = _try_ha_unit((c, measurand)) + if val is not None: + return val - First the configuration key is read using GetConfiguration. The key's - value is compared with the target value. If the key is already set to - the correct value nothing is done. + return None - If the key has a different value a ChangeConfiguration request is issued. + def get_extra_attr(self, id: str, measurand: str, connector_id: int | None = None): + """Return extra attributes for given measurand.""" + cp_id, m, cp, n_connectors = self._get_metrics(id) - """ - req = call.GetConfigurationPayload(key=[key]) + if cp is None: + return None - resp = await self.call(req) + def _try_extra(key): + with contextlib.suppress(Exception): + val = m[key].extra_attr + if isinstance(val, dict) and not val: + return None + return val + return None - if resp.unknown_key is not None: - if key in resp.unknown_key: - _LOGGER.warning("%s is unknown (not supported)", key) - return + if connector_id is not None: + conn = self._norm_conn(connector_id) + return _try_extra((conn, measurand)) - for key_value in resp.configuration_key: - # If the key already has the targeted value we don't need to set - # it. - if key_value[om.key.value] == key and key_value[om.value.value] == value: - return + val = _try_extra((0, measurand)) + if val is not None: + return val - if key_value.get(om.readonly.name, False): - _LOGGER.warning("%s is a read only setting", key) - await self.notify_ha(f"Warning: {key} is read-only") + with contextlib.suppress(Exception): + val = m[measurand].extra_attr + if isinstance(val, dict) and not val: + val = None + if val is not None: + return val - req = call.ChangeConfigurationPayload(key=key, value=value) + if n_connectors >= 1: + val = _try_extra((1, measurand)) + if val is not None: + return val - resp = await self.call(req) + for c in range(2, int(n_connectors) + 1): + val = _try_extra((c, measurand)) + if val is not None: + return val - if resp.status in [ - ConfigurationStatus.rejected, - ConfigurationStatus.not_supported, - ]: - _LOGGER.warning("%s while setting %s to %s", resp.status, key, value) - await self.notify_ha( - f"Warning: charger reported {resp.status} while setting {key}={value}" - ) + return None - if resp.status == ConfigurationStatus.reboot_required: - self._requires_reboot = True - await self.notify_ha(f"A reboot is required to apply {key}={value}") + def get_available(self, id: str, connector_id: int | None = None): + """Return whether the charger (or a specific connector) is available.""" + cp_id, m, cp, n_connectors = self._get_metrics(id) - async def _get_specific_response(self, unique_id, timeout): - # The ocpp library silences CallErrors by default. See - # https://github.com/mobilityhouse/ocpp/issues/104. - # This code 'unsilences' CallErrors by raising them as exception - # upon receiving. - resp = await super()._get_specific_response(unique_id, timeout) + if cp is None: + return None - if isinstance(resp, CallError): - raise resp.to_exception() + if self._norm_conn(connector_id) == 0: + return cp.status == STATE_OK - return resp + status_val = None + with contextlib.suppress(Exception): + status_val = m[ + (self._norm_conn(connector_id), cstat.status_connector.value) + ].value - async def monitor_connection(self): - """Monitor the connection, by measuring the connection latency.""" - self._metrics[cstat.latency_ping.value].unit = "ms" - self._metrics[cstat.latency_pong.value].unit = "ms" - connection = self._connection - timeout_counter = 0 - while connection.open: + if not status_val: try: - await asyncio.sleep(self.central.websocket_ping_interval) - time0 = time.perf_counter() - latency_ping = self.central.websocket_ping_timeout * 1000 - pong_waiter = await asyncio.wait_for( - connection.ping(), timeout=self.central.websocket_ping_timeout - ) - time1 = time.perf_counter() - latency_ping = round(time1 - time0, 3) * 1000 - latency_pong = self.central.websocket_ping_timeout * 1000 - await asyncio.wait_for( - pong_waiter, timeout=self.central.websocket_ping_timeout - ) - timeout_counter = 0 - time2 = time.perf_counter() - latency_pong = round(time2 - time1, 3) * 1000 - _LOGGER.debug( - f"Connection latency from '{self.central.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", - ) - self._metrics[cstat.latency_ping.value].value = latency_ping - self._metrics[cstat.latency_pong.value].value = latency_pong + flat = m[cstat.status_connector.value] + if hasattr(flat, "extra_attr"): + status_val = flat.extra_attr.get( + self._norm_conn(connector_id) + ) or getattr(flat, "value", None) + except Exception: + pass + + if not status_val: + return cp.status == STATE_OK + + ok_statuses_norm = { + "available", + "preparing", + "charging", + "suspendedev", + "suspendedevse", + "finishing", + "occupied", + "reserved", + "unavailable", # do NOT make HA entities unavailable for this OCPP state + } - except asyncio.TimeoutError as timeout_exception: - _LOGGER.debug( - f"Connection latency from '{self.central.csid}' to '{self.id}': ping={latency_ping} ms, pong={latency_pong} ms", - ) - self._metrics[cstat.latency_ping.value].value = latency_ping - self._metrics[cstat.latency_pong.value].value = latency_pong - timeout_counter += 1 - if timeout_counter > self.central.websocket_ping_tries: - _LOGGER.debug( - f"Connection to '{self.id}' timed out after '{self.central.websocket_ping_tries}' ping tries", - ) - raise timeout_exception - else: - continue + ret = _norm(status_val) in ok_statuses_norm + # If backend/WS is down, entity should be unavailable regardless. + return ret and (cp.status == STATE_OK) - async def _handle_call(self, msg): - try: - await super()._handle_call(msg) - except NotImplementedError as e: - response = msg.create_call_error(e).to_json() - await self._send(response) - - async def start(self): - """Start charge point.""" - await self.run( - [super().start(), self.post_connect(), self.monitor_connection()] - ) + def get_supported_features(self, id: str): + """Return what profiles the charger supports.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) - async def run(self, tasks): - """Run a specified list of tasks.""" - self.tasks = [asyncio.ensure_future(task) for task in tasks] - try: - await asyncio.gather(*self.tasks) - except asyncio.TimeoutError: - pass - except websockets.exceptions.WebSocketException as websocket_exception: - _LOGGER.debug(f"Connection closed to '{self.id}': {websocket_exception}") - except Exception as other_exception: - _LOGGER.error( - f"Unexpected exception in connection to '{self.id}': '{other_exception}'", - exc_info=True, - ) - finally: - await self.stop() - - async def stop(self): - """Close connection and cancel ongoing tasks.""" - self.status = STATE_UNAVAILABLE - if self._connection.open: - _LOGGER.debug(f"Closing websocket to '{self.id}'") - await self._connection.close() - for task in self.tasks: - task.cancel() - - async def reconnect(self, connection: websockets.server.WebSocketServerProtocol): - """Reconnect charge point.""" - _LOGGER.debug(f"Reconnect websocket to {self.id}") - - await self.stop() - self.status = STATE_OK - self._connection = connection - self._metrics[cstat.reconnects.value].value += 1 - if self.post_connect_success is True: - await self.run([super().start(), self.monitor_connection()]) - else: - await self.run( - [super().start(), self.post_connect(), self.monitor_connection()] - ) + if cp_id in self.charge_points: + return self.charge_points[cp_id].supported_features + return 0 - async def async_update_device_info(self, boot_info: dict): - """Update device info asynchronuously.""" + async def set_max_charge_rate_amps( + self, id: str, value: float, connector_id: int = 0 + ): + """Set the maximum charge rate in amps.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) - _LOGGER.debug("Updating device info %s: %s", self.central.cpid, boot_info) - identifiers = { - (DOMAIN, self.central.cpid), - (DOMAIN, self.id), - } - serial = boot_info.get(om.charge_point_serial_number.name, None) - if serial is not None: - identifiers.add((DOMAIN, serial)) - - registry = device_registry.async_get(self.hass) - registry.async_get_or_create( - config_entry_id=self.entry.entry_id, - identifiers=identifiers, - name=self.central.cpid, - manufacturer=boot_info.get(om.charge_point_vendor.name, None), - model=boot_info.get(om.charge_point_model.name, None), - suggested_area="Garage", - sw_version=boot_info.get(om.firmware_version.name, None), - ) + if cp_id in self.charge_points: + return await self.charge_points[cp_id].set_charge_rate( + limit_amps=value, conn_id=connector_id + ) + return False - def process_phases(self, data): - """Process phase data from meter values payload.""" - - def average_of_nonzero(values): - nonzero_values: list = [v for v in values if float(v) != 0.0] - nof_values: int = len(nonzero_values) - average = sum(nonzero_values) / nof_values if nof_values > 0 else 0 - return average - - measurand_data = {} - for item in data: - # create ordered Dict for each measurand, eg {"voltage":{"unit":"V","L1-N":"230"...}} - measurand = item.get(om.measurand.value, None) - phase = item.get(om.phase.value, None) - value = item.get(om.value.value, None) - unit = item.get(om.unit.value, None) - context = item.get(om.context.value, None) - if measurand is not None and phase is not None and unit is not None: - if measurand not in measurand_data: - measurand_data[measurand] = {} - measurand_data[measurand][om.unit.value] = unit - measurand_data[measurand][phase] = float(value) - self._metrics[measurand].unit = unit - self._metrics[measurand].extra_attr[om.unit.value] = unit - self._metrics[measurand].extra_attr[phase] = float(value) - self._metrics[measurand].extra_attr[om.context.value] = context - - line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value] - line_to_neutral_phases = [Phase.l1_n.value, Phase.l2_n.value, Phase.l3_n.value] - line_to_line_phases = [Phase.l1_l2.value, Phase.l2_l3.value, Phase.l3_l1.value] - - for metric, phase_info in measurand_data.items(): - metric_value = None - if metric in [Measurand.voltage.value]: - if not phase_info.keys().isdisjoint(line_to_neutral_phases): - # Line to neutral voltages are averaged - metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_to_neutral_phases] - ) - elif not phase_info.keys().isdisjoint(line_to_line_phases): - # Line to line voltages are averaged and converted to line to neutral - metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_to_line_phases] - ) / sqrt(3) - elif not phase_info.keys().isdisjoint(line_phases): - # Workaround for chargers that don't follow engineering convention - # Assumes voltages are line to neutral - metric_value = average_of_nonzero( - [phase_info.get(phase, 0) for phase in line_phases] - ) - else: - if not phase_info.keys().isdisjoint(line_phases): - metric_value = sum( - phase_info.get(phase, 0) for phase in line_phases - ) - elif not phase_info.keys().isdisjoint(line_to_neutral_phases): - # Workaround for some chargers that erroneously use line to neutral for current - metric_value = sum( - phase_info.get(phase, 0) for phase in line_to_neutral_phases - ) + async def set_charger_state( + self, + id: str, + service_name: str, + state: bool = True, + connector_id: int | None = 1, + ): + """Carry out requested service/state change on connected charger.""" + # allow id to be either cpid or cp_id + cp_id = self.cpids.get(id, id) - if metric_value is not None: - metric_unit = phase_info.get(om.unit.value) - _LOGGER.debug( - "process_phases: metric: %s, phase_info: %s value: %f unit :%s", - metric, - phase_info, - metric_value, - metric_unit, - ) - if metric_unit == DEFAULT_POWER_UNIT: - self._metrics[metric].value = float(metric_value) / 1000 - self._metrics[metric].unit = HA_POWER_UNIT - elif metric_unit == DEFAULT_ENERGY_UNIT: - self._metrics[metric].value = float(metric_value) / 1000 - self._metrics[metric].unit = HA_ENERGY_UNIT - else: - self._metrics[metric].value = float(metric_value) - self._metrics[metric].unit = metric_unit - - @on(Action.MeterValues) - def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): - """Request handler for MeterValues Calls.""" - - transaction_id: int = kwargs.get(om.transaction_id.name, 0) - - # If missing meter_start or active_transaction_id try to restore from HA states. If HA - # does not have values either, generate new ones. - if self._metrics[csess.meter_start.value].value is None: - value = self.get_ha_metric(csess.meter_start.value) - if value is None: - value = self._metrics[DEFAULT_MEASURAND].value - else: - value = float(value) - _LOGGER.debug( - f"{csess.meter_start.value} was None, restored value={value} from HA." + resp = False + if cp_id in self.charge_points: + if service_name == csvcs.service_availability.name: + resp = await self.charge_points[cp_id].set_availability( + state, connector_id=connector_id ) - self._metrics[csess.meter_start.value].value = value - if self._metrics[csess.transaction_id.value].value is None: - value = self.get_ha_metric(csess.transaction_id.value) - if value is None: - value = kwargs.get(om.transaction_id.name) - else: - value = int(value) - _LOGGER.debug( - f"{csess.transaction_id.value} was None, restored value={value} from HA." + if service_name == csvcs.service_charge_start.name: + resp = await self.charge_points[cp_id].start_transaction( + connector_id=connector_id ) - self._metrics[csess.transaction_id.value].value = value - self.active_transaction_id = value - - transaction_matches: bool = False - # match is also false if no transaction is in progress ie active_transaction_id==transaction_id==0 - if transaction_id == self.active_transaction_id and transaction_id != 0: - transaction_matches = True - elif transaction_id != 0: - _LOGGER.warning("Unknown transaction detected with id=%i", transaction_id) - - for bucket in meter_value: - unprocessed = bucket[om.sampled_value.name] - processed_keys = [] - for idx, sampled_value in enumerate(bucket[om.sampled_value.name]): - measurand = sampled_value.get(om.measurand.value, None) - value = sampled_value.get(om.value.value, None) - unit = sampled_value.get(om.unit.value, None) - phase = sampled_value.get(om.phase.value, None) - location = sampled_value.get(om.location.value, None) - context = sampled_value.get(om.context.value, None) - - if len(sampled_value.keys()) == 1: # Backwards compatibility - measurand = DEFAULT_MEASURAND - unit = DEFAULT_ENERGY_UNIT - - if phase is None: - if unit == DEFAULT_POWER_UNIT: - self._metrics[measurand].value = float(value) / 1000 - self._metrics[measurand].unit = HA_POWER_UNIT - elif unit == DEFAULT_ENERGY_UNIT or "Energy" in str(measurand): - if self._metrics[csess.meter_start.value].value == 0: - # Charger reports Energy.Active.Import.Register directly as Session energy for transactions - self._charger_reports_session_energy = True - if ( - transaction_matches - and self._charger_reports_session_energy - and measurand == DEFAULT_MEASURAND - and connector_id - ): - self._metrics[csess.session_energy.value].value = ( - float(value) / 1000 - ) - self._metrics[csess.session_energy.value].extra_attr[ - cstat.id_tag.name - ] = self._metrics[cstat.id_tag.value].value - elif ( - transaction_matches or self._charger_reports_session_energy - ): - self._metrics[measurand].value = float(value) / 1000 - self._metrics[measurand].unit = HA_ENERGY_UNIT - else: - self._metrics[measurand].value = float(value) - self._metrics[measurand].unit = unit - if location is not None: - self._metrics[measurand].extra_attr[ - om.location.value - ] = location - if context is not None: - self._metrics[measurand].extra_attr[om.context.value] = context - processed_keys.append(idx) - for idx in sorted(processed_keys, reverse=True): - unprocessed.pop(idx) - # _LOGGER.debug("Meter data not yet processed: %s", unprocessed) - if unprocessed is not None: - self.process_phases(unprocessed) - if transaction_matches: - self._metrics[csess.session_time.value].value = round( - ( - int(time.time()) - - float(self._metrics[csess.transaction_id.value].value) + if service_name == csvcs.service_charge_stop.name: + resp = await self.charge_points[cp_id].stop_transaction( + connector_id=connector_id ) - / 60 - ) - self._metrics[csess.session_time.value].unit = "min" - if ( - self._metrics[csess.meter_start.value].value is not None - and not self._charger_reports_session_energy - ): - self._metrics[csess.session_energy.value].value = float( - self._metrics[DEFAULT_MEASURAND].value or 0 - ) - float(self._metrics[csess.meter_start.value].value) - self._metrics[csess.session_energy.value].extra_attr[ - cstat.id_tag.name - ] = self._metrics[cstat.id_tag.value].value - self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.MeterValuesPayload() - - @on(Action.BootNotification) - def on_boot_notification(self, **kwargs): - """Handle a boot notification.""" - resp = call_result.BootNotificationPayload( - current_time=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - interval=3600, - status=RegistrationStatus.accepted.value, - ) - self.received_boot_notification = True - _LOGGER.debug("Received boot notification for %s: %s", self.id, kwargs) - # update metrics - self._metrics[cdet.model.value].value = kwargs.get( - om.charge_point_model.name, None - ) - self._metrics[cdet.vendor.value].value = kwargs.get( - om.charge_point_vendor.name, None - ) - self._metrics[cdet.firmware_version.value].value = kwargs.get( - om.firmware_version.name, None - ) - self._metrics[cdet.serial.value].value = kwargs.get( - om.charge_point_serial_number.name, None - ) - - self.hass.async_create_task(self.async_update_device_info(kwargs)) - self.hass.async_create_task(self.central.update(self.central.cpid)) - if self.triggered_boot_notification is False: - self.hass.async_create_task(self.notify_ha(f"Charger {self.id} rebooted")) - self.hass.async_create_task(self.post_connect()) + if service_name == csvcs.service_reset.name: + resp = await self.charge_points[cp_id].reset() + if service_name == csvcs.service_unlock.name: + resp = await self.charge_points[cp_id].unlock(connector_id=connector_id) return resp - @on(Action.StatusNotification) - def on_status_notification(self, connector_id, error_code, status, **kwargs): - """Handle a status notification.""" - - if connector_id == 0 or connector_id is None: - self._metrics[cstat.status.value].value = status - self._metrics[cstat.error_code.value].value = error_code - elif connector_id == 1: - self._metrics[cstat.status_connector.value].value = status - self._metrics[cstat.error_code_connector.value].value = error_code - if connector_id >= 1: - self._metrics[cstat.status_connector.value].extra_attr[ - connector_id - ] = status - self._metrics[cstat.error_code_connector.value].extra_attr[ - connector_id - ] = error_code - if ( - status == ChargePointStatus.suspended_ev.value - or status == ChargePointStatus.suspended_evse.value - ): - if Measurand.current_import.value in self._metrics: - self._metrics[Measurand.current_import.value].value = 0 - if Measurand.power_active_import.value in self._metrics: - self._metrics[Measurand.power_active_import.value].value = 0 - if Measurand.power_reactive_import.value in self._metrics: - self._metrics[Measurand.power_reactive_import.value].value = 0 - if Measurand.current_export.value in self._metrics: - self._metrics[Measurand.current_export.value].value = 0 - if Measurand.power_active_export.value in self._metrics: - self._metrics[Measurand.power_active_export.value].value = 0 - if Measurand.power_reactive_export.value in self._metrics: - self._metrics[Measurand.power_reactive_export.value].value = 0 - self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.StatusNotificationPayload() - - @on(Action.FirmwareStatusNotification) - def on_firmware_status(self, status, **kwargs): - """Handle firmware status notification.""" - self._metrics[cstat.firmware_status.value].value = status - self.hass.async_create_task(self.central.update(self.central.cpid)) - self.hass.async_create_task(self.notify_ha(f"Firmware upload status: {status}")) - return call_result.FirmwareStatusNotificationPayload() - - @on(Action.DiagnosticsStatusNotification) - def on_diagnostics_status(self, status, **kwargs): - """Handle diagnostics status notification.""" - _LOGGER.info("Diagnostics upload status: %s", status) - self.hass.async_create_task( - self.notify_ha(f"Diagnostics upload status: {status}") - ) - return call_result.DiagnosticsStatusNotificationPayload() - - @on(Action.SecurityEventNotification) - def on_security_event(self, type, timestamp, **kwargs): - """Handle security event notification.""" - _LOGGER.info( - "Security event notification received: %s at %s [techinfo: %s]", - type, - timestamp, - kwargs.get(om.tech_info.name, "none"), - ) - self.hass.async_create_task( - self.notify_ha(f"Security event notification received: {type}") - ) - return call_result.SecurityEventNotificationPayload() - - def get_authorization_status(self, id_tag): - """Get the authorization status for an id_tag.""" - # get the domain wide configuration - config = self.hass.data[DOMAIN].get(CONFIG, {}) - # get the default authorization status. Use accept if not configured - default_auth_status = config.get( - CONF_DEFAULT_AUTH_STATUS, AuthorizationStatus.accepted.value - ) - # get the authorization list - auth_list = config.get(CONF_AUTH_LIST, {}) - # search for the entry, based on the id_tag - auth_status = None - for auth_entry in auth_list: - id_entry = auth_entry.get(CONF_ID_TAG, None) - if id_tag == id_entry: - # get the authorization status, use the default if not configured - auth_status = auth_entry.get(CONF_AUTH_STATUS, default_auth_status) - _LOGGER.debug( - f"id_tag='{id_tag}' found in auth_list, authorization_status='{auth_status}'" - ) - break - - if auth_status is None: - auth_status = default_auth_status - _LOGGER.debug( - f"id_tag='{id_tag}' not found in auth_list, default authorization_status='{auth_status}'" - ) - return auth_status - - @on(Action.Authorize) - def on_authorize(self, id_tag, **kwargs): - """Handle an Authorization request.""" - self._metrics[cstat.id_tag.value].value = id_tag - auth_status = self.get_authorization_status(id_tag) - return call_result.AuthorizePayload(id_tag_info={om.status.value: auth_status}) - - @on(Action.StartTransaction) - def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): - """Handle a Start Transaction request.""" - - auth_status = self.get_authorization_status(id_tag) - if auth_status == AuthorizationStatus.accepted.value: - self.active_transaction_id = int(time.time()) - self._metrics[cstat.id_tag.value].value = id_tag - self._metrics[cstat.stop_reason.value].value = "" - self._metrics[csess.transaction_id.value].value = self.active_transaction_id - self._metrics[csess.meter_start.value].value = int(meter_start) / 1000 - result = call_result.StartTransactionPayload( - id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, - transaction_id=self.active_transaction_id, - ) - else: - result = call_result.StartTransactionPayload( - id_tag_info={om.status.value: auth_status}, transaction_id=0 - ) - self.hass.async_create_task(self.central.update(self.central.cpid)) - return result - - @on(Action.StopTransaction) - def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): - """Stop the current transaction.""" - - if transaction_id != self.active_transaction_id: - _LOGGER.error( - "Stop transaction received for unknown transaction id=%i", - transaction_id, - ) - self.active_transaction_id = 0 - self._metrics[cstat.stop_reason.value].value = kwargs.get(om.reason.name, None) - if ( - self._metrics[csess.meter_start.value].value is not None - and not self._charger_reports_session_energy - ): - self._metrics[csess.session_energy.value].value = int( - meter_stop - ) / 1000 - float(self._metrics[csess.meter_start.value].value) - if Measurand.current_import.value in self._metrics: - self._metrics[Measurand.current_import.value].value = 0 - if Measurand.power_active_import.value in self._metrics: - self._metrics[Measurand.power_active_import.value].value = 0 - if Measurand.power_reactive_import.value in self._metrics: - self._metrics[Measurand.power_reactive_import.value].value = 0 - if Measurand.current_export.value in self._metrics: - self._metrics[Measurand.current_export.value].value = 0 - if Measurand.power_active_export.value in self._metrics: - self._metrics[Measurand.power_active_export.value].value = 0 - if Measurand.power_reactive_export.value in self._metrics: - self._metrics[Measurand.power_reactive_export.value].value = 0 - self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.StopTransactionPayload( - id_tag_info={om.status.value: AuthorizationStatus.accepted.value} - ) - - @on(Action.DataTransfer) - def on_data_transfer(self, vendor_id, **kwargs): - """Handle a Data transfer request.""" - _LOGGER.debug("Data transfer received from %s: %s", self.id, kwargs) - self._metrics[cdet.data_transfer.value].value = datetime.now(tz=timezone.utc) - self._metrics[cdet.data_transfer.value].extra_attr = {vendor_id: kwargs} - return call_result.DataTransferPayload(status=DataTransferStatus.accepted.value) - - @on(Action.Heartbeat) - def on_heartbeat(self, **kwargs): - """Handle a Heartbeat.""" - now = datetime.now(tz=timezone.utc) - self._metrics[cstat.heartbeat.value].value = now - self.hass.async_create_task(self.central.update(self.central.cpid)) - return call_result.HeartbeatPayload( - current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ") - ) - - @property - def supported_features(self) -> int: - """Flag of Ocpp features that are supported.""" - return self._attr_supported_features - - def get_metric(self, measurand: str): - """Return last known value for given measurand.""" - return self._metrics[measurand].value - - def get_ha_metric(self, measurand: str): - """Return last known value in HA for given measurand.""" - entity_id = "sensor." + "_".join( - [self.central.cpid.lower(), measurand.lower().replace(".", "_")] - ) - try: - value = self.hass.states.get(entity_id).state - except Exception as e: - _LOGGER.debug(f"An error occurred when getting entity state from HA: {e}") - return None - if value == STATE_UNAVAILABLE or value == STATE_UNKNOWN: - return None - return value - - def get_extra_attr(self, measurand: str): - """Return last known extra attributes for given measurand.""" - return self._metrics[measurand].extra_attr + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, self.id)}, + } - def get_unit(self, measurand: str): - """Return unit of given measurand.""" - return self._metrics[measurand].unit + def check_charger_available(func): + """Check charger is available before executing service with Decorator.""" - def get_ha_unit(self, measurand: str): - """Return home assistant unit of given measurand.""" - return self._metrics[measurand].ha_unit - - async def notify_ha(self, msg: str, title: str = "Ocpp integration"): - """Notify user via HA web frontend.""" - await self.hass.services.async_call( - PN_DOMAIN, - "create", - service_data={ - "title": title, - "message": msg, - }, - blocking=False, - ) - return True - - -class Metric: - """Metric class.""" - - def __init__(self, value, unit): - """Initialize a Metric.""" - self._value = value - self._unit = unit - self._extra_attr = {} - - @property - def value(self): - """Get the value of the metric.""" - return self._value - - @value.setter - def value(self, value): - """Set the value of the metric.""" - self._value = value - - @property - def unit(self): - """Get the unit of the metric.""" - return self._unit - - @unit.setter - def unit(self, unit: str): - """Set the unit of the metric.""" - self._unit = unit - - @property - def ha_unit(self): - """Get the home assistant unit of the metric.""" - return UNITS_OCCP_TO_HA.get(self._unit, self._unit) - - @property - def extra_attr(self): - """Get the extra attributes of the metric.""" - return self._extra_attr - - @extra_attr.setter - def extra_attr(self, extra_attr: dict): - """Set the unit of the metric.""" - self._extra_attr = extra_attr + async def wrapper(self, call, *args, **kwargs): + try: + cp_id = self.cpids.get(call.data["devid"], call.data["devid"]) + cp = self.charge_points[cp_id] + except KeyError: + cp = list(self.charge_points.values())[0] + if cp.status == STATE_UNAVAILABLE: + _LOGGER.warning(f"{cp_id}: charger is currently unavailable") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unavailable", + translation_placeholders={"message": cp_id}, + ) + return await func(self, call, cp, *args, **kwargs) + + return wrapper + + # Define custom service handles for charge point + @check_charger_available + async def handle_trigger_custom_message(self, call, cp): + """Handle the message request with a custom message.""" + requested_message = call.data.get("requested_message") + await cp.trigger_custom_message(requested_message) + + @check_charger_available + async def handle_clear_profile(self, call, cp): + """Handle the clear profile service call.""" + await cp.clear_profile() + + @check_charger_available + async def handle_update_firmware(self, call, cp): + """Handle the firmware update service call.""" + url = call.data.get("firmware_url") + delay = int(call.data.get("delay_hours", 0)) + await cp.update_firmware(url, delay) + + @check_charger_available + async def handle_get_diagnostics(self, call, cp): + """Handle the get get diagnostics service call.""" + url = call.data.get("upload_url") + await cp.get_diagnostics(url) + + @check_charger_available + async def handle_data_transfer(self, call, cp): + """Handle the data transfer service call.""" + vendor = call.data.get("vendor_id") + message = call.data.get("message_id", "") + data = call.data.get("data", "") + await cp.data_transfer(vendor, message, data) + + @check_charger_available + async def handle_set_charge_rate(self, call, cp): + """Handle the data transfer service call.""" + amps = call.data.get("limit_amps", None) + watts = call.data.get("limit_watts", None) + id = call.data.get("conn_id", 0) + custom_profile = call.data.get("custom_profile", None) + if custom_profile is not None: + if type(custom_profile) is str: + custom_profile = custom_profile.replace("'", '"') + custom_profile = json.loads(custom_profile) + await cp.set_charge_rate(profile=custom_profile, conn_id=id) + elif watts is not None: + await cp.set_charge_rate(limit_watts=watts, conn_id=id) + elif amps is not None: + await cp.set_charge_rate(limit_amps=amps, conn_id=id) + + @check_charger_available + async def handle_configure(self, call, cp) -> ServiceResponse: + """Handle the configure service call.""" + key = call.data.get("ocpp_key") + value = call.data.get("value") + result: SetVariableResult = await cp.configure(key, value) + return {"reboot_required": result == SetVariableResult.reboot_required} + + @check_charger_available + async def handle_get_configuration(self, call, cp) -> ServiceResponse: + """Handle the get configuration service call.""" + key = call.data.get("ocpp_key", "") + value = await cp.get_configuration(key) + return {"value": value} diff --git a/custom_components/ocpp/button.py b/custom_components/ocpp/button.py index 043e4d85..6722a3fc 100644 --- a/custom_components/ocpp/button.py +++ b/custom_components/ocpp/button.py @@ -1,4 +1,5 @@ """Button platform for ocpp.""" + from __future__ import annotations from dataclasses import dataclass @@ -10,10 +11,21 @@ ButtonEntity, ButtonEntityDescription, ) +from homeassistant.core import callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.util import slugify from .api import CentralSystem -from .const import CONF_CPID, DEFAULT_CPID, DOMAIN +from .const import ( + CONF_CPID, + CONF_CPIDS, + CONF_NUM_CONNECTORS, + DATA_UPDATED, + DEFAULT_NUM_CONNECTORS, + DOMAIN, +) from .enums import HAChargerServices @@ -22,6 +34,7 @@ class OcppButtonDescription(ButtonEntityDescription): """Class to describe a Button entity.""" press_action: str | None = None + per_connector: bool = False BUTTONS: Final = [ @@ -31,27 +44,83 @@ class OcppButtonDescription(ButtonEntityDescription): device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=HAChargerServices.service_reset.name, + per_connector=False, ), OcppButtonDescription( key="unlock", name="Unlock", - device_class=ButtonDeviceClass.UPDATE, + device_class=None, entity_category=EntityCategory.CONFIG, press_action=HAChargerServices.service_unlock.name, + per_connector=True, ), ] async def async_setup_entry(hass, entry, async_add_devices): """Configure the Button platform.""" - - central_system = hass.data[DOMAIN][entry.entry_id] - cp_id = entry.data.get(CONF_CPID, DEFAULT_CPID) - - entities = [] - - for ent in BUTTONS: - entities.append(ChargePointButton(central_system, cp_id, ent)) + central_system: CentralSystem = hass.data[DOMAIN][entry.entry_id] + entities: list[ChargePointButton] = [] + ent_reg = er.async_get(hass) + + for charger in entry.data[CONF_CPIDS]: + cp_id_settings = list(charger.values())[0] + cpid = cp_id_settings[CONF_CPID] + + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break + + if num_connectors > 1: + for desc in BUTTONS: + if not desc.per_connector: + continue + uid_flat = ".".join([BUTTON_DOMAIN, DOMAIN, cpid, desc.key]) + stale_eid = ent_reg.async_get_entity_id(BUTTON_DOMAIN, DOMAIN, uid_flat) + if stale_eid: + ent_reg.async_remove(stale_eid) + + for desc in BUTTONS: + if desc.per_connector: + if num_connectors > 1: + for connector_id in range(1, num_connectors + 1): + entities.append( + ChargePointButton( + central_system=central_system, + cpid=cpid, + description=desc, + connector_id=connector_id, + op_connector_id=connector_id, + ) + ) + else: + entities.append( + ChargePointButton( + central_system=central_system, + cpid=cpid, + description=desc, + connector_id=None, + op_connector_id=1, + ) + ) + else: + entities.append( + ChargePointButton( + central_system=central_system, + cpid=cpid, + description=desc, + connector_id=None, + op_connector_id=None, + ) + ) async_add_devices(entities, False) @@ -59,35 +128,79 @@ async def async_setup_entry(hass, entry, async_add_devices): class ChargePointButton(ButtonEntity): """Individual button for charge point.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppButtonDescription def __init__( self, central_system: CentralSystem, - cp_id: str, + cpid: str, description: OcppButtonDescription, + connector_id: int | None = None, + op_connector_id: int | None = None, ): """Instantiate instance of a ChargePointButton.""" - self.cp_id = cp_id + self.cpid = cpid self.central_system = central_system self.entity_description = description - self._attr_unique_id = ".".join( - [BUTTON_DOMAIN, DOMAIN, self.cp_id, self.entity_description.key] - ) + self.connector_id = connector_id + self._op_connector_id = op_connector_id + parts = [BUTTON_DOMAIN, DOMAIN, cpid, description.key] + if self.connector_id: + parts.insert(3, f"conn{self.connector_id}") + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cp_id)}, - via_device=(DOMAIN, self.central_system.id), - ) + if self.connector_id: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) + if self.connector_id is not None: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{BUTTON_DOMAIN}.{slugify(object_id)}" @property def available(self) -> bool: """Return charger availability.""" - return self.central_system.get_available(self.cp_id) # type: ignore [no-any-return] + return self.central_system.get_available(self.cpid, self._op_connector_id) async def async_press(self) -> None: """Triggers the charger press action service.""" await self.central_system.set_charger_state( - self.cp_id, self.entity_description.press_action + self.cpid, + self.entity_description.press_action, + connector_id=self._op_connector_id, ) + + async def async_added_to_hass(self) -> None: + """Handle entity added to hass.""" + await super().async_added_to_hass() + + @callback + def _maybe_update(*args): + """Handle dispatcher updates.""" + active_lookup = None + if args: + try: + active_lookup = set(args[0]) + except Exception: + active_lookup = None + + if active_lookup is None or self.entity_id in active_lookup: + self.async_schedule_update_ha_state(True) + + # Register dispatcher listener + self.async_on_remove( + async_dispatcher_connect(self.hass, DATA_UPDATED, _maybe_update) + ) + + # Ensure button is shown as available after reload + self.async_schedule_update_ha_state(True) diff --git a/custom_components/ocpp/chargepoint.py b/custom_components/ocpp/chargepoint.py new file mode 100644 index 00000000..ad67edba --- /dev/null +++ b/custom_components/ocpp/chargepoint.py @@ -0,0 +1,1081 @@ +"""Common classes for charge points of all OCPP versions.""" + +import asyncio +from collections import defaultdict +from collections.abc import MutableMapping +from dataclasses import dataclass +from enum import Enum +import logging +from math import sqrt +import secrets +import string +import time + +from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.const import STATE_OK, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import UnitOfTime +from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers.dispatcher import async_dispatcher_send +from websockets.asyncio.server import ServerConnection +from websockets.exceptions import WebSocketException +from websockets.protocol import State + +from ocpp.charge_point import ChargePoint as cp +from ocpp.v16 import call as callv16 +from ocpp.v16 import call_result as call_resultv16 +from ocpp.v16.enums import ( + AuthorizationStatus, + Measurand, + Phase, + ReadingContext, +) +from ocpp.v201 import call as callv201 +from ocpp.v201 import call_result as call_resultv201 +from ocpp.messages import CallError +from ocpp.exceptions import NotImplementedError + +from .enums import ( + HAChargerDetails as cdet, + HAChargerSession as csess, + HAChargerStatuses as cstat, + OcppMisc as om, + Profiles as prof, +) + +from .const import ( + CentralSystemSettings, + ChargerSystemSettings, + CONF_AUTH_LIST, + CONF_AUTH_STATUS, + CONF_DEFAULT_AUTH_STATUS, + CONF_ID_TAG, + CONF_MONITORED_VARIABLES, + CONF_NUM_CONNECTORS, + CONF_CPIDS, + CONFIG, + DATA_UPDATED, + DEFAULT_ENERGY_UNIT, + DEFAULT_NUM_CONNECTORS, + DEFAULT_POWER_UNIT, + DEFAULT_MEASURAND, + DOMAIN, + HA_ENERGY_UNIT, + HA_POWER_UNIT, + UNITS_OCCP_TO_HA, +) + +TIME_MINUTES = UnitOfTime.MINUTES +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class Metric: + """Metric class.""" + + def __init__(self, value, unit): + """Initialize a Metric.""" + self._value = value + self._unit = unit + self._extra_attr = {} + + @property + def value(self): + """Get the value of the metric.""" + return self._value + + @value.setter + def value(self, value): + """Set the value of the metric.""" + self._value = value + + @property + def unit(self): + """Get the unit of the metric.""" + return self._unit + + @unit.setter + def unit(self, unit: str): + """Set the unit of the metric.""" + self._unit = unit + + @property + def ha_unit(self): + """Get the home assistant unit of the metric.""" + return UNITS_OCCP_TO_HA.get(self._unit, self._unit) + + @property + def extra_attr(self): + """Get the extra attributes of the metric.""" + return self._extra_attr + + @extra_attr.setter + def extra_attr(self, extra_attr: dict): + """Set the unit of the metric.""" + self._extra_attr = extra_attr + + +class _ConnectorAwareMetrics(MutableMapping): + """Backwards compatible mapping for metrics. + + - m["Power.Active.Import"] -> Metric for connector 0 (flat access) + - m[(2, "Power.Active.Import")] -> Metric for connector 2 (per connector) + - m[2] -> dict[str -> Metric] for connector 2 + + Iteration, len, keys(), values(), items() operate on connector 0 (flat view). + """ + + def __init__(self): + self._by_conn = defaultdict(lambda: defaultdict(lambda: Metric(None, None))) + + def __getitem__(self, key): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + return self._by_conn[conn][meas] + if isinstance(key, int): + return self._by_conn[key] + return self._by_conn[0][key] + + def __setitem__(self, key, value): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + if not isinstance(value, Metric): + raise TypeError("Metric assignment must be a Metric instance.") + self._by_conn[conn][meas] = value + return + if isinstance(key, int): + if not isinstance(value, dict): + raise TypeError("Connector mapping must be dict[str, Metric].") + self._by_conn[key] = value + return + if not isinstance(value, Metric): + raise TypeError("Metric assignment must be a Metric instance.") + self._by_conn[0][key] = value + + def __delitem__(self, key): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + del self._by_conn[conn][meas] + return + if isinstance(key, int): + del self._by_conn[key] + return + del self._by_conn[0][key] + + def __iter__(self): + return iter(self._by_conn[0]) + + def __len__(self): + return len(self._by_conn[0]) + + def get(self, key, default=None): + if key in self: + return self[key] + return default + + def keys(self): + return self._by_conn[0].keys() + + def values(self): + return self._by_conn[0].values() + + def items(self): + return self._by_conn[0].items() + + def clear(self): + self._by_conn.clear() + + def __contains__(self, key): + if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int): + conn, meas = key + return meas in self._by_conn.get(conn, {}) + if isinstance(key, int): + return key in self._by_conn + return key in self._by_conn[0] + + +class OcppVersion(str, Enum): + """OCPP version choice.""" + + V16 = "1.6" + V201 = "2.0.1" + V21 = "2.1" + + +class SetVariableResult(Enum): + """A response to successful SetVariable call.""" + + accepted = 0 + reboot_required = 1 + + +@dataclass +class MeasurandValue: + """Version-independent representation of a measurand.""" + + measurand: str + value: float + phase: str | None + unit: str | None + context: str | None + location: str | None + + +class ChargePoint(cp): + """Server side representation of a charger.""" + + def __init__( + self, + id, # is charger cp_id not HA cpid + connection, + version: OcppVersion, + hass: HomeAssistant, + entry: ConfigEntry, + central: CentralSystemSettings, + charger: ChargerSystemSettings, + ): + """Instantiate a ChargePoint.""" + + super().__init__(id, connection, 10) + if version == OcppVersion.V16: + self._call = callv16 + self._call_result = call_resultv16 + self._ocpp_version = "1.6" + elif version == OcppVersion.V201: + self._call = callv201 + self._call_result = call_resultv201 + self._ocpp_version = "2.0.1" + elif version == OcppVersion.V21: + self._call = callv201 + self._call_result = call_resultv201 + self._ocpp_version = "2.1" + + for action in self.route_map: + self.route_map[action]["_skip_schema_validation"] = ( + charger.skip_schema_validation + ) + + self.hass = hass + self.entry = entry + self.cs_settings = central + self.settings = charger + self.status = "init" + # Indicates if the charger requires a reboot to apply new + # configuration. + self._requires_reboot = False + self.preparing = asyncio.Event() + self.active_transaction_id: int = 0 + self.triggered_boot_notification = False + self.received_boot_notification = False + self.post_connect_success = False + self.tasks = None + self._charger_reports_session_energy = False + + # Connector-aware, but backwards compatible: + self._metrics: _ConnectorAwareMetrics = _ConnectorAwareMetrics() + + # Init standard metrics for connector 0 + self._metrics[(0, cdet.identifier.value)].value = id + self._metrics[(0, cstat.reconnects.value)].value = 0 + + self._attr_supported_features = prof.NONE + alphabet = string.ascii_uppercase + string.digits + self._remote_id_tag = "".join(secrets.choice(alphabet) for i in range(20)) + self.num_connectors: int = DEFAULT_NUM_CONNECTORS + + def _init_connector_slots(self, conn_id: int) -> None: + """Ensure connector-scoped metrics exist and carry the right units.""" + _ = self._metrics[(conn_id, cstat.status_connector.value)] + _ = self._metrics[(conn_id, cstat.error_code_connector.value)] + _ = self._metrics[(conn_id, csess.transaction_id.value)] + + self._metrics[(conn_id, csess.session_time.value)].unit = TIME_MINUTES + self._metrics[(conn_id, csess.session_energy.value)].unit = HA_ENERGY_UNIT + self._metrics[(conn_id, csess.meter_start.value)].unit = HA_ENERGY_UNIT + + async def get_number_of_connectors(self) -> int: + """Return number of connectors on this charger.""" + return self.num_connectors + + async def get_heartbeat_interval(self): + """Retrieve heartbeat interval from the charger and store it.""" + pass + + async def get_supported_measurands(self) -> str: + """Get comma-separated list of measurands supported by the charger.""" + return "" + + async def set_standard_configuration(self): + """Send configuration values to the charger.""" + pass + + async def get_supported_features(self) -> prof: + """Get features supported by the charger.""" + return prof.NONE + + async def fetch_supported_features(self): + """Get supported features.""" + self._attr_supported_features = await self.get_supported_features() + self._metrics[(0, cdet.features.value)].value = self._attr_supported_features + _LOGGER.debug( + "Feature profiles returned: %s", self._attr_supported_features.labels() + ) + + async def post_connect(self): + """Logic to be executed right after a charger connects.""" + try: + self.status = STATE_OK + await self.fetch_supported_features() + self.num_connectors = await self.get_number_of_connectors() + for conn in range(1, self.num_connectors + 1): + self._init_connector_slots(conn) + self._metrics[(0, cdet.connectors.value)].value = self.num_connectors + await self.get_heartbeat_interval() + + accepted_measurands: str = await self.get_supported_measurands() + updated_entry = {**self.entry.data} + for i in range(len(updated_entry[CONF_CPIDS])): + if self.id in updated_entry[CONF_CPIDS][i]: + s = updated_entry[CONF_CPIDS][i][self.id] + if s.get(CONF_MONITORED_VARIABLES) != accepted_measurands or s.get( + CONF_NUM_CONNECTORS + ) != int(self.num_connectors): + s[CONF_MONITORED_VARIABLES] = accepted_measurands + s[CONF_NUM_CONNECTORS] = int(self.num_connectors) + break + # if an entry differs this will unload/reload and stop/restart the central system/websocket + self.hass.config_entries.async_update_entry(self.entry, data=updated_entry) + + await self.set_standard_configuration() + + self.post_connect_success = True + _LOGGER.debug("'%s' post connection setup completed successfully", self.id) + + # nice to have, but not needed for integration to function + # and can cause issues with some chargers + try: + await self.set_availability() + except asyncio.CancelledError: + raise + except Exception as ex: + _LOGGER.debug("post_connect: set_availability ignored error: %s", ex) + + if prof.REM in self._attr_supported_features: + if self.received_boot_notification is False: + try: + await asyncio.wait_for( + self.trigger_boot_notification(), timeout=3 + ) + except Exception as ex: + _LOGGER.debug("trigger_boot_notification ignored: %s", ex) + try: + await asyncio.wait_for( + self.trigger_status_notification(), timeout=3 + ) + except Exception as ex: + _LOGGER.debug("trigger_status_notification ignored: %s", ex) + + # Ensure HA states are correct immediately after connection + self.hass.async_create_task(self.update(self.settings.cpid)) + + except Exception as e: + _LOGGER.debug("post_connect aborted non-fatally: %s", e) + + async def trigger_boot_notification(self): + """Trigger a boot notification.""" + pass + + async def trigger_status_notification(self): + """Trigger status notifications for all connectors.""" + pass + + async def trigger_custom_message( + self, + requested_message: str = "StatusNotification", + ): + """Trigger message request with a custom message.""" + pass + + async def clear_profile(self): + """Clear all charging profiles.""" + pass + + async def set_charge_rate( + self, + limit_amps: int = 32, + limit_watts: int = 22000, + conn_id: int = 0, + profile: dict | None = None, + ): + """Set a charging profile with defined limit.""" + pass + + async def set_availability(self, state: bool = True) -> bool: + """Change availability.""" + return False + + async def start_transaction(self, connector_id: int = 1) -> bool: + """Remote start a transaction.""" + return False + + async def stop_transaction(self, connector_id: int | None = None) -> bool: + """Request remote stop of current transaction. + + Leaves charger in finishing state until unplugged. + Use reset() to make the charger available again for remote start + """ + return False + + async def reset(self, typ: str | None = None) -> bool: + """Hard reset charger unless soft reset requested.""" + return False + + async def unlock(self, connector_id: int = 1) -> bool: + """Unlock charger if requested.""" + return False + + async def update_firmware(self, firmware_url: str, wait_time: int = 0): + """Update charger with new firmware if available. + + - firmware_url is the http or https url of the new firmware + - wait_time is hours from now to wait before install + """ + pass + + async def get_diagnostics(self, upload_url: str): + """Upload diagnostic data to server from charger.""" + pass + + async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): + """Request vendor specific data transfer from charger.""" + pass + + async def get_configuration(self, key: str = "") -> str | dict | None: + """Get Configuration of charger for supported keys else return None.""" + return None + + async def configure(self, key: str, value: str) -> SetVariableResult | None: + """Configure charger by setting the key to target value.""" + return None + + async def _get_specific_response(self, unique_id, timeout): + # The ocpp library silences CallErrors by default. See + # https://github.com/mobilityhouse/ocpp/issues/104. + # This code 'unsilences' CallErrors by raising them as exception + # upon receiving. + resp = await super()._get_specific_response(unique_id, timeout) + + if isinstance(resp, CallError): + raise resp.to_exception() + + return resp + + async def monitor_connection(self): + """Monitor the connection, by measuring the connection latency.""" + self._metrics[(0, cstat.latency_ping.value)].unit = "ms" + self._metrics[(0, cstat.latency_pong.value)].unit = "ms" + connection = self._connection + timeout_counter = 0 + + # Add backstop to start post connect for non-compliant chargers + # after 10s to allow for when a boot notification has not been received + await asyncio.sleep(10) + if not self.post_connect_success: + self.hass.async_create_task(self.post_connect()) + + while connection.state is State.OPEN: + try: + await asyncio.sleep(self.cs_settings.websocket_ping_interval) + time0 = time.perf_counter() + latency_ping = self.cs_settings.websocket_ping_timeout * 1000 + latency_pong = self.cs_settings.websocket_ping_timeout * 1000 + pong_waiter = await asyncio.wait_for( + connection.ping(), timeout=self.cs_settings.websocket_ping_timeout + ) + time1 = time.perf_counter() + latency_ping = round(time1 - time0, 3) * 1000 + + await asyncio.wait_for( + pong_waiter, timeout=self.cs_settings.websocket_ping_timeout + ) + timeout_counter = 0 + time2 = time.perf_counter() + latency_pong = round(time2 - time1, 3) * 1000 + + _LOGGER.debug( + f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': " + f"ping={latency_ping} ms, pong={latency_pong} ms", + ) + self._metrics[(0, cstat.latency_ping.value)].value = latency_ping + self._metrics[(0, cstat.latency_pong.value)].value = latency_pong + + except TimeoutError as timeout_exception: + timeout_counter += 1 + _LOGGER.debug( + f"Connection latency from '{self.cs_settings.csid}' to '{self.id}': " + f"ping={latency_ping} ms, pong={latency_pong} ms", + ) + self._metrics[(0, cstat.latency_ping.value)].value = latency_ping + self._metrics[(0, cstat.latency_pong.value)].value = latency_pong + + if timeout_counter > self.cs_settings.websocket_ping_tries: + _LOGGER.debug( + f"Connection to '{self.id}' timed out after '{self.cs_settings.websocket_ping_tries}' ping tries", + ) + raise timeout_exception + else: + continue + except Exception as ex: + _LOGGER.debug(f"monitor_connection stopping due to exception: {ex}") + break + + async def _handle_call(self, msg): + try: + await super()._handle_call(msg) + except NotImplementedError as e: + response = msg.create_call_error(e).to_json() + await self._send(response) + + async def start(self): + """Start charge point.""" + await self.run([super().start(), self.monitor_connection()]) + + async def run(self, tasks): + """Run a specified list of tasks.""" + self.tasks = [asyncio.ensure_future(task) for task in tasks] + try: + await asyncio.gather(*self.tasks) + except TimeoutError: + pass + except WebSocketException as websocket_exception: + _LOGGER.debug(f"Connection closed to '{self.id}': {websocket_exception}") + except Exception as other_exception: + _LOGGER.error( + f"Unexpected exception in connection to '{self.id}': '{other_exception}'", + exc_info=True, + ) + finally: + await self.stop() + + async def stop(self): + """Close connection and cancel ongoing tasks.""" + self.status = STATE_UNAVAILABLE + if self._connection.state is State.OPEN: + _LOGGER.debug(f"Closing websocket to '{self.id}'") + await self._connection.close() + for task in self.tasks: + task.cancel() + + async def reconnect(self, connection: ServerConnection): + """Reconnect charge point.""" + _LOGGER.debug(f"Reconnect websocket to {self.id}") + + await self.stop() + self.status = STATE_OK + self._connection = connection + self._metrics[(0, cstat.reconnects.value)].value += 1 + # post connect now handled on receiving boot notification or with backstop in monitor connection + await self.run([super().start(), self.monitor_connection()]) + + async def async_update_device_info( + self, serial: str, vendor: str, model: str, firmware_version: str + ): + """Update device info asynchronously.""" + + self._metrics[(0, cdet.model.value)].value = model + self._metrics[(0, cdet.vendor.value)].value = vendor + self._metrics[(0, cdet.firmware_version.value)].value = firmware_version + self._metrics[(0, cdet.serial.value)].value = serial + + identifiers = {(DOMAIN, self.id), (DOMAIN, self.settings.cpid)} + + registry = device_registry.async_get(self.hass) + registry.async_get_or_create( + config_entry_id=self.entry.entry_id, + identifiers=identifiers, + manufacturer=vendor, + model=model, + sw_version=firmware_version, + ) + + def _register_boot_notification(self): + if self.triggered_boot_notification is False: + self.hass.async_create_task(self.notify_ha(f"Charger {self.id} rebooted")) + if not self.post_connect_success: + self.hass.async_create_task(self.post_connect()) + + async def update(self, cpid: str): + """Update sensors values in HA (charger + connector child devices).""" + er = entity_registry.async_get(self.hass) + dr = device_registry.async_get(self.hass) + + identifiers = {(DOMAIN, cpid), (DOMAIN, self.id)} + root_dev = dr.async_get_device(identifiers) + if root_dev is None: + return + + to_visit: list[str] = [root_dev.id] + visited: set[str] = set() + active_entities: set[str] = set() + + while to_visit: + dev_id = to_visit.pop(0) + if dev_id in visited: + continue + visited.add(dev_id) + + # Collect enabled and currently loaded entities for this device + for ent in entity_registry.async_entries_for_device(er, dev_id): + if getattr(ent, "disabled", False) or getattr(ent, "disabled_by", None): + continue + if self.hass.states.get(ent.entity_id) is None: + continue + active_entities.add(ent.entity_id) + + for dev in dr.devices.values(): + if dev.via_device_id == dev_id and dev.id not in visited: + to_visit.append(dev.id) + + async_dispatcher_send(self.hass, DATA_UPDATED, active_entities) + + def get_authorization_status(self, id_tag): + """Get the authorization status for an id_tag.""" + # authorize if its the tag of this charger used for remote start_transaction + if id_tag == self._remote_id_tag: + return AuthorizationStatus.accepted.value + config = self.hass.data[DOMAIN].get(CONFIG, {}) + # get the default authorization status. Use accept if not configured + default_auth_status = config.get( + CONF_DEFAULT_AUTH_STATUS, AuthorizationStatus.accepted.value + ) + # get the authorization list + auth_list = config.get(CONF_AUTH_LIST, {}) + # search for the entry, based on the id_tag + auth_status = None + for auth_entry in auth_list: + id_entry = auth_entry.get(CONF_ID_TAG, None) + if id_tag == id_entry: + # get the authorization status, use the default if not configured + auth_status = auth_entry.get(CONF_AUTH_STATUS, default_auth_status) + _LOGGER.debug( + f"id_tag='{id_tag}' found in auth_list, authorization_status='{auth_status}'" + ) + break + + if auth_status is None: + auth_status = default_auth_status + _LOGGER.debug( + f"id_tag='{id_tag}' not found in auth_list, default authorization_status='{auth_status}'" + ) + return auth_status + + def process_phases(self, data: list[MeasurandValue], connector_id: int = 0): + """Process per-phase MeterValues and aggregate them into per-connector metrics. + + Rules: + - Voltage: average (L1-N/L2-N/L3-N or L-L divided by √3); fall back to averaging L1/L2/L3 if needed. + - Current.*: average of L1/L2/L3 (ignore N). + - Power.Factor: **average** of L1/L2/L3 (ignore N). *Do not sum; unit is dimensionless and may be missing.* + - Other (e.g. Power.Active.*): sum of L1/L2/L3 (ignore N). + """ + # For single-connector chargers, use connector 1. + n_connectors = getattr(self, CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) or 1 + if connector_id in (None, 0): + target_cid = 1 if n_connectors == 1 else 0 + else: + try: + target_cid = int(connector_id) + except Exception: + target_cid = 1 if n_connectors == 1 else 0 + + def average_of_nonzero(values: list[float]) -> float: + """Average only non-zero values; return 0.0 if all are zero or list is empty.""" + nonzero = [v for v in values if v != 0.0] + return (sum(nonzero) / len(nonzero)) if nonzero else 0.0 + + measurand_data: dict[str, dict[str, float]] = {} + + for item in data: + # create ordered Dict for each measurand, eg {"voltage":{"unit":"V","L1-N":"230"...}} + measurand = item.measurand + phase = item.phase + value = item.value + unit = item.unit + context = item.context + + if measurand is None or phase is None: + continue + + if measurand not in measurand_data: + measurand_data[measurand] = {} + + if unit is not None: + measurand_data[measurand][om.unit.value] = unit + self._metrics[(target_cid, measurand)].unit = unit + self._metrics[(target_cid, measurand)].extra_attr[om.unit.value] = unit + + measurand_data[measurand][phase] = value + self._metrics[(target_cid, measurand)].extra_attr[phase] = value + if context is not None: + self._metrics[(target_cid, measurand)].extra_attr[om.context.value] = ( + context + ) + + line_phases_all = [ + Phase.l1.value, + Phase.l2.value, + Phase.l3.value, + Phase.n.value, + ] + phases_l123 = [Phase.l1.value, Phase.l2.value, Phase.l3.value] + line_to_neutral_phases = [Phase.l1_n.value, Phase.l2_n.value, Phase.l3_n.value] + line_to_line_phases = [Phase.l1_l2.value, Phase.l2_l3.value, Phase.l3_l1.value] + + def _avg_l123(phase_info: dict) -> float: + return average_of_nonzero( + [phase_info.get(phase, 0.0) for phase in phases_l123] + ) + + def _sum_l123(phase_info: dict) -> float: + return sum(phase_info.get(phase, 0.0) for phase in phases_l123) + + for metric, phase_info in measurand_data.items(): + metric_value: float | None = None + mname = str(metric) + + # --- THE NEUTRAL SHIELD --- + # If the charger sends the "N" phase on its own, skip it to prevent overwriting the real voltage. + active_phases = set(phase_info.keys()) - {"unit"} + if active_phases == {"N"}: + continue + # -------------------------- + + if metric in [Measurand.voltage.value]: + if not phase_info.keys().isdisjoint(line_to_neutral_phases): + # Line to neutral voltages are averaged + metric_value = average_of_nonzero( + [phase_info.get(phase, 0.0) for phase in line_to_neutral_phases] + ) + elif not phase_info.keys().isdisjoint(line_to_line_phases): + # Line to line voltages are averaged and converted to line to neutral + metric_value = average_of_nonzero( + [phase_info.get(phase, 0.0) for phase in line_to_line_phases] + ) / sqrt(3) + elif not phase_info.keys().isdisjoint(line_phases_all): + # Workaround for chargers that don't follow engineering convention + # Assumes voltages are line to neutral + metric_value = _avg_l123(phase_info) + + else: + is_current = mname.lower().startswith("current") + if is_current: + # Current.* shown per phase -> avg of L1/L2/L3, ignore N + if not phase_info.keys().isdisjoint(phases_l123): + metric_value = _avg_l123(phase_info) + elif not phase_info.keys().isdisjoint(line_to_neutral_phases): + # Workaround for some chargers that erroneously use line to neutral for current + metric_value = average_of_nonzero( + [ + phase_info.get(phase, 0.0) + for phase in line_to_neutral_phases + ] + ) + + # Special-case: Power.Factor must be averaged, never summed + elif metric == Measurand.power_factor.value: + if not phase_info.keys().isdisjoint(phases_l123): + metric_value = _avg_l123(phase_info) + elif not phase_info.keys().isdisjoint(line_to_neutral_phases): + metric_value = average_of_nonzero( + [phase_info.get(p, 0.0) for p in line_to_neutral_phases] + ) + # If only a single phase value exists, just pass it through + else: + metric_value = next( + (v for k, v in phase_info.items() if k != om.unit.value), + None, + ) + + else: + # Other (e.g. Power.*): total is sum over phases + if not phase_info.keys().isdisjoint(phases_l123): + metric_value = _sum_l123(phase_info) + elif not phase_info.keys().isdisjoint(line_to_neutral_phases): + metric_value = sum( + phase_info.get(phase, 0.0) + for phase in line_to_neutral_phases + ) + + if metric_value is not None: + metric_unit = phase_info.get(om.unit.value) + + if metric_unit == DEFAULT_POWER_UNIT: + self._metrics[(target_cid, metric)].value = metric_value / 1000 + self._metrics[(target_cid, metric)].unit = HA_POWER_UNIT + elif metric_unit == DEFAULT_ENERGY_UNIT: + self._metrics[(target_cid, metric)].value = metric_value / 1000 + self._metrics[(target_cid, metric)].unit = HA_ENERGY_UNIT + else: + self._metrics[(target_cid, metric)].value = metric_value + self._metrics[(target_cid, metric)].unit = metric_unit + + @staticmethod + def get_energy_kwh(measurand_value: MeasurandValue) -> float: + """Convert energy value from charger to kWh.""" + if (measurand_value.unit == "Wh") or (measurand_value.unit is None): + return measurand_value.value / 1000 + return measurand_value.value + + def process_measurands( + self, + meter_values: list[list[MeasurandValue]], + is_transaction: bool, + connector_id: int = 0, + ): + """Process all values from OCPP 1.6 MeterValues or OCPP 2.0.1 TransactionEvent.""" + + for bucket in meter_values: + # --- Preselect best EAIR in this bucket (ignore Transaction.Begin) --- + best_eair_idx = None + best_pr = -1 + best_val = None + for j, sv in enumerate(bucket): + meas = sv.measurand if sv.measurand is not None else DEFAULT_MEASURAND + if meas != DEFAULT_MEASURAND: + continue + ctx = sv.context or ReadingContext.sample_periodic.value + # Always ignore Transaction.Begin for EAIR (prevents resets to 0) + if ctx == ReadingContext.transaction_begin.value: + continue + try: + kwh = float( + ChargePoint.get_energy_kwh( + MeasurandValue( + meas, + sv.value, + sv.phase, + sv.unit, + ctx, + sv.location, + ) + ) + ) + except Exception: + continue + if kwh < 0.0 or kwh != kwh: + continue + pr = 0 + if ctx == ReadingContext.transaction_end.value: + pr = 3 + elif ctx == ReadingContext.sample_periodic.value: + pr = 2 + elif ctx == ReadingContext.sample_clock.value: + pr = 1 + if (pr > best_pr) or ( + pr == best_pr and (best_val is None or kwh > best_val) + ): + best_pr = pr + best_val = kwh + best_eair_idx = j + + unprocessed: list[MeasurandValue] = [] + + # Pre-scan: Count how many distinct phases are reported for the main energy register + eair_phases = set() + for v in bucket: + v_measurand = getattr(v, "measurand", None) or DEFAULT_MEASURAND + if v_measurand == DEFAULT_MEASURAND and getattr(v, "phase", None): + eair_phases.add(v.phase) + + for idx, sampled_value in enumerate(bucket): + measurand = sampled_value.measurand + value = sampled_value.value + unit = sampled_value.unit + phase = sampled_value.phase + location = sampled_value.location + context = sampled_value.context or ReadingContext.sample_periodic.value + + # Strip the phase tag ONLY if a single-phase charger sends an isolated L1 energy reading. + # If multiple phases exist (e.g., L1, L2), leave them intact so process_phases() can sum them. + normalized_measurand = measurand or DEFAULT_MEASURAND + if ( + normalized_measurand == DEFAULT_MEASURAND + and phase == Phase.l1.value + and len(eair_phases) == 1 + ): + phase = None + + # Backwards compatibility + if sampled_value.measurand is None: + measurand = DEFAULT_MEASURAND + unit = unit or DEFAULT_ENERGY_UNIT + + if measurand == DEFAULT_MEASURAND and unit is None: + unit = DEFAULT_ENERGY_UNIT + + # Normalize units + if unit == DEFAULT_ENERGY_UNIT: + value = ChargePoint.get_energy_kwh( + MeasurandValue(measurand, value, phase, unit, context, location) + ) + unit = HA_ENERGY_UNIT + + if unit == DEFAULT_POWER_UNIT: + value = value / 1000 + unit = HA_POWER_UNIT + + if self._metrics[(connector_id, csess.meter_start.value)].value == 0: + # Charger reports Energy.Active.Import.Register directly as Session energy for transactions. + self._charger_reports_session_energy = True + + if phase is None: + is_eair = measurand == DEFAULT_MEASURAND + + # Determine if this is a single-connector charger (only if explicitly known) + try: + n_connectors = int(getattr(self, "num_connectors", 1) or 1) + except Exception: + n_connectors = 1 + + single = n_connectors == 1 + + # Choose target connector id + if is_eair: + if connector_id and connector_id > 0: + # Always honor a positive connector_id for EAIR, even without txId + target_cid = connector_id + else: + # connector_id == 0 or missing → map based on topology + target_cid = 1 if single else 0 + else: + target_cid = connector_id + + # For EAIR: process only the best candidate in this bucket, skip others (incl. Transaction.Begin) + if is_eair and idx != best_eair_idx: + continue + + # Determine whether to skip writing EAIR to the main metric: + # - Skip only if this is an EAIR reading, + # - AND the charger reports session energy (meter_start == 0), + # - AND the reading belongs to an active transaction. + # + # Reason: in this situation, the EAIR value represents **session energy** for the current transaction, + # not the lifetime total meter. Writing it to the main metric would overwrite the true cumulative + # energy with a session-only value. For all other cases (non-EAIR readings or non-transaction readings), + # it is safe to write the metric normally. + skip_eair = ( + is_eair + and self._charger_reports_session_energy + and is_transaction + ) + + if not skip_eair: + # Normal write + self._metrics[(target_cid, measurand)].value = value + self._metrics[(target_cid, measurand)].unit = unit + if location is not None: + self._metrics[(target_cid, measurand)].extra_attr[ + om.location.value + ] = location + self._metrics[(target_cid, measurand)].extra_attr[ + om.context.value + ] = context + + # Session handling, only for EAIR during a transaction (per-connector) + if is_transaction and is_eair: + if self._charger_reports_session_energy: + # Charger reports session energy directly; ignore Transaction.Begin. + if context != ReadingContext.transaction_begin.value: + self._metrics[ + (target_cid, csess.session_energy.value) + ].value = value + self._metrics[ + (target_cid, csess.session_energy.value) + ].unit = unit + self._metrics[ + (target_cid, csess.session_energy.value) + ].extra_attr[cstat.id_tag.name] = self._metrics[ + (target_cid, cstat.id_tag.value) + ].value + else: + # Initialize baseline on first tx-bound EAIR; then derive Session = EAIR - meter_start. + ms_metric = self._metrics[(target_cid, csess.meter_start)] + if ms_metric.value is None: + ms_metric.value = value + ms_metric.unit = unit + self._metrics[ + (target_cid, csess.session_energy.value) + ].value = 0.0 + self._metrics[ + (target_cid, csess.session_energy.value) + ].unit = unit + elif ms_metric.unit == unit: + self._metrics[ + (target_cid, csess.session_energy.value) + ].value = round(1000 * (value - ms_metric.value)) / 1000 + self._metrics[ + (target_cid, csess.session_energy.value) + ].unit = unit + else: + unprocessed.append(sampled_value) + + try: + self.process_phases(unprocessed, connector_id) + except TypeError: + self.process_phases(unprocessed) + + @property + def supported_features(self) -> int: + """Flag of Ocpp features that are supported.""" + # Tests (and some external callers) may set supported features as a + # `set` of `Profiles` members. Normalize to an IntFlag value so + # callers can consistently perform bitwise operations or membership + # checks. + if isinstance(self._attr_supported_features, set): + flags = prof.NONE + for p in self._attr_supported_features: + try: + flags |= p + except Exception: + # ignore non-Profiles items + continue + return flags + return self._attr_supported_features + + def get_ha_metric(self, measurand: str, connector_id: int | None = None): + """Return last known value in HA for given measurand, or None if not available.""" + base = self.settings.cpid.lower() + meas_slug = measurand.lower().replace(".", "_") + + # Build list of possible sensor entity IDs. + # Include connector-specific ID if applicable, then the generic one as fallback. + candidates: list[str] = [] + if connector_id and connector_id > 0: + candidates.append(f"sensor.{base}_connector_{connector_id}_{meas_slug}") + candidates.append(f"sensor.{base}_{meas_slug}") + + # Return the first valid state found among candidates. + for entity_id in candidates: + try: + st = self.hass.states.get(entity_id) + except Exception as e: + _LOGGER.debug("Error getting entity %s from HA: %s", entity_id, e) + st = None + + if st and st.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + return st.state + + return None + + async def notify_ha(self, msg: str, title: str = "Ocpp integration"): + """Notify user via HA web frontend.""" + await self.hass.services.async_call( + PN_DOMAIN, + "create", + service_data={ + "title": title, + "message": msg, + }, + blocking=False, + ) + return True diff --git a/custom_components/ocpp/config_flow.py b/custom_components/ocpp/config_flow.py index 7539d6e2..b3049b26 100644 --- a/custom_components/ocpp/config_flow.py +++ b/custom_components/ocpp/config_flow.py @@ -1,9 +1,18 @@ """Adds config flow for ocpp.""" -from homeassistant import config_entries + +from typing import Any +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + CONN_CLASS_LOCAL_PUSH, +) +from homeassistant.helpers import config_validation as cv import voluptuous as vol from .const import ( CONF_CPID, + CONF_CPIDS, CONF_CSID, CONF_FORCE_SMART_CHARGING, CONF_HOST, @@ -11,6 +20,8 @@ CONF_MAX_CURRENT, CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -28,6 +39,9 @@ DEFAULT_MAX_CURRENT, DEFAULT_MEASURAND, DEFAULT_METER_INTERVAL, + DEFAULT_MONITORED_VARIABLES, + DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_NUM_CONNECTORS, DEFAULT_PORT, DEFAULT_SKIP_SCHEMA_VALIDATION, DEFAULT_SSL, @@ -41,18 +55,14 @@ MEASURANDS, ) -STEP_USER_DATA_SCHEMA = vol.Schema( +STEP_USER_CS_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST, default=DEFAULT_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_SSL, default=DEFAULT_SSL): bool, vol.Required(CONF_SSL_CERTFILE_PATH, default=DEFAULT_SSL_CERTFILE_PATH): str, vol.Required(CONF_SSL_KEYFILE_PATH, default=DEFAULT_SSL_KEYFILE_PATH): str, - vol.Required(CONF_CSID, default=DEFAULT_CSID): str, - vol.Required(CONF_CPID, default=DEFAULT_CPID): str, - vol.Required(CONF_MAX_CURRENT, default=DEFAULT_MAX_CURRENT): int, - vol.Required(CONF_METER_INTERVAL, default=DEFAULT_METER_INTERVAL): int, - vol.Required(CONF_IDLE_INTERVAL, default=DEFAULT_IDLE_INTERVAL): int, + vol.Required(CONF_CSID, default=DEFAULT_CSID): vol.All(str, vol.Length(max=20)), vol.Required( CONF_WEBSOCKET_CLOSE_TIMEOUT, default=DEFAULT_WEBSOCKET_CLOSE_TIMEOUT ): int, @@ -65,6 +75,19 @@ vol.Required( CONF_WEBSOCKET_PING_TIMEOUT, default=DEFAULT_WEBSOCKET_PING_TIMEOUT ): int, + } +) + +STEP_USER_CP_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CPID, default=DEFAULT_CPID): str, + vol.Required(CONF_MAX_CURRENT, default=DEFAULT_MAX_CURRENT): int, + vol.Required( + CONF_MONITORED_VARIABLES_AUTOCONFIG, + default=DEFAULT_MONITORED_VARIABLES_AUTOCONFIG, + ): bool, + vol.Required(CONF_METER_INTERVAL, default=DEFAULT_METER_INTERVAL): int, + vol.Required(CONF_IDLE_INTERVAL, default=DEFAULT_IDLE_INTERVAL): int, vol.Required( CONF_SKIP_SCHEMA_VALIDATION, default=DEFAULT_SKIP_SCHEMA_VALIDATION ): bool, @@ -73,6 +96,7 @@ ): bool, } ) + STEP_USER_MEASURANDS_SCHEMA = vol.Schema( { vol.Required(m, default=(True if m == DEFAULT_MEASURAND else False)): bool @@ -81,27 +105,101 @@ ) -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class ConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OCPP.""" - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + VERSION = 2 + MINOR_VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH def __init__(self): """Initialize.""" - self._data = {} + self._data: dict[str, Any] = {} + self._cp_id: str + self._entry: ConfigEntry + self._measurands: str = "" + self._detected_num_connectors: int = DEFAULT_NUM_CONNECTORS - async def async_step_user(self, user_input=None): - """Handle user initiated configuration.""" + async def async_step_user(self, user_input=None) -> ConfigFlowResult: + """Handle user central system initiated configuration.""" errors: dict[str, str] = {} if user_input is not None: - # Todo: validate the user input + # Don't allow servers to use same websocket port + self._async_abort_entries_match({CONF_PORT: user_input[CONF_PORT]}) self._data = user_input - return await self.async_step_measurands() + # Add placeholder for cpid settings + self._data[CONF_CPIDS] = [] + return self.async_create_entry(title=self._data[CONF_CSID], data=self._data) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_CS_DATA_SCHEMA, + errors=errors, + description_placeholders={"docs_url": "https://github.com/lbbrhzn/ocpp"}, + ) + + async def async_step_integration_discovery( + self, discovery_info=None + ) -> ConfigFlowResult: + """Handle charger discovery initiated configuration.""" + + self._entry = discovery_info["entry"] + self._cp_id = discovery_info["cp_id"] + self._data = {**self._entry.data} + + self._detected_num_connectors = discovery_info.get( + CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS + ) + + await self.async_set_unique_id(self._cp_id) + # Abort the flow if a config entry with the same unique ID exists + self._abort_if_unique_id_configured() + return await self.async_step_cp_user() + + async def async_step_cp_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure charger by user.""" + errors: dict[str, str] = {} + + if user_input is not None: + # Don't allow duplicate cpids to be used + self._async_abort_entries_match({CONF_CPID: user_input[CONF_CPID]}) + # Validate cpid format against entity id requirements (lowercase letters, digits and _) + schema = vol.Schema( + {vol.Required(CONF_CPID): cv.matches_regex(r"^[\da-z_]+$")} + ) + try: + schema({CONF_CPID: user_input[CONF_CPID]}) + except vol.Invalid: + errors["base"] = "invalid_cpid" + else: + cp_data = { + **user_input, + CONF_NUM_CONNECTORS: self._detected_num_connectors, + } + cpids_list = self._data.get(CONF_CPIDS, []).copy() + cpids_list.append({self._cp_id: cp_data}) + self._data = {**self._data, CONF_CPIDS: cpids_list} + + if user_input[CONF_MONITORED_VARIABLES_AUTOCONFIG]: + self._data[CONF_CPIDS][-1][self._cp_id][ + CONF_MONITORED_VARIABLES + ] = DEFAULT_MONITORED_VARIABLES + self.hass.config_entries.async_update_entry( + self._entry, data=self._data + ) + return self.async_abort(reason="Added/Updated charge point") + + else: + return await self.async_step_measurands() return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="cp_user", + data_schema=STEP_USER_CP_DATA_SCHEMA, + errors=errors, + description_placeholders={"docs_url": "https://github.com/lbbrhzn/ocpp"}, ) async def async_step_measurands(self, user_input=None): @@ -110,13 +208,24 @@ async def async_step_measurands(self, user_input=None): errors: dict[str, str] = {} if user_input is not None: selected_measurands = [m for m, value in user_input.items() if value] - if set(selected_measurands).issubset(set(MEASURANDS)): - self._data[CONF_MONITORED_VARIABLES] = ",".join(selected_measurands) - return self.async_create_entry( - title=self._data[CONF_CSID], data=self._data + if not set(selected_measurands).issubset(set(MEASURANDS)): + errors["base"] = "no_measurands_selected" + return self.async_show_form( + step_id="measurands", + data_schema=STEP_USER_MEASURANDS_SCHEMA, + errors=errors, ) else: - errors["base"] = "measurand" + self._measurands = ",".join(selected_measurands) + self._data[CONF_CPIDS][-1][self._cp_id][CONF_MONITORED_VARIABLES] = ( + self._measurands + ) + + self.hass.config_entries.async_update_entry( + self._entry, data=self._data + ) + return self.async_abort(reason="Added/Updated charge point") + return self.async_show_form( step_id="measurands", data_schema=STEP_USER_MEASURANDS_SCHEMA, diff --git a/custom_components/ocpp/const.py b/custom_components/ocpp/const.py index 3bf16bbc..fbb5b2ad 100644 --- a/custom_components/ocpp/const.py +++ b/custom_components/ocpp/const.py @@ -1,16 +1,18 @@ """Define constants for OCPP integration.""" + import pathlib +from dataclasses import dataclass, field import homeassistant.components.input_number as input_number from homeassistant.components.sensor import SensorDeviceClass import homeassistant.const as ha - from ocpp.v16.enums import Measurand, UnitOfMeasure CONF_AUTH_LIST = "authorization_list" CONF_AUTH_STATUS = "authorization_status" CONF_CPI = "charge_point_identity" CONF_CPID = "cpid" +CONF_CPIDS = "cpids" CONF_CSID = "csid" CONF_DEFAULT_AUTH_STATUS = "default_authorization_status" CONF_HOST = ha.CONF_HOST @@ -21,7 +23,9 @@ CONF_METER_INTERVAL = "meter_interval" CONF_MODE = ha.CONF_MODE CONF_MONITORED_VARIABLES = ha.CONF_MONITORED_VARIABLES +CONF_MONITORED_VARIABLES_AUTOCONFIG = "monitored_variables_autoconfig" CONF_NAME = ha.CONF_NAME +CONF_NUM_CONNECTORS = "num_connectors" CONF_PASSWORD = ha.CONF_PASSWORD CONF_PORT = ha.CONF_PORT CONF_SKIP_SCHEMA_VALIDATION = "skip_schema_validation" @@ -42,13 +46,15 @@ DEFAULT_CPID = "charger" DEFAULT_HOST = "0.0.0.0" DEFAULT_MAX_CURRENT = 32 +DEFAULT_NUM_CONNECTORS = 1 DEFAULT_PORT = 9000 DEFAULT_SKIP_SCHEMA_VALIDATION = False DEFAULT_FORCE_SMART_CHARGING = False DEFAULT_SSL = False DEFAULT_SSL_CERTFILE_PATH = pathlib.Path.cwd().joinpath("fullchain.pem") DEFAULT_SSL_KEYFILE_PATH = pathlib.Path.cwd().joinpath("privkey.pem") -DEFAULT_SUBPROTOCOL = "ocpp1.6" +DEFAULT_SUBPROTOCOLS = ["ocpp1.6", "ocpp2.0.1", "ocpp2.1"] +OCPP_2_0 = "ocpp2" DEFAULT_METER_INTERVAL = 60 DEFAULT_IDLE_INTERVAL = 900 DEFAULT_WEBSOCKET_CLOSE_TIMEOUT = 10 @@ -70,31 +76,32 @@ # Ocpp supported measurands MEASURANDS = [ - Measurand.energy_active_import_register.value, - Measurand.energy_reactive_import_register.value, + Measurand.current_export.value, + Measurand.current_import.value, + Measurand.current_offered.value, + Measurand.energy_active_export_interval.value, + Measurand.energy_active_export_register.value, Measurand.energy_active_import_interval.value, + Measurand.energy_active_import_register.value, + Measurand.energy_reactive_export_interval.value, + Measurand.energy_reactive_export_register.value, Measurand.energy_reactive_import_interval.value, + Measurand.energy_reactive_import_register.value, + Measurand.frequency.value, + Measurand.power_active_export.value, Measurand.power_active_import.value, - Measurand.power_reactive_import.value, - Measurand.power_offered.value, Measurand.power_factor.value, - Measurand.current_import.value, - Measurand.current_offered.value, - Measurand.voltage.value, - Measurand.frequency.value, + Measurand.power_offered.value, + Measurand.power_reactive_export.value, + Measurand.power_reactive_import.value, Measurand.rpm.value, Measurand.soc.value, Measurand.temperature.value, - Measurand.current_export.value, - Measurand.energy_active_export_register.value, - Measurand.energy_reactive_export_register.value, - Measurand.energy_active_export_interval.value, - Measurand.energy_reactive_export_interval.value, - Measurand.power_active_export.value, - Measurand.power_reactive_export.value, + Measurand.voltage.value, ] DEFAULT_MEASURAND = Measurand.energy_active_import_register.value DEFAULT_MONITORED_VARIABLES = ",".join(MEASURANDS) +DEFAULT_MONITORED_VARIABLES_AUTOCONFIG = True DEFAULT_ENERGY_UNIT = UnitOfMeasure.wh.value DEFAULT_POWER_UNIT = UnitOfMeasure.w.value HA_ENERGY_UNIT = UnitOfMeasure.kwh.value @@ -110,7 +117,7 @@ UnitOfMeasure.kw: ha.UnitOfPower.KILO_WATT, UnitOfMeasure.va: ha.UnitOfApparentPower.VOLT_AMPERE, UnitOfMeasure.kva: UnitOfMeasure.kva, - UnitOfMeasure.var: UnitOfMeasure.var, + UnitOfMeasure.var: ha.UnitOfReactivePower.VOLT_AMPERE_REACTIVE, UnitOfMeasure.kvar: UnitOfMeasure.kvar, UnitOfMeasure.a: ha.UnitOfElectricCurrent.AMPERE, UnitOfMeasure.v: ha.UnitOfElectricPotential.VOLT, @@ -118,7 +125,6 @@ UnitOfMeasure.fahrenheit: ha.UnitOfTemperature.FAHRENHEIT, UnitOfMeasure.k: ha.UnitOfTemperature.KELVIN, UnitOfMeasure.percent: ha.PERCENTAGE, - UnitOfMeasure.hertz: ha.UnitOfFrequency.HERTZ, } # Where an occp unit is not reported and only one possibility assign HA unit on device class @@ -128,5 +134,47 @@ SensorDeviceClass.FREQUENCY: ha.UnitOfFrequency.HERTZ, SensorDeviceClass.BATTERY: ha.PERCENTAGE, SensorDeviceClass.POWER: ha.UnitOfPower.KILO_WATT, + SensorDeviceClass.REACTIVE_POWER: ha.UnitOfReactivePower.VOLT_AMPERE_REACTIVE, SensorDeviceClass.ENERGY: ha.UnitOfEnergy.KILO_WATT_HOUR, + SensorDeviceClass.TEMPERATURE: ha.UnitOfTemperature.CELSIUS, } + + +@dataclass +class ChargerSystemSettings: + """CentralSystem configuration passed to a ChargePoint.""" + + cpid: str + max_current: int + idle_interval: int + meter_interval: int + monitored_variables: str + monitored_variables_autoconfig: bool + skip_schema_validation: bool + force_smart_charging: bool + connection: int | None = None # number of this connection in central server + num_connectors: int = DEFAULT_NUM_CONNECTORS + + +@dataclass +class CentralSystemSettings: + """CentralSystem configuration values.""" + + csid: str + host: str + port: str + ssl: bool + ssl_certfile_path: str + ssl_keyfile_path: str + websocket_close_timeout: int + websocket_ping_interval: int + websocket_ping_timeout: int + websocket_ping_tries: int + cpids: list = field(default_factory=list) # holds cpid config flow settings + subprotocols: list = field(default_factory=lambda: DEFAULT_SUBPROTOCOLS) + + # def __post_init__(self): + # i = 0 + # for id in self.cpids: + # self.cpids[i] = ChargerSystemSettings(**id) + # i =+ 1 diff --git a/custom_components/ocpp/enums.py b/custom_components/ocpp/enums.py index b0bd05e1..9a206f60 100644 --- a/custom_components/ocpp/enums.py +++ b/custom_components/ocpp/enums.py @@ -1,4 +1,5 @@ """Additional enumerated values to use in home assistant.""" + from enum import Enum, IntFlag, auto @@ -16,6 +17,7 @@ class HAChargerServices(str, Enum): service_configure = "configure" service_get_configuration = "get_configuration" service_get_diagnostics = "get_diagnostics" + service_trigger_custom_message = "trigger_custom_message" service_clear_profile = "clear_profile" service_data_transfer = "data_transfer" @@ -63,8 +65,7 @@ class HAChargerSession(str, Enum): class Profiles(IntFlag): """Flags to indicate supported feature profiles.""" - __str__ = Enum.__str__ - + NONE = 0 CORE = auto() # Core FW = auto() # FirmwareManagement SMART = auto() # SmartCharging @@ -72,6 +73,12 @@ class Profiles(IntFlag): REM = auto() # RemoteTrigger AUTH = auto() # LocalAuthListManagement + def labels(self): + """Get labels for profiles.""" + if self == Profiles.NONE: + return "NONE" + return "|".join([p.name for p in Profiles if p & self]) + class OcppMisc(str, Enum): """Miscellaneous strings used in ocpp v1.6 responses.""" diff --git a/custom_components/ocpp/exception.py b/custom_components/ocpp/exception.py index 7b9b6130..f98426b6 100644 --- a/custom_components/ocpp/exception.py +++ b/custom_components/ocpp/exception.py @@ -1,7 +1 @@ -"""Implement Exceptions.""" - - -class ConfigurationError(Exception): - """Error used to signal a error while configuring the charger.""" - - pass +"""This file is imported by home assistant, and can be used to define custom exceptions.""" diff --git a/custom_components/ocpp/manifest.json b/custom_components/ocpp/manifest.json index 65145087..f39890fe 100644 --- a/custom_components/ocpp/manifest.json +++ b/custom_components/ocpp/manifest.json @@ -5,16 +5,16 @@ "persistent_notification" ], "codeowners": [ - "@lbbrhzn" + "@lbbrhzn", + "@drc38" ], "config_flow": true, "documentation": "https://github.com/lbbrhzn/ocpp/blob/main/README.md", "iot_class": "local_push", "issue_tracker": "https://github.com/lbbrhzn/ocpp/issues", "requirements": [ - "ocpp>=0.14.1", - "websockets>=10.2", - "jsonschema==4.19.0" + "ocpp>=2.1.0", + "websockets>=14.1" ], - "version": "0.4.36" + "version": "0.8.0" } diff --git a/custom_components/ocpp/number.py b/custom_components/ocpp/number.py index 36f42b4b..8302cc66 100644 --- a/custom_components/ocpp/number.py +++ b/custom_components/ocpp/number.py @@ -1,7 +1,9 @@ """Number platform for ocpp.""" + from __future__ import annotations from dataclasses import dataclass +import logging from typing import Final from homeassistant.components.number import ( @@ -10,23 +12,29 @@ NumberEntityDescription, RestoreNumber, ) -from homeassistant.const import ELECTRIC_CURRENT_AMPERE +from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo +from homeassistant.util import slugify from .api import CentralSystem from .const import ( CONF_CPID, + CONF_CPIDS, CONF_MAX_CURRENT, + CONF_NUM_CONNECTORS, DATA_UPDATED, - DEFAULT_CPID, DEFAULT_MAX_CURRENT, + DEFAULT_NUM_CONNECTORS, DOMAIN, ICON, ) from .enums import Profiles +_LOGGER: logging.Logger = logging.getLogger(__package__) + @dataclass class OcppNumberDescription(NumberEntityDescription): @@ -35,6 +43,8 @@ class OcppNumberDescription(NumberEntityDescription): initial_value: float | None = None +ELECTRIC_CURRENT_AMPERE = UnitOfElectricCurrent.AMPERE + NUMBERS: Final = [ OcppNumberDescription( key="maximum_current", @@ -51,82 +61,190 @@ class OcppNumberDescription(NumberEntityDescription): async def async_setup_entry(hass, entry, async_add_devices): """Configure the number platform.""" - central_system = hass.data[DOMAIN][entry.entry_id] - cp_id = entry.data.get(CONF_CPID, DEFAULT_CPID) - - entities = [] - - for ent in NUMBERS: - if ent.key == "maximum_current": - ent.initial_value = entry.data.get(CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT) - ent.native_max_value = entry.data.get(CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT) - entities.append(OcppNumber(hass, central_system, cp_id, ent)) + entities: list[ChargePointNumber] = [] + ent_reg = er.async_get(hass) + + for charger in entry.data[CONF_CPIDS]: + cp_id_settings = list(charger.values())[0] + cpid = cp_id_settings[CONF_CPID] + + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break + + if num_connectors > 1: + for desc in NUMBERS: + uid_flat = ".".join([NUMBER_DOMAIN, DOMAIN, cpid, desc.key]) + stale_eid = ent_reg.async_get_entity_id(NUMBER_DOMAIN, DOMAIN, uid_flat) + if stale_eid: + ent_reg.async_remove(stale_eid) + + for desc in NUMBERS: + if desc.key == "maximum_current": + max_cur = float( + cp_id_settings.get(CONF_MAX_CURRENT, DEFAULT_MAX_CURRENT) + ) + ent_initial = max_cur + ent_max = max_cur + else: + ent_initial = desc.initial_value + ent_max = desc.native_max_value + + if num_connectors > 1: + for conn_id in range(1, num_connectors + 1): + entities.append( + ChargePointNumber( + hass=hass, + central_system=central_system, + cpid=cpid, + description=OcppNumberDescription( + key=desc.key, + name=desc.name, + icon=desc.icon, + initial_value=ent_initial, + native_min_value=desc.native_min_value, + native_max_value=ent_max, + native_step=desc.native_step, + native_unit_of_measurement=desc.native_unit_of_measurement, + ), + connector_id=conn_id, + op_connector_id=conn_id, + ) + ) + else: + entities.append( + ChargePointNumber( + hass=hass, + central_system=central_system, + cpid=cpid, + description=OcppNumberDescription( + key=desc.key, + name=desc.name, + icon=desc.icon, + initial_value=ent_initial, + native_min_value=desc.native_min_value, + native_max_value=ent_max, + native_step=desc.native_step, + native_unit_of_measurement=desc.native_unit_of_measurement, + ), + connector_id=None, + op_connector_id=0, + ) + ) async_add_devices(entities, False) -class OcppNumber(RestoreNumber, NumberEntity): +class ChargePointNumber(RestoreNumber, NumberEntity): """Individual slider for setting charge rate.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppNumberDescription def __init__( self, hass: HomeAssistant, central_system: CentralSystem, - cp_id: str, + cpid: str, description: OcppNumberDescription, + connector_id: int | None = None, + op_connector_id: int | None = None, ): """Initialize a Number instance.""" - self.cp_id = cp_id + self.cpid = cpid self._hass = hass self.central_system = central_system self.entity_description = description - self._attr_unique_id = ".".join( - [NUMBER_DOMAIN, self.cp_id, self.entity_description.key] + self.connector_id = connector_id + self._op_connector_id = ( + op_connector_id if op_connector_id is not None else (connector_id or 1) ) + + parts = [NUMBER_DOMAIN, DOMAIN, cpid, description.key] + if self.connector_id: + parts.insert(3, f"conn{self.connector_id}") + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cp_id)}, - via_device=(DOMAIN, self.central_system.id), - ) + if self.connector_id: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) + if self.connector_id is not None: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{NUMBER_DOMAIN}.{slugify(object_id)}" self._attr_native_value = self.entity_description.initial_value self._attr_should_poll = False - self._attr_available = True async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() if restored := await self.async_get_last_number_data(): self._attr_native_value = restored.native_value - async_dispatcher_connect( - self._hass, DATA_UPDATED, self._schedule_immediate_update - ) - @callback - def _schedule_immediate_update(self): - self.async_schedule_update_ha_state(True) + @callback + def _maybe_update(*args): + active_lookup = None + if args: + try: + active_lookup = set(args[0]) + except Exception: + active_lookup = None - # @property - # def available(self) -> bool: - # """Return if entity is available.""" - # if not ( - # Profiles.SMART & self.central_system.get_supported_features(self.cp_id) - # ): - # return False - # return self.central_system.get_available(self.cp_id) # type: ignore [no-any-return] + if active_lookup is None or self.entity_id in active_lookup: + self.async_schedule_update_ha_state(True) + + self.async_on_remove( + async_dispatcher_connect(self.hass, DATA_UPDATED, _maybe_update) + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + features = self.central_system.get_supported_features(self.cpid) + has_smart = bool(features & Profiles.SMART) + return bool( + self.central_system.get_available(self.cpid, self._op_connector_id) + and has_smart + ) async def async_set_native_value(self, value): - """Set new value.""" - num_value = float(value) - if self.central_system.get_available( - self.cp_id - ) and Profiles.SMART & self.central_system.get_supported_features(self.cp_id): - resp = await self.central_system.set_max_charge_rate_amps( - self.cp_id, num_value + """Set new value for max current (station-wide when _op_connector_id==0, otherwise per-connector). + + - Optimistic UI: move the slider immediately; attempt backend; never raise. + """ + self._attr_native_value = float(value) + self.async_write_ha_state() + + try: + ok = await self.central_system.set_max_charge_rate_amps( + self.cpid, self._attr_native_value, connector_id=self._op_connector_id + ) + if not ok: + _LOGGER.warning( + "Set current limit rejected by CP (kept optimistic UI at %.1f A).", + value, + ) + except Exception as ex: + _LOGGER.warning( + "Set current limit failed: %s (kept optimistic UI at %.1f A).", + ex, + value, ) - if resp is True: - self._attr_native_value = num_value - self.async_write_ha_state() diff --git a/custom_components/ocpp/ocppv16.py b/custom_components/ocpp/ocppv16.py new file mode 100644 index 00000000..b80a8a76 --- /dev/null +++ b/custom_components/ocpp/ocppv16.py @@ -0,0 +1,1234 @@ +"""Representation of a OCPP 1.6 charging station.""" + +from datetime import datetime, timedelta, UTC +import logging + +import time + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.const import UnitOfTime +import voluptuous as vol +from websockets.asyncio.server import ServerConnection + +from ocpp.routing import on +from ocpp.v16 import call, call_result +from ocpp.v16.enums import ( + Action, + AuthorizationStatus, + AvailabilityStatus, + AvailabilityType, + ChargePointStatus, + ChargingProfileKindType, + ChargingProfilePurposeType, + ChargingProfileStatus, + ChargingRateUnitType, + ClearChargingProfileStatus, + ConfigurationStatus, + DataTransferStatus, + Measurand, + MessageTrigger, + RegistrationStatus, + RemoteStartStopStatus, + ResetStatus, + ResetType, + TriggerMessageStatus, + UnlockStatus, +) + +from .chargepoint import ( + OcppVersion, + MeasurandValue, + SetVariableResult, +) +from .chargepoint import ChargePoint as cp + +from .enums import ( + ConfigurationKey as ckey, + HAChargerDetails as cdet, + HAChargerSession as csess, + HAChargerStatuses as cstat, + OcppMisc as om, + Profiles as prof, +) + +from .const import ( + CentralSystemSettings, + ChargerSystemSettings, + DEFAULT_MEASURAND, + HA_ENERGY_UNIT, + MEASURANDS, +) + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +def _to_message_trigger(name: str) -> MessageTrigger | None: + if isinstance(name, MessageTrigger): + return name + key = str(name).strip().replace(" ", "").replace("_", "").lower() + mapping = { + "bootnotification": MessageTrigger.boot_notification, + "heartbeat": MessageTrigger.heartbeat, + "metervalues": MessageTrigger.meter_values, + "statusnotification": MessageTrigger.status_notification, + "diagnosticsstatusnotification": MessageTrigger.diagnostics_status_notification, + "firmwarestatusnotification": MessageTrigger.firmware_status_notification, + } + return mapping.get(key) + + +class ChargePoint(cp): + """Server side representation of a charger.""" + + def __init__( + self, + id: str, + connection: ServerConnection, + hass: HomeAssistant, + entry: ConfigEntry, + central: CentralSystemSettings, + charger: ChargerSystemSettings, + ): + """Instantiate a ChargePoint.""" + + super().__init__( + id, + connection, + OcppVersion.V16, + hass, + entry, + central, + charger, + ) + self._active_tx: dict[int, int] = {} # connector_id -> transaction_id + + async def get_number_of_connectors(self) -> int: + """Return number of connectors on this charger.""" + resp = None + + try: + req = call.GetConfiguration(key=["NumberOfConnectors"]) + resp = await self.call(req) + except Exception: + resp = None + + cfg = None + if resp is not None: + cfg = getattr(resp, "configuration_key", None) + + if ( + cfg is None + and isinstance(resp, list | tuple) + and len(resp) >= 3 + and isinstance(resp[2], dict) + ): + cfg = resp[2].get("configurationKey") or resp[2].get( + "configuration_key" + ) + + if cfg: + for kv in cfg: + k = getattr(kv, "key", None) + v = getattr(kv, "value", None) + if k is None and isinstance(kv, dict): + k = kv.get("key") + v = kv.get("value") + if k == "NumberOfConnectors" and v not in (None, ""): + try: + n = int(str(v).strip()) + if n > 0: + return n + except (ValueError, TypeError): + pass + + return 1 + + async def get_heartbeat_interval(self): + """Retrieve heartbeat interval from the charger and store it.""" + await self.get_configuration(ckey.heartbeat_interval.value) + + async def get_supported_measurands(self) -> str: + """Get comma-separated list of measurands supported by the charger.""" + + def _filter_measurands(raw_csv: str) -> str: + """Keep only compliant measurands found as tokens in the charger's string.""" + # Protect against empty lists and the "Unknown" sentinel (checked by test_measurands_manual_set_rejected_returns_empty) + if not raw_csv or raw_csv.strip().lower() == "unknown": + return "" + + matched = [] + for token in raw_csv.split(","): + token = token.strip() + if not token: + continue + + for m in MEASURANDS: + # Token-aware match: Exact match OR prefix match with a dot (e.g. "Voltage.L1") + if token == m or token.startswith(f"{m}."): + if m not in matched: + matched.append(m) + break # Match found for this token, move to the next one + + if not matched: + _LOGGER.debug( + "Charger '%s' returned no valid measurands; falling back to %s.", + self.id, + DEFAULT_MEASURAND, + ) + return DEFAULT_MEASURAND + + return ",".join(matched) + + all_measurands = self.settings.monitored_variables or "" + autodetect_measurands = bool(self.settings.monitored_variables_autoconfig) + key = ckey.meter_values_sampled_data.value + + desired_csv = all_measurands.strip().strip(",") + cfg_ok = {ConfigurationStatus.accepted, ConfigurationStatus.reboot_required} + + effective_csv: str = "" + + if autodetect_measurands: + if desired_csv: + _LOGGER.debug( + "'%s' attempting CSV set for measurands: %s", self.id, desired_csv + ) + try: + resp = await self.call( + call.ChangeConfiguration(key=key, value=desired_csv) + ) + if getattr(resp, "status", None) in cfg_ok: + _LOGGER.debug( + "'%s' measurands CSV accepted with status=%s", + self.id, + resp.status, + ) + effective_csv = desired_csv + else: + _LOGGER.debug( + "'%s' measurands CSV rejected with status=%s; falling back to GetConfiguration", + self.id, + getattr(resp, "status", None), + ) + except Exception as ex: + _LOGGER.debug( + "get_supported_measurands CSV set raised for '%s': %s", + self.id, + ex, + ) + + # Read from charger and filter it using lenient logic + chgr_csv = await self.get_configuration(key) + chgr_csv = _filter_measurands(chgr_csv) + + if not effective_csv: + _LOGGER.debug( + "'%s' measurands not configurable by integration", self.id + ) + _LOGGER.debug("'%s' allowed measurands: '%s'", self.id, chgr_csv) + return chgr_csv + + _LOGGER.debug( + "Returning accepted measurands for '%s': '%s'", self.id, effective_csv + ) + await self.configure(key, effective_csv) + return effective_csv + + # Non-autodetect path: + if desired_csv: + try: + resp = await self.call( + call.ChangeConfiguration(key=key, value=desired_csv) + ) + _LOGGER.debug( + "'%s' measurands set manually to %s", self.id, desired_csv + ) + if getattr(resp, "status", None) in cfg_ok: + effective_csv = desired_csv + else: + _LOGGER.debug( + "'%s' manual measurands set not accepted (status=%s); using charger's value", + self.id, + getattr(resp, "status", None), + ) + effective_csv = await self.get_configuration(key) + except Exception as ex: + _LOGGER.debug( + "Manual measurands set failed for '%s': %s; using charger's value", + self.id, + ex, + ) + effective_csv = await self.get_configuration(key) + else: + effective_csv = await self.get_configuration(key) + + # Filter whatever resulted from the manual path + effective_csv = _filter_measurands(effective_csv) + + if effective_csv: + _LOGGER.debug("'%s' allowed measurands: '%s'", self.id, effective_csv) + await self.configure(key, effective_csv) + else: + _LOGGER.debug("'%s' measurands not configurable by integration", self.id) + + return effective_csv + + async def set_standard_configuration(self): + """Send configuration values to the charger.""" + await self.configure( + ckey.meter_value_sample_interval.value, + str(self.settings.meter_interval), + ) + await self.configure( + ckey.clock_aligned_data_interval.value, + str(self.settings.idle_interval), + ) + + async def get_supported_features(self) -> prof: + """Get features supported by the charger.""" + features = prof.NONE + req = call.GetConfiguration(key=[ckey.supported_feature_profiles.value]) + resp = await self.call(req) + try: + feature_list = (resp.configuration_key[0][om.value.value]).split(",") + except (IndexError, KeyError, TypeError): + feature_list = [""] + if feature_list[0] == "": + _LOGGER.warning("No feature profiles detected, defaulting to Core") + await self.notify_ha("No feature profiles detected, defaulting to Core") + feature_list = [om.feature_profile_core.value] + + if self.settings.force_smart_charging: + _LOGGER.warning("Force Smart Charging feature profile") + features |= prof.SMART + + for item in feature_list: + item = item.strip().replace(" ", "") + if item == om.feature_profile_core.value: + features |= prof.CORE + elif item == om.feature_profile_firmware.value: + features |= prof.FW + elif item == om.feature_profile_smart.value: + features |= prof.SMART + elif item == om.feature_profile_reservation.value: + features |= prof.RES + elif item == om.feature_profile_remote.value: + features |= prof.REM + elif item == om.feature_profile_auth.value: + features |= prof.AUTH + else: + _LOGGER.warning("Unknown feature profile detected ignoring: %s", item) + await self.notify_ha( + f"Warning: Unknown feature profile detected ignoring {item}" + ) + return features + + async def trigger_boot_notification(self): + """Trigger a boot notification.""" + req = call.TriggerMessage(requested_message=MessageTrigger.boot_notification) + resp = await self.call(req) + if resp.status == TriggerMessageStatus.accepted: + self.triggered_boot_notification = True + return True + else: + self.triggered_boot_notification = False + _LOGGER.warning("Failed with response: %s", resp.status) + return False + + async def trigger_status_notification(self): + """Trigger status notifications for all connectors.""" + try: + n = int(self._metrics[0][cdet.connectors.value].value or 1) + except Exception: + n = 1 + + # Single connector: only probe 1. Multi: probe 0 then 1..n. + attempts = [1] if n <= 1 else [0] + list(range(1, n + 1)) + + for cid in attempts: + _LOGGER.debug("trigger status notification for connector=%s", cid) + try: + req = call.TriggerMessage( + requested_message=MessageTrigger.status_notification, + connector_id=int(cid), + ) + resp = await self.call(req) + status = getattr(resp, "status", None) + except Exception as ex: + _LOGGER.debug("TriggerMessage failed for connector=%s: %s", cid, ex) + status = None + + if status != TriggerMessageStatus.accepted: + if cid > 0: + _LOGGER.warning("Failed with response: %s", status) + # Reduce to the last known-good connector index. + self._metrics[0][cdet.connectors.value].value = max(1, cid - 1) + return False + # If connector 0 is rejected, continue probing numbered connectors. + + return True + + async def trigger_custom_message( + self, + requested_message: str | MessageTrigger = "StatusNotification", + ): + """Trigger Custom Message.""" + trig = _to_message_trigger(requested_message) + if trig is None: + _LOGGER.warning("Unsupported TriggerMessage: %s", requested_message) + return False + + req = call.TriggerMessage(requested_message=trig) + resp = await self.call(req) + if resp.status != TriggerMessageStatus.accepted: + _LOGGER.warning("Failed with response: %s", resp.status) + return False + return True + + async def clear_profile( + self, + conn_id: int | None = None, + purpose: ChargingProfilePurposeType | None = None, + ) -> bool: + """Clear charging profiles (per connector and/or purpose).""" + try: + req = call.ClearChargingProfile( + connector_id=(int(conn_id) if conn_id is not None else None), + charging_profile_purpose=(purpose.value if purpose else None), + ) + resp = await self.call(req) + return resp.status in ( + ClearChargingProfileStatus.accepted, + ClearChargingProfileStatus.unknown, + ) + except Exception as ex: + _LOGGER.debug("ClearChargingProfile raised %s (ignored)", ex) + return False + + async def set_charge_rate( + self, + limit_amps: int = 32, + limit_watts: int = 22000, + conn_id: int = 0, + profile: dict | None = None, + ) -> bool: + """Set charge rate.""" + if profile is not None: + try: + req = call.SetChargingProfile( + connector_id=int(conn_id), cs_charging_profiles=profile + ) + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + return True + _LOGGER.warning("Custom SetChargingProfile rejected: %s", resp.status) + except Exception as ex: + _LOGGER.warning("Custom SetChargingProfile failed: %s", ex) + await self.notify_ha( + "Warning: Set charging profile failed with response Exception" + ) + return False + + if not (int(self.supported_features or 0) & prof.SMART): + _LOGGER.info("Smart charging is not supported by this charger") + return False + + # Determine allowed unit (default to Amps if not reported) + units_resp = await self.get_configuration( + ckey.charging_schedule_allowed_charging_rate_unit.value + ) + if not units_resp: + _LOGGER.debug("Charging rate unit not reported; assuming Amps") + units_resp = om.current.value + + use_amps = om.current.value in units_resp + limit_value = float(limit_amps if use_amps else limit_watts) + units_value = ( + ChargingRateUnitType.amps.value + if use_amps + else ChargingRateUnitType.watts.value + ) + + try: + stack_level_resp = await self.get_configuration( + ckey.charge_profile_max_stack_level.value + ) + stack_level = int(stack_level_resp) + except Exception: + stack_level = 1 + + # Helper to build a simple relative schedule with one period + def _mk_schedule(_units: str, _limit: float) -> dict: + return { + om.charging_rate_unit.value: _units, + om.charging_schedule_period.value: [ + {om.start_period.value: 0, om.limit.value: _limit} + ], + } + + # Helper to generate a unique, stable chargingProfileId per purpose+connector + def _profile_id(purpose: str, cid: int) -> int: + base = { + ChargingProfilePurposeType.charge_point_max_profile.value: 1000, + ChargingProfilePurposeType.tx_default_profile.value: 2000, + ChargingProfilePurposeType.tx_profile.value: 3000, + }.get(purpose, 9000) + try: + n = int(cid or 0) + except Exception: + n = 0 + return base + max(0, n) + + # Try ChargePointMaxProfile (connectorId = 0) + try: + req = call.SetChargingProfile( + connector_id=0, + cs_charging_profiles={ + om.charging_profile_id.value: _profile_id( + ChargingProfilePurposeType.charge_point_max_profile.value, 0 + ), + om.stack_level.value: stack_level, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: ChargingProfilePurposeType.charge_point_max_profile.value, + om.charging_schedule.value: _mk_schedule(units_value, limit_value), + }, + ) + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + return True + _LOGGER.debug( + "ChargePointMaxProfile not accepted (%s); will continue.", + resp.status, + ) + except Exception as ex: + _LOGGER.debug("ChargePointMaxProfile call raised: %s", ex) + + # Target connector (default 1 if unspecified/0) + target_cid = int(conn_id) if conn_id and int(conn_id) > 0 else 1 + + # Read active transaction on this connector + try: + active_tx_id = int(self._active_tx.get(target_cid, 0) or 0) + except Exception: + active_tx_id = 0 + + txp_ok = False + txd_ok = False + + # If an active transaction exists on this connector, try TxProfile first (affects ongoing charging) + if active_tx_id > 0: + try: + txp_stack = max(1, stack_level) # keep same or higher than defaults + req = call.SetChargingProfile( + connector_id=target_cid, + cs_charging_profiles={ + om.charging_profile_id.value: _profile_id( + ChargingProfilePurposeType.tx_profile.value, target_cid + ), + om.stack_level.value: txp_stack, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_profile.value, + om.charging_schedule.value: _mk_schedule( + units_value, limit_value + ), + # Bind to the ongoing transaction + om.transaction_id.value: active_tx_id, + }, + ) + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + txp_ok = True + else: + _LOGGER.debug("TxProfile not accepted (%s).", resp.status) + except Exception as ex: + _LOGGER.debug("TxProfile call raised: %s.", ex) + + # Always attempt TxDefaultProfile as well (for future sessions) + try: + tx_stack = max( + 1, stack_level - 1 + ) # slightly lower to avoid overriding TxProfile + req = call.SetChargingProfile( + connector_id=target_cid, + cs_charging_profiles={ + om.charging_profile_id.value: _profile_id( + ChargingProfilePurposeType.tx_default_profile.value, target_cid + ), + om.stack_level.value: tx_stack, + om.charging_profile_kind.value: ChargingProfileKindType.relative.value, + om.charging_profile_purpose.value: ChargingProfilePurposeType.tx_default_profile.value, + om.charging_schedule.value: _mk_schedule(units_value, limit_value), + }, + ) + resp = await self.call(req) + if resp.status == ChargingProfileStatus.accepted: + txd_ok = True + else: + _LOGGER.debug("Set TxDefaultProfile rejected: %s", resp.status) + if txp_ok: + _LOGGER.debug( + f"Note: Active TxProfile applied, but TxDefaultProfile was rejected ({resp.status})." + ) + except Exception as ex: + _LOGGER.debug("Set TxDefaultProfile failed: %s", ex) + if txp_ok: + _LOGGER.debug( + f"Note: Active TxProfile applied, but TxDefaultProfile failed: {ex}" + ) + + return bool(txp_ok or txd_ok) + + async def set_availability(self, state: bool = True, connector_id: int | None = 0): + """Change availability.""" + try: + conn = 0 if connector_id in (None, 0) else int(connector_id) + except Exception: + conn = 0 + + typ = AvailabilityType.operative if state else AvailabilityType.inoperative + req = call.ChangeAvailability(connector_id=conn, type=typ) + + try: + resp = await self.call(req) + except TimeoutError as ex: + _LOGGER.debug("ChangeAvailability timed out (conn=%s): %s", conn, ex) + return False + except Exception as ex: + _LOGGER.debug("ChangeAvailability failed (conn=%s): %s", conn, ex) + return False + + try: + status = getattr(resp, "status", None) + + # Fallback: some single-connector chargers reject station-level (connectorId=0). + if status == AvailabilityStatus.rejected and conn == 0: + try: + n = int(getattr(self, "num_connectors", 1) or 1) + except Exception: + n = 1 + if n == 1: + _LOGGER.debug( + "Station-level ChangeAvailability rejected; retrying on connector 1." + ) + return await self.set_availability(state=state, connector_id=1) + + pending_key = "availability_pending" + target_str = "Operative" if state else "Inoperative" + scope_str = "station" if conn == 0 else "connector" + + metric_key = (conn, cstat.status_connector.value) + metric = self._metrics.get(metric_key) + + if status == AvailabilityStatus.scheduled: + info = { + "target": target_str, + "scope": scope_str, + "since": datetime.now(tz=UTC).isoformat(), + } + if metric is not None: + metric.extra_attr[pending_key] = info + + self.hass.async_create_task(self.update(self.settings.cpid)) + return True + + if status == AvailabilityStatus.accepted: + if metric is not None: + metric.extra_attr.pop(pending_key, None) + self.hass.async_create_task(self.update(self.settings.cpid)) + return True + + _LOGGER.warning("Failed with response: %s", resp.status) + return False + + except Exception: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Set availability failed with response {resp.status}" + ) + return False + + async def start_transaction(self, connector_id: int = 1): + """Remote start a transaction.""" + _LOGGER.info("Start transaction with remote ID tag: %s", self._remote_id_tag) + req = call.RemoteStartTransaction( + connector_id=connector_id, id_tag=self._remote_id_tag + ) + resp = await self.call(req) + if resp.status == RemoteStartStopStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Start transaction failed with response {resp.status}" + ) + return False + + async def stop_transaction(self, connector_id: int | None = None): + """Request remote stop of current transaction. + + If connector_id is provided, only stop the transaction running on that connector. + """ + # Resolve which transaction to stop + tx_id = 0 + if connector_id is not None: + # Per-connector stop: do NOT fall back to other connectors + try: + tx_id = int(self._active_tx.get(int(connector_id), 0) or 0) + except Exception: + tx_id = 0 + + # For single-connector chargers, maintain compatibility with legacy global field + if tx_id == 0: + try: + n = int(getattr(self, "num_connectors", 0) or 0) + except Exception: + n = 0 + if n == 1 and int(connector_id) in (0, 1): + tx_id = int(self.active_transaction_id or 0) + else: + # Global stop (legacy behavior): stop the known active tx, or any active tx + tx_id = int(self.active_transaction_id or 0) + if tx_id == 0: + tx_id = next((int(v) for v in self._active_tx.values() if v), 0) + + # Nothing to stop - succeed as no-op + if tx_id == 0: + return True + + req = call.RemoteStopTransaction(transaction_id=tx_id) + resp = await self.call(req) + if resp.status == RemoteStartStopStatus.accepted: + return True + + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Stop transaction failed with response {resp.status}" + ) + return False + + async def reset(self, typ: str = ResetType.hard): + """Hard reset charger unless soft reset requested.""" + self._metrics[0][cstat.reconnects.value].value = 0 + req = call.Reset(typ) + resp = await self.call(req) + if resp.status == ResetStatus.accepted: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha(f"Warning: Reset failed with response {resp.status}") + return False + + async def unlock(self, connector_id: int = 1): + """Unlock charger if requested.""" + req = call.UnlockConnector(connector_id) + resp = await self.call(req) + if resp.status == UnlockStatus.unlocked: + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha(f"Warning: Unlock failed with response {resp.status}") + return False + + async def update_firmware(self, firmware_url: str, wait_time: int = 0): + """Update charger with new firmware if available. + + - firmware_url: http/https URL of the new firmware + - wait_time: hours from now to wait before install + """ + features = int(self.supported_features or 0) + if not (features & prof.FW): + _LOGGER.warning("Charger does not support OCPP firmware updating") + return False + + schema = vol.Schema(vol.Url()) + try: + url = schema(firmware_url) + except vol.MultipleInvalid as e: + _LOGGER.warning("Failed to parse url: %s", e) + return False + + try: + retrieve_time = ( + datetime.now(tz=UTC) + timedelta(hours=max(0, int(wait_time or 0))) + ).strftime("%Y-%m-%dT%H:%M:%SZ") + except Exception: + retrieve_time = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + try: + req = call.UpdateFirmware(location=str(url), retrieve_date=retrieve_time) + resp = await self.call(req) + _LOGGER.info("UpdateFirmware response: %s", resp) + return True + except Exception as e: + _LOGGER.error("UpdateFirmware failed: %s", e) + return False + + async def get_diagnostics(self, upload_url: str): + """Upload diagnostic data to server from charger.""" + features = int(self.supported_features or 0) + if features & prof.FW: + schema = vol.Schema(vol.Url()) + try: + url = schema(upload_url) + except vol.MultipleInvalid as e: + _LOGGER.warning("Failed to parse url: %s", e) + return + req = call.GetDiagnostics(location=str(url)) + resp = await self.call(req) + _LOGGER.info("Response: %s", resp) + return True + else: + _LOGGER.debug( + "Charger %s does not support ocpp diagnostics uploading", + self.id, + ) + return False + + async def data_transfer(self, vendor_id: str, message_id: str = "", data: str = ""): + """Request vendor specific data transfer from charger.""" + req = call.DataTransfer(vendor_id=vendor_id, message_id=message_id, data=data) + resp = await self.call(req) + if resp.status == DataTransferStatus.accepted: + _LOGGER.info( + "Data transfer [vendorId(%s), messageId(%s), data(%s)] response: %s", + vendor_id, + message_id, + data, + resp.data, + ) + self._metrics[0][cdet.data_response.value].value = datetime.now(tz=UTC) + self._metrics[0][cdet.data_response.value].extra_attr = { + message_id: resp.data + } + return True + else: + _LOGGER.warning("Failed with response: %s", resp.status) + await self.notify_ha( + f"Warning: Data transfer failed with response {resp.status}" + ) + return False + + async def get_configuration(self, key: str = "") -> str | dict | None: + """Get Configuration of charger for supported keys. + + When key is empty, returns a dict of all configuration key-value pairs. + When key is specified, returns the value as a string. + """ + if key == "": + req = call.GetConfiguration() + else: + req = call.GetConfiguration(key=[key]) + resp = await self.call(req) + if resp.configuration_key: + if key == "": + result = {} + for entry in resp.configuration_key: + entry_key = entry.get("key", "") + entry_value = entry.get(om.value.value, "") + result[entry_key] = entry_value + _LOGGER.debug("Get Configuration returned %d keys", len(result)) + return result + value = resp.configuration_key[0][om.value.value] + _LOGGER.debug("Get Configuration for %s: %s", key, value) + self._metrics[0][cdet.config_response.value].value = datetime.now(tz=UTC) + self._metrics[0][cdet.config_response.value].extra_attr = {key: value} + return value + if resp.unknown_key: + _LOGGER.warning("Get Configuration returned unknown key for: %s", key) + await self.notify_ha(f"Warning: charger reports {key} is unknown") + return "Unknown" + + async def configure(self, key: str, value: str): + """Configure charger by setting the key to target value. + + First the configuration key is read using GetConfiguration. The key's + value is compared with the target value. If the key is already set to + the correct value nothing is done. + + If the key has a different value a ChangeConfiguration request is issued. + + """ + req = call.GetConfiguration(key=[key]) + + resp = await self.call(req) + + if resp.unknown_key is not None: + if key in resp.unknown_key: + _LOGGER.warning("%s is unknown (not supported)", key) + return "Unknown" + + for key_value in resp.configuration_key: + # If the key already has the targeted value we don't need to set + # it. + if key_value[om.key.value] == key and key_value[om.value.value] == value: + return + + if key_value.get(om.readonly.name, False): + _LOGGER.warning("%s is a read only setting", key) + await self.notify_ha(f"Warning: {key} is read-only") + + req = call.ChangeConfiguration(key=key, value=value) + + resp = await self.call(req) + + if resp.status in [ + ConfigurationStatus.rejected, + ConfigurationStatus.not_supported, + ]: + _LOGGER.warning("%s while setting %s to %s", resp.status, key, value) + await self.notify_ha( + f"Warning: charger reported {resp.status} while setting {key}={value}" + ) + return resp.status + + if resp.status == ConfigurationStatus.reboot_required: + self._requires_reboot = True + await self.notify_ha(f"A reboot is required to apply {key}={value}") + return SetVariableResult.reboot_required + + return SetVariableResult.accepted + + async def async_update_device_info_v16(self, boot_info: dict): + """Update device info asynchronuously.""" + + _LOGGER.debug("Updating device info %s: %s", self.settings.cpid, boot_info) + await self.async_update_device_info( + boot_info.get(om.charge_point_serial_number.name, None), + boot_info.get(om.charge_point_vendor.name, None), + boot_info.get(om.charge_point_model.name, None), + boot_info.get(om.firmware_version.name, None), + ) + + @on(Action.meter_values) + def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs): + """Request handler for MeterValues Calls (multi-connector aware).""" + + transaction_id: int = int(kwargs.get(om.transaction_id.name, 0) or 0) + tx_has_id: bool = transaction_id not in (None, 0) + + # Restore missing per-connector meter_start / active_transaction_id from HA if possible. + ms_key = (connector_id, csess.meter_start.value) + tx_key = (connector_id, csess.transaction_id.value) + session_key = (connector_id, csess.session_time.value) + + if self._metrics[ms_key].value is None: + value = self.get_ha_metric(csess.meter_start.value, connector_id) + if value is None: + m = self._metrics.get((connector_id, DEFAULT_MEASURAND)) + value = m.value if m is not None else None + else: + try: + value = float(value) + _LOGGER.debug( + "%s[%s] was None, restored value=%s from HA.", + csess.meter_start.value, + connector_id, + value, + ) + except (ValueError, TypeError): + value = None + self._metrics[ms_key].value = value + + if self._metrics[tx_key].value is None: + value = self.get_ha_metric(csess.transaction_id.value, connector_id) + if value is None: + value = transaction_id if transaction_id else None + else: + try: + value = int(value) + _LOGGER.debug( + "%s[%s] was None, restored value=%s from HA.", + csess.transaction_id.value, + connector_id, + value, + ) + except (ValueError, TypeError): + value = None + self._metrics[tx_key].value = value + # Track active tx per connector + self._active_tx[connector_id] = value + + if connector_id not in self._active_tx: + try: + self._active_tx[connector_id] = int(self._metrics[tx_key].value or 0) + except Exception: + self._active_tx[connector_id] = 0 + + recorded_tx = int(self._metrics[tx_key].value or 0) + active_tx = int(self._active_tx.get(connector_id, 0) or 0) + + # Self-heal after restart: adopt incoming txId if we have none recorded yet + if transaction_id and (recorded_tx == 0 and active_tx == 0): + self._metrics[tx_key].value = transaction_id + self._active_tx[connector_id] = transaction_id + active_tx = transaction_id + recorded_tx = transaction_id + _LOGGER.debug( + "Restored transactionId=%s on conn %s from MeterValues.", + transaction_id, + connector_id, + ) + + # Keep legacy field synced for single-connector chargers, + # even if self-heal did not run (e.g., values were already restored). + try: + n_con = int(getattr(self, "num_connectors", 1) or 1) + except Exception: + n_con = 1 + if n_con == 1: + try: + legacy = int(getattr(self, "active_transaction_id", 0) or 0) + except Exception: + legacy = 0 + if legacy != int(active_tx or 0): + self.active_transaction_id = int(active_tx or 0) + + transaction_matches: bool = False + # Match is also false if no transaction is in progress, i.e. active_tx==transaction_id==0 + if transaction_id == active_tx and transaction_id != 0: + transaction_matches = True + elif transaction_id != 0 and active_tx != 0 and transaction_id != active_tx: + _LOGGER.warning( + "Unknown transaction detected on conn %s with id=%i (expected %s)", + connector_id, + transaction_id, + active_tx, + ) + + meter_values: list[list[MeasurandValue]] = [] + for bucket in meter_value: + measurands: list[MeasurandValue] = [] + for sampled_value in bucket.get(om.sampled_value.name, []): + measurand = sampled_value.get(om.measurand.value, None) + value = sampled_value.get(om.value.value, None) + # Where an empty string is supplied convert to 0 + try: + value = float(value) + except (ValueError, TypeError): + value = 0.0 + unit = sampled_value.get(om.unit.value, None) + phase = sampled_value.get(om.phase.value, None) + location = sampled_value.get(om.location.value, None) + context = sampled_value.get(om.context.value, None) + measurands.append( + MeasurandValue(measurand, value, phase, unit, context, location) + ) + meter_values.append(measurands) + + self.process_measurands(meter_values, transaction_matches, connector_id) + + if tx_has_id and transaction_matches: + try: + tx_start_epoch = float(self._metrics[tx_key].value) + except (TypeError, ValueError): + tx_start_epoch = time.time() + if tx_start_epoch > 0: + self._metrics[session_key].value = round( + (time.time() - tx_start_epoch) / 60 + ) + self._metrics[session_key].unit = UnitOfTime.MINUTES + else: + _LOGGER.debug( + "Skipping session time calc — invalid tx_start_epoch=%s", + tx_start_epoch, + ) + self.hass.async_create_task(self.update(self.settings.cpid)) + return call_result.MeterValues() + + @on(Action.boot_notification) + def on_boot_notification(self, **kwargs): + """Handle a boot notification.""" + resp = call_result.BootNotification( + current_time=datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + interval=3600, + status=RegistrationStatus.accepted.value, + ) + self.received_boot_notification = True + _LOGGER.debug("Received boot notification for %s: %s", self.id, kwargs) + + self.hass.async_create_task(self.async_update_device_info_v16(kwargs)) + self._register_boot_notification() + return resp + + @on(Action.status_notification) + def on_status_notification(self, connector_id, error_code, status, **kwargs): + """Handle a status notification.""" + + if connector_id == 0 or connector_id is None: + self._metrics[(0, cstat.status.value)].value = status + self._metrics[(0, cstat.error_code.value)].value = error_code + else: + self._metrics[(connector_id, cstat.status_connector.value)].value = status + self._metrics[ + (connector_id, cstat.error_code_connector.value) + ].value = error_code + + if status in ( + ChargePointStatus.suspended_ev.value, + ChargePointStatus.suspended_evse.value, + ): + for meas in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + if meas in self._metrics[connector_id]: + self._metrics[(connector_id, meas)].value = 0 + + self.hass.async_create_task(self.update(self.settings.cpid)) + return call_result.StatusNotification() + + @on(Action.firmware_status_notification) + def on_firmware_status(self, status, **kwargs): + """Handle firmware status notification.""" + self._metrics[0][cstat.firmware_status.value].value = status + self.hass.async_create_task(self.update(self.settings.cpid)) + self.hass.async_create_task(self.notify_ha(f"Firmware upload status: {status}")) + return call_result.FirmwareStatusNotification() + + @on(Action.diagnostics_status_notification) + def on_diagnostics_status(self, status, **kwargs): + """Handle diagnostics status notification.""" + _LOGGER.info("Diagnostics upload status: %s", status) + self.hass.async_create_task( + self.notify_ha(f"Diagnostics upload status: {status}") + ) + return call_result.DiagnosticsStatusNotification() + + @on(Action.security_event_notification) + def on_security_event(self, type, timestamp, **kwargs): + """Handle security event notification.""" + _LOGGER.info( + "Security event notification received: %s at %s [techinfo: %s]", + type, + timestamp, + kwargs.get(om.tech_info.name, "none"), + ) + self.hass.async_create_task( + self.notify_ha(f"Security event notification received: {type}") + ) + return call_result.SecurityEventNotification() + + @on(Action.authorize) + def on_authorize(self, id_tag, **kwargs): + """Handle an Authorization request.""" + self._metrics[0][cstat.id_tag.value].value = id_tag + auth_status = self.get_authorization_status(id_tag) + return call_result.Authorize(id_tag_info={om.status.value: auth_status}) + + @on(Action.start_transaction) + def on_start_transaction(self, connector_id, id_tag, meter_start, **kwargs): + """Handle a Start Transaction request.""" + + auth_status = self.get_authorization_status(id_tag) + if auth_status == AuthorizationStatus.accepted.value: + tx_id = int(time.time()) + self._active_tx[connector_id] = tx_id + self.active_transaction_id = tx_id + self._metrics[(connector_id, cstat.id_tag.value)].value = id_tag + self._metrics[(connector_id, cstat.stop_reason.value)].value = "" + self._metrics[(connector_id, csess.transaction_id.value)].value = tx_id + try: + meter_start_kwh = float(meter_start) / 1000.0 + except Exception: + meter_start_kwh = 0.0 + self._metrics[ + (connector_id, csess.meter_start.value) + ].value = meter_start_kwh + self._metrics[(connector_id, csess.meter_start.value)].unit = HA_ENERGY_UNIT + + self._metrics[(connector_id, csess.session_time.value)].value = 0 + self._metrics[ + (connector_id, csess.session_time.value) + ].unit = UnitOfTime.MINUTES + self._metrics[(connector_id, csess.session_energy.value)].value = 0.0 + self._metrics[ + (connector_id, csess.session_energy.value) + ].unit = HA_ENERGY_UNIT + + result = call_result.StartTransaction( + id_tag_info={om.status.value: AuthorizationStatus.accepted.value}, + transaction_id=tx_id, + ) + else: + result = call_result.StartTransaction( + id_tag_info={om.status.value: auth_status}, + transaction_id=0, + ) + + self.hass.async_create_task(self.update(self.settings.cpid)) + return result + + @on(Action.stop_transaction) + def on_stop_transaction(self, meter_stop, timestamp, transaction_id, **kwargs): + """Stop the current transaction (multi-connector).""" + + # Resolve connector from active tx map + conn = next( + (c for c, tx in self._active_tx.items() if tx == transaction_id), None + ) + if conn is None: + _LOGGER.error( + "Stop transaction received for unknown transaction id=%i", + transaction_id, + ) + conn = 1 # conservative fallback + + # Reset active transaction (global + per-connector) + self._active_tx[conn] = 0 + self.active_transaction_id = 0 + self._metrics[(conn, cstat.id_tag.value)].value = "" + self._metrics[(conn, csess.transaction_id.value)].value = 0 + self._metrics[(conn, cstat.stop_reason.value)].value = kwargs.get( + om.reason.name, None + ) + + ms_key = (conn, csess.meter_start.value) + if ( + self._metrics[ms_key].value is not None + and not self._charger_reports_session_energy + ): + try: + session_kwh = int(meter_stop) / 1000.0 - float( + self._metrics[ms_key].value + ) + except Exception: + session_kwh = 0.0 + self._metrics[(conn, csess.session_energy.value)].value = session_kwh + + for meas in [ + Measurand.current_import.value, + Measurand.power_active_import.value, + Measurand.power_reactive_import.value, + Measurand.current_export.value, + Measurand.power_active_export.value, + Measurand.power_reactive_export.value, + ]: + key = (conn, meas) + if key in self._metrics: + self._metrics[key].value = 0 + + self.hass.async_create_task(self.update(self.settings.cpid)) + return call_result.StopTransaction( + id_tag_info={om.status.value: AuthorizationStatus.accepted.value} + ) + + @on(Action.data_transfer) + def on_data_transfer(self, vendor_id, **kwargs): + """Handle a Data transfer request.""" + _LOGGER.debug("Data transfer received from %s: %s", self.id, kwargs) + self._metrics[0][cdet.data_transfer.value].value = datetime.now(tz=UTC) + self._metrics[0][cdet.data_transfer.value].extra_attr = {vendor_id: kwargs} + return call_result.DataTransfer(status=DataTransferStatus.accepted.value) + + @on(Action.heartbeat) + def on_heartbeat(self, **kwargs): + """Handle a Heartbeat.""" + now = datetime.now(tz=UTC) + self._metrics[0][cstat.heartbeat.value].value = now + self.hass.async_create_task(self.update(self.settings.cpid)) + return call_result.Heartbeat(current_time=now.strftime("%Y-%m-%dT%H:%M:%SZ")) diff --git a/custom_components/ocpp/ocppv201.py b/custom_components/ocpp/ocppv201.py new file mode 100644 index 00000000..cd6c07ff --- /dev/null +++ b/custom_components/ocpp/ocppv201.py @@ -0,0 +1,900 @@ +"""Representation of a OCPP 2.0.1 or 2.1 charging station.""" + +import asyncio +import contextlib +from datetime import datetime, UTC +from dataclasses import dataclass, field +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError, HomeAssistantError +from websockets.asyncio.server import ServerConnection + +import ocpp.exceptions +from ocpp.exceptions import OCPPError +from ocpp.routing import on +from ocpp.v201 import call, call_result +from ocpp.v16.enums import ChargePointStatus as ChargePointStatusv16 +from ocpp.v201.enums import ( + Action, + ConnectorStatusEnumType, + GetVariableStatusEnumType, + IdTokenEnumType, + MeasurandEnumType, + OperationalStatusEnumType, + ResetEnumType, + ResetStatusEnumType, + SetVariableStatusEnumType, + AuthorizationStatusEnumType, + TransactionEventEnumType, + ReadingContextEnumType, + RequestStartStopStatusEnumType, + ChargingStateEnumType, + ChargingProfilePurposeEnumType, + ChargingRateUnitEnumType, + ChargingProfileKindEnumType, + ChargingProfileStatusEnumType, +) + +from .chargepoint import ( + SetVariableResult, + MeasurandValue, +) +from .chargepoint import ChargePoint as cp + +from .enums import Profiles + +from .enums import ( + HAChargerStatuses as cstat, + HAChargerSession as csess, +) + +from .const import ( + CentralSystemSettings, + ChargerSystemSettings, + DOMAIN, + HA_ENERGY_UNIT, +) + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +@dataclass +class InventoryReport: + """Cached full inventory report for a charger.""" + + evse_count: int = 0 + connector_count: list[int] = field(default_factory=list) + smart_charging_available: bool = False + reservation_available: bool = False + local_auth_available: bool = False + tx_updated_measurands: list[MeasurandEnumType] = field(default_factory=list) + + +class ChargePoint(cp): + """Server side representation of a charger.""" + + _inventory: InventoryReport | None = None + _wait_inventory: asyncio.Event | None = None + _connector_status: list[list[ConnectorStatusEnumType | None]] + _tx_start_time: dict[int, datetime] + _global_to_evse: dict[int, tuple[int, int]] # global_idx -> (evse_id, connector_id) + _evse_to_global: dict[tuple[int, int], int] # (evse_id, connector_id) -> global_idx + _pending_status_notifications: list[ + tuple[str, str, int, int] + ] # (timestamp, connector_status, evse_id, connector_id) + + def __init__( + self, + id: str, + connection: ServerConnection, + hass: HomeAssistant, + entry: ConfigEntry, + central: CentralSystemSettings, + charger: ChargerSystemSettings, + ): + """Instantiate a ChargePoint.""" + + super().__init__( + id, + connection, + connection.subprotocol.replace("ocpp", ""), + hass, + entry, + central, + charger, + ) + self._tx_start_time = {} + self._global_to_evse: dict[int, tuple[int, int]] = {} + self._evse_to_global: dict[tuple[int, int], int] = {} + self._pending_status_notifications: list[tuple[str, str, int, int]] = [] + self._connector_status = [] + + # --- Connector mapping helpers (EVSE <-> global index) --- + def _build_connector_map(self) -> bool: + if not self._inventory or self._inventory.evse_count == 0: + return False + if self._evse_to_global and self._global_to_evse: + return True + + g = 1 + self._evse_to_global.clear() + self._global_to_evse.clear() + for evse_id in range(1, self._inventory.evse_count + 1): + count = 0 + if len(self._inventory.connector_count) >= evse_id: + count = int(self._inventory.connector_count[evse_id - 1] or 0) + for conn_id in range(1, count + 1): + self._evse_to_global[(evse_id, conn_id)] = g + self._global_to_evse[g] = (evse_id, conn_id) + g += 1 + return bool(self._evse_to_global) + + def _ensure_connector_map(self) -> bool: + if self._evse_to_global and self._global_to_evse: + return True + return self._build_connector_map() + + def _pair_to_global(self, evse_id: int, conn_id: int) -> int: + """Return global index for (evse_id, conn_id).""" + # Exact match available + idx = self._evse_to_global.get((evse_id, conn_id)) + if idx is not None: + return idx + # Build from inventory if we have it + if self._inventory and not self._evse_to_global: + self._build_connector_map() + idx = self._evse_to_global.get( + (evse_id, conn_id) + ) or self._evse_to_global.get((evse_id, 1)) + if idx is not None: + return idx + # Allocate a unique index to avoid collisions until inventory arrives + new_idx = max(self._global_to_evse.keys(), default=0) + 1 + self._global_to_evse[new_idx] = (evse_id, conn_id) + self._evse_to_global[(evse_id, conn_id)] = new_idx + return new_idx + + def _global_to_pair(self, global_idx: int) -> tuple[int, int]: + """Return (evse_id, connector_id) for a global index. Fallback: (global_idx,1).""" + return self._global_to_evse.get(global_idx, (global_idx, 1)) + + def _apply_status_notification( + self, timestamp: str, connector_status: str, evse_id: int, connector_id: int + ): + """Update per connector and evse aggregated.""" + if evse_id > len(self._connector_status): + needed = evse_id - len(self._connector_status) + self._connector_status.extend([[] for _ in range(needed)]) + if connector_id > len(self._connector_status[evse_id - 1]): + self._connector_status[evse_id - 1] += [None] * ( + connector_id - len(self._connector_status[evse_id - 1]) + ) + + evse_list = self._connector_status[evse_id - 1] + evse_list[connector_id - 1] = ConnectorStatusEnumType(connector_status) + + global_idx = self._pair_to_global(evse_id, connector_id) + self._metrics[ + (global_idx, cstat.status_connector.value) + ].value = ConnectorStatusEnumType(connector_status).value + + evse_status: ConnectorStatusEnumType | None = None + for st in evse_list: + if st is None: + evse_status = None + break + evse_status = st + if st != ConnectorStatusEnumType.available: + break + if evse_status is not None: + if evse_status == ConnectorStatusEnumType.available: + v16 = ChargePointStatusv16.available + elif evse_status == ConnectorStatusEnumType.faulted: + v16 = ChargePointStatusv16.faulted + elif evse_status == ConnectorStatusEnumType.unavailable: + v16 = ChargePointStatusv16.unavailable + else: + v16 = ChargePointStatusv16.preparing + self._report_evse_status(evse_id, v16) + + def _flush_pending_status_notifications(self): + """Flush buffered status notifications when the map is ready.""" + if not self._ensure_connector_map(): + return + pending = self._pending_status_notifications + self._pending_status_notifications = [] + for t, st, evse_id, conn_id in pending: + self._apply_status_notification(t, st, evse_id, conn_id) + self.hass.async_create_task(self.update(self.settings.cpid)) + + def _total_connectors(self) -> int: + """Total physical connectors across all EVSE.""" + if not self._inventory: + return 0 + return sum(self._inventory.connector_count or [0]) + + async def async_update_device_info_v201(self, boot_info: dict): + """Update device info asynchronuously.""" + + _LOGGER.debug("Updating device info %s: %s", self.settings.cpid, boot_info) + await self.async_update_device_info( + boot_info.get("serial_number", None), + boot_info.get("vendor_name", None), + boot_info.get("model", None), + boot_info.get("firmware_version", None), + ) + + async def _get_inventory(self): + if self._inventory is not None: + return + self._wait_inventory = asyncio.Event() + req = call.GetBaseReport(1, "FullInventory") + resp: call_result.GetBaseReport | None = None + try: + resp = await self.call(req) + except ocpp.exceptions.NotImplementedError: + self._inventory = InventoryReport() + except OCPPError: + self._inventory = None + if (resp is not None) and (resp.status == "Accepted"): + await asyncio.wait_for(self._wait_inventory.wait(), self._response_timeout) + self._wait_inventory = None + if self._inventory: + self._build_connector_map() + + async def get_number_of_connectors(self) -> int: + """Return number of connectors on this charger.""" + await self._get_inventory() + return self._total_connectors() + + async def set_standard_configuration(self): + """Send configuration values to the charger.""" + req = call.SetVariables( + [ + { + "component": {"name": "SampledDataCtrlr"}, + "variable": {"name": "TxUpdatedInterval"}, + "attribute_value": str(self.settings.meter_interval), + } + ] + ) + await self.call(req) + + async def get_supported_measurands(self) -> str: + """Get comma-separated list of measurands supported by the charger.""" + await self._get_inventory() + if self._inventory: + measurands: str = ",".join( + measurand.value for measurand in self._inventory.tx_updated_measurands + ) + req = call.SetVariables( + [ + { + "component": {"name": "SampledDataCtrlr"}, + "variable": {"name": "TxUpdatedMeasurands"}, + "attribute_value": measurands, + } + ] + ) + await self.call(req) + return measurands + return "" + + async def get_supported_features(self) -> Profiles: + """Get feature profiles supported by the charger.""" + await self._get_inventory() + features = Profiles.CORE + if self._inventory and self._inventory.smart_charging_available: + features |= Profiles.SMART + if self._inventory and self._inventory.reservation_available: + features |= Profiles.RES + if self._inventory and self._inventory.local_auth_available: + features |= Profiles.AUTH + + fw_req = call.UpdateFirmware( + 1, + { + "location": "dummy://dummy", + "retrieveDateTime": datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + "signature": "☺", + }, + ) + try: + await self.call(fw_req) + features |= Profiles.FW + except OCPPError as e: + _LOGGER.info("Firmware update not supported: %s", e) + + trigger_req = call.TriggerMessage("StatusNotification") + try: + await self.call(trigger_req) + features |= Profiles.REM + except OCPPError as e: + _LOGGER.info("TriggerMessage not supported: %s", e) + + return features + + async def trigger_status_notification(self): + """Trigger status notifications for all connectors.""" + if not self._inventory: + return + for evse_id in range(1, self._inventory.evse_count + 1): + for connector_id in range( + 1, self._inventory.connector_count[evse_id - 1] + 1 + ): + req = call.TriggerMessage( + "StatusNotification", + evse={"id": evse_id, "connector_id": connector_id}, + ) + await self.call(req) + + async def clear_profile(self): + """Clear all charging profiles.""" + req: call.ClearChargingProfile = call.ClearChargingProfile( + None, + { + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value + }, + ) + await self.call(req) + + async def set_charge_rate( + self, + limit_amps: int | None = None, + limit_watts: int | None = None, + conn_id: int = 0, + profile: dict | None = None, + ): + """Set a charging profile with defined limit (OCPP 2.x). + + - conn_id=0 (default) targets the Charging Station (evse_id=0). + - conn_id>0 targets the specific EVSE corresponding to the global connector index. + """ + + evse_target = 0 + if conn_id and conn_id > 0: + with contextlib.suppress(Exception): + evse_target, _ = self._global_to_pair(int(conn_id)) + if profile is not None: + req = call.SetChargingProfile(evse_target, profile) + resp: call_result.SetChargingProfile = await self.call(req) + if resp.status != ChargingProfileStatusEnumType.accepted: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_variables_error", + translation_placeholders={ + "message": f"{str(resp.status)}: {str(resp.status_info)}" + }, + ) + return + + if limit_watts is not None: + if float(limit_watts) >= 22000: + await self.clear_profile() + return + period_limit = int(limit_watts) + unit_value = ChargingRateUnitEnumType.watts.value + + elif limit_amps is not None: + if float(limit_amps) >= 32: + await self.clear_profile() + return + period_limit = ( + int(limit_amps) if float(limit_amps).is_integer() else float(limit_amps) + ) + unit_value = ChargingRateUnitEnumType.amps.value + + else: + await self.clear_profile() + return + + schedule: dict = { + "id": 1, + "charging_rate_unit": unit_value, + "charging_schedule_period": [{"start_period": 0, "limit": period_limit}], + } + + charging_profile: dict = { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, + "charging_schedule": [schedule], + } + + req: call.SetChargingProfile = call.SetChargingProfile( + evse_target, charging_profile + ) + resp: call_result.SetChargingProfile = await self.call(req) + if resp.status != ChargingProfileStatusEnumType.accepted: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_variables_error", + translation_placeholders={ + "message": f"{str(resp.status)}: {str(resp.status_info)}" + }, + ) + + async def set_availability(self, state: bool = True, connector_id: int | None = 0): + """Change availability.""" + status = ( + OperationalStatusEnumType.operative.value + if state + else OperationalStatusEnumType.inoperative.value + ) + if not connector_id: + await self.call(call.ChangeAvailability(status)) + return + + evse_id = None + with contextlib.suppress(Exception): + evse_id, _ = self._global_to_pair(int(connector_id)) + + if evse_id: + await self.call(call.ChangeAvailability(status, evse={"id": evse_id})) + else: + await self.call(call.ChangeAvailability(status)) + + async def start_transaction(self, connector_id: int = 1) -> bool: + """Remote start a transaction.""" + evse_id = connector_id + if connector_id and connector_id > 0: + evse_id, _ = self._global_to_pair(connector_id) + + req: call.RequestStartTransaction = call.RequestStartTransaction( + evse_id=evse_id, + id_token={ + "id_token": self._remote_id_tag, + "type": IdTokenEnumType.central.value, + }, + remote_start_id=1, + ) + resp: call_result.RequestStartTransaction = await self.call(req) + return resp.status == RequestStartStopStatusEnumType.accepted.value + + async def stop_transaction(self, connector_id: int | None = None) -> bool: + """Request remote stop of current transaction. + + If connector_id is provided, only stop the transaction running on that EVSE. + If connector_id is None, stop the first active transaction found (legacy behavior). + """ + await self._get_inventory() + + # Determine total EVSEs (connectors) if available + total = int(self._total_connectors() or 1) + + tx_id: str | None = None + + if connector_id is not None: + # Per-connector stop: do NOT fall back to other EVSEs + evse = int(connector_id) + if evse < 1 or evse > total: + _LOGGER.info("Requested EVSE %s is out of range (1..%s)", evse, total) + return False + val = self._metrics[(evse, csess.transaction_id.value)].value + tx_id = str(val) if val else None + else: + # Global stop: find the first active transaction across EVSEs + for evse in range(1, total + 1): + val = self._metrics[(evse, csess.transaction_id.value)].value + if val: + tx_id = str(val) + break + + if not tx_id: + _LOGGER.info("No active transaction found to stop") + return False + + req: call.RequestStopTransaction = call.RequestStopTransaction( + transaction_id=tx_id + ) + resp: call_result.RequestStopTransaction = await self.call(req) + return resp.status == RequestStartStopStatusEnumType.accepted.value + + async def reset(self, typ: str = ""): + """Hard reset charger unless soft reset requested.""" + req: call.Reset = call.Reset(ResetEnumType.immediate) + resp = await self.call(req) + if resp.status != ResetStatusEnumType.accepted.value: + status_suffix: str = f": {resp.status_info}" if resp.status_info else "" + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ocpp_call_error", + translation_placeholders={"message": resp.status + status_suffix}, + ) + + @staticmethod + def _parse_ocpp_key(key: str) -> tuple: + try: + [c, v] = key.split("/") + except ValueError: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_ocpp_key", + ) + [cname, paren, cinstance] = c.partition("(") + cinstance = cinstance.partition(")")[0] + [vname, paren, vinstance] = v.partition("(") + vinstance = vinstance.partition(")")[0] + component: dict = {"name": cname} + if cinstance: + component["instance"] = cinstance + variable: dict = {"name": vname} + if vinstance: + variable["instance"] = vinstance + return component, variable + + async def get_configuration(self, key: str = "") -> str | None: + """Get Configuration of charger for supported keys else return None.""" + component, variable = self._parse_ocpp_key(key) + req: call.GetVariables = call.GetVariables( + [{"component": component, "variable": variable}] + ) + try: + resp: call_result.GetVariables = await self.call(req) + except Exception as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ocpp_call_error", + translation_placeholders={"message": str(e)}, + ) + result: dict = resp.get_variable_result[0] + if result["attribute_status"] != GetVariableStatusEnumType.accepted: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_variables_error", + translation_placeholders={"message": str(result)}, + ) + return result["attribute_value"] + + async def configure(self, key: str, value: str) -> SetVariableResult: + """Configure charger by setting the key to target value.""" + component, variable = self._parse_ocpp_key(key) + req: call.SetVariables = call.SetVariables( + [{"component": component, "variable": variable, "attribute_value": value}] + ) + try: + resp: call_result.SetVariables = await self.call(req) + except Exception as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ocpp_call_error", + translation_placeholders={"message": str(e)}, + ) + result: dict = resp.set_variable_result[0] + if result["attribute_status"] == SetVariableStatusEnumType.accepted: + return SetVariableResult.accepted + elif result["attribute_status"] == SetVariableStatusEnumType.reboot_required: + return SetVariableResult.reboot_required + else: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_variables_error", + translation_placeholders={"message": str(result)}, + ) + + @on(Action.boot_notification) + def on_boot_notification(self, charging_station, reason, **kwargs): + """Perform OCPP callback.""" + resp = call_result.BootNotification( + current_time=datetime.now(tz=UTC).isoformat(), + interval=10, + status="Accepted", + ) + + self.hass.async_create_task( + self.async_update_device_info_v201(charging_station) + ) + self._inventory = None + self._register_boot_notification() + return resp + + @on(Action.heartbeat) + def on_heartbeat(self, **kwargs): + """Perform OCPP callback.""" + return call_result.Heartbeat(current_time=datetime.now(tz=UTC).isoformat()) + + def _report_evse_status(self, evse_id: int, evse_status_v16: ChargePointStatusv16): + """Report EVSE-level status on the global connector.""" + self._metrics[(0, cstat.status_connector.value)].value = evse_status_v16.value + self.hass.async_create_task(self.update(self.settings.cpid)) + + @on(Action.status_notification) + def on_status_notification( + self, timestamp: str, connector_status: str, evse_id: int, connector_id: int + ): + """Perform OCPP callback.""" + if not self._ensure_connector_map(): + self._pending_status_notifications.append( + (timestamp, connector_status, evse_id, connector_id) + ) + return call_result.StatusNotification() + + self._apply_status_notification( + timestamp, connector_status, evse_id, connector_id + ) + self.hass.async_create_task(self.update(self.settings.cpid)) + return call_result.StatusNotification() + + @on(Action.firmware_status_notification) + def on_firmware_status_notification(self, **kwargs): + """Perform OCPP callback.""" + return call_result.FirmwareStatusNotification() + + @on(Action.meter_values) + def on_meter_values(self, **kwargs): + """Perform OCPP callback.""" + return call_result.MeterValues() + + @on(Action.log_status_notification) + def on_log_status_notification(self, **kwargs): + """Perform OCPP callback.""" + return call_result.LogStatusNotification() + + @on(Action.notify_event) + def on_notify_event(self, **kwargs): + """Perform OCPP callback.""" + return call_result.NotifyEvent() + + @on(Action.notify_report) + def on_report(self, request_id: int, generated_at: str, seq_no: int, **kwargs): + """Handle OCPP 2.x inventory/report updates.""" + if self._wait_inventory is None: + return call_result.NotifyReport() + + if self._inventory is None: + self._inventory = InventoryReport() + + reports: list[dict] = kwargs.get("report_data", []) or [] + for report_data in reports: + component: dict = report_data.get("component", {}) or {} + variable: dict = report_data.get("variable", {}) or {} + component_name: str = str(component.get("name", "") or "") + variable_name: str = str(variable.get("name", "") or "") + + value: str | None = None + for attr in report_data.get("variable_attribute", []) or []: + if ("type" not in attr) or ( + str(attr.get("type", "")).casefold() == "actual" + ): + if "value" in attr: + v = attr.get("value") + value = str(v) if v is not None else None + break + + bool_value: bool = False + if value is not None and str(value).strip(): + bool_value = str(value).strip().casefold() == "true" + + if (component_name == "SmartChargingCtrlr") and ( + variable_name == "Available" + ): + self._inventory.smart_charging_available = bool_value + continue + if (component_name == "ReservationCtrlr") and ( + variable_name == "Available" + ): + self._inventory.reservation_available = bool_value + continue + if (component_name == "LocalAuthListCtrlr") and ( + variable_name == "Available" + ): + self._inventory.local_auth_available = bool_value + continue + + if (component_name == "EVSE") and ("evse" in component): + evse_id = int(component["evse"].get("id", 0) or 0) + if evse_id > 0: + self._inventory.evse_count = max( + self._inventory.evse_count, evse_id + ) + if ( + len(self._inventory.connector_count) + < self._inventory.evse_count + ): + self._inventory.connector_count += [0] * ( + self._inventory.evse_count + - len(self._inventory.connector_count) + ) + continue + + if ( + (component_name == "Connector") + and ("evse" in component) + and ("connector_id" in component["evse"]) + ): + evse_id = int(component["evse"].get("id", 0) or 0) + conn_id = int(component["evse"].get("connector_id", 0) or 0) + if evse_id > 0 and conn_id > 0: + self._inventory.evse_count = max( + self._inventory.evse_count, evse_id + ) + if ( + len(self._inventory.connector_count) + < self._inventory.evse_count + ): + self._inventory.connector_count += [0] * ( + self._inventory.evse_count + - len(self._inventory.connector_count) + ) + self._inventory.connector_count[evse_id - 1] = max( + self._inventory.connector_count[evse_id - 1], conn_id + ) + continue + + if (component_name == "SampledDataCtrlr") and ( + variable_name == "TxUpdatedMeasurands" + ): + characteristics: dict = ( + report_data.get("variable_characteristics", {}) or {} + ) + values: str = str(characteristics.get("values_list", "") or "") + meas_list = [ + s.strip() for s in values.split(",") if s is not None and s.strip() + ] + self._inventory.tx_updated_measurands = [ + MeasurandEnumType(s) for s in meas_list + ] + continue + + if not kwargs.get("tbc", False): + if hasattr(self, "_build_connector_map"): + self._build_connector_map() + if hasattr(self, "_flush_pending_status_notifications"): + self._flush_pending_status_notifications() + self._wait_inventory.set() + + return call_result.NotifyReport() + + @on(Action.authorize) + def on_authorize(self, id_token: dict, **kwargs): + """Perform OCPP callback.""" + status: str = AuthorizationStatusEnumType.unknown.value + token_type: str = id_token["type"] + token: str = id_token["id_token"] + if ( + (token_type == IdTokenEnumType.iso14443) + or (token_type == IdTokenEnumType.iso15693) + or (token_type == IdTokenEnumType.central) + ): + status = self.get_authorization_status(token) + return call_result.Authorize(id_token_info={"status": status}) + + def _set_meter_values( + self, + tx_event_type: str, + meter_values: list[dict], + evse_id: int, + connector_id: int, + ): + global_idx: int = self._pair_to_global(evse_id, connector_id) + converted_values: list[list[MeasurandValue]] = [] + for meter_value in meter_values: + measurands: list[MeasurandValue] = [] + for sampled_value in meter_value["sampled_value"]: + measurand: str = sampled_value.get( + "measurand", MeasurandEnumType.energy_active_import_register.value + ) + value: float = sampled_value["value"] + context: str = sampled_value.get("context", None) + phase: str = sampled_value.get("phase", None) + location: str = sampled_value.get("location", None) + unit_struct: dict = sampled_value.get("unit_of_measure", {}) + unit: str = unit_struct.get("unit", None) + multiplier: int = unit_struct.get("multiplier", 0) + if multiplier != 0: + value *= pow(10, multiplier) + measurands.append( + MeasurandValue(measurand, value, phase, unit, context, location) + ) + converted_values.append(measurands) + + if (tx_event_type == TransactionEventEnumType.started.value) or ( + (tx_event_type == TransactionEventEnumType.updated.value) + and (self._metrics[(global_idx, csess.meter_start.value)].value is None) + ): + energy_measurand = MeasurandEnumType.energy_active_import_register.value + for meter_value in converted_values: + for measurand_item in meter_value: + if measurand_item.measurand == energy_measurand: + energy_value = cp.get_energy_kwh(measurand_item) + energy_unit = HA_ENERGY_UNIT if measurand_item.unit else None + self._metrics[ + (global_idx, csess.meter_start.value) + ].value = energy_value + self._metrics[ + (global_idx, csess.meter_start.value) + ].unit = energy_unit + + self.process_measurands(converted_values, True, global_idx) + + if tx_event_type == TransactionEventEnumType.ended.value: + measurands_in_tx: set[str] = set() + tx_end_context = ReadingContextEnumType.transaction_end.value + for meter_value in converted_values: + for measurand_item in meter_value: + if measurand_item.context == tx_end_context: + measurands_in_tx.add(measurand_item.measurand) + if self._inventory: + for measurand in self._inventory.tx_updated_measurands: + if ( + (measurand not in measurands_in_tx) + and ((global_idx, measurand) in self._metrics) + and not measurand.startswith("Energy") + ): + self._metrics[(global_idx, measurand)].value = 0 + + @on(Action.transaction_event) + def on_transaction_event( + self, + event_type, + timestamp, + trigger_reason, + seq_no, + transaction_info, + **kwargs, + ): + """Perform OCPP callback.""" + evse_id: int = kwargs["evse"]["id"] if "evse" in kwargs else 1 + evse_conn_id: int = ( + kwargs["evse"].get("connector_id", 1) if "evse" in kwargs else 1 + ) + global_idx: int = self._pair_to_global(evse_id, evse_conn_id) + offline: bool = kwargs.get("offline", False) + meter_values: list[dict] = kwargs.get("meter_value", []) + self._set_meter_values(event_type, meter_values, evse_id, evse_conn_id) + t = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + + if "charging_state" in transaction_info: + state = transaction_info["charging_state"] + evse_status_v16: ChargePointStatusv16 | None = None + if state == ChargingStateEnumType.idle: + evse_status_v16 = ChargePointStatusv16.available + elif state == ChargingStateEnumType.ev_connected: + evse_status_v16 = ChargePointStatusv16.preparing + elif state == ChargingStateEnumType.suspended_evse: + evse_status_v16 = ChargePointStatusv16.suspended_evse + elif state == ChargingStateEnumType.suspended_ev: + evse_status_v16 = ChargePointStatusv16.suspended_ev + elif state == ChargingStateEnumType.charging: + evse_status_v16 = ChargePointStatusv16.charging + if evse_status_v16: + self._report_evse_status(evse_id, evse_status_v16) + + response = call_result.TransactionEvent() + id_token = kwargs.get("id_token") + if id_token: + response.id_token_info = {"status": AuthorizationStatusEnumType.accepted} + id_tag_string: str = id_token["type"] + ":" + id_token["id_token"] + self._metrics[(global_idx, cstat.id_tag.value)].value = id_tag_string + + if event_type == TransactionEventEnumType.started.value: + self._tx_start_time[global_idx] = t + tx_id: str = transaction_info["transaction_id"] + self._metrics[(global_idx, csess.transaction_id.value)].value = tx_id + self._metrics[(global_idx, csess.session_time.value)].value = 0 + self._metrics[ + (global_idx, csess.session_time.value) + ].unit = UnitOfTime.MINUTES + else: + if self._tx_start_time.get(global_idx): + elapsed = (t - self._tx_start_time[global_idx]).total_seconds() + duration_minutes: int = int((elapsed + 59) // 60) + self._metrics[ + (global_idx, csess.session_time.value) + ].value = duration_minutes + self._metrics[ + (global_idx, csess.session_time.value) + ].unit = UnitOfTime.MINUTES + if event_type == TransactionEventEnumType.ended.value: + self._metrics[(global_idx, csess.transaction_id.value)].value = "" + self._metrics[(global_idx, cstat.id_tag.value)].value = "" + self._tx_start_time.pop(global_idx, None) + + if not offline: + self.hass.async_create_task(self.update(self.settings.cpid)) + + return response diff --git a/custom_components/ocpp/sensor.py b/custom_components/ocpp/sensor.py index e8ba9887..cc62490f 100644 --- a/custom_components/ocpp/sensor.py +++ b/custom_components/ocpp/sensor.py @@ -1,8 +1,8 @@ """Sensor platform for ocpp.""" + from __future__ import annotations from dataclasses import dataclass - import homeassistant from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -14,15 +14,19 @@ ) from homeassistant.const import CONF_MONITORED_VARIABLES from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.util import slugify from .api import CentralSystem from .const import ( CONF_CPID, + CONF_CPIDS, + CONF_NUM_CONNECTORS, DATA_UPDATED, DEFAULT_CLASS_UNITS_HA, - DEFAULT_CPID, + DEFAULT_NUM_CONNECTORS, DOMAIN, ICON, Measurand, @@ -34,45 +38,145 @@ class OcppSensorDescription(SensorEntityDescription): """Class to describe a Sensor entity.""" - scale: int = 1 # used for rounding metric metric: str | None = None async def async_setup_entry(hass, entry, async_add_devices): """Configure the sensor platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - cp_id = entry.data.get(CONF_CPID, DEFAULT_CPID) - entities = [] - SENSORS = [] - for metric in list( - set(entry.data[CONF_MONITORED_VARIABLES].split(",") + list(HAChargerSession)) - ): - SENSORS.append( - OcppSensorDescription( - key=metric.lower(), - name=metric.replace(".", " "), - metric=metric, - ) - ) - for metric in list(HAChargerStatuses) + list(HAChargerDetails): - SENSORS.append( - OcppSensorDescription( - key=metric.lower(), - name=metric.replace(".", " "), - metric=metric, - entity_category=EntityCategory.DIAGNOSTIC, + entities: list[ChargePointMetric] = [] + ent_reg = er.async_get(hass) + + # setup all chargers added to config + for charger in entry.data[CONF_CPIDS]: + cp_id_settings = list(charger.values())[0] + cpid = cp_id_settings[CONF_CPID] + + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break + + configured = [ + m.strip() + for m in str(cp_id_settings.get(CONF_MONITORED_VARIABLES, "")).split(",") + if m and m.strip() + ] + default_measurands: list[str] = [] + measurands = sorted(configured or default_measurands) + + CHARGER_ONLY = [ + HAChargerStatuses.status.value, + HAChargerStatuses.error_code.value, + HAChargerStatuses.firmware_status.value, + HAChargerStatuses.heartbeat.value, + HAChargerStatuses.id_tag.value, + HAChargerStatuses.latency_ping.value, + HAChargerStatuses.latency_pong.value, + HAChargerStatuses.reconnects.value, + HAChargerDetails.identifier.value, + HAChargerDetails.vendor.value, + HAChargerDetails.model.value, + HAChargerDetails.serial.value, + HAChargerDetails.firmware_version.value, + HAChargerDetails.features.value, + HAChargerDetails.connectors.value, + HAChargerDetails.config_response.value, + HAChargerDetails.data_response.value, + HAChargerDetails.data_transfer.value, + ] + + CONNECTOR_ONLY = measurands + [ + HAChargerStatuses.status_connector.value, + HAChargerStatuses.error_code_connector.value, + HAChargerStatuses.stop_reason.value, + HAChargerSession.transaction_id.value, + HAChargerSession.session_time.value, + HAChargerSession.session_energy.value, + HAChargerSession.meter_start.value, + ] + + def _mk_desc(metric: str, *, cat_diag: bool = False) -> OcppSensorDescription: + ms = str(metric).strip() + return OcppSensorDescription( + key=ms.lower().replace(".", "_"), + name=ms.replace(".", " "), + metric=ms, + entity_category=EntityCategory.DIAGNOSTIC if cat_diag else None, ) - ) - for ent in SENSORS: - entities.append( - ChargePointMetric( - hass, - central_system, - cp_id, - ent, + def _uid(cpid: str, key: str, connector_id: int | None) -> str: + """Mirror ChargePointMetric unique_id construction.""" + key = key.lower() + parts = [DOMAIN, cpid, key, SENSOR_DOMAIN] + if connector_id is not None: + parts.insert(2, f"conn{connector_id}") + return ".".join(parts) + + if num_connectors > 1: + for metric in CONNECTOR_ONLY: + uid = _uid(cpid, metric, connector_id=None) + stale_eid = ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, uid) + if stale_eid: + # Remove the old entity so it doesn't linger as 'unavailable' + ent_reg.async_remove(stale_eid) + + # Root/charger-entities + for metric in CHARGER_ONLY: + entities.append( + ChargePointMetric( + hass, + central_system, + cpid, + _mk_desc(metric, cat_diag=True), + connector_id=None, + ) ) - ) + + if num_connectors > 1: + for conn_id in range(1, num_connectors + 1): + for metric in CONNECTOR_ONLY: + entities.append( + ChargePointMetric( + hass, + central_system, + cpid, + _mk_desc( + metric, + cat_diag=metric + in [ + HAChargerStatuses.status_connector.value, + HAChargerStatuses.error_code_connector.value, + ], + ), + connector_id=conn_id, + ) + ) + else: + for metric in CONNECTOR_ONLY: + entities.append( + ChargePointMetric( + hass, + central_system, + cpid, + _mk_desc( + metric, + cat_diag=metric + in [ + HAChargerStatuses.status_connector.value, + HAChargerStatuses.error_code_connector.value, + ], + ), + connector_id=None, + ) + ) async_add_devices(entities, False) @@ -80,52 +184,70 @@ async def async_setup_entry(hass, entry, async_add_devices): class ChargePointMetric(RestoreSensor, SensorEntity): """Individual sensor for charge point metrics.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppSensorDescription def __init__( self, hass: HomeAssistant, central_system: CentralSystem, - cp_id: str, + cpid: str, description: OcppSensorDescription, + connector_id: int | None = None, ): """Instantiate instance of a ChargePointMetrics.""" self.central_system = central_system - self.cp_id = cp_id + self.cpid = cpid self.entity_description = description self.metric = self.entity_description.metric + self.connector_id = connector_id self._hass = hass self._extra_attr = {} self._last_reset = homeassistant.util.dt.utc_from_timestamp(0) - self._attr_unique_id = ".".join( - [DOMAIN, self.cp_id, self.entity_description.key, SENSOR_DOMAIN] - ) + parts = [DOMAIN, self.cpid, self.entity_description.key, SENSOR_DOMAIN] + if self.connector_id is not None: + parts.insert(2, f"conn{self.connector_id}") + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cp_id)}, - via_device=(DOMAIN, self.central_system.id), - ) + if self.connector_id is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) + + if self.connector_id is not None: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{SENSOR_DOMAIN}.{slugify(object_id)}" self._attr_icon = ICON self._attr_native_unit_of_measurement = None @property def available(self) -> bool: """Return if sensor is available.""" - return self.central_system.get_available(self.cp_id) + return self.central_system.get_available(self.cpid, self.connector_id) @property - def should_poll(self): + def should_poll(self) -> bool: """Return True if entity has to be polled for state. False if entity pushes its state to HA. """ - return True + return False @property def extra_state_attributes(self): """Return the state attributes.""" - return self.central_system.get_extra_attr(self.cp_id, self.metric) + return self.central_system.get_extra_attr( + self.cpid, self.metric, self.connector_id + ) @property def state_class(self): @@ -137,12 +259,14 @@ def state_class(self): SensorDeviceClass.CURRENT, SensorDeviceClass.VOLTAGE, SensorDeviceClass.POWER, + SensorDeviceClass.REACTIVE_POWER, SensorDeviceClass.TEMPERATURE, SensorDeviceClass.BATTERY, SensorDeviceClass.FREQUENCY, ] or self.metric in [ HAChargerStatuses.latency_ping.value, HAChargerStatuses.latency_pong.value, + HAChargerSession.session_time.value, ]: state_class = SensorStateClass.MEASUREMENT @@ -156,18 +280,22 @@ def device_class(self): device_class = SensorDeviceClass.CURRENT elif self.metric.lower().startswith("voltage"): device_class = SensorDeviceClass.VOLTAGE - elif self.metric.lower().startswith("energy."): + elif self.metric.lower().startswith("energy.r"): + device_class = None + elif self.metric.lower().startswith("energy"): device_class = SensorDeviceClass.ENERGY elif self.metric in [ Measurand.frequency, Measurand.rpm, ] or self.metric.lower().startswith("frequency"): device_class = SensorDeviceClass.FREQUENCY - elif self.metric.lower().startswith(tuple(["power.a", "power.o", "power.r"])): + elif self.metric.lower().startswith(("power.a", "power.o")): device_class = SensorDeviceClass.POWER - elif self.metric.lower().startswith("temperature."): + elif self.metric.lower().startswith("power.r"): + device_class = SensorDeviceClass.REACTIVE_POWER + elif self.metric.lower().startswith("temperature"): device_class = SensorDeviceClass.TEMPERATURE - elif self.metric.lower().startswith("timestamp.") or self.metric in [ + elif self.metric.lower().startswith("timestamp") or self.metric in [ HAChargerDetails.config_response.value, HAChargerDetails.data_response.value, HAChargerStatuses.heartbeat.value, @@ -180,9 +308,19 @@ def device_class(self): @property def native_value(self): """Return the state of the sensor, rounding if a number.""" - value = self.central_system.get_metric(self.cp_id, self.metric) - if isinstance(value, float): - value = round(value, self.entity_description.scale) + value = self.central_system.get_metric( + self.cpid, self.metric, self.connector_id + ) + + # Special case for features - show profiles as labels from IntFlag + if self.metric == HAChargerDetails.features.value and value is not None: + if hasattr(value, "labels"): + self._attr_native_value = value.labels() + else: + self._attr_native_value = str(value) + + return self._attr_native_value + if value is not None: self._attr_native_value = value return self._attr_native_value @@ -190,7 +328,9 @@ def native_value(self): @property def native_unit_of_measurement(self): """Return the native unit of measurement.""" - value = self.central_system.get_ha_unit(self.cp_id, self.metric) + value = self.central_system.get_ha_unit( + self.cpid, self.metric, self.connector_id + ) if value is not None: self._attr_native_unit_of_measurement = value else: @@ -202,14 +342,25 @@ def native_unit_of_measurement(self): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() + if restored := await self.async_get_last_sensor_data(): self._attr_native_value = restored.native_value self._attr_native_unit_of_measurement = restored.native_unit_of_measurement - async_dispatcher_connect( - self._hass, DATA_UPDATED, self._schedule_immediate_update + @callback + def _maybe_update(*args): + active_lookup = None + if args: + try: + active_lookup = set(args[0]) + except Exception: + active_lookup = None + + if active_lookup is None or self.entity_id in active_lookup: + self.async_schedule_update_ha_state(True) + + self.async_on_remove( + async_dispatcher_connect(self.hass, DATA_UPDATED, _maybe_update) ) - @callback - def _schedule_immediate_update(self): self.async_schedule_update_ha_state(True) diff --git a/custom_components/ocpp/services.yaml b/custom_components/ocpp/services.yaml index eba8be3f..b70969fe 100644 --- a/custom_components/ocpp/services.yaml +++ b/custom_components/ocpp/services.yaml @@ -4,42 +4,91 @@ set_charge_rate: name: Set maximum charge rate # Description of the service description: Sets the maximum charge rate in Amps or Watts (dependent on charger support) - # If the service accepts entity IDs, target allows the user to specify entities by entity, device, or area. If `target` is specified, `entity_id` should not be defined in the `fields` map. By default it shows only targets matching entities from the same domain as the service, but if further customization is required, target supports the entity, device, and area selectors (https://www.home-assistant.io/docs/blueprint/selectors/). Entity selector parameters will automatically be applied to device and area, and device selector parameters will automatically be applied to area. + # If the service accepts entity IDs, target allows the user to specify entities by entity, device, or area. If `target` is specified, `entity_id` should not be defined in the `fields` map. By default it shows only targets matching entities from the same domain as the service, but if further customization is required, target supports the entity, device, and area selectors (https://www.home-assistant.io/docs/blueprint/selectors/). Entity selector parameters will automatically be applied to device and area, and device selector parameters will automatically be applied to area. #target: # entity: # integration: ocpp # Different fields that your service accepts fields: # Key of the field + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger limit_amps: # Field name as shown in UI name: Limit (A) # Description of the field - description: Maximum charge rate in Amps + description: Maximum charge rate in Amps (optional) # Whether or not field is required (default = false) required: false # Advanced fields are only shown when the advanced mode is enabled for the user (default = false) - advanced: true + advanced: false # Example value that can be passed for this field example: 16 # The default field value default: 32 limit_watts: name: Limit (W) - description: Maximum charge rate in Watts + description: Maximum charge rate in Watts (optional) required: false advanced: true example: 1500 default: 22000 - + conn_id: + name: Connector identifier + description: Optional, 0 = all connectors (default), 1 is first connector + required: false + advanced: true + example: 0 + default: 0 + custom_profile: + name: Custom profile + description: Used to send a custom charge profile to charger (for advanced users only use >- or '' to ensure profile is a string variable) + required: false + advanced: true + example: '{"chargingProfileId":8,"stackLevel":0,"chargingProfileKind":"Relative","chargingProfilePurpose":"ChargePointMaxProfile","chargingSchedule":{"chargingRateUnit":"A","chargingSchedulePeriod":[{"startPeriod":0,"limit":16}]}}' + +trigger_custom_message: + name: Trigger Message + description: Request a specific message from the charger + fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger + requested_message: + name: Requested Message + description: Message Requested from the charger + required: true + advanced: true + example: "StatusNotification" + clear_profile: name: Clear charging profiles description: Clears all charging profiles (limits) set (dependent on charger support) + fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger update_firmware: name: Update charger firmware description: Specify server to download firmware and time to delay updating (dependent on charger support), supported transfer protocols can be requested by the configuration key SupportedFileTransferProtocols fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger firmware_url: name: Url of firmware description: Full url of firmware file (http or https) @@ -53,14 +102,20 @@ update_firmware: advanced: true example: 12 default: 0 - + configure: name: Configure charger features - description: Change supported Ocpp v1.6 configuration values + description: Change supported Ocpp configuration values fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger ocpp_key: - name: Configuration key name - description: Write-enabled key name supported + name: Write-enabled configuration key name + description: v1.6- Key name supported v2.0.1- Component name/Key name required: true advanced: true example: "WebSocketPingInterval" @@ -70,33 +125,51 @@ configure: required: true advanced: true example: "60" - + get_configuration: name: Get configuration values for charger - description: Change supported Ocpp v1.6 configuration values + description: Get supported Ocpp configuration values fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger ocpp_key: - name: Configuration key name - description: Key name supported - required: true + name: Configuration key name + description: v1.6- Key name v2.0.1- Component name/Key name. Leave empty to retrieve all configuration keys (v1.6 only). + required: false advanced: true example: "WebSocketPingInterval" - + get_diagnostics: name: Request diagnostic data from charger description: Specify server url to upload diagnostic data to (dependent on charger support), supported transfer protocols can be requested by the configuration key SupportedFileTransferProtocols fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger upload_url: name: Url for upload description: Full url to upload to required: true advanced: true example: "https://webhook.site/abc" - + data_transfer: name: Request data transfer from charger description: Note this is specific to charger, see manufacturer's documentation fields: + devid: + name: Charger identifier + description: Either HA charger id or Ocpp id + required: false + advanced: true + example: charger vendor_id: name: vendorId description: Defined by charger manufacturer @@ -114,4 +187,4 @@ data_transfer: description: Defined by charger manufacturer required: false advanced: true - example: "ABC" \ No newline at end of file + example: "ABC" diff --git a/custom_components/ocpp/switch.py b/custom_components/ocpp/switch.py index d0da4997..3cf2bf96 100644 --- a/custom_components/ocpp/switch.py +++ b/custom_components/ocpp/switch.py @@ -1,21 +1,32 @@ """Switch platform for ocpp.""" + from __future__ import annotations from dataclasses import dataclass -from typing import Any, Final +from typing import Final from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import POWER_KILO_WATT +from homeassistant.core import callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo - -from ocpp.v16.enums import ChargePointStatus, Measurand +from homeassistant.util import slugify +from ocpp.v16.enums import ChargePointStatus from .api import CentralSystem -from .const import CONF_CPID, DEFAULT_CPID, DOMAIN, ICON +from .const import ( + CONF_CPID, + CONF_CPIDS, + CONF_NUM_CONNECTORS, + DEFAULT_NUM_CONNECTORS, + DATA_UPDATED, + DOMAIN, + ICON, +) from .enums import HAChargerServices, HAChargerStatuses @@ -29,11 +40,12 @@ class OcppSwitchDescription(SwitchEntityDescription): on_action: str | None = None off_action: str | None = None metric_state: str | None = None - metric_condition: str | None = None + metric_condition: list[str] | None = None default_state: bool = False + per_connector: bool = False -SWITCHES: Final = [ +SWITCHES: Final[list[OcppSwitchDescription]] = [ OcppSwitchDescription( key="charge_control", name="Charge Control", @@ -46,6 +58,7 @@ class OcppSwitchDescription(SwitchEntityDescription): ChargePointStatus.suspended_evse.value, ChargePointStatus.suspended_ev.value, ], + per_connector=True, ), OcppSwitchDescription( key="availability", @@ -53,22 +66,91 @@ class OcppSwitchDescription(SwitchEntityDescription): icon=ICON, on_action=HAChargerServices.service_availability.name, off_action=HAChargerServices.service_availability.name, - metric_state=HAChargerStatuses.status_connector.value, + metric_state=HAChargerStatuses.status.value, # charger-level status metric_condition=[ChargePointStatus.available.value], default_state=True, + per_connector=False, + ), + OcppSwitchDescription( + key="connnector_availability", + name="Connector Availability", + icon=ICON, + on_action=HAChargerServices.service_availability.name, + off_action=HAChargerServices.service_availability.name, + metric_state=HAChargerStatuses.status_connector.value, # connector-level status + metric_condition=[ + ChargePointStatus.available.value, + ChargePointStatus.preparing.value, + ChargePointStatus.charging.value, + ChargePointStatus.suspended_evse.value, + ChargePointStatus.suspended_ev.value, + ChargePointStatus.finishing.value, + ChargePointStatus.reserved.value, + ], + default_state=True, + per_connector=True, ), ] async def async_setup_entry(hass, entry, async_add_devices): - """Configure the sensor platform.""" + """Configure the switch platform.""" central_system = hass.data[DOMAIN][entry.entry_id] - cp_id = entry.data.get(CONF_CPID, DEFAULT_CPID) + entities: list[ChargePointSwitch] = [] + ent_reg = er.async_get(hass) - entities = [] + for charger in entry.data[CONF_CPIDS]: + cp_settings = list(charger.values())[0] + cpid = cp_settings[CONF_CPID] - for ent in SWITCHES: - entities.append(ChargePointSwitch(central_system, cp_id, ent)) + num_connectors = 1 + for item in entry.data.get(CONF_CPIDS, []): + for _, cfg in item.items(): + if cfg.get(CONF_CPID) == cpid: + num_connectors = int( + cfg.get(CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) + ) + break + else: + continue + break + flatten_single = num_connectors == 1 + + if num_connectors > 1: + for desc in SWITCHES: + if not desc.per_connector: + continue + # unique_id used when flattened: "..." + uid_flat = ".".join([SWITCH_DOMAIN, DOMAIN, cpid, desc.key]) + stale_eid = ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, uid_flat) + if stale_eid: + ent_reg.async_remove(stale_eid) + + for desc in SWITCHES: + if desc.per_connector: + # Only create Connector Availability switches for multi-connector chargers + if desc.key == "connnector_availability" and num_connectors <= 1: + continue + for conn_id in range(1, num_connectors + 1): + entities.append( + ChargePointSwitch( + central_system, + cpid, + desc, + connector_id=conn_id, + flatten_single=flatten_single, + ) + ) + else: + entities.append( + ChargePointSwitch( + central_system, + cpid, + desc, + connector_id=None, + flatten_single=False, + ) + ) async_add_devices(entities, False) @@ -76,82 +158,127 @@ async def async_setup_entry(hass, entry, async_add_devices): class ChargePointSwitch(SwitchEntity): """Individual switch for charge point.""" - _attr_has_entity_name = True + _attr_has_entity_name = False entity_description: OcppSwitchDescription def __init__( self, central_system: CentralSystem, - cp_id: str, + cpid: str, description: OcppSwitchDescription, + connector_id: int | None = None, + flatten_single: bool = False, ): """Instantiate instance of a ChargePointSwitch.""" - self.cp_id = cp_id + self.cpid = cpid self.central_system = central_system self.entity_description = description + self.connector_id = connector_id + self._flatten_single = flatten_single self._state = self.entity_description.default_state - self._attr_unique_id = ".".join( - [SWITCH_DOMAIN, DOMAIN, self.cp_id, self.entity_description.key] - ) + parts = [SWITCH_DOMAIN, DOMAIN, cpid] + if self.connector_id and not self._flatten_single: + parts.append(f"conn{self.connector_id}") + parts.append(description.key) + self._attr_unique_id = ".".join(parts) self._attr_name = self.entity_description.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.cp_id)}, - via_device=(DOMAIN, self.central_system.id), - ) + if self.connector_id and not self._flatten_single: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{cpid}-conn{self.connector_id}")}, + name=f"{cpid} Connector {self.connector_id}", + via_device=(DOMAIN, cpid), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, cpid)}, + name=cpid, + ) + if self.connector_id is not None and not flatten_single: + object_id = f"{self.cpid}_connector_{self.connector_id}_{self.entity_description.key}" + else: + object_id = f"{self.cpid}_{self.entity_description.key}" + self.entity_id = f"{SWITCH_DOMAIN}.{slugify(object_id)}" @property def available(self) -> bool: """Return if switch is available.""" - return self.central_system.get_available(self.cp_id) # type: ignore [no-any-return] + target_conn = ( + self.connector_id if self.entity_description.per_connector else None + ) + return bool(self.central_system.get_available(self.cpid, target_conn)) + + @property + def should_poll(self) -> bool: + """Don't poll - updates will be pushed.""" + return False @property def is_on(self) -> bool: """Return true if the switch is on.""" """Test metric state against condition if present""" if self.entity_description.metric_state is not None: + metric_conn = ( + self.connector_id + if ( + self.entity_description.metric_state + == HAChargerStatuses.status_connector.value + or self.entity_description.per_connector + ) + else None + ) resp = self.central_system.get_metric( - self.cp_id, self.entity_description.metric_state + self.cpid, self.entity_description.metric_state, metric_conn ) - if resp in self.entity_description.metric_condition: - self._state = True + if self.entity_description.metric_condition is not None: + self._state = resp in self.entity_description.metric_condition else: - self._state = False - return self._state # type: ignore [no-any-return] + self._state = bool(resp) + return self._state - async def async_turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs): """Turn the switch on.""" + target_conn = self.connector_id if self.entity_description.per_connector else 0 self._state = await self.central_system.set_charger_state( - self.cp_id, self.entity_description.on_action + self.cpid, self.entity_description.on_action, True, connector_id=target_conn ) - async def async_turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs): """Turn the switch off.""" - """Response is True if successful but State is False""" + target_conn = self.connector_id if self.entity_description.per_connector else 0 if self.entity_description.off_action is None: resp = True elif self.entity_description.off_action == self.entity_description.on_action: resp = await self.central_system.set_charger_state( - self.cp_id, self.entity_description.off_action, False + self.cpid, + self.entity_description.off_action, + False, + connector_id=target_conn, ) else: resp = await self.central_system.set_charger_state( - self.cp_id, self.entity_description.off_action + self.cpid, self.entity_description.off_action, connector_id=target_conn ) self._state = not resp - @property - def current_power_w(self) -> Any: - """Return the current power usage in W.""" - if self.entity_description.key == "charge_control": - value = self.central_system.get_metric( - self.cp_id, Measurand.power_active_import.value - ) - if ( - self.central_system.get_ha_unit( - self.cp_id, Measurand.power_active_import.value - ) - == POWER_KILO_WATT - ): - value = value * 1000 - return value - return None + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + @callback + def update(*args): + """Pass through real-time updates to state.""" + active_lookup = None + if args: + try: + active_lookup = set(args[0]) + except Exception: + active_lookup = None + + if active_lookup is None or self.entity_id in active_lookup: + self.async_schedule_update_ha_state(True) + + # subscribe to updates + self.async_on_remove(async_dispatcher_connect(self.hass, DATA_UPDATED, update)) + + # Ensure switch publishes its current state immediately after being added + self.async_schedule_update_ha_state(True) diff --git a/custom_components/ocpp/translations/de.json b/custom_components/ocpp/translations/de.json index 2400971d..2a4ea7ac 100644 --- a/custom_components/ocpp/translations/de.json +++ b/custom_components/ocpp/translations/de.json @@ -3,49 +3,59 @@ "step": { "user": { "title": "OCPP-Konfiguration", - "description": "Wenn du Hilfe bei der Konfiguration benötigst, schaue hier nach: https://github.com/lbbrhzn/ocpp", + "description": "Wenn du Hilfe bei der Konfiguration benötigst, schaue hier [nach]({docs_url})", "data": { "host": "Host-Adresse des Zentralsystems", "port": "Portnummer des Zentralsystems", "csid": "Identität des Zentralsystems", - "cpid": "Identität der Ladestation", - "max_current": "Maximaler Ladestrom", - "meter_interval": "Abtastintervall Laden (Sekunden)", - "idle_interval": "Abtastintervall Leerlauf (Sekunden)", "websocket_close_timeout": "Timeout beim Schließen des Websockets (Sekunden)", "websocket_ping_tries": "Verbindungsversuche vor dem Schließen des Websockets", "websocket_ping_interval": "Websocket-Ping-Intervall (Sekunden)", "websocket_ping_timeout": "Websocket-Ping-Timeout (Sekunden)", + "ssl": "Verschlüsselte Verbindung", + "ssl_certfile_path": "Pfad zum SSL Zertifikat", + "ssl_keyfile_path": "Pfad zum SSL Schlüssel" + } + }, + "cp_user": { + "title": "OCPP-Konfiguration", + "description": "Wenn du Hilfe bei der Konfiguration benötigst, schaue hier [nach]({docs_url})", + "data": { + "cpid": "Identität der Ladestation", + "max_current": "Maximaler Ladestrom", + "meter_interval": "Abtastintervall Laden (Sekunden)", + "idle_interval": "Abtastintervall Leerlauf (Sekunden)", "skip_schema_validation": "Überspringe OCPP-Schemavalidierung", - "force_smart_charging": "Erzwinge Smart Charging Funktionsprofil" + "force_smart_charging": "Erzwinge Smart Charging Funktionsprofil", + "monitored_variables_autoconfig": "Automatische Erkennung der OCPP-Messwerte" } }, "measurands": { "title": "OCPP-Messwerte", "description": "Wähle aus welche Messwerte in Home Assistant angezeigt werden sollen.", "data": { - "Current.Export": "Momentane Stromstärke vom E-Auto", - "Current.Import": "Momentaner Stromstärke zum E-Auto", - "Current.Offered": "Maximale dem E-Auto angebotene Stromstärke", - "Energy.Active.Export.Register": "In das Netz exportierte Wirkenergie", - "Energy.Active.Import.Register": "Aus dem Netz importierte Wirkenergie", - "Energy.Reactive.Export.Register": "In das Netz exportierte Blindenergie", - "Energy.Reactive.Import.Register": "Aus dem Netz importierte Blindenergie", - "Energy.Active.Export.Interval": "Im letzten Intervall in das Netz exportierte Wirkenergie", - "Energy.Active.Import.Interval": "Im letzten Intervall aus dem Netz importierte Wirkenergie", - "Energy.Reactive.Export.Interval": "Im letzten Intervall ins Netz exportierte Blindenergie", - "Energy.Reactive.Import.Interval": "Im letzten Intervall aus dem Netz importierte Blindenergie", - "Frequency": "Netzfrequenz", - "Power.Active.Export": "Von E-Auto exportierte momentane Wirkleistung", - "Power.Active.Import": "Von E-Auto importierte momentane Wirkleistung", - "Power.Factor": "Unmittelbarer Leistungsfaktor des gesamten Energieflusses", - "Power.Offered": "Maximale dem E-Auto angebotene Leistung", - "Power.Reactive.Export": "Von E-Auto exportierte momentane Blindleistung", - "Power.Reactive.Import": "Von E-Auto importierte momentane Blindleistung", - "RPM": "Lüfterdrehzahl in RPM", - "SoC": "Ladezustand des E-Autos in Prozent", - "Temperature": "Temperaturmesswert in der Ladestation", - "Voltage": "Momentane AC-Effektivspannung" + "Current.Export": "Current.Export: Momentane Stromstärke vom E-Auto", + "Current.Import": "Current.Import: Momentaner Stromstärke zum E-Auto", + "Current.Offered": "Current.Offered: Maximale dem E-Auto angebotene Stromstärke", + "Energy.Active.Export.Register": "Energy.Active.Export.Register: In das Netz exportierte Wirkenergie", + "Energy.Active.Import.Register": "Energy.Active.Import.Register: Aus dem Netz importierte Wirkenergie", + "Energy.Reactive.Export.Register": "Energy.Reactive.Export.Register: In das Netz exportierte Blindenergie", + "Energy.Reactive.Import.Register": "Energy.Reactive.Import.Register: Aus dem Netz importierte Blindenergie", + "Energy.Active.Export.Interval": "Energy.Active.Export.Interval: Im letzten Intervall in das Netz exportierte Wirkenergie", + "Energy.Active.Import.Interval": "Energy.Active.Import.Interval: Im letzten Intervall aus dem Netz importierte Wirkenergie", + "Energy.Reactive.Export.Interval": "Energy.Reactive.Export.Interval: Im letzten Intervall ins Netz exportierte Blindenergie", + "Energy.Reactive.Import.Interval": "Energy.Reactive.Import.Interval: Im letzten Intervall aus dem Netz importierte Blindenergie", + "Frequency": "Frequency: Netzfrequenz", + "Power.Active.Export": "Power.Active.Export: Von E-Auto exportierte momentane Wirkleistung", + "Power.Active.Import": "Power.Active.Import: Von E-Auto importierte momentane Wirkleistung", + "Power.Factor": "Power.Factor: Unmittelbarer Leistungsfaktor des gesamten Energieflusses", + "Power.Offered": "Power.Offered: Maximale dem E-Auto angebotene Leistung", + "Power.Reactive.Export": "Power.Reactive.Export: Von E-Auto exportierte momentane Blindleistung", + "Power.Reactive.Import": "Power.Reactive.Import: Von E-Auto importierte momentane Blindleistung", + "RPM": "RPM: Lüfterdrehzahl in RPM", + "SoC": "SoC: Ladezustand des E-Autos in Prozent", + "Temperature": "Temperature: Temperaturmesswert in der Ladestation", + "Voltage": "Voltage: Momentane AC-Effektivspannung" } } }, @@ -57,4 +67,4 @@ "single_instance_allowed": "Es ist nur eine Instanz erlaubt." } } -} +} \ No newline at end of file diff --git a/custom_components/ocpp/translations/en.json b/custom_components/ocpp/translations/en.json index 2f3940e5..7bb44931 100644 --- a/custom_components/ocpp/translations/en.json +++ b/custom_components/ocpp/translations/en.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "OCPP Configuration", - "description": "If you need help with the configuration have a look here: https://github.com/lbbrhzn/ocpp", + "title": "OCPP Central System Configuration", + "description": "If you need help with the configuration have a look [here]({docs_url})", "data": { "host": "Central system host address", "port": "Central system port number", @@ -11,53 +11,76 @@ "ssl_certfile_path": "Path to SSL certificate or (None)", "ssl_keyfile_path": "Path to SSL key or (None)", "csid": "Central system identity", + "websocket_close_timeout": "Websocket close timeout (seconds)", + "websocket_ping_tries": "Websocket successive times to try connection before closing", + "websocket_ping_interval": "Websocket ping interval (seconds)", + "websocket_ping_timeout": "Websocket ping timeout (seconds)" + } + }, + "cp_user": { + "title": "OCPP Charger Configuration", + "description": "If you need help with the configuration have a look [here]({docs_url})", + "data": { "cpid": "Charge point identity", "max_current": "Maximum charging current", "meter_interval": "Charging sample interval (seconds)", + "monitored_variables_autoconfig": "Automatic detection of OCPP Measurands", "idle_interval": "Charger idle sampling interval (seconds)", - "websocket_close_timeout": "Websocket close timeout (seconds)", - "websocket_ping_tries": "Websocket successive times to try connection before closing", - "websocket_ping_interval": "Websocket ping interval (seconds)", - "websocket_ping_timeout": "Websocket ping timeout (seconds)", "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Force Smart Charging feature profile" } }, "measurands": { "title": "OCPP Measurands", - "description": "Select which measurand(s) should be shown in Home Assistant.", + "description": "Select which measurand(s) should be used in Home Assistant.", "data": { - "Current.Export": "Instantaneous current flow from EV", - "Current.Import": "Instantaneous current flow to EV", - "Current.Offered": "Maximum current offered to EV", - "Energy.Active.Export.Register": "Active energy exported to the grid", - "Energy.Active.Import.Register": "Active energy imported from the grid", - "Energy.Reactive.Export.Register": "Reactive energy exported to the grid", - "Energy.Reactive.Import.Register": "Reactive energy imported from the grid", - "Energy.Active.Export.Interval": "Active energy exported to the grid during last interval", - "Energy.Active.Import.Interval": "Active energy imported from the grid during last interval", - "Energy.Reactive.Export.Interval": "Reactive energy exported to the grid during last interval", - "Energy.Reactive.Import.Interval": "Reactive energy imported from the grid during last interval", - "Frequency": "Powerline frequency", - "Power.Active.Export": "Instantaneous active power exported by EV", - "Power.Active.Import": "Instantaneous active power imported by EV", - "Power.Factor": "Instantaneous power factor of total energy flow", - "Power.Offered": "Maximum power offered to EV", - "Power.Reactive.Export": "Instantaneous reactive power exported by EV", - "Power.Reactive.Import": "Instantaneous reactive power imported by EV", - "RPM": "Fan speed in RPM", - "SoC": "State of charge of EV in percentage", - "Temperature": "Temperature reading inside Charge Point", - "Voltage": "Instantaneous AC RMS supply voltage" + "Current.Export": "Current.Export: Instantaneous current flow from EV", + "Current.Import": "Current.Import: Instantaneous current flow to EV", + "Current.Offered": "Current.Offered: Maximum current offered to EV", + "Energy.Active.Export.Register": "Energy.Active.Export.Register: Active energy exported to the grid", + "Energy.Active.Import.Register": "Energy.Active.Import.Register: Active energy imported from the grid", + "Energy.Reactive.Export.Register": "Energy.Reactive.Export.Register: Reactive energy exported to the grid", + "Energy.Reactive.Import.Register": "Energy.Reactive.Import.Register: Reactive energy imported from the grid", + "Energy.Active.Export.Interval": "Energy.Active.Export.Interval: Active energy exported to the grid during last interval", + "Energy.Active.Import.Interval": "Energy.Active.Import.Interval: Active energy imported from the grid during last interval", + "Energy.Reactive.Export.Interval": "Energy.Reactive.Export.Interval: Reactive energy exported to the grid during last interval", + "Energy.Reactive.Import.Interval": "Energy.Reactive.Import.Interval: Reactive energy imported from the grid during last interval", + "Frequency": "Frequency: Powerline frequency", + "Power.Active.Export": "Power.Active.Export: Instantaneous active power exported by EV", + "Power.Active.Import": "Power.Active.Import: Instantaneous active power imported by EV", + "Power.Factor": "Power.Factor: Instantaneous power factor of total energy flow", + "Power.Offered": "Power.Offered: Maximum power offered to EV", + "Power.Reactive.Export": "Power.Reactive.Export: Instantaneous reactive power exported by EV", + "Power.Reactive.Import": "Power.Reactive.Import: Instantaneous reactive power imported by EV", + "RPM": "RPM: Fan speed in RPM", + "SoC": "SoC: State of charge of EV in percentage", + "Temperature": "Temperature: Temperature reading inside Charge Point", + "Voltage": "Voltage: Instantaneous AC RMS supply voltage" } } }, "error": { "auth": "Username/Password is wrong.", - "measurand": "Unknown measurand" + "no_measurands_selected": "No measurand selected: please select at least one", + "invalid_cpid": "Invalid charge point identity name: use only lower case letters, digits and _" }, "abort": { - "single_instance_allowed": "Only a single instance is allowed." + "single_instance_allowed": "Only a single instance is allowed", + "reauth_successful": "New charger configured" + } + }, + "exceptions": { + "invalid_ocpp_key": { + "message": "Invalid OCPP key" + }, + "ocpp_call_error": { + "message": "OCPP call failed: {message}" + }, + "get_variables_error": { + "message": "Failed to get variable: {message}" + }, + "set_variables_error": { + "message": "Failed to set variable: {message}" } } } diff --git a/custom_components/ocpp/translations/es.json b/custom_components/ocpp/translations/es.json index f1c6397e..74525e99 100644 --- a/custom_components/ocpp/translations/es.json +++ b/custom_components/ocpp/translations/es.json @@ -3,18 +3,24 @@ "step": { "user": { "title": "Configuración OCPP", - "description": "Si necesitas ayuda con la configuración puedes hechar un vistazo en: https://github.com/lbbrhzn/ocpp", + "description": "Si necesitas ayuda con la configuración puedes hechar un [vistazo en]({docs_url})", "data": { "host": "Dirección de host del sistema central", "port": "Número de puerto del sistema central", "csid": "Nombre del sistema central", - "cpid": "Nombre del punto de carga", - "meter_interval": "Intervalo de mediciones (segundos)", - "idle_interval": "Intervalo de muestreo del cargador en reposo (segundos)", "websocket_close_timeout": "Tiempo de espera Websocket (segundos)", "websocket_ping_tries": "Reintentos de conexión Websocket", "websocket_ping_interval": "Intervalo ping Websocket (segundos)", - "websocket_ping_timeout": "Tiempo de espera ping Websocket (segundos)", + "websocket_ping_timeout": "Tiempo de espera ping Websocket (segundos)" + } + }, + "cp_user": { + "title": "Configuración OCPP", + "description": "Si necesitas ayuda con la configuración puedes hechar un [vistazo en]({docs_url}", + "data": { + "cpid": "Nombre del punto de carga", + "meter_interval": "Intervalo de mediciones (segundos)", + "idle_interval": "Intervalo de muestreo del cargador en reposo (segundos)", "skip_schema_validation": "Omitir validación esquema OCPP", "force_smart_charging": "Forzar perfil de función Smart Charging" } @@ -23,28 +29,28 @@ "title": "Mediciones OCPP", "description": "Seleccione qué medida(s) debe(n) mostrarse en el Asistente de Inicio.", "data": { - "Current.Export": "Flujo de corriente instantáneo del VE", - "Current.Import": "Flujo instantáneo de corriente hacia el VE", - "Current.Offered": "Corriente máxima ofrecida al VE", - "Energy.Active.Export.Register": "Energía activa exportada a la red", - "Energy.Active.Import.Register": "Energía activa importada de la red", - "Energy.Reactive.Export.Register": "Energía reactiva exportada a la red", - "Energy.Reactive.Import.Register": "Energía reactiva importada de la red", - "Energy.Active.Export.Interval": "Energía activa exportada a la red durante el último intervalo", - "Energy.Active.Import.Interval": "Energía activa importada de la red durante el último intervalo", - "Energy.Reactive.Export.Interval": "Energía reactiva exportada a la red durante el último intervalo", - "Energy.Reactive.Import.Interval": "Energía reactiva importada de la red durante el último intervalo", - "Frequency": "Frecuencia de la línea eléctrica", - "Power.Active.Export": "Potencia activa instantánea exportada por el VE", - "Power.Active.Import": "Potencia activa instantánea importada por el VE", - "Power.Factor": "Factor de potencia instantáneo del flujo de energía total", - "Power.Offered": "Potencia máxima ofrecida al VE", - "Power.Reactive.Export": "Potencia reactiva instantánea exportada por el VE", - "Power.Reactive.Import": "Potencia reactiva instantánea importada por el VE", - "RPM": "Velocidad del ventilador en RPM", - "SoC": "Estado de carga del VE en porcentaje", - "Temperature": "Lectura de la temperatura en el interior del punto de carga", - "Voltage": "Tensión de alimentación AC RMS instantánea" + "Current.Export": "Current.Export: Flujo de corriente instantáneo del VE", + "Current.Import": "Current.Import: Flujo instantáneo de corriente hacia el VE", + "Current.Offered": "Current.Offered: Corriente máxima ofrecida al VE", + "Energy.Active.Export.Register": "Energy.Active.Export.Register: Energía activa exportada a la red", + "Energy.Active.Import.Register": "Energy.Active.Import.Register: Energía activa importada de la red", + "Energy.Reactive.Export.Register": "Energy.Reactive.Export.Register: Energía reactiva exportada a la red", + "Energy.Reactive.Import.Register": "Energy.Reactive.Import.Register: Energía reactiva importada de la red", + "Energy.Active.Export.Interval": "Energy.Active.Export.Interval: Energía activa exportada a la red durante el último intervalo", + "Energy.Active.Import.Interval": "Energy.Active.Import.Interval: Energía activa importada de la red durante el último intervalo", + "Energy.Reactive.Export.Interval": "Energy.Reactive.Export.Interval: Energía reactiva exportada a la red durante el último intervalo", + "Energy.Reactive.Import.Interval": "Energy.Reactive.Import.Interval: Energía reactiva importada de la red durante el último intervalo", + "Frequency": "Frequency: Frecuencia de la línea eléctrica", + "Power.Active.Export": "Power.Active.Export: Potencia activa instantánea exportada por el VE", + "Power.Active.Import": "Power.Active.Import: Potencia activa instantánea importada por el VE", + "Power.Factor": "Power.Factor: Factor de potencia instantáneo del flujo de energía total", + "Power.Offered": "Power.Offered: Potencia máxima ofrecida al VE", + "Power.Reactive.Export": "Power.Reactive.Export: Potencia reactiva instantánea exportada por el VE", + "Power.Reactive.Import": "Power.Reactive.Import: Potencia reactiva instantánea importada por el VE", + "RPM": "RPM: Velocidad del ventilador en RPM", + "SoC": "SoC: Estado de carga del VE en porcentaje", + "Temperature": "Temperature: Lectura de la temperatura en el interior del punto de carga", + "Voltage": "Voltage: Tensión de alimentación AC RMS instantánea" } } }, @@ -56,4 +62,4 @@ "single_instance_allowed": "Sólo se permite una única instancia." } } -} +} \ No newline at end of file diff --git a/custom_components/ocpp/translations/i-default.json b/custom_components/ocpp/translations/i-default.json index 2f3940e5..2f0a6152 100644 --- a/custom_components/ocpp/translations/i-default.json +++ b/custom_components/ocpp/translations/i-default.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "OCPP Configuration", - "description": "If you need help with the configuration have a look here: https://github.com/lbbrhzn/ocpp", + "title": "OCPP Central System Configuration", + "description": "If you need help with the configuration have a look [here]({docs_url})", "data": { "host": "Central system host address", "port": "Central system port number", @@ -11,53 +11,79 @@ "ssl_certfile_path": "Path to SSL certificate or (None)", "ssl_keyfile_path": "Path to SSL key or (None)", "csid": "Central system identity", + "websocket_close_timeout": "Websocket close timeout (seconds)", + "websocket_ping_tries": "Websocket successive times to try connection before closing", + "websocket_ping_interval": "Websocket ping interval (seconds)", + "websocket_ping_timeout": "Websocket ping timeout (seconds)" + } + }, + "cp_user": { + "title": "OCPP Charger Configuration", + "description": "If you need help with the configuration have a look [here]({docs_url})", + "data": { "cpid": "Charge point identity", "max_current": "Maximum charging current", "meter_interval": "Charging sample interval (seconds)", + "monitored_variables_autoconfig": "Automatic detection of OCPP Measurands", "idle_interval": "Charger idle sampling interval (seconds)", - "websocket_close_timeout": "Websocket close timeout (seconds)", - "websocket_ping_tries": "Websocket successive times to try connection before closing", - "websocket_ping_interval": "Websocket ping interval (seconds)", - "websocket_ping_timeout": "Websocket ping timeout (seconds)", "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Force Smart Charging feature profile" } }, "measurands": { "title": "OCPP Measurands", - "description": "Select which measurand(s) should be shown in Home Assistant.", + "description": "Select which measurand(s) should be used in Home Assistant.", "data": { - "Current.Export": "Instantaneous current flow from EV", - "Current.Import": "Instantaneous current flow to EV", - "Current.Offered": "Maximum current offered to EV", - "Energy.Active.Export.Register": "Active energy exported to the grid", - "Energy.Active.Import.Register": "Active energy imported from the grid", - "Energy.Reactive.Export.Register": "Reactive energy exported to the grid", - "Energy.Reactive.Import.Register": "Reactive energy imported from the grid", - "Energy.Active.Export.Interval": "Active energy exported to the grid during last interval", - "Energy.Active.Import.Interval": "Active energy imported from the grid during last interval", - "Energy.Reactive.Export.Interval": "Reactive energy exported to the grid during last interval", - "Energy.Reactive.Import.Interval": "Reactive energy imported from the grid during last interval", - "Frequency": "Powerline frequency", - "Power.Active.Export": "Instantaneous active power exported by EV", - "Power.Active.Import": "Instantaneous active power imported by EV", - "Power.Factor": "Instantaneous power factor of total energy flow", - "Power.Offered": "Maximum power offered to EV", - "Power.Reactive.Export": "Instantaneous reactive power exported by EV", - "Power.Reactive.Import": "Instantaneous reactive power imported by EV", - "RPM": "Fan speed in RPM", - "SoC": "State of charge of EV in percentage", - "Temperature": "Temperature reading inside Charge Point", - "Voltage": "Instantaneous AC RMS supply voltage" + "Current.Export": "Current.Export: Instantaneous current flow from EV", + "Current.Import": "Current.Import: Instantaneous current flow to EV", + "Current.Offered": "Current.Offered: Maximum current offered to EV", + "Energy.Active.Export.Register": "Energy.Active.Export.Register: Active energy exported to the grid", + "Energy.Active.Import.Register": "Energy.Active.Import.Register: Active energy imported from the grid", + "Energy.Reactive.Export.Register": "Energy.Reactive.Export.Register: Reactive energy exported to the grid", + "Energy.Reactive.Import.Register": "Energy.Reactive.Import.Register: Reactive energy imported from the grid", + "Energy.Active.Export.Interval": "Energy.Active.Export.Interval: Active energy exported to the grid during last interval", + "Energy.Active.Import.Interval": "Energy.Active.Import.Interval: Active energy imported from the grid during last interval", + "Energy.Reactive.Export.Interval": "Energy.Reactive.Export.Interval: Reactive energy exported to the grid during last interval", + "Energy.Reactive.Import.Interval": "Energy.Reactive.Import.Interval: Reactive energy imported from the grid during last interval", + "Frequency": "Frequency: Powerline frequency", + "Power.Active.Export": "Power.Active.Export: Instantaneous active power exported by EV", + "Power.Active.Import": "Power.Active.Import: Instantaneous active power imported by EV", + "Power.Factor": "Power.Factor: Instantaneous power factor of total energy flow", + "Power.Offered": "Power.Offered: Maximum power offered to EV", + "Power.Reactive.Export": "Power.Reactive.Export: Instantaneous reactive power exported by EV", + "Power.Reactive.Import": "Power.Reactive.Import: Instantaneous reactive power imported by EV", + "RPM": "RPM: Fan speed in RPM", + "SoC": "SoC: State of charge of EV in percentage", + "Temperature": "Temperature: Temperature reading inside Charge Point", + "Voltage": "Voltage: Instantaneous AC RMS supply voltage" } } }, "error": { "auth": "Username/Password is wrong.", - "measurand": "Unknown measurand" + "no_measurands_selected": "No measurand selected: please select at least one", + "invalid_cpid": "Invalid charge point identity name: use only lower case letters, digits and _" }, "abort": { - "single_instance_allowed": "Only a single instance is allowed." + "single_instance_allowed": "Only a single instance is allowed", + "reauth_successful": "New charger configured" + } + }, + "exceptions": { + "invalid_ocpp_key": { + "message": "Invalid OCPP key" + }, + "ocpp_call_error": { + "message": "OCPP call failed: {message}" + }, + "get_variables_error": { + "message": "Failed to get variable: {message}" + }, + "set_variables_error": { + "message": "Failed to set variable: {message}" + }, + "unavailable": { + "message": "Charger is unavailable: {message}" } } -} +} \ No newline at end of file diff --git a/custom_components/ocpp/translations/nl.json b/custom_components/ocpp/translations/nl.json index 3923a1fa..c0fab88b 100644 --- a/custom_components/ocpp/translations/nl.json +++ b/custom_components/ocpp/translations/nl.json @@ -3,18 +3,24 @@ "step": { "user": { "title": "OCPP Configuratie", - "description": "Voor hulp bij configuratie zie: https://github.com/lbbrhzn/ocpp", + "description": "Voor hulp bij configuratie [zie]({docs_url})", "data": { "host": "Host", "port": "Poort", "csid": "Central system identifier", - "cpid": "Charge point identifier", - "max_current": "Maximale laadstroom", - "meter_interval": "Meetinterval (secondes)", "websocket_close_timeout": "Websocket close timeout (secondes)", "websocket_ping_tries": "Websocket successive times to try connection before closing", "websocket_ping_interval": "Websocket ping interval (secondes)", - "websocket_ping_timeout": "Websocket ping timeout (secondes)", + "websocket_ping_timeout": "Websocket ping timeout (secondes)" + } + }, + "cp_user": { + "title": "OCPP Configuratie", + "description": "Voor hulp bij configuratie [zie]({docs_url})", + "data": { + "cpid": "Charge point identifier", + "max_current": "Maximale laadstroom", + "meter_interval": "Meetinterval (secondes)", "skip_schema_validation": "Skip OCPP schema validation", "force_smart_charging": "Functieprofiel Smart Charging forceren" } @@ -23,28 +29,28 @@ "title": "OCPP Meetgrootheden", "description": "Selecteer welke meetgrootheden weergegeven worden.", "data": { - "Current.Export": "Instantane stroom van EV", - "Current.Import": "Instantane stroom naar EV", - "Current.Offered": "Maximale stroom aangeboden aan EV", - "Energy.Active.Export.Register": "Actieve energie geexporteerd naar het net", - "Energy.Active.Import.Register": "Actieve energie geimporteed vanaf het net", - "Energy.Reactive.Export.Register": "Reactieve energie gexporteerd naar het net", - "Energy.Reactive.Import.Register": "Reactieve energie geimporteerd van het net", - "Energy.Active.Export.Interval": "Actieve energie geexporteerd naar het net tijdens het laatste meetinterval", - "Energy.Active.Import.Interval": "Actieve energie geimporteerd van het net tijdens het laatste meetinterval", - "Energy.Reactive.Export.Interval": "Reactieve energie geexporteerd naar het net tijdens het laatste meetinterval", - "Energy.Reactive.Import.Interval": "Reactieve energie geimporteerd van het net tijdens het laatste meetinterval", - "Frequency": "Netfrequentie", - "Power.Active.Export": "Instantane actieve vermogen geexporteerd door EV", - "Power.Active.Import": "Instantane active vermorgen geimporteerd door EV", - "Power.Factor": "Instantane vermogensfactor can de totale energiestroom", - "Power.Offered": "Maximale vermogen aangeboden aan EV", - "Power.Reactive.Export": "Instantane reactive vermogen geexporteerd door EV", - "Power.Reactive.Import": "Instantane reactieve vermogen geimporteerd door EV", - "RPM": "Ventilator snelheid in RPM", - "SoC": "Laadstand in procent", - "Temperature": "Temperatuur in het laadpunt", - "Voltage": "Instantane RMS waarde voedingsspanning" + "Current.Export": "Current.Export: Instantane stroom van EV", + "Current.Import": "Current.Import: Instantane stroom naar EV", + "Current.Offered": "Current.Offered: Maximale stroom aangeboden aan EV", + "Energy.Active.Export.Register": "Energy.Active.Export.Register: Actieve energie geexporteerd naar het net", + "Energy.Active.Import.Register": "Energy.Active.Import.Register: Actieve energie geimporteed vanaf het net", + "Energy.Reactive.Export.Register": "Energy.Reactive.Export.Register: Reactieve energie gexporteerd naar het net", + "Energy.Reactive.Import.Register": "Energy.Reactive.Import.Register: Reactieve energie geimporteerd van het net", + "Energy.Active.Export.Interval": "Energy.Active.Export.Interval: Actieve energie geexporteerd naar het net tijdens het laatste meetinterval", + "Energy.Active.Import.Interval": "Energy.Active.Import.Interval: Actieve energie geimporteerd van het net tijdens het laatste meetinterval", + "Energy.Reactive.Export.Interval": "Energy.Reactive.Export.Interval: Reactieve energie geexporteerd naar het net tijdens het laatste meetinterval", + "Energy.Reactive.Import.Interval": "Energy.Reactive.Import.Interval: Reactieve energie geimporteerd van het net tijdens het laatste meetinterval", + "Frequency": "Frequency: Netfrequentie", + "Power.Active.Export": "Power.Active.Export: Instantane actieve vermogen geexporteerd door EV", + "Power.Active.Import": "Power.Active.Import: Instantane active vermorgen geimporteerd door EV", + "Power.Factor": "Power.Factor: Instantane vermogensfactor can de totale energiestroom", + "Power.Offered": "Power.Offered: Maximale vermogen aangeboden aan EV", + "Power.Reactive.Export": "Power.Reactive.Export: Instantane reactive vermogen geexporteerd door EV", + "Power.Reactive.Import": "Power.Reactive.Import: Instantane reactieve vermogen geimporteerd door EV", + "RPM": "RPM: Ventilator snelheid in RPM", + "SoC": "SoC: Laadstand in procent", + "Temperature": "Temperature: Temperatuur in het laadpunt", + "Voltage": "Voltage: Instantane RMS waarde voedingsspanning" } } }, diff --git a/docs/Charge_automation.md b/docs/Charge_automation.md new file mode 100644 index 00000000..63534020 --- /dev/null +++ b/docs/Charge_automation.md @@ -0,0 +1,108 @@ +## Dynamically Adjusting EV Charge Current: A Smart Approach + +Dynamically adjusting the charge current of an electric vehicle (EV) within a home automation system offers significant advantages: + +* **Preventing Overload:** + * By monitoring real-time energy consumption, you can automatically reduce the EV's charging rate to prevent overloading the household's electrical circuits and potentially tripping the main fuse. + +* **Optimizing Solar Power Usage:** + * When the solar panel production is available in Home Assistant, you can prioritize charging the EV with excess solar energy. + +* **Demand Response:** + * When you have dynamic energy pricing, you can adjust charging rates based on time-of-use electricity pricing. + +This page provides several examples and hints to illustrate some of the many potential use cases." + +## Adjusting the charge current + +When the OCPP integration is added to your Home Assistant, you get a slider to control the maximum charge current named (or one per connector, if your charger has multiple connectors): +number._maximum_current + +While using this entity in your automation might seem logical, it could potentially lead to permanent damage to your charger in the long run. +This entity controls the OCPP ChargePointMaxProfile, which configures the maximum power or current available for the entire charging station. +This setting is typically written to non-volatile storage (like EEPROM or flash memory) to persist across reboots. +Frequent writes to these types of memory can accelerate wear, potentially shortening the lifespan of your charger. Ten updates per day is no problem at all, 1 update per 10s could break your charger somewhere between 3 days and 3 years depending on the HW solution. + +⚠️ **Warning**: Using the maximum current slider in automations can lead to permanent hardware damage due to frequent writes to non-volatile memory. + +### TXprofile + +Therefore, it is better to utilize a specific profile that is active exclusively during the current charging session. This approach allows you to adjust the charge current downwards while still respecting the upper limit defined by the ChargePointMaxProfile. + +Essentially, the slider in your GUI maintains control over the absolute maximum current the charger can utilize. + +To dynamically set the session-specific charge current within an automation, use the following action code snippet: + + - action: ocpp.set_charge_rate + data: + custom_profile: | + { + "transactionId": {{ states('sensor._transaction_id') | int }}, + "chargingProfileId": 1, + "stackLevel": 0, + "chargingProfilePurpose": "TxProfile", + "chargingProfileKind": "Relative", + "chargingSchedule": { + "chargingRateUnit": "A", + "chargingSchedulePeriod": [ + {"startPeriod": 0, "limit": {{ states('entity_charge_limit') | int }}} + ] + } + } + conn_id: 1 + + +Where entity_charge_limit refers to your chosen entity (e.g., a number or sensor) that holds the desired current value. + +NB! The action above only changes your current session limits. If you want to impact future sessions, run the same action, but replace "chargingProfilePurpose": "TxProfile" with "chargingProfilePurpose": "TxDefaultProfile". + +## solar current +The solar system usually reports its production in Watts or kW. To convert this to amps available for your EV charger, simply divide the watts by the mains voltage (e.g., 230V for the EU) + +You can create a template sensor for this: + + - platform: template + sensors: + solaredge_amps: + value_template: "{{ (states('sensor.solaredge_power') | round(0, 'floor')| float) / 230 }}" + unit_of_measurement: 'A' + device_class: current + +This sensor contains the solar current in amps rounded down to the nearest whole number. It could be directly used in the action above. + +## Smart-meter +The paragraph above suggests that nearly all the solar current is prioritized for the EV charger. However, this can lead to situations where other high-demand appliances, such as a washing machine or hot tub, still draw power from the grid even when solar energy is available. + +To avoid this, you can use the data provided by your smart meter sensors. By integrating smart meter data into your home automation system, you can dynamically adjust the EV charging rate based on real-time energy consumption. This ensures that the EV primarily charges using excess solar power while minimizing reliance on grid electricity during periods of high household demand.you might have it as power. + +For this it is important to know the current you deliver or receive from the grid. Depending on you smart meter sensors you might have this current available in a sensor or you might have this as power. The example below uses power to and from the grid and converts it to a current which is negative when receiving from the grid. +In this example the power is in kW and the current is calculated using at actual mains voltage. +This template sensor gives the right value: + + - platform: template + sensors: + grid_current_available: # Calculate the current still available to use, can be negative + value_template: >- + {{((states('sensor.p1_meter_p1_returned') | float - states('sensor.p1_meter_p1_power') | float )*1000 + / (states('sensor.p1_meter_p1_voltage') | float )) }} + unit_of_measurement: 'A' + device_class: current + +For solar charging a positive grid current avaiable means you can increase your EV charge number with this number, when negative you need to decrease. This means you need to know the actual charge current and modify this. To do this easily you can use a variable inside your automation to store the actual charge current. + + variables: + actual_charge_current: "{{ (states('sensor.charge_current') | float) }}" + new_amps: "{{ actual_charge_current + (states('sensor.grid_current_available') | float) }} + +This could lead to a negative charge current, to avoid this create a new variable with a minimum value: + + charge_current: "{{ [new_amps, 0] | max }}" + +The `max` filter ensures the charge current never goes below 0 amps, which would be invalid for the charging station. + +## maximum charge +A simular solution could be used to check how much power is still available from the grid substracting all power used by other appliances in your house. This way you can charge you EV as fast as possible without overloading your main fuse. + +:exclamation: By specificatiomn your main fuse can withand 1.2 its rate current for at least 1 hour + +So a response time up to a minute is usually no problem if the overload is limited. diff --git a/docs/conf.py b/docs/conf.py index 02224c43..fb30111d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,4 @@ -# Configuration file for the Sphinx documentation builder. +"""Configuration file for the Sphinx documentation builder.""" # -- Project information diff --git a/docs/development.md b/docs/development.md index 67484a43..246f6844 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,3 +3,5 @@ Development It is recommended to use Visual Studio Code, and run home assistant in a devcontainer. See [https://hacs.xyz/docs/developer/devcontainer](https://hacs.xyz/docs/developer/devcontainer) + +Online development is supported through [GitHub Codespaces](https://github.com/features/codespaces) diff --git a/docs/installation.md b/docs/installation.md index d4a18df6..598a1956 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -27,17 +27,21 @@ Installation ### Secure Connection -If using [Let’s Encrypt](https://github.com/home-assistant/addons/tree/master/letsencrypt), [Duck DNS](https://www.home-assistant.io/integrations/duckdns/) or other add-ons that enables HTTPS you can get a secure WWS connection for OCPP. For more information on how to generate keys [see this blog post](https://www.home-assistant.io/blog/2017/09/27/effortless-encryption-with-lets-encrypt-and-duckdns/). -- The option secure connection enables connection thru WSS (HTTPS). With the option disabled the connection will be WS (HTTP). -- If enabled provide pathways to SSL certificate and key files. The pathways will be ignored if secure connection is disabled. +If you are using [Let’s Encrypt](https://github.com/home-assistant/addons/tree/master/letsencrypt), [Duck DNS](https://www.home-assistant.io/integrations/duckdns/) or other add-on that enables secure HTTPS for your Home Assistant instance, you can get a secure WSS connection for OCPP. +To use a secure connection: +- Enable the option _Secure connection_ +- Provide the pathways to your HA's SSL certificate and key files. These are typically located in the /config or /ssl folder, and typically named fullchain.pem and privkey.pem respectively. +- If you provide incorrect pathways, the integration will fail to setup with no clear indication of why. + +If you do not use HTTPS for your Home Assistant instance: +- Disable the option _Secure connection_ +- _Path to SSL certificate/key_ will be ignored. ### Measurands -- Select which measurands you would like to become available as sensor entities. - Most chargers only support a subset of all possible measurands. This depends most on the Feature profiles that are supported by the charger. - -![image](https://user-images.githubusercontent.com/8673442/129494804-cdff0dfb-a421-490c-af1e-e939f01455b4.png) +- The integration will autodetect the supported measurands when the charger connects. This can be disabled for chargers that do not support autodetection. ## Add the entities to your Dashboard - On the OCPP integration, click on devices to navigate to your Charge Point device. @@ -55,12 +59,14 @@ If using [Let’s Encrypt](https://github.com/home-assistant/addons/tree/master/ - Configure your charger to use the OCPP websocket of your Central System (e.g. ws://homeassistant.local:9000). This is charger specific, so consult your manual. - Some chargers require the protocol section 'ws://' to be removed, or require the url to end with a '/'. +- If you have configured _Secure connection_ in previous step, you should use 'wss://' - Some chargers require the url to be specified as an IP address, i.e. '192.168.178.1:9000' - You may need to reboot your charger before the changes become effective. +- If your charger has multiple connectors, wait until the charger device has populated its data (e.g., the Connectors sensor). Then reload the integration. A separate device will be created for each connector, and you’ll find all connector-specific entities there. ![image](https://user-images.githubusercontent.com/8673442/129495720-2ed9f0d6-b736-409a-8e14-fbd447dea078.png) -## Start Charging +## Start Charging - Use the charge control switch to start the charging process. ![image](https://user-images.githubusercontent.com/8673442/129495891-91f40bf9-f48e-4ced-b303-bf0fb77898f3.png) diff --git a/docs/requirements.txt b/docs/requirements.txt index 44544bb7..24dc069c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,5 @@ -myst-parser==0.18.1 -sphinx_rtd_theme==1.3.0 \ No newline at end of file +myst-parser==3.0.1 +docutils==0.18.1 +Jinja2==3.1.6 +sphinx==7.1.2 +sphinx_rtd_theme==3.0.2 diff --git a/docs/support.md b/docs/support.md index f44c9994..4c6ecd48 100644 --- a/docs/support.md +++ b/docs/support.md @@ -1,4 +1,54 @@ -Support +# Support ======= +- [General](#general) +- [FAQ](#faq) + - [too many notifications in home assistant](#too-many-notifications-in-home-assistant) + +## General + If you need help, check out our [forum](https://github.com/lbbrhzn/ocpp/discussions) or submit an [issue](https://github.com/lbbrhzn/ocpp/issues). + +## FAQ + +### too many notifications in home assistant + +The OCPP sends a notification when the charger is rebooted. This can be due to a bad network connection. The notifications can be managed with automations in home assistant. (see https://github.com/lbbrhzn/ocpp/discussions/938) + +Example: + +``` +trigger: + - platform: persistent_notification + update_type: + - added + notification_id: "" +condition: + - condition: template + value_template: "{{ trigger.notification.title | lower == \"ocpp integration\" }}" +action: + - delay: + hours: 0 + minutes: 10 + seconds: 0 + milliseconds: 0 + - service: persistent_notification.dismiss + data: + notification_id: "{{ trigger.notification.notification_id }}" +mode: parallel +max: 10 +``` + +### unstable behavior when setting the charger maximum current + +If your charger is acting strange when you're changing the maximum current, or sending an ocpp.set_charge_rate action, it might help to clear the charging profiles from the charger. + +Run the following action (from Developer Tools): + +``` + - action: ocpp.clear_profile + data: + devid: charger +``` + +Where charger refers to your selected charger device identity. diff --git a/docs/supported-devices.md b/docs/supported-devices.md index 400d5ff6..9835e5da 100644 --- a/docs/supported-devices.md +++ b/docs/supported-devices.md @@ -3,22 +3,185 @@ Supported devices All OCPP 1.6j compatible devices should be supported, but not every device offers the same level of functionality. So far, we've tried: -## [ABB Terra AC-W7-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w7-g5-r-0) -## [ABB Terra AC-W11-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w11-g5-r-0) +## ABB Terra AC chargers + +ABB Terra AC chargers with firmware version 1.8.21 and earlier fail to respond correctly when OCPP measurands are automatically detected by the OCPP integration. As of this writing, ABB has been notified, but no corresponding firmware fix is available. + +### Issue Description + +When automatic measurand detection is used in the OCPP integration with ABB Terra AC chargers: + +1. The charger responds as if it supports all proposed measurands. +2. The integration then asks for all measurands to be reported. +3. When the integration tries to query which measurands are available after this configuration, the ABB Terra AC reboots. + +As a result, the ABB charger becomes unusable with the OCPP integration, as the integration checks for available measurands on every charger boot, leading to a boot loop. + +For more details and symptoms, see [Issue #1275](https://github.com/lbbrhzn/ocpp/issues/1275). + +### Workaround + +Fortunately, it is possible to configure the charger using manual configuration and to restore correct settings. + +To use these chargers: + +1. Disable "Automatic detection of OCPP Measurands". + - Note: Automatic detection is enabled by default. Until configuration changes can be made online, you may need to remove the devices from this integration and add them again. + - If "Automatic detection of OCPP Measurands" is disabled during configuration, you will be presented with a list of possible measurands. + +2. When presented with the list of measurands, select only the following: + - `Current.Import` + - `Current.Offered` + - `Energy.Active.Import.Register` + - `Power.Active.Import` + - `Voltage` + +This list is based on the overview of OCPP 1.6 implementation for ABB Terra AC (firmware 1.6.6). + +### [ABB Terra AC-W7-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w7-g5-r-0) + +### [ABB Terra AC-W11-G5-R-0](https://new.abb.com/products/6AGC082156/tac-w11-g5-r-0) + +### [ABB Terra AC-W22-T-0](https://new.abb.com/products/6AGC081279/tac-w22-t-0) + +### [ABB Terra TAC-W22-T-RD-MC-0](https://new.abb.com/products/6AGC081281/tac-w22-t-rd-mc-0) + ## [Alfen - Eve Single Pro-line](https://alfen.com/en/ev-charge-points/alfen-product-range) + ## [Alfen - Eve Single S-line](https://alfen.com/en/ev-charge-points/alfen-product-range) + +## [CTEK Chargestorm Connected 1, dual connectors](https://www.ctek.com/uk/ev-charging/chargestorm-connected-1) +See CTEK Chargestorm Connected 2 below for getting started instructions. + ## [CTEK Chargestorm Connected 2](https://www.ctek.com/uk/ev-charging/chargestorm%C2%AE-connected-2) [Jonas Karlsson](https://github.com/jonasbkarlsson) has written a [getting started guide](https://github.com/jonasbkarlsson/ocpp/wiki/CTEK-Chargestorm-Connected-2) for connecting CTEK Chargestorm Connected 2. + +## [EN+ Caro Series Home Wallbox](https://www.en-plustech.com/product/caro-series-wallbox/) +This charger is often white-labelled by other vendors, including [cord](https://www.cord-ev.com/cord-one.html) and [EV Switch](https://www.evswitchstore.com.au/pages/ev-charger-range). + +Note the charger's serial number - this is the number that you need to specify for the `Charge point identity` when you configure the OCPP integration in Home Assistant if the OCPP integration does not discover your charger, and also to request a firmware update for versions earlier than 1.0.25.130. + +For firmware versions earlier than 1.0.25.130 the only way you can update firmware is by connecting to the evchargo OCPP server at `wss://ocpp16.evchargo.com:33033/` and emailing your serial number to `support@en-plus.com.cn` requesting that your firmware is updated. + +You will probably want to update your firmware if it is earlier than 1.0.25.130 before configuring your charger to connect to your own OCPP server. + +Firmware 1.0.25.130 has a firmware update option on the configuration interface (on IP address 192.168.4.1) which you can access by power-cycling the charger and connecting to its access point (see below). + +If you have already installed the OCPP integration and have the default `charger` charge point installed, then you will need to re-configure this with the correct charge point identity (by removing and re-adding the OCPP integration) to change from the default `charger` charge point identity before configuring the charger. + +Connect to the charger's access point (AP) by powering down the charger (i.e. switch off the charger's isolator or circuit breaker) and powering it back on a few seconds later. The charger's access point becomes available for 15 minutes, and the SSID matches the charger's serial number (starting with SN). Log in to the configuration interface on the IP address 192.168.4.1. + +If doing this from a phone, you may need to set the phone to _Flight Mode_ first, and enable WiFi if required to enable the rest of the configuration to complete. + +The username and password for the web interface are provided in the charger manual (case sensitive). + +Configure the network mode to WiFi or Ethernet, and (in the field with an icon that looks like a router with three aerials) enter the address of your Home Assistant server including the port and protocol (e.g. `ws://myhomeassistant.tld:9000` or `wss://myhomeassistant.tld:9000` if you are using secure sockets). + +The charger user interface will append the serial number when you leave the field - this is correct and expected. + +Save, and the charger will reboot. + +Reconnect to the charger's SSID, and log in again to 192.168.4.1 to confirm that the Network Status is online. This confirms that the charger has an internet connection via Ethernet or WiFi and is connected to your OCPP server in Home Assistant. Once enabled, the charger doesn't connect to the vendor server anymore and can be controlled only from Home Assistant or locally via Bluetooth. + +Even though the device accepts all measurands, the key working ones are + - `Current.Import` + - `Current.Offered` + - `Energy.Active.Import.Register` + - `Voltage` - although this shows a constant voltage or zero unless a charging session is in progress. + - `Transaction.ID` + +You may wish to disable sensors that show `Unknown` after you've completed a charging session, as they will never provide data with the current firmware 1.0.25.130. + +## [Etrel - Inch Pro](https://etrel.com/charging-solutions/inch-pro/) +To allow a custom OCPP server such as HA to set up a transaction ID, it is necessary to set under Users > Charging Authorization the +authorization type to either `Central system only` or `Charger whitelist and central system` otherwise the OCPP integration won't +match transactions and it won't report some meter values such as session time. + ## [EVBox Elvi](https://evbox.com/en/ev-chargers/elvi) + ## [EVLink Wallbox Plus](https://www.se.com/ww/en/product/EVH3S22P0CK/evlink-wallbox-plus---t2-attached-cable---3-phase---32a-22kw/) -## [Evnex E Series & X Series Charging Stations](https://www.evnex.com/) + +## [Evnex E Series & X Series Charging Stations](https://www.evnex.com/) (Ability to configure a custom OCPP server such as HA is being discontinued) -## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) -(has some defects in OCPP implementation, which can be worked around. See [User Guide](https://github.com/lbbrhzn/ocpp/blob/main/docs/user-guide.md) section in Documentation for details.) -## [Wallbox Pulsar](https://wallbox.com/en_uk/wallbox-pulsar) -## [Vestel EVC04-AC22SW](https://www.vestel-echarger.com/EVC04_HomeSmart22kW.html) + +## [Garo Entity Pro](https://www.garo.se/en/professional/products/e-mobility/wallbox/entity-pro/wallbox-entity-pro-22-sigi-o) + +## [MaXpeedingrods Ev Charger](https://www.maxpeedingrods.com/category/ev-charger.html) + +## [Mennekes Amtron Charge Control](https://www.mennekes.de/emobility/produkte/charge-control/) + +## [Morek Smart AC Charger](https://ev.morek.eu/products-morek-quick-charge-11-22-kw/) +Successful connection requires firmware version **A0-MEV-V2.0.9** or newer. + +The "Charger idle sampling interval" is not supported. Set this to **0** to avoid a "ClockAlignedDataInterval is read-only" warning. + +## [Rolec EVO](https://www.rolecserv.com/ev-products/evo) +Tested single phase 7kW model (ROLEC5011) with firmware 1.2.7, appears to be working fine. + +Need to configure the OCPP server using the **Rolec Connect** mobile app, and +set the current to the maximum (32A), otherwise the exposed `Maximum Current` +entity in HA will be capped. + +You can still connect with the EVO app via Bluetooth after setting the OCPP server, +but certain features (eg. scheduling) may not work. + +## [Simpson & Partners](https://simpson-partners.com/home-ev-charger/) +All basic functions work properly + +## [SyncEV Compact EVCP](https://sync.energy/support/instruction-manuals) +These are a discontinued (but cheap) 7kw 1PH smart charger, with an OCPP implementation that's seemingly quite close to standard, and tolerent. +Mine works well with the plugin, OCPP setup is done through the local AP-Wifi. The admin panel password is admin. +A few plugin tweaks to get full functionality... + - Force SMART mode, to allow setting charge rates (use action ocpp.set_charge_rate) and retreiving meter values (use action ocpp.trigger_custom_message) + - Manually specify the Measurands + - Voltage + - Temperature + - Current.Offered + - Current.Import + - Power.Active.Import + - Energy.Active.Import.Register + - Create an automation triggering action: ocpp.trigger_custom_message with requested_message set to MeterValues on a schedule of your choice to retrieve the Measurands. + - Optionally create an automation updating the hearbeat interval (you have to set a value different to the one in the chargepoint) when the chargepoint reboots. + - I haven't tested using secure mode. + - If you have problems with charging profiles, check your firmware version is 1.6.3 (the latest in Mar 2025) + - Firmware updates can be done through the app, by reconnecting the charger to the original OCPP backend (wss://cpc.uk.charge.ampeco.tech:443/syncev/) and if it says you're on the latest, call them (+44 1952 983 940) to get it updated. + +## [Teison Smart MINI Wallbox](https://www.teison.com/ac_smart_mini_ev_wallbox.html) +Use *My Teison* app to enable webSocket. In the socket URL field enter the address of your Home Assistant server including the port. In the socket port field enter *ocpp1.6* for insecure connection or *socpp1.6* for secure connection with certificates. Once enabled, charger doesn't connect to the vendor server anymore and can be controlled only from Home Assistant or locally via Bluetooth. + +Even though the device accepts all measurands, the working ones are + - `Current.Import` + - `Energy.Active.Import.Register` + - `Power.Active.Import` + - `Temperature` + - `Voltage` + +If the devices loses connection to Home Assistant (due to Wi-Fi disconnection or update, for example) it doesn't seem to reconnect automatically. It is necessary to reboot the charger via Bluetooth for it to reconnect. + +## [United Chargers Inc. - Grizzl-E](https://grizzl-e.com/about/) + +Grizzl-E chargers with firmware 3.x.x work mostly without issue, such as the following: +* Grizzl-E Mini Connect 2024 +* Grizzl-E Ultimate + +Known issue: In firmware 03.09.0 amperage changes are accepted but not applied. This is due to the firmware accepting but not handling a value of `ChargePointMaxProfile` in `ChargerProfilePurpose`. United Chargers has stated that this will be addressed in firmware version 03.11.0. + +Supported OCPP requests for the 3.x.x firmware are documented in a PDF on their site in under https://grizzl-e.com/connect-to-third-party-ocpp-backend/ + +Other Grizzl-E chargers on the 5.x.x firmware have some defects in OCPP implementation, which can be worked around. See [User Guide](https://github.com/lbbrhzn/ocpp/blob/main/docs/user-guide.md) section in Documentation for details.) + ## [V2C Trydan](https://v2charge.com/trydan) +## [Vestel EVC04-AC22SW](https://www.vestel-echarger.com/EVC04_HomeSmart22kW.html) + +## [Wallbox Pulsar & Copper SB](https://wallbox.com/en_uk/wallbox-pulsar) +The Wallbox Pulsar and Copper SB have been verified. +In the OCPP-config, leave the password field empty. + +## [ZJ Beny BCP-A2N-P](https://ultipower.com.au/products/zj-beny-home-charging-station-ac-7kw-32a-type-2-ocpp) +Note that there are different models with similar model names, some of which support OCPP and some with other features. + +## Others When a charger is not listed as a supported charger it simply means that it has not been reported to work. Whether it will work or not in practice really depends on whether it is compliant with the OCPP standard. Some vendors claim their device is compliant without bothering to do a compliance test, because that takes time and costs money! When it is fully compliant, then it should work out of the box, since the ocpp integration is designed to work for fully compliant chargers. Any issues should be reported, and we will do out best to analyze them. In some cases modifications or workarounds may be needed. As long as these workarounds do not break compliance to the OCPP standard they can be added to this repository. diff --git a/docs/user-guide.md b/docs/user-guide.md index 53af6557..c58c8820 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -15,7 +15,7 @@ The 'baggage' referred to above, is every single repository available through HA ![Central System Configuration](https://user-images.githubusercontent.com/8673442/129494762-08052152-f057-4563-93b5-5aae810dfbfc.png) -The `Central system identity` shown above with a default of `central` can be anything you like. Whatever is entered in that field will be used as a device identifier in Home Assistant (HA), so it's probably best to avoid spaces and punctuation symbols, but otherwise, enter anything you like. +The `Central system identity` shown above with a default of `central` can be anything you like up to a **maximum** of **20 characters**. Whatever is entered in that field will be used as a device identifier in Home Assistant (HA), so it's probably best to avoid spaces and punctuation symbols, but otherwise, enter anything you like. The `Charge point identity` shown above with a default of `charger` is a little different. Whatever you enter in that field will determine the prefix of all Charger entities added to Home Assistant (HA). My recommendation is that it's best left at the default of charger. If you put anything else in that field, it will be used as the prefix for all Charger entities added to HA during installation, however, new entities subsequently added in later version releases sometimes revert to the default prefix, regardless of what was entered during installation. So you end up with a mixture of different prefixes which can be avoided simply by leaving `Charge point identity` set to the default of `charger`. @@ -23,6 +23,47 @@ The `Charge point identity` shown above with a default of `charger` is a little Measurands (according to OCPP terminology) are actually metrics provided by the charger. Each charger supports a subset of the available metrics and for each one supported, a sensor entity is available in HA. Some of these sensor entities will give erroneous readings whilst others give no readings at all. Sensor entities not supported by the charger will show as `Unknown` if you try to create a sensor entity for them. Below is a table of the metrics I've found useful for the Wallbox Pulsar Plus. Tables for other chargers will follow as contributions come in from owners of each supported charger. +OCPP integration can automatically detect supported measurands. However, some chargers have faulty firmware that causes the detection mechanism to fail. For such chargers, it is possible to disable automatic measurand detection and manually set the measurands to those supported by the charger. When set manually, selected measurands are not checked for compatibility with the charger and are requested from it. See below for OCPP compliance notes and charger-specific instructions in [supported devices](supported-devices). + +For chargers with multiple connectors (outlets), the OCPP integration will create one device per connector, named `charger Connector 1`, `charger Connector 2` etc. All measurands and other entities (buttons, numbers, switches, diagnostics sensors) that are connector-specific per the OCPP standard will be found on these devices. + +## Understanding status + +Your charger exposes a connector status sensor: +* Single-connector: `sensor._status_connector` +* Multi-connector: `sensor._connector__status_connector` + +For OCPP 1.6, the sensor can show these values: + +* **Available** – No EV is connected; the connector is free. +* **Preparing** – EV is connected and/or authenticated but charging hasn’t started yet (handshake, cable lock, internal checks). +* **Charging** – Energy is being delivered. +* **SuspendedEV** – The EV has paused energy transfer (e.g., target SoC reached, schedule, thermal limit). +* **SuspendedEVSE** – The charger has paused energy transfer (e.g., power limit, smart charging profile, grid signal). +* **Finishing** – Charging has stopped, but the session isn’t fully closed yet (typically waiting for the cable to be unplugged). +* **Reserved** – The connector is reserved (via ReserveNow); only the intended user/ID may start. This is not supported by the OCPP integration yet. +* **Unavailable** – Intentionally set out of service (e.g., ChangeAvailability(Inoperative)) or temporarily not usable. (Entities remain available in Home Assistant.) +* **Faulted** – A fault prevents charging (e.g., ground fault, over-temp, lock error). Check the sensor errorCode for details. + +Note +In OCPP 1.6, `connectorId = 0` (station level) only uses Available, Unavailable, or Faulted.
+In OCPP 2.0.1, connector status is simplified to Available / Occupied / Reserved / Unavailable / Faulted; “Preparing/Finishing” are reflected in TransactionEvent rather than as connector statuses. + +If your integration shows extra attributes on the connector status sensor like availability_change or availability_pending, they indicate that a status change (e.g., after ChangeAvailability) has been accepted or scheduled and will take effect once current conditions allow (e.g., after an active session ends). + +## Changing availability + +* **Availability (charger-level) switch**
+ Sets the entire charger to `Unavailable` (station-level). All idle connectors switch to `Unavailable` immediately. Any connector with an ongoing session is marked as scheduled and will switch to `Unavailable` after the session ends. + +* **Availability (per-connector) switches**
+ Set a specific connector to `Unavailable`. If that connector currently has an ongoing session, the change is scheduled and will take effect once the session ends. + +* **Charge Control switch**
+ Turning off ends the ongoing charging session (remote stop). The connector typically transitions to `Finishing` and then back to its normal idle state once the cable is unplugged. Turning the switch on again resets the session metrics; the charger returns to its previous state (this does not force a new session to start). + + + ## Useful Entities for Wallbox Pulsar Plus ### Metrics @@ -46,6 +87,16 @@ Measurands (according to OCPP terminology) are actually metrics provided by the * `Maximum Current` (sets maximum charging current available) * `Reset` +## Useful Entities for ABB Terra AC + +### Metrics + +* `Current.Import` (instantaneous current flow to EV) +* `Energy.Active.Import.Register` (active energy imported from the grid) +* `Power.Active.Import` (instantaneous active power imported by EV) +* `Voltage` (instantaneous AC RMS supply voltage) + + ## Useful Entities for EVBox Elvi ### Metrics @@ -68,7 +119,7 @@ Measurands (according to OCPP terminology) are actually metrics provided by the ## Useful Entities and Workarounds for United Chargers Grizzl-E -Comments below relate to Grizzl-E firmware version 5.633, tested Oct-Nov 2022. +Comments below relate to Grizzl-E firmware version 5.633, tested Oct-Nov 2022. ### Metrics The Grizzl-E updates these metrics every 30s during charging sessions: @@ -112,9 +163,43 @@ The Grizzl-E updates these metrics every 30s during charging sessions: * `Maximum Current` (sets maximum charging current available) * `Reset` -### OCPP Compatibility Issues +## Useful Entities for Rolec EVO + +### Metrics + +* `Current Import` +* `Current Offered` (may be limited by the settings on the charger itself, check the EVO app) +* `Energy Session` (charge for present/last session - kWh) +* `Power Active Import` (active charging power - kW) +* `Temperature` (internal temperature - degrees C) +* `Time Session` (duration of active/last charging session) +* `Voltage` (seems to report a little higher than expected) + +There are several other metrics too, I'm not sure what they mean, and also `Export` variants of some of the `Import` entities, but they seem to always be zero for me. + +### Diagnostics + +* `Status Connector` (Available, Preparing, Charging, etc) + +There are many other diagnostic entities about the features, ids, model, firmware etc, not sure if they'd be much practical use. + +### Controls + +* `Availability` (turning off switches the halo from flashing blue to constant red) +* `Charge Control` +* `Maximum Current` (if `Current Offered` doesn't reach this when charging, raise the current to the max in the EVO app itself, connect via Bluetooth) +* `Reset` (reboot the charger) +* `Unlock` (I think this will unlock the charging cable, if permanent lock is enabled from the app) + +## OCPP Compatibility Issues + +### ABB Terra AC + +ABB Terra AC firmware 1.8.21 and earlier versions fail to respond correctly when OCPP measurands are automatically detected by the OCPP integration. As of this writing, ABB has been notified, but no corresponding firmware fix is available. As a result, users must configure measurands manually. See the suggested ABB Terra AC configuration in [supported devices](supported-devices.md). + +### Grizzl-E -Grizzl-E firmware has a few OCPP-compliance defects, including responding to certain OCPP server messages with invalid JSON. Symptoms of this problem include repeated reboots of the charger. By editing the OCPP server source code, one can avoid these problematic messages and obtain useful charger behaviour. ChargeLabs (the company working on the Grizzl-E firmware) expects to release version 6 of the firmware in early 2023, which may fix these problems. +Grizzl-E firmware 5.x has a few OCPP-compliance defects, including responding to certain OCPP server messages with invalid JSON. Firmware 3.x.x on chargers such as the Mini Connect and Ultimate does not seem to have these issues. Symptoms of this problem include repeated reboots of the charger. By editing the OCPP server source code, one can avoid these problematic messages and obtain useful charger behaviour. ChargeLabs (the company working on the Grizzl-E firmware) expects to release version 6 of the firmware in early 2023, which may fix these problems. The workaround consists of: - checking the *Skip OCPP schema validation* checkbox during OCPP server configuration diff --git a/manage/update_manifest.py b/manage/update_manifest.py index 55208c3d..f52f7761 100644 --- a/manage/update_manifest.py +++ b/manage/update_manifest.py @@ -1,4 +1,5 @@ """Update the manifest file.""" + # https://github.com/hacs/integration/blob/main/manage/update_manifest.py import json import os diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..1880a623 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +colorlog==6.10.1 +uv>=0.4 +ruff==0.15.8 +ocpp==2.1.0 +websockets==16.0 +jsonschema==4.26.0 +pre-commit==4.5.1 +pytest-homeassistant-custom-component==0.13.319 +setuptools>=70.0.0 # not directly required, pinned by Snyk to avoid a vulnerability +zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index 318fa82c..00000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,4 +0,0 @@ -homeassistant>=2023.1.0b1 -ocpp==0.19.0 -websockets==11.0.3 -jsonschema==4.19.0 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt deleted file mode 100644 index 1ee66462..00000000 --- a/requirements_test.txt +++ /dev/null @@ -1,5 +0,0 @@ --r requirements_dev.txt -pytest-homeassistant-custom-component==0.12.50 -ocpp==0.19.0 -websockets==11.0.3 -pytest-cov \ No newline at end of file diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 00000000..7bc0759e --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,9 @@ +#!/bin/sh +# Resolve all dependencies that the application requires to run. + +# Stop on errors +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install -r requirements.txt diff --git a/scripts/develop b/scripts/develop new file mode 100755 index 00000000..002d933f --- /dev/null +++ b/scripts/develop @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -e + +cd "$(dirname "$0")/.." + +# Create/prepare config +mkdir -p "${PWD}/config" +if [[ ! -f "${PWD}/config/configuration.yaml" ]]; then + /home/vscode/.local/ha-venv/bin/python -m homeassistant --config "${PWD}/config" --script ensure_config +fi + +# Ensure custom components dir exists +mkdir -p "${PWD}/config/custom_components" + +# Link the ocpp integration (overwrite if exists) +src="${PWD}/custom_components/ocpp" +dst="${PWD}/config/custom_components/ocpp" +if [[ ! -d "$src" ]]; then + echo "Missing integration sources at: $src" >&2 + exit 1 +fi + +# Remove existing dir/symlink/file at destination +if [[ -e "$dst" || -L "$dst" ]]; then + rm -rf -- "$dst" +fi + +# Create the symlink +ln -s "$src" "$dst" + +# Install debugpy if missing +if ! /home/vscode/.local/ha-venv/bin/python -c 'import debugpy' >/dev/null 2>&1; then + /home/vscode/.local/ha-venv/bin/python -m pip install --quiet debugpy +fi + +# Start Home Assistant (prefer debugger if available) +DEBUGPY_LISTEN="${DEBUGPY_LISTEN:-0.0.0.0:5678}" +if /home/vscode/.local/ha-venv/bin/python -c 'import debugpy' >/dev/null 2>&1; then + exec /home/vscode/.local/ha-venv/bin/python -m debugpy --listen "${DEBUGPY_LISTEN}" \ + -m homeassistant --config "${PWD}/config" --debug +else + echo "debugpy not available; starting Home Assistant without debugger" >&2 + exec /home/vscode/.local/ha-venv/bin/python -m homeassistant --config "${PWD}/config" --debug +fi \ No newline at end of file diff --git a/scripts/json_schemas/manifest_schema.json b/scripts/json_schemas/manifest_schema.json new file mode 100644 index 00000000..3a382cc5 --- /dev/null +++ b/scripts/json_schemas/manifest_schema.json @@ -0,0 +1,449 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Home Assistant integration manifest", + "description": "The manifest for a Home Assistant integration", + "type": "object", + "if": { + "properties": { + "integration_type": { + "const": "virtual" + } + }, + "required": [ + "integration_type" + ] + }, + "then": { + "oneOf": [ + { + "properties": { + "domain": { + "description": "The domain identifier of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#domain", + "examples": [ + "mobile_app" + ], + "type": "string", + "pattern": "[0-9a-z_]+" + }, + "name": { + "description": "The friendly name of the integration.", + "type": "string" + }, + "integration_type": { + "description": "The integration type.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-type", + "const": "virtual" + }, + "iot_standards": { + "description": "The IoT standards which supports devices or services of this virtual integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#iot-standards", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "homekit", + "zigbee", + "zwave" + ] + } + } + }, + "additionalProperties": false, + "required": [ + "domain", + "name", + "integration_type", + "iot_standards" + ] + }, + { + "properties": { + "domain": { + "description": "The domain identifier of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#domain", + "examples": [ + "mobile_app" + ], + "type": "string", + "pattern": "[0-9a-z_]+" + }, + "name": { + "description": "The friendly name of the integration.", + "type": "string" + }, + "integration_type": { + "description": "The integration type.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-type", + "const": "virtual" + }, + "supported_by": { + "description": "The integration which supports devices or services of this virtual integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#supported-by", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "domain", + "name", + "integration_type", + "supported_by" + ] + } + ] + }, + "else": { + "properties": { + "domain": { + "description": "The domain identifier of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#domain", + "examples": [ + "mobile_app" + ], + "type": "string", + "pattern": "[0-9a-z_]+" + }, + "name": { + "description": "The friendly name of the integration.", + "type": "string" + }, + "integration_type": { + "description": "The integration type.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-type", + "type": "string", + "default": "hub", + "enum": [ + "device", + "entity", + "hardware", + "helper", + "hub", + "service", + "system" + ] + }, + "config_flow": { + "description": "Whether the integration is configurable from the UI.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#config-flow", + "type": "boolean" + }, + "mqtt": { + "description": "A list of topics to subscribe for the discovery of devices via MQTT.\nThis requires to specify \"mqtt\" in either the \"dependencies\" or \"after_dependencies\".\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#mqtt", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "zeroconf": { + "description": "A list containing service domains to search for devices to discover via Zeroconf. Items can either be strings, which discovers all devices in the specific service domain, and/or objects which include filters. (useful for generic service domains like _http._tcp.local.)\nA device is discovered if it matches one of the items, but inside the individual item all properties have to be matched.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#zeroconf", + "type": "array", + "minItems": 1, + "items": { + "anyOf": [ + { + "type": "string", + "pattern": "^.*\\.local\\.$", + "description": "Service domain to search for devices." + }, + { + "type": "object", + "properties": { + "type": { + "description": "The service domain to search for devices.", + "examples": [ + "_http._tcp.local." + ], + "type": "string", + "pattern": "^.*\\.local\\.$" + }, + "name": { + "description": "The name or name pattern of the devices to filter.", + "type": "string" + }, + "properties": { + "description": "The properties of the Zeroconf advertisement to filter.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + "ssdp": { + "description": "A list of matchers to find devices discoverable via SSDP/UPnP. In order to be discovered, the device has to match all properties of any of the matchers.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#ssdp", + "type": "array", + "minItems": 1, + "items": { + "description": "A matcher for the SSDP discovery.", + "type": "object", + "properties": { + "st": { + "type": "string" + }, + "deviceType": { + "type": "string" + }, + "manufacturer": { + "type": "string" + }, + "modelDescription": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + } + } + }, + "bluetooth": { + "description": "A list of matchers to find devices discoverable via Bluetooth. In order to be discovered, the device has to match all properties of any of the matchers.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#bluetooth", + "type": "array", + "minItems": 1, + "items": { + "description": "A matcher for the bluetooth discovery", + "type": "object", + "properties": { + "connectable": { + "description": "Whether the device needs to be connected to or it works with just advertisement data.", + "type": "boolean" + }, + "local_name": { + "description": "The name or a name pattern of the device to match.", + "type": "string", + "pattern": "^([^*]+|[^*]{3,}[*].*)$" + }, + "service_uuid": { + "description": "The 128-bit service data UUID to match.", + "type": "string", + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + }, + "service_data_uuid": { + "description": "The 16-bit service data UUID to match, converted into the corresponding 128-bit UUID by replacing the 3rd and 4th byte of `00000000-0000-1000-8000-00805f9b34fb` with the 16-bit UUID.", + "examples": [ + "0000fd3d-0000-1000-8000-00805f9b34fb" + ], + "type": "string", + "pattern": "0000[0-9a-f]{4}-0000-1000-8000-00805f9b34fb" + }, + "manufacturer_id": { + "description": "The Manufacturer ID to match.", + "type": "integer" + }, + "manufacturer_data_start": { + "description": "The start bytes of the manufacturer data to match.", + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + }, + "additionalProperties": false + }, + "uniqueItems": true + }, + "homekit": { + "description": "A list of model names to find devices which are discoverable via HomeKit. A device is discovered if the model name of the device starts with any of the specified model names.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#homekit", + "type": "object", + "properties": { + "models": { + "description": "The model names to search for.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": [ + "models" + ], + "additionalProperties": false + }, + "dhcp": { + "description": "A list of matchers to find devices discoverable via DHCP. In order to be discovered, the device has to match all properties of any of the matchers.\nYou can specify an item with \"registered_devices\" set to true to check for devices with MAC addresses specified in the device registry.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#dhcp", + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "registered_devices": { + "description": "Whether the MAC addresses of devices in the device registry should be used for discovery, useful if the discovery is used to update the IP address of already registered devices.", + "const": true + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "hostname": { + "description": "The hostname or hostname pattern to match.", + "type": "string" + }, + "macaddress": { + "description": "The MAC address or MAC address pattern to match.", + "type": "string", + "maxLength": 12 + } + }, + "additionalProperties": false + } + ] + }, + "uniqueItems": true + }, + "usb": { + "description": "A list of matchers to find devices discoverable via USB. In order to be discovered, the device has to match all properties of any of the matchers.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#usb", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "vid": { + "description": "The vendor ID to match.", + "type": "string", + "pattern": "[0-9A-F]{4}" + }, + "pid": { + "description": "The product ID to match.", + "type": "string", + "pattern": "[0-9A-F]{4}" + }, + "description": { + "description": "The USB device description to match.", + "type": "string" + }, + "manufacturer": { + "description": "The manufacturer to match.", + "type": "string" + }, + "serial_number": { + "description": "The serial number to match.", + "type": "string" + }, + "known_devices": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "documentation": { + "description": "The website containing the documentation for the integration. It has to be in the format \"https://www.home-assistant.io/integrations/[domain]\"\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#documentation", + "type": "string", + "pattern": "^https://www.home-assistant.io/integrations/[0-9a-z_]+$", + "format": "uri" + }, + "quality_scale": { + "description": "The quality scale of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-quality-scale", + "type": "string", + "enum": [ + "bronze", + "silver", + "gold", + "platinum", + "internal", + "legacy" + ] + }, + "requirements": { + "description": "The PyPI package requirements for the integration. The package has to be pinned to a specific version.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#requirements", + "type": "array", + "items": { + "type": "string", + "pattern": ".+==.+" + }, + "uniqueItems": true + }, + "dependencies": { + "description": "A list of integrations which need to be loaded before this integration can be set up.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#dependencies", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "after_dependencies": { + "description": "A list of integrations which need to be loaded before this integration is set up when it is configured. The integration will still be set up when the \"after_dependencies\" are not configured.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#after-dependencies", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "codeowners": { + "description": "A list of GitHub usernames or GitHub team names of the integration owners.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#code-owners", + "type": "array", + "minItems": 0, + "items": { + "type": "string", + "pattern": "^@.+$" + }, + "uniqueItems": true + }, + "loggers": { + "description": "A list of logger names used by the requirements.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#loggers", + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "disabled": { + "description": "The reason for the integration being disabled.", + "type": "string" + }, + "iot_class": { + "description": "The IoT class of the integration, describing how the integration connects to the device or service.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#iot-class", + "type": "string", + "enum": [ + "assumed_state", + "cloud_polling", + "cloud_push", + "local_polling", + "local_push", + "calculated" + ] + }, + "single_config_entry": { + "description": "Whether the integration only supports a single config entry.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#single-config-entry-only", + "const": true + } + }, + "additionalProperties": false, + "required": [ + "domain", + "name", + "codeowners", + "documentation" + ], + "dependencies": { + "mqtt": { + "anyOf": [ + { + "required": [ + "dependencies" + ] + }, + { + "required": [ + "after_dependencies" + ] + } + ] + } + } + } +} \ No newline at end of file diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 00000000..9b5b1df0 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 00000000..555e977b --- /dev/null +++ b/scripts/setup @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +scripts/bootstrap +pre-commit install +pre-commit run --all \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index edc1a331..d3d043b2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,45 +1,9 @@ -[flake8] -exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build -doctests = True -# To work with Black -max-line-length = 88 -# E501: line too long -# W503: Line break occurred before a binary operator -# E203: Whitespace before ':' -# D202 No blank lines allowed after function docstring -# W504 line break after binary operator -ignore = - E501, - W503, - E203, - D202, - W504 - -[isort] -# https://github.com/timothycrosley/isort -# https://github.com/timothycrosley/isort/wiki/isort-Settings -# splits long import on multiple lines indented by 4 spaces -multi_line_output = 3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -indent = " " -# will group `import x` and `from x import` of the same module. -force_sort_within_sections = true -sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -default_section = THIRDPARTY -known_first_party = custom_components.ocpp, tests -combine_as_imports = true - [tool:pytest] addopts = -qq --cov=custom_components.ocpp --allow-unix-socket --allow-hosts=127.0.0.1 console_output_style = count asyncio_mode = auto - -[coverage:run] -branch = False +asyncio_default_fixture_loop_scope = function [coverage:report] show_missing = true -fail_under = 90 \ No newline at end of file +fail_under = 95 \ No newline at end of file diff --git a/tests/charge_point_test.py b/tests/charge_point_test.py new file mode 100644 index 00000000..a9f3db95 --- /dev/null +++ b/tests/charge_point_test.py @@ -0,0 +1,137 @@ +"""Implement common functions for simulating charge points of any OCPP version.""" + +import asyncio + +from homeassistant.core import HomeAssistant +from websockets import Subprotocol + +from custom_components.ocpp import CentralSystem +from .const import CONF_PORT +import contextlib +from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.button.const import SERVICE_PRESS +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from ocpp.charge_point import ChargePoint +from pytest_homeassistant_custom_component.common import MockConfigEntry +from typing import Any +from collections.abc import Callable, Awaitable +from websockets import connect +from websockets.asyncio.client import ClientConnection + + +async def set_switch(hass: HomeAssistant, cpid: str, key: str, on: bool): + """Toggle a switch.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON if on else SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.{cpid}_{key}"}, + blocking=True, + ) + + +async def set_number(hass: HomeAssistant, cpid: str, key: str, value: int): + """Set a numeric slider.""" + await hass.services.async_call( + NUMBER_DOMAIN, + "set_value", + service_data={"value": value}, + blocking=True, + target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{cpid}_{key}"}, + ) + + +set_switch.__test__ = False + + +async def press_button(hass: HomeAssistant, cpid: str, key: str): + """Press a button.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"{BUTTON_DOMAIN}.{cpid}_{key}"}, + blocking=True, + ) + + +press_button.__test__ = False + + +async def create_configuration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> CentralSystem: + """Create an integration.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return hass.data[OCPP_DOMAIN][config_entry.entry_id] + + +create_configuration.__test__ = False + + +async def remove_configuration(hass: HomeAssistant, config_entry: MockConfigEntry): + """Remove an integration.""" + if entry := hass.config_entries.async_get_entry(config_entry.entry_id): + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert config_entry.entry_id not in hass.data[OCPP_DOMAIN] + + +remove_configuration.__test__ = False + + +async def wait_ready(cp: ChargePoint): + """Wait until charge point is connected and initialised.""" + while not cp.post_connect_success: + await asyncio.sleep(0.1) + + +def _check_complete( + test_routine: Callable[[ChargePoint], Awaitable], +) -> Callable[[ChargePoint, list[bool]], Awaitable]: + async def extended_routine(cp: ChargePoint, completed: list[bool]): + await test_routine(cp) + completed.append(True) + + return extended_routine + + +async def run_charge_point_test( + config_entry: MockConfigEntry, + identity: str, + subprotocols: list[str] | None, + charge_point: Callable[[ClientConnection], ChargePoint], + parallel_tests: list[Callable[[ChargePoint], Awaitable]], +) -> Any: + """Connect web socket client to the CSMS and run a number of tests in parallel.""" + completed: list[list[bool]] = [[] for _ in parallel_tests] + async with connect( + f"ws://127.0.0.1:{config_entry.data[CONF_PORT]}/{identity}", + subprotocols=[Subprotocol(s) for s in subprotocols] + if subprotocols is not None + else None, + ) as ws: + cp = charge_point(ws) + with contextlib.suppress(asyncio.TimeoutError): + test_results = [ + _check_complete(parallel_tests[i])(cp, completed[i]) + for i in range(len(parallel_tests)) + ] + await asyncio.wait_for( + asyncio.gather(*([cp.start()] + test_results)), + timeout=30, + ) + await ws.close() + for test_completed in completed: + assert test_completed == [True] + + +run_charge_point_test.__test__ = False diff --git a/tests/conftest.py b/tests/conftest.py index c9153a46..3a2dd6c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,21 @@ """Global fixtures for ocpp integration.""" + import asyncio +from collections.abc import AsyncGenerator from unittest.mock import patch - +from pytest_homeassistant_custom_component.common import MockConfigEntry import pytest import websockets +from custom_components.ocpp.api import CentralSystem +from custom_components.ocpp.const import CONF_CPIDS, CONF_PORT, DOMAIN as OCPP_DOMAIN +from tests.const import MOCK_CONFIG_CP_APPEND, MOCK_CONFIG_DATA +from .charge_point_test import ( + create_configuration, + remove_configuration, +) +from homeassistant.core import State + pytest_plugins = "pytest_homeassistant_custom_component" @@ -20,22 +31,33 @@ def auto_enable_custom_integrations(enable_custom_integrations): @pytest.fixture(name="skip_notifications", autouse=True) def skip_notifications_fixture(): """Skip notification calls.""" - with patch("homeassistant.components.persistent_notification.async_create"), patch( - "homeassistant.components.persistent_notification.async_dismiss" - ), patch("custom_components.ocpp.api.ChargePoint.notify_ha"): + with ( + patch("homeassistant.components.persistent_notification.async_create"), + patch("homeassistant.components.persistent_notification.async_dismiss"), + patch("custom_components.ocpp.chargepoint.ChargePoint.notify_ha"), + ): yield # This fixture, when used, will result in calls to websockets to be bypassed. To have the call # return a value, we would add the `return_value=` parameter to the patch call. +# include patch for hass.states.get for use with migration to return cp_id @pytest.fixture(name="bypass_get_data") def bypass_get_data_fixture(): """Skip calls to get data from API.""" future = asyncio.Future() - future.set_result(websockets.WebSocketServer) - with patch("websockets.server.serve", return_value=future), patch( - "websockets.server.WebSocketServer.close" - ), patch("websockets.server.WebSocketServer.wait_closed"): + future.set_result(websockets.asyncio.server.Server) + # Return a HomeAssistant State object instead of a plain string. Some HA + # helpers expect a State instance (with attributes) during restore/cleanup. + with ( + patch("websockets.asyncio.server.serve", return_value=future), + patch("websockets.asyncio.server.Server.close"), + patch("websockets.asyncio.server.Server.wait_closed"), + patch( + "homeassistant.core.StateMachine.get", + return_value=State("sensor.test_cp_id", "test_cp_id"), + ), + ): yield @@ -49,3 +71,26 @@ def error_get_data_fixture(): # side_effect=Exception, # ): yield + + +@pytest.fixture +async def setup_config_entry(hass, request) -> AsyncGenerator[CentralSystem, None]: + """Setup/teardown mock config entry and central system.""" + # Create a mock entry so we don't have to go through config flow + # Both version and minor need to match config flow so as not to trigger migration flow + config_data = MOCK_CONFIG_DATA.copy() + config_data[CONF_CPIDS].append( + {request.param["cp_id"]: MOCK_CONFIG_CP_APPEND.copy()} + ) + config_data[CONF_PORT] = request.param["port"] + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=config_data, + entry_id=request.param["cms"], + title=request.param["cms"], + version=2, + minor_version=0, + ) + yield await create_configuration(hass, config_entry) + # tear down + await remove_configuration(hass, config_entry) diff --git a/tests/const.py b/tests/const.py index b91c491e..a3f76911 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,6 +1,8 @@ """Constants for ocpp tests.""" + from custom_components.ocpp.const import ( CONF_CPID, + CONF_CPIDS, CONF_CSID, CONF_FORCE_SMART_CHARGING, CONF_HOST, @@ -8,6 +10,8 @@ CONF_MAX_CURRENT, CONF_METER_INTERVAL, CONF_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG, + CONF_NUM_CONNECTORS, CONF_PORT, CONF_SKIP_SCHEMA_VALIDATION, CONF_SSL, @@ -17,62 +21,65 @@ CONF_WEBSOCKET_PING_INTERVAL, CONF_WEBSOCKET_PING_TIMEOUT, CONF_WEBSOCKET_PING_TRIES, + DEFAULT_MONITORED_VARIABLES, ) -from ocpp.v16.enums import Measurand -MOCK_CONFIG = { +MOCK_CONFIG_CS = { CONF_HOST: "127.0.0.1", - CONF_PORT: 9000, + CONF_PORT: 9005, CONF_SSL: False, CONF_SSL_CERTFILE_PATH: "/tests/fullchain.pem", CONF_SSL_KEYFILE_PATH: "/tests/privkey.pem", + CONF_CSID: "test_csid_flow", + CONF_WEBSOCKET_CLOSE_TIMEOUT: 1, + CONF_WEBSOCKET_PING_TRIES: 0, + CONF_WEBSOCKET_PING_INTERVAL: 1, + CONF_WEBSOCKET_PING_TIMEOUT: 1, + CONF_CPIDS: [], +} + +MOCK_CONFIG_CP = { CONF_CPID: "test_cpid", - CONF_CSID: "test_csid", CONF_IDLE_INTERVAL: 900, CONF_MAX_CURRENT: 32, CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, CONF_SKIP_SCHEMA_VALIDATION: False, CONF_FORCE_SMART_CHARGING: True, +} + +MOCK_CONFIG_FLOW = { + CONF_HOST: "127.0.0.1", + CONF_PORT: 9005, + CONF_CSID: "test_csid_flow", + CONF_SSL: False, + CONF_SSL_CERTFILE_PATH: "/tests/fullchain.pem", + CONF_SSL_KEYFILE_PATH: "/tests/privkey.pem", CONF_WEBSOCKET_CLOSE_TIMEOUT: 1, CONF_WEBSOCKET_PING_TRIES: 0, CONF_WEBSOCKET_PING_INTERVAL: 1, CONF_WEBSOCKET_PING_TIMEOUT: 1, + CONF_CPIDS: [ + { + "test_cp_id": { + CONF_CPID: "test_cpid", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, + CONF_SKIP_SCHEMA_VALIDATION: False, + CONF_FORCE_SMART_CHARGING: True, + } + }, + ], } -MOCK_CONFIG_2 = { - Measurand.current_export.value: True, - Measurand.current_import.value: True, - Measurand.current_offered.value: True, - Measurand.energy_active_export_register.value: True, - Measurand.energy_active_import_register.value: True, - Measurand.energy_reactive_export_register.value: True, - Measurand.energy_reactive_import_register.value: True, - Measurand.energy_active_export_interval.value: True, - Measurand.energy_active_import_interval.value: True, - Measurand.energy_reactive_export_interval.value: True, - Measurand.energy_reactive_import_interval.value: True, - Measurand.frequency.value: True, - Measurand.power_active_export.value: True, - Measurand.power_active_import.value: True, - Measurand.power_factor.value: True, - Measurand.power_offered.value: True, - Measurand.power_reactive_export.value: True, - Measurand.power_reactive_import.value: True, - Measurand.rpm.value: True, - Measurand.soc.value: True, - Measurand.temperature.value: True, - Measurand.voltage.value: True, -} + +# test_cpid configuration with skip schema validation enabled, and auto config false MOCK_CONFIG_DATA = { CONF_HOST: "127.0.0.1", CONF_PORT: 9000, - CONF_CPID: "test_cpid", CONF_CSID: "test_csid", - CONF_IDLE_INTERVAL: 900, - CONF_MAX_CURRENT: 32, - CONF_METER_INTERVAL: 60, - CONF_MONITORED_VARIABLES: "Current.Export,Current.Import,Current.Offered,Energy.Active.Export.Register,Energy.Active.Import.Register,Energy.Reactive.Export.Register,Energy.Reactive.Import.Register,Energy.Active.Export.Interval,Energy.Active.Import.Interval,Energy.Reactive.Export.Interval,Energy.Reactive.Import.Interval,Frequency,Power.Active.Export,Power.Active.Import,Power.Factor,Power.Offered,Power.Reactive.Export,Power.Reactive.Import,RPM,SoC,Temperature,Voltage", - CONF_SKIP_SCHEMA_VALIDATION: False, - CONF_FORCE_SMART_CHARGING: True, CONF_SSL: False, CONF_SSL_CERTFILE_PATH: "/tests/fullchain.pem", CONF_SSL_KEYFILE_PATH: "/tests/privkey.pem", @@ -80,25 +87,110 @@ CONF_WEBSOCKET_PING_TRIES: 0, CONF_WEBSOCKET_PING_INTERVAL: 1, CONF_WEBSOCKET_PING_TIMEOUT: 1, + CONF_CPIDS: [], } -# configuration with skip schema validation enabled -MOCK_CONFIG_DATA_2 = { +# Mock a charger that can be appended to config data +MOCK_CONFIG_CP_APPEND = { + CONF_CPID: "test_cpid", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, + CONF_SKIP_SCHEMA_VALIDATION: False, + CONF_FORCE_SMART_CHARGING: True, +} + +# different port with skip schema validation enabled, and auto config false +MOCK_CONFIG_DATA_1 = { **MOCK_CONFIG_DATA, - CONF_PORT: 9002, - CONF_CPID: "test_cpid_2", - CONF_SKIP_SCHEMA_VALIDATION: True, + CONF_CSID: "test_csid_1", + CONF_PORT: 9001, + CONF_CPIDS: [ + { + "CP_1_nosub": { + CONF_CPID: "test_cpid_9001", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: False, + CONF_NUM_CONNECTORS: 2, + CONF_SKIP_SCHEMA_VALIDATION: True, + CONF_FORCE_SMART_CHARGING: True, + } + }, + ], } -# separate entry for switch so tests can run concurrently -MOCK_CONFIG_SWITCH = { - CONF_HOST: "127.0.0.1", +# different port with skip schema validation enabled, auto config false +# and multiple connector support +MOCK_CONFIG_DATA_1_MC = { + **MOCK_CONFIG_DATA, + CONF_CSID: "test_csid_1_mc", CONF_PORT: 9001, - CONF_CPID: "test_cpid_2", + CONF_CPIDS: [ + { + "CP_1_mc": { + CONF_CPID: "test_cpid_9001", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: False, + CONF_NUM_CONNECTORS: 2, + CONF_SKIP_SCHEMA_VALIDATION: True, + CONF_FORCE_SMART_CHARGING: True, + } + }, + ], +} + +# allow many chargers to connect +MOCK_CONFIG_DATA_2 = { + **MOCK_CONFIG_DATA, CONF_CSID: "test_csid_2", - CONF_MAX_CURRENT: 32, +} + +# empty monitored variables +MOCK_CONFIG_DATA_3 = { + **MOCK_CONFIG_DATA, + CONF_CSID: "test_csid_3", + CONF_CPIDS: [ + { + "test_cpid": { + CONF_CPID: "test_cpid", + CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, + CONF_METER_INTERVAL: 60, + CONF_MONITORED_VARIABLES: "", + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, + CONF_SKIP_SCHEMA_VALIDATION: False, + CONF_FORCE_SMART_CHARGING: True, + } + }, + ], +} + + +MOCK_CONFIG_MIGRATION_FLOW = { + CONF_HOST: "127.0.0.1", + CONF_PORT: 9005, + CONF_CSID: "test_migration_flow", + CONF_SSL: False, + CONF_SSL_CERTFILE_PATH: "/tests/fullchain.pem", + CONF_SSL_KEYFILE_PATH: "/tests/privkey.pem", + CONF_WEBSOCKET_CLOSE_TIMEOUT: 1, + CONF_WEBSOCKET_PING_TRIES: 0, + CONF_WEBSOCKET_PING_INTERVAL: 1, + CONF_WEBSOCKET_PING_TIMEOUT: 1, + CONF_CPID: "test_cpid_migration_flow", CONF_IDLE_INTERVAL: 900, + CONF_MAX_CURRENT: 32, CONF_METER_INTERVAL: 60, - CONF_MONITORED_VARIABLES: "Current.Export,Current.Import,Current.Offered,Energy.Active.Export.Register,Energy.Active.Import.Register,Energy.Reactive.Export.Register,Energy.Reactive.Import.Register,Energy.Active.Export.Interval,Energy.Active.Import.Interval,Energy.Reactive.Export.Interval,Energy.Reactive.Import.Interval,Frequency,Power.Active.Export,Power.Active.Import,Power.Factor,Power.Offered,Power.Reactive.Export,Power.Reactive.Import,RPM,SoC,Temperature,Voltage", + CONF_MONITORED_VARIABLES: DEFAULT_MONITORED_VARIABLES, + CONF_MONITORED_VARIABLES_AUTOCONFIG: True, + CONF_SKIP_SCHEMA_VALIDATION: False, + CONF_FORCE_SMART_CHARGING: True, } -DEFAULT_NAME = "test" diff --git a/tests/test_additional_charge_point_v16.py b/tests/test_additional_charge_point_v16.py new file mode 100644 index 00000000..b6c9e971 --- /dev/null +++ b/tests/test_additional_charge_point_v16.py @@ -0,0 +1,1168 @@ +"""Test additional v16 paths.""" + +import asyncio +import contextlib +from datetime import datetime, UTC +from types import SimpleNamespace + +import pytest +import websockets + +from ocpp.v16 import call +from ocpp.v16.enums import ( + TriggerMessageStatus, + ChargePointStatus, + ConfigurationStatus, +) + +from custom_components.ocpp.enums import ( + HAChargerDetails as cdet, + ConfigurationKey as ckey, +) + +from .test_charge_point_v16 import wait_ready, ChargePoint + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9116, "cp_id": "CP_trig_timeout_nonzero_adjusts", "cms": "cms_trig_tnz"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_timeout_nonzero_adjusts"]) +@pytest.mark.parametrize("port", [9116]) +async def test_trigger_status_timeout_on_nonzero_adjusts_and_stops( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test trigger status timeout on nonzero adjusts and stops.""" + cs = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 2 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + if req.connector_id == 2: + raise TimeoutError("simulated") + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is False + # Should stop after the failing connector + assert attempts == [0, 1, 2] + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9310, "cp_id": "CP_cov_conn_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_conn_exc"]) +@pytest.mark.parametrize("port", [9310]) +async def test_get_number_of_connectors_exception_defaults( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test get number of connectors when exception defaults to 1.""" + cs = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + async def fake_call(req): + # Simulate failure when requesting NumberOfConnectors + if isinstance(req, call.GetConfiguration): + raise TypeError("boom") + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + n = await srv.get_number_of_connectors() + assert n == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9311, "cp_id": "CP_cov_conn_bad", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_conn_bad"]) +@pytest.mark.parametrize("port", [9311]) +async def test_get_number_of_connectors_invalid_value_defaults( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test get number of connectors with invalid value defaults to 1.""" + cs = setup_config_entry + + class FakeResp: + def __init__(self): + self.configuration_key = [{"key": "NumberOfConnectors", "value": "n/a"}] + self.unknown_key = None + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + async def fake_call(req): + if isinstance(req, call.GetConfiguration): + return FakeResp() + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + n = await srv.get_number_of_connectors() + assert n == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9312, "cp_id": "CP_cov_auto_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_auto_exc"]) +@pytest.mark.parametrize("port", [9312]) +async def test_autodetect_measurands_change_configuration_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test autodetect measurands when ChangeConfiguration raises and fallback occurs.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Enable autodetect and set a desired CSV to trigger the path + srv.settings.monitored_variables_autoconfig = True + srv.settings.monitored_variables = "Power.Active.Import,Voltage" + + async def fake_call(req): + # Fail on ChangeConfiguration, so code reads back via GetConfiguration + if isinstance(req, call.ChangeConfiguration): + raise TypeError("set failed") + if isinstance(req, call.GetConfiguration): + return SimpleNamespace( + configuration_key=[{"value": "Voltage"}], unknown_key=None + ) + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + result = await srv.get_supported_measurands() + assert result == "Voltage" + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9313, "cp_id": "CP_cov_manual_ok", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_manual_ok"]) +@pytest.mark.parametrize("port", [9313]) +async def test_measurands_manual_set_accepted_configures( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test manual measurands set accepted and configure is called.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Disable autodetect; set a desired CSV + srv.settings.monitored_variables_autoconfig = False + srv.settings.monitored_variables = "Energy.Active.Import.Register,Voltage" + + called_configure = [] + + async def fake_call(req): + if isinstance(req, call.ChangeConfiguration): + return SimpleNamespace(status=ConfigurationStatus.accepted) + return SimpleNamespace() + + async def fake_configure(key, value): + called_configure.append((key, value)) + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + monkeypatch.setattr(srv, "configure", fake_configure, raising=True) + + result = await srv.get_supported_measurands() + assert result == srv.settings.monitored_variables + # configure() should have been called with the accepted CSV + assert ( + called_configure + and called_configure[0][0] == ckey.meter_values_sampled_data.value + ) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9314, "cp_id": "CP_cov_manual_rej", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_manual_rej"]) +@pytest.mark.parametrize("port", [9314]) +async def test_measurands_manual_set_rejected_returns_empty( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test manual measurands rejected and fallback returns empty string.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + srv.settings.monitored_variables_autoconfig = False + srv.settings.monitored_variables = "Energy.Active.Import.Register" + + async def fake_call(req): + if isinstance(req, call.ChangeConfiguration): + return SimpleNamespace(status=ConfigurationStatus.rejected) + if isinstance(req, call.GetConfiguration): + # Simulate charger returning no value for the requested key + # by providing an empty configuration_key list (attribute present). + return SimpleNamespace(configuration_key=[], unknown_key=None) + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + result = await srv.get_supported_measurands() + assert result == "" # effective_csv was empty + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9315, "cp_id": "CP_cov_trig_bn", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_trig_bn"]) +@pytest.mark.parametrize("port", [9315]) +async def test_trigger_boot_notification_accepts( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test trigger boot notification accepted path.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + ok = await srv.trigger_boot_notification() + assert ok is True and srv.triggered_boot_notification is True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9316, "cp_id": "CP_cov_trig_stat_n_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_trig_stat_n_exc"]) +@pytest.mark.parametrize("port", [9316]) +async def test_trigger_status_notification_connector_count_parse_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test trigger status notification when connector count parse exception causes n=1.""" + cs = setup_config_entry + attempts = [] + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + # Force parse error so n=1 + srv._metrics[0][cdet.connectors.value].value = "bad" + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + ok = await srv.trigger_status_notification() + assert ok is True + assert attempts == [1] # n<=1 -> probe only connector 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9317, "cp_id": "CP_cov_trig_custom_bad", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_trig_custom_bad"]) +@pytest.mark.parametrize("port", [9317]) +async def test_trigger_custom_message_unsupported_name( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test trigger custom message rejects unsupported trigger names.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + ok = await srv.trigger_custom_message("not_a_trigger") + assert ok is False + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9319, "cp_id": "CP_cov_stop_tx_early", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_stop_tx_early"]) +@pytest.mark.parametrize("port", [9319]) +async def test_stop_transaction_early_return( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test stop transaction early return when there is no active transaction.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + srv.active_transaction_id = 0 + srv._active_tx.clear() + ok = await srv.stop_transaction() + assert ok is True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9320, "cp_id": "CP_cov_update_fw", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_update_fw"]) +@pytest.mark.parametrize("port", [9320]) +async def test_update_firmware_wait_time_invalid_falls_back( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test update firmware path when wait_time is invalid and falls back to immediate time.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + async def fake_call(req): + return SimpleNamespace() # success path + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + ok = await srv.update_firmware( + "https://example.com/fw.bin", wait_time="not-int" + ) + assert ok is True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9321, "cp_id": "CP_cov_getcfg_empty", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_getcfg_empty"]) +@pytest.mark.parametrize("port", [9321]) +async def test_get_configuration_empty_key_path( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test get_configuration empty key path uses call without key property.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + async def fake_call(req): + # When key is empty, code constructs call.GetConfiguration() (no key list) + assert isinstance(req, call.GetConfiguration) and not getattr( + req, "key", None + ) + return SimpleNamespace( + configuration_key=[ + {"key": "HeartbeatInterval", "value": "300"}, + {"key": "MeterValueSampleInterval", "value": "60"}, + ], + unknown_key=None, + ) + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + val = await srv.get_configuration("") + assert val == {"HeartbeatInterval": "300", "MeterValueSampleInterval": "60"} + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9322, "cp_id": "CP_cov_config_ro", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_config_ro"]) +@pytest.mark.parametrize("port", [9322]) +async def test_configure_readonly_warns_and_notifies( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test configure warns and notifies when key is read-only.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + notified = [] + + async def fake_call(req): + if isinstance(req, call.GetConfiguration): + return SimpleNamespace( + configuration_key=[ + {"key": "Foo", "value": "Bar", "readonly": True} + ], + unknown_key=None, + ) + # ChangeConfiguration may still be issued; return accepted for completeness + if isinstance(req, call.ChangeConfiguration): + return SimpleNamespace(status=ConfigurationStatus.accepted) + return SimpleNamespace() + + async def fake_notify(msg): + notified.append(msg) + + monkeypatch.setattr(srv, "call", fake_call, raising=True) + monkeypatch.setattr(srv, "notify_ha", fake_notify, raising=True) + await srv.configure("Foo", "Baz") + # A warning/notification should be pushed for read-only + assert any("read-only" in m for m in notified) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9323, "cp_id": "CP_cov_restore_ms", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_restore_ms"]) +@pytest.mark.parametrize("port", [9323]) +async def test_restore_meter_start_cast_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test restore meter start from HA when cast raises and remains None.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + # Force metric slot to look missing + srv._metrics[(1, "Energy.Meter.Start")].value = None + + def fake_get_ha_metric(name, connector_id=None): + if name == "Energy.Meter.Start" and connector_id == 1: + return "not-a-float" + return None + + monkeypatch.setattr(srv, "get_ha_metric", fake_get_ha_metric, raising=True) + + # Send a MeterValues WITHOUT transactionId to trigger restore branch + mv_no_tx = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "15000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Inlet", + "context": "Sample.Clock", + } + ], + } + ], + ) + # Open a WS client to deliver this message + resp = await cp.call(mv_no_tx) + assert resp is not None + assert srv._metrics[(1, "Energy.Meter.Start")].value is None + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9324, "cp_id": "CP_cov_restore_tx", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_restore_tx"]) +@pytest.mark.parametrize("port", [9324]) +async def test_restore_transaction_id_cast_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test restore transaction id when cast fails leaving candidate as None.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + srv._metrics[(1, "Transaction.Id")].value = None + + def fake_get_ha_metric(name, connector_id=None): + if name == "Transaction.Id" and connector_id == 1: + return "not-an-int" + return None + + monkeypatch.setattr(srv, "get_ha_metric", fake_get_ha_metric, raising=True) + + # Trigger the handler with a MeterValues (no strict need to carry tx) + mv_no_tx = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Inlet", + "context": "Sample.Clock", + } + ], + } + ], + ) + _ = await cp.call(mv_no_tx) + # Value remains unset because candidate was None + assert srv._metrics[(1, "Transaction.Id")].value is None + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9325, "cp_id": "CP_cov_new_tx", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_new_tx"]) +@pytest.mark.parametrize("port", [9325]) +async def test_new_transaction_resets_tx_bound_metrics( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test new transaction detection resets tx-bound EAIR and meter_start.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + # Preload per-connector values which should be cleared when new tx starts + srv._metrics[(1, "Energy.Active.Import.Register")].value = 999.0 + srv._metrics[(1, "Energy.Meter.Start")].value = 888.0 + + # Send MeterValues with a new transactionId + mv_tx = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "0", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "context": "Sample.Clock", + "location": "Inlet", + } + ], + } + ], + transaction_id=12345, + ) + resp = await cp.call(mv_tx) + assert resp is not None + # New tx should be registered + assert srv._active_tx.get(1) == 12345 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9326, "cp_id": "CP_cov_eair_cast_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_eair_cast_exc"]) +@pytest.mark.parametrize("port", [9326]) +async def test_eair_get_energy_kwh_exception_ignored( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test EAIR scan ignores entries when energy_kwh conversion raises.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Monkeypatch energy conversion to raise + from custom_components.ocpp.ocppv16 import cp as cp_mod + + monkeypatch.setattr( + cp_mod, + "get_energy_kwh", + lambda item: (_ for _ in ()).throw(RuntimeError("bad")), + ) + + mv = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "context": "Sample.Clock", + } + ], + } + ], + transaction_id=1, + ) + # Should not raise + _ = await cp.call(mv) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9327, "cp_id": "CP_cov_num_conn_int_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_num_conn_int_exc"]) +@pytest.mark.parametrize("port", [9327]) +async def test_mirror_single_connector_handles_int_exception( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test single connector mirroring handles int casting exception by falling back.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + class Bad: + def __int__(self): + raise ValueError("no int") + + srv.num_connectors = Bad() + + mv = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "context": "Sample.Clock", + } + ], + } + ], + transaction_id=2, + ) + _ = await cp.call(mv) + # If we reach here without exceptions, fallback worked (n_connectors=1) + assert True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9328, "cp_id": "CP_cov_sess_energy_cast_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_sess_energy_cast_exc"]) +@pytest.mark.parametrize("port", [9328]) +async def test_session_energy_get_energy_kwh_exception_ignored( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test session energy calculation ignores EAIR entries raising conversion errors.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + from custom_components.ocpp.ocppv16 import cp as cp_mod + + monkeypatch.setattr( + cp_mod, + "get_energy_kwh", + lambda item: (_ for _ in ()).throw(RuntimeError("bad")), + ) + + mv = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "context": "Sample.Clock", + } + ], + } + ], + transaction_id=3, + ) + _ = await cp.call(mv) + # No crash == lines 1005-1006 exercised + assert True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9329, "cp_id": "CP_cov_sess_ms_cast_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_sess_ms_cast_exc"]) +@pytest.mark.parametrize("port", [9329]) +async def test_session_energy_meter_start_cast_exception( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test session energy path when meter_start cannot be cast to float.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + # Poison meter_start with a non-float so that float() raises + srv._metrics[(1, "Energy.Meter.Start")].value = object() + + mv = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "context": "Sample.Clock", + } + ], + } + ], + transaction_id=4, + ) + _ = await cp.call(mv) + # No crash is sufficient to cover lines 1023-1024 + assert True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9330, "cp_id": "CP_cov_status_suspended", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_status_suspended"]) +@pytest.mark.parametrize("port", [9330]) +async def test_status_notification_suspended_resets_metrics( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test status notification suspended state resets power/current metrics to zero.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Pre-populate metrics (non-zero) so they can be zeroed on suspended status + for meas in [ + "Current.Import", + "Power.Active.Import", + "Power.Reactive.Import", + "Current.Export", + "Power.Active.Export", + "Power.Reactive.Export", + ]: + srv._metrics[(1, meas)].value = 123 + + # Simulate status notification + resp = srv.on_status_notification( + connector_id=1, + error_code="NoError", + status=ChargePointStatus.suspended_ev.value, + ) + assert resp is not None + for meas in [ + "Current.Import", + "Power.Active.Import", + "Power.Reactive.Import", + "Current.Export", + "Power.Active.Export", + "Power.Reactive.Export", + ]: + assert int(srv._metrics[(1, meas)].value) == 0 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9331, "cp_id": "CP_cov_start_tx_ms_cast_exc", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_start_tx_ms_cast_exc"]) +@pytest.mark.parametrize("port", [9331]) +async def test_start_transaction_meter_start_cast_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test start transaction handler path when meter_start cast fails defaults to 0.0.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Ensure authorization passes so the handler proceeds normally + monkeypatch.setattr( + srv, "get_authorization_status", lambda id_tag: "Accepted", raising=True + ) + + # Call the handler directly with a non-numeric meter_start + result = srv.on_start_transaction( + connector_id=1, id_tag="test_cp", meter_start="not-a-number" + ) + assert result is not None + # The cast fails internally and baseline defaults to 0.0 (lines 1147-1148) + assert float(srv._metrics[(1, "Energy.Meter.Start")].value) == 0.0 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9332, "cp_id": "CP_cov_start_tx_denied", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_start_tx_denied"]) +@pytest.mark.parametrize("port", [9332]) +async def test_start_transaction_auth_denied_returns_tx0( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test start transaction returns transaction id 0 when authorization is denied.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Force non-accepted authorization + monkeypatch.setattr( + srv, + "get_authorization_status", + lambda id_tag: "Invalid", + raising=True, + ) + # Call handler directly to inspect response + result = srv.on_start_transaction( + connector_id=1, id_tag="bad", meter_start=0 + ) + assert result.transaction_id == 0 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9333, "cp_id": "CP_cov_stop_tx_paths", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_stop_tx_paths"]) +@pytest.mark.parametrize("port", [9333]) +async def test_stop_transaction_misc_paths( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test stop transaction paths covering unknown unit and meter_stop cast exception.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Simulate a running transaction + srv._active_tx[1] = 777 + srv.active_transaction_id = 777 + + # Prepare main-meter EAIR with unknown unit to drive line 1213 + srv._metrics[(1, "Energy.Active.Import.Register")].value = 123.456 + srv._metrics[(1, "Energy.Active.Import.Register")].unit = "kWs" # unknown + + # Call stop_transaction with a non-numeric meter_stop so 1225-1226 -> 0.0 + result = srv.on_stop_transaction( + meter_stop="not-a-number", timestamp=None, transaction_id=777 + ) + assert result is not None + # Session energy should be computed; we mainly care lines executed without crash + assert srv._active_tx[1] == 0 and srv.active_transaction_id == 0 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py new file mode 100644 index 00000000..48a899e3 --- /dev/null +++ b/tests/test_api_paths.py @@ -0,0 +1,414 @@ +"""Test exceptions paths in api.py.""" + +import contextlib +from types import SimpleNamespace + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from homeassistant.const import STATE_OK, STATE_UNAVAILABLE +from homeassistant.exceptions import HomeAssistantError +from websockets import NegotiationError + +from custom_components.ocpp.api import CentralSystem +from custom_components.ocpp.const import DOMAIN +from custom_components.ocpp.enums import ( + HAChargerServices as csvcs, + HAChargerStatuses as cstat, +) +from custom_components.ocpp.chargepoint import Metric as M +from custom_components.ocpp.chargepoint import SetVariableResult + +from tests.const import MOCK_CONFIG_DATA + + +class DummyCP: + """Minimal fake ChargePoint for exercising CentralSystem API paths.""" + + def __init__(self, *, status=STATE_OK, num_connectors=3, supported_features=0b101): + """Initialize.""" + self.status = status + self.num_connectors = num_connectors + self.supported_features = supported_features + self._metrics = {} + # service call sinks + self.calls = [] + + # ---- services the API calls into ---- + async def set_charge_rate(self, **kw): + """Set charge rate.""" + self.calls.append(("set_charge_rate", kw)) + return True + + async def set_availability(self, state, connector_id=None): + """Set availability.""" + self.calls.append( + ("set_availability", {"state": state, "connector_id": connector_id}) + ) + return True + + async def start_transaction(self, connector_id=None): + """Start transaction.""" + self.calls.append(("start_transaction", {"connector_id": connector_id})) + return True + + async def stop_transaction(self, connector_id: int | None = None): + """Stop transaction.""" + self.calls.append(("stop_transaction", {})) + return True + + async def reset(self): + """Reset.""" + self.calls.append(("reset", {})) + return True + + async def unlock(self, connector_id=None): + """Unlock.""" + self.calls.append(("unlock", {"connector_id": connector_id})) + return True + + async def trigger_custom_message(self, requested_message): + """Trigger custom message.""" + self.calls.append( + ("trigger_custom_message", {"requested_message": requested_message}) + ) + return True + + async def clear_profile(self): + """Clear profile.""" + self.calls.append(("clear_profile", {})) + return True + + async def update_firmware(self, url, delay): + """Update firmware.""" + self.calls.append(("update_firmware", {"url": url, "delay": delay})) + return True + + async def get_diagnostics(self, url): + """Get diagnostics.""" + self.calls.append(("get_diagnostics", {"url": url})) + return True + + async def data_transfer(self, vendor, message, data): + """Handle data transfer.""" + self.calls.append( + ("data_transfer", {"vendor": vendor, "message": message, "data": data}) + ) + return True + + async def configure(self, key, value): + """Configure.""" + self.calls.append(("configure", {"key": key, "value": value})) + # alternate responses by key to cover both branches + return ( + SetVariableResult.reboot_required + if key == "needs_reboot" + else SetVariableResult.accepted + ) + + async def get_configuration(self, key): + """Get configuration.""" + self.calls.append(("get_configuration", {"key": key})) + return f"value-for:{key}" + + +def _install_dummy_cp( + cs: CentralSystem, *, cpid="test_cpid", cp_id="CP_DUMMY", **kw +) -> DummyCP: + cp = DummyCP(**kw) + cs.charge_points[cp_id] = cp + cs.cpids[cpid] = cp_id + return cp + + +@pytest.mark.asyncio +async def test_select_subprotocol_variants(hass): + """Test select subprotocol variants.""" + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + + # client offers none -> None + assert cs.select_subprotocol(None, []) is None + + # overlap -> pick shared + shared = cs.subprotocols[0] + assert cs.select_subprotocol(None, [shared, "other"]) == shared + + with pytest.raises(NegotiationError): + cs.select_subprotocol(None, ["nope1", "nope2"]) + + +@pytest.mark.asyncio +async def test_get_metric_all_fallbacks(hass): + """Test all fallbacks in get_metric.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cp = _install_dummy_cp(cs, num_connectors=3) + + meas = "Voltage" + # 1) explicit connector + cp._metrics[(2, meas)] = M(230.0, "V") + assert cs.get_metric("test_cpid", meas, connector_id=2) == 230.0 + + # 2) charger level (0) + cp._metrics[(0, meas)] = M(231.0, "V") + assert cs.get_metric("test_cpid", meas) == 231.0 + + # 3) flat legacy key + cp._metrics[meas] = M(232.0, "V") + # delete (0,measurand) so flat is used + cp._metrics.pop((0, meas), None) + assert cs.get_metric("test_cpid", meas) == 232.0 + + # 4) fallback connector 1 + cp._metrics.pop(meas, None) + cp._metrics[(1, meas)] = M(233.0, "V") + assert cs.get_metric("test_cpid", meas) == 233.0 + + # 5) scan 2..N + # del_metric: remove via (0, meas) and flat fallback + + # Make sure earlier fallbacks don't win + for k in [(0, meas), (1, meas), (2, meas)]: + if k in cp._metrics: + cp._metrics[k].value = None + + # Also remove/neutralize the legacy flat key if present + with contextlib.suppress(KeyError): + cp._metrics.pop(meas) + + # Now seed the value only on connector 3 + cp._metrics[(3, meas)] = cp._metrics.get((3, meas), M(None, None)) + cp._metrics[(3, meas)].value = 234.0 + cp._metrics[(3, meas)].unit = "V" + + # Ensure the CS thinks there are at least 3 connectors + srv = cs.charge_points[cs.cpids["test_cpid"]] + srv.num_connectors = max(getattr(srv, "num_connectors", 1) or 1, 3) + + assert cs.get_metric("test_cpid", meas) == 234.0 + + +@pytest.mark.asyncio +async def test_get_units_and_attrs_fallbacks(hass): + """Test fallbacks in get_units and get_extra_attrs.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cp = _install_dummy_cp(cs, num_connectors=3) + + meas = "Power.Active.Import" + # units via (3, meas) + cp._metrics[(3, meas)] = M(10.0, "W") + cp._metrics[(3, meas)].__dict__["_ha_unit"] = "W" + cp._metrics[(3, meas)].extra_attr = {"ctx": "Sample.Periodic"} + + # ensure earlier probes are empty/missing so it scans to c>=2 + assert cs.get_unit("test_cpid", meas) == "W" + assert cs.get_ha_unit("test_cpid", meas) == "W" + assert cs.get_extra_attr("test_cpid", meas) == {"ctx": "Sample.Periodic"} + + # explicit connector wins + cp._metrics[(1, meas)] = M(11.0, "kW") + cp._metrics[(3, meas)].__dict__["_ha_unit"] = "kW" + cp._metrics[(1, meas)].extra_attr = {"src": "conn1"} + assert cs.get_unit("test_cpid", meas, connector_id=1) == "kW" + assert cs.get_ha_unit("test_cpid", meas, connector_id=1) == "kW" + assert cs.get_extra_attr("test_cpid", meas, connector_id=1) == {"src": "conn1"} + + +@pytest.mark.asyncio +async def test_get_available_paths(hass): + """Test paths in get_available.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + # charger unavailable by status for connector 0 + cp = _install_dummy_cp(cs, status=STATE_UNAVAILABLE) + assert cs.get_available("test_cpid", connector_id=0) is False + + # specific connector via per-connector metric, charger available + cp = _install_dummy_cp(cs, status=STATE_OK) + meas = cstat.status_connector.value + cp._metrics[(1, meas)] = M("Charging", None) + assert cs.get_available("test_cpid", connector_id=1) is True + + # via flat extra_attr aggregator + cp2 = _install_dummy_cp(cs, cpid="agg", cp_id="CP_AGG", status=STATE_OK) + flat = M("Available", None) + flat.extra_attr = {2: "Finishing"} + cp2._metrics[meas] = flat + assert cs.get_available("agg", connector_id=2) is True + + # fall back to charger status if no info + assert cs.get_available("agg", connector_id=3) is True # charger STATE_OK + + +@pytest.mark.asyncio +async def test_supported_features_and_device_info(hass): + """Test supported features and device info.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cp = _install_dummy_cp(cs) + assert cs.get_supported_features("test_cpid") == cp.supported_features + assert cs.get_supported_features("unknown") == 0 + assert cs.device_info() == {"identifiers": {(DOMAIN, cs.id)}} + + +@pytest.mark.asyncio +async def test_setters_when_missing_and_present(hass): + """Test set_charger_state various conditions.""" + + # Create a MockConfigEntry with existing standard config + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + # missing -> False + assert await cs.set_max_charge_rate_amps("missing", 10.0) is False + + # present -> routes and returns True + cp = _install_dummy_cp(cs) + assert await cs.set_max_charge_rate_amps("test_cpid", 16.0, connector_id=2) is True + assert ("set_charge_rate", {"limit_amps": 16.0, "conn_id": 2}) in cp.calls + + # set_charger_state branches + await cs.set_charger_state( + "test_cpid", csvcs.service_availability.name, True, connector_id=1 + ) + await cs.set_charger_state( + "test_cpid", csvcs.service_charge_start.name, connector_id=2 + ) + await cs.set_charger_state("test_cpid", csvcs.service_charge_stop.name) + await cs.set_charger_state("test_cpid", csvcs.service_reset.name) + await cs.set_charger_state("test_cpid", csvcs.service_unlock.name, connector_id=3) + kinds = [ + k + for k, _ in cp.calls + if k + in { + "set_availability", + "start_transaction", + "stop_transaction", + "reset", + "unlock", + } + ] + assert set(kinds) == { + "set_availability", + "start_transaction", + "stop_transaction", + "reset", + "unlock", + } + + +@pytest.mark.asyncio +async def test_check_charger_available_decorator_and_services(hass): + """Test the check_charger_available and services when cp not available.""" + + # 1) CentralSystem without websocket + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + + # 2) Register two CP: one OK and one UNAVAILABLE + _install_dummy_cp(cs, cpid="ok", cp_id="CP_OK", status=STATE_OK) + _install_dummy_cp(cs, cpid="bad", cp_id="CP_BAD", status=STATE_UNAVAILABLE) + + # 3) Minimal hass.data-structure (some handlers read config) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault("config", {}) + + # 4) Unavailable -> should throw HomeAssistantError + with pytest.raises(HomeAssistantError): + await cs.handle_clear_profile( + SimpleNamespace(data={"devid": "bad"}), + ) + + # 5) Available -> handlers reach CP methods without exception + await cs.handle_trigger_custom_message( + SimpleNamespace( + data={"devid": "ok", "requested_message": "StatusNotification"} + ), + ) + await cs.handle_clear_profile( + SimpleNamespace(data={"devid": "ok"}), + ) + await cs.handle_update_firmware( + SimpleNamespace( + data={"devid": "ok", "firmware_url": "http://x/fw.bin", "delay_hours": 2} + ), + ) + await cs.handle_get_diagnostics( + SimpleNamespace(data={"devid": "ok", "upload_url": "http://u/diag"}), + ) + await cs.handle_data_transfer( + SimpleNamespace( + data={"devid": "ok", "vendor_id": "V", "message_id": "M", "data": "D"} + ), + ) + + # 6) set_charge_rate – test all three variants + await cs.handle_set_charge_rate( + SimpleNamespace( + data={"devid": "ok", "custom_profile": "{'foo': 1, 'bar': 'x'}"} + ), + ) + await cs.handle_set_charge_rate( + SimpleNamespace(data={"devid": "ok", "limit_watts": 3500, "conn_id": 1}), + ) + await cs.handle_set_charge_rate( + SimpleNamespace(data={"devid": "ok", "limit_amps": 10.5}), + ) + + # 7) configure + get_configuration – check return format + resp = await cs.handle_configure( + SimpleNamespace(data={"devid": "ok", "ocpp_key": "needs_reboot", "value": "1"}), + ) + assert resp == {"reboot_required": True} + + resp = await cs.handle_configure( + SimpleNamespace(data={"devid": "ok", "ocpp_key": "just_apply", "value": "x"}), + ) + assert resp == {"reboot_required": False} + + resp = await cs.handle_get_configuration( + SimpleNamespace(data={"devid": "ok", "ocpp_key": "Foo"}), + ) + assert resp == {"value": "value-for:Foo"} + + +def test_del_metric_variants(hass): + """Test the del_metric function.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA.copy()) + cs = CentralSystem(hass, entry) + cpid = "test_cpid" + cp = _install_dummy_cp(cs, cpid=cpid, num_connectors=3) + + # --- Case A: connector-scoped metric exists -> set to None + meas_conn = "Voltage" + cp._metrics[(1, meas_conn)] = M(230.0, "V") + # sanity + assert cs.get_metric(cpid, meas_conn, connector_id=1) == 230.0 + + cs.del_metric(cpid, meas_conn, connector_id=1) + assert cs.get_metric(cpid, meas_conn, connector_id=1) is None + + # --- Case B: (0, meas) missing => fallback to legacy flat key when conn==0 + meas_flat = "Power.Active.Import" + if (0, meas_flat) in cp._metrics: + del cp._metrics[(0, meas_flat)] + cp._metrics[meas_flat] = M(123.0, "W") + assert cs.get_metric(cpid, meas_flat) == 123.0 + + cs.del_metric(cpid, meas_flat, connector_id=0) + assert cs.get_metric(cpid, meas_flat) is None + assert cp._metrics[meas_flat].value is None + + # --- Case C: unknown cpid -> returns None, no exception + assert cs.del_metric("unknown_cpid", "Voltage") is None diff --git a/tests/test_charge_point.py b/tests/test_charge_point.py deleted file mode 100644 index c5abe69e..00000000 --- a/tests/test_charge_point.py +++ /dev/null @@ -1,1067 +0,0 @@ -"""Implement a test by a simulating a chargepoint.""" -import asyncio -from datetime import datetime, timezone # timedelta, - -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.button.const import SERVICE_PRESS -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.switch import ( - DOMAIN as SWITCH_DOMAIN, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, -) -from homeassistant.const import ATTR_ENTITY_ID -import pytest -from pytest_homeassistant_custom_component.common import MockConfigEntry -import websockets - -from custom_components.ocpp import async_setup_entry, async_unload_entry -from custom_components.ocpp.button import BUTTONS -from custom_components.ocpp.const import DOMAIN as OCPP_DOMAIN -from custom_components.ocpp.enums import ConfigurationKey, HAChargerServices as csvcs -from custom_components.ocpp.number import NUMBERS -from custom_components.ocpp.switch import SWITCHES -from ocpp.routing import on -from ocpp.v16 import ChargePoint as cpclass, call, call_result -from ocpp.v16.enums import ( - Action, - AuthorizationStatus, - AvailabilityStatus, - ChargePointErrorCode, - ChargePointStatus, - ChargingProfileStatus, - ClearChargingProfileStatus, - ConfigurationStatus, - DataTransferStatus, - DiagnosticsStatus, - FirmwareStatus, - RegistrationStatus, - RemoteStartStopStatus, - ResetStatus, - TriggerMessageStatus, - UnlockStatus, -) - -from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_2 - - -@pytest.mark.timeout(60) # Set timeout to 60 seconds for this test -async def test_cms_responses(hass, socket_enabled): - """Test central system responses to a charger.""" - - async def test_switches(hass, socket_enabled): - """Test switch operations.""" - for switch in SWITCHES: - result = await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - service_data={ - ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.test_cpid_{switch.key}" - }, - blocking=True, - ) - assert result - await asyncio.sleep(1) - result = await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - service_data={ - ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.test_cpid_{switch.key}" - }, - blocking=True, - ) - assert result - - async def test_buttons(hass, socket_enabled): - """Test button operations.""" - for button in BUTTONS: - result = await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: f"{BUTTON_DOMAIN}.test_cpid_{button.key}"}, - blocking=True, - ) - assert result - - async def test_services(hass, socket_enabled): - """Test service operations.""" - SERVICES = [ - csvcs.service_update_firmware, - csvcs.service_configure, - csvcs.service_get_configuration, - csvcs.service_get_diagnostics, - csvcs.service_clear_profile, - csvcs.service_data_transfer, - ] - for service in SERVICES: - data = {} - if service == csvcs.service_update_firmware: - data = {"firmware_url": "http://www.charger.com/firmware.bin"} - if service == csvcs.service_configure: - data = {"ocpp_key": "WebSocketPingInterval", "value": "60"} - if service == csvcs.service_get_configuration: - data = {"ocpp_key": "UnknownKeyTest"} - if service == csvcs.service_get_diagnostics: - data = {"upload_url": "https://webhook.site/abc"} - if service == csvcs.service_data_transfer: - data = {"vendor_id": "ABC"} - - result = await hass.services.async_call( - OCPP_DOMAIN, - service.value, - service_data=data, - blocking=True, - ) - assert result - - for number in NUMBERS: - # test setting value of number slider - result = await hass.services.async_call( - NUMBER_DOMAIN, - "set_value", - service_data={"value": "10"}, - blocking=True, - target={ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.test_cpid_{number.key}"}, - ) - assert result - - # Test MOCK_CONFIG_DATA_2 - if True: - # Create a mock entry so we don't have to go through config flow - config_entry2 = MockConfigEntry( - domain=OCPP_DOMAIN, data=MOCK_CONFIG_DATA_2, entry_id="test_cms2" - ) - assert await async_setup_entry(hass, config_entry2) - await hass.async_block_till_done() - - # no subprotocol - async with websockets.connect( - "ws://127.0.0.1:9002/CP_1_nosub", - ) as ws2: - # use a different id for debugging - cp2 = ChargePoint("CP_1_no_subprotocol", ws2) - try: - await asyncio.wait_for( - asyncio.gather( - cp2.start(), - cp2.send_boot_notification(), - cp2.send_authorize(), - cp2.send_heartbeat(), - cp2.send_status_notification(), - cp2.send_firmware_status(), - cp2.send_data_transfer(), - cp2.send_start_transaction(), - cp2.send_stop_transaction(), - cp2.send_meter_periodic_data(), - ), - timeout=3, - ) - except asyncio.TimeoutError: - pass - await ws2.close() - await asyncio.sleep(1) - await async_unload_entry(hass, config_entry2) - await hass.async_block_till_done() - - # Create a mock entry so we don't have to go through config flow - config_entry = MockConfigEntry( - domain=OCPP_DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test_cms" - ) - assert await async_setup_entry(hass, config_entry) - await hass.async_block_till_done() - - cs = hass.data[OCPP_DOMAIN][config_entry.entry_id] - - # no subprotocol - async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_unsup", - ) as ws: - # use a different id for debugging - cp = ChargePoint("CP_1_no_subprotocol", ws) - try: - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cp.send_boot_notification(), - cp.send_authorize(), - cp.send_heartbeat(), - cp.send_status_notification(), - cp.send_firmware_status(), - cp.send_data_transfer(), - cp.send_start_transaction(), - cp.send_stop_transaction(), - cp.send_meter_periodic_data(), - ), - timeout=3, - ) - except websockets.exceptions.ConnectionClosedOK: - pass - await ws.close() - - await asyncio.sleep(1) - - # unsupported subprotocol - async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_unsup", - subprotocols=["ocpp0.0"], - ) as ws: - # use a different id for debugging - cp = ChargePoint("CP_1_unsupported_subprotocol", ws) - try: - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cp.send_boot_notification(), - cp.send_authorize(), - cp.send_heartbeat(), - cp.send_status_notification(), - cp.send_firmware_status(), - cp.send_data_transfer(), - cp.send_start_transaction(), - cp.send_stop_transaction(), - cp.send_meter_periodic_data(), - ), - timeout=3, - ) - except websockets.exceptions.ConnectionClosedOK: - pass - await ws.close() - - await asyncio.sleep(1) - - # test restore feature of meter_start and active_tranasction_id. - async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_res_vals", - subprotocols=["ocpp1.6"], - ) as ws: - # use a different id for debugging - cp = ChargePoint("CP_1_restore_values", ws) - cp.active_transactionId = None - # send None values - try: - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cp.send_meter_periodic_data(), - ), - timeout=5, - ) - except asyncio.TimeoutError: - pass - # check if None - assert cs.get_metric("test_cpid", "Energy.Meter.Start") is None - assert cs.get_metric("test_cpid", "Transaction.Id") is None - # send new data - try: - await asyncio.wait_for( - asyncio.gather( - cp.send_start_transaction(12344), - cp.send_meter_periodic_data(), - ), - timeout=5, - ) - except asyncio.TimeoutError: - pass - # save for reference the values for meter_start and transaction_id - saved_meter_start = int(cs.get_metric("test_cpid", "Energy.Meter.Start")) - saved_transactionId = int(cs.get_metric("test_cpid", "Transaction.Id")) - # delete current values from api memory - cs.del_metric("test_cpid", "Energy.Meter.Start") - cs.del_metric("test_cpid", "Transaction.Id") - # send new data - try: - await asyncio.wait_for( - asyncio.gather( - cp.send_meter_periodic_data(), - ), - timeout=5, - ) - except asyncio.TimeoutError: - pass - await ws.close() - - # check if restored old values from HA when api have lost the values, i.e. simulated reboot of HA - assert int(cs.get_metric("test_cpid", "Energy.Meter.Start")) == saved_meter_start - assert int(cs.get_metric("test_cpid", "Transaction.Id")) == saved_transactionId - - await asyncio.sleep(1) - - # test ocpp messages sent from charger to cms - async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_norm", - subprotocols=["ocpp1.5", "ocpp1.6"], - ) as ws: - # use a different id for debugging - cp = ChargePoint("CP_1_normal", ws) - try: - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cp.send_boot_notification(), - cp.send_authorize(), - cp.send_heartbeat(), - cp.send_status_notification(), - cp.send_security_event(), - cp.send_firmware_status(), - cp.send_data_transfer(), - cp.send_start_transaction(12345), - cp.send_meter_err_phases(), - cp.send_meter_line_voltage(), - cp.send_meter_periodic_data(), - cp.send_main_meter_clock_data(), - # add delay to allow meter data to be processed - cp.send_stop_transaction(2), - ), - timeout=5, - ) - except asyncio.TimeoutError: - pass - await ws.close() - assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == int( - 1305570 / 1000 - ) - assert int(cs.get_metric("test_cpid", "Energy.Session")) == int( - (54321 - 12345) / 1000 - ) - assert int(cs.get_metric("test_cpid", "Current.Import")) == int(0) - assert int(cs.get_metric("test_cpid", "Voltage")) == int(228) - assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh" - assert cs.get_metric("unknown_cpid", "Energy.Active.Import.Register") is None - assert cs.get_unit("unknown_cpid", "Energy.Active.Import.Register") is None - assert cs.get_extra_attr("unknown_cpid", "Energy.Active.Import.Register") is None - assert int(cs.get_supported_features("unknown_cpid")) == int(0) - assert ( - await asyncio.wait_for( - cs.set_max_charge_rate_amps("unknown_cpid", 0), timeout=1 - ) - is False - ) - assert ( - await asyncio.wait_for( - cs.set_charger_state("unknown_cpid", csvcs.service_clear_profile, False), - timeout=1, - ) - is False - ) - - await asyncio.sleep(1) - # test ocpp messages sent from cms to charger, through HA switches/services - # should reconnect as already started above - # test processing of clock aligned meter data - async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_serv", - subprotocols=["ocpp1.6"], - ) as ws: - cp = ChargePoint("CP_1_services", ws) - try: - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cs.charge_points[cs.cpid].trigger_boot_notification(), - cs.charge_points[cs.cpid].trigger_status_notification(), - test_switches(hass, socket_enabled), - test_services(hass, socket_enabled), - test_buttons(hass, socket_enabled), - cp.send_meter_clock_data(), - ), - timeout=5, - ) - except asyncio.TimeoutError: - pass - await ws.close() - assert int(cs.get_metric("test_cpid", "Frequency")) == int(50) - - await asyncio.sleep(1) - - # test ocpp messages sent from charger that don't support errata 3.9 - # i.e. "Energy.Meter.Start" starts from 0 for each session and "Energy.Active.Import.Register" - # reports starting from 0 Wh for every new transaction id. Total main meter values are without transaction id. - async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_non_er_3.9", - subprotocols=["ocpp1.6"], - ) as ws: - # use a different id for debugging - cp = ChargePoint("CP_1_non_errata_3.9", ws) - try: - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cp.send_start_transaction(0), - cp.send_meter_periodic_data(), - cp.send_main_meter_clock_data(), - # add delay to allow meter data to be processed - cp.send_stop_transaction(2), - ), - timeout=5, - ) - except asyncio.TimeoutError: - pass - await ws.close() - - # Last sent "Energy.Active.Import.Register" value without transaction id should be here. - assert int(cs.get_metric("test_cpid", "Energy.Active.Import.Register")) == int( - 67230012 / 1000 - ) - assert cs.get_unit("test_cpid", "Energy.Active.Import.Register") == "kWh" - - # Last sent "Energy.Active.Import.Register" value with transaction id should be here. - assert int(cs.get_metric("test_cpid", "Energy.Session")) == int(1305570 / 1000) - assert cs.get_unit("test_cpid", "Energy.Session") == "kWh" - - await asyncio.sleep(1) - - # test ocpp rejection messages sent from charger to cms - cs.charge_points["test_cpid"].received_boot_notification = False - cs.charge_points["test_cpid"].post_connect_success = False - async with websockets.connect( - "ws://127.0.0.1:9000/CP_1_error", - subprotocols=["ocpp1.6"], - ) as ws: - cp = ChargePoint("CP_1_error", ws) - cp.accept = False - try: - await asyncio.wait_for( - asyncio.gather( - cp.start(), - cs.charge_points[cs.cpid].trigger_boot_notification(), - cs.charge_points[cs.cpid].trigger_status_notification(), - test_switches(hass, socket_enabled), - test_services(hass, socket_enabled), - test_buttons(hass, socket_enabled), - ), - timeout=3, - ) - except asyncio.TimeoutError: - pass - except websockets.exceptions.ConnectionClosedOK: - pass - await ws.close() - - await asyncio.sleep(1) - # test ping timeout, change cpid to start new connection - cs.cpid = "CP_3_test" - async with websockets.connect( - "ws://127.0.0.1:9000/CP_3", - subprotocols=["ocpp1.6"], - ) as ws: - cp = ChargePoint("CP_3_test", ws) - ws.state = 3 # CLOSED = 3 - await asyncio.sleep(3) - await ws.close() - - # test services when charger is unavailable - await asyncio.sleep(1) - await test_services(hass, socket_enabled) - await async_unload_entry(hass, config_entry) - await hass.async_block_till_done() - - -class ChargePoint(cpclass): - """Representation of real client Charge Point.""" - - def __init__(self, id, connection, response_timeout=30): - """Init extra variables for testing.""" - super().__init__(id, connection) - self.active_transactionId: int = 0 - self.accept: bool = True - - @on(Action.GetConfiguration) - def on_get_configuration(self, key, **kwargs): - """Handle a get configuration requests.""" - if key[0] == ConfigurationKey.supported_feature_profiles.value: - if self.accept is True: - return call_result.GetConfigurationPayload( - configuration_key=[ - { - "key": key[0], - "readonly": False, - "value": "Core,FirmwareManagement,LocalAuthListManagement,Reservation,SmartCharging,RemoteTrigger,Dummy", - } - ] - ) - else: - return call_result.GetConfigurationPayload( - configuration_key=[ - { - "key": key[0], - "readonly": False, - "value": "", - } - ] - ) - if key[0] == ConfigurationKey.heartbeat_interval.value: - return call_result.GetConfigurationPayload( - configuration_key=[{"key": key[0], "readonly": False, "value": "300"}] - ) - if key[0] == ConfigurationKey.number_of_connectors.value: - return call_result.GetConfigurationPayload( - configuration_key=[{"key": key[0], "readonly": False, "value": "1"}] - ) - if key[0] == ConfigurationKey.web_socket_ping_interval.value: - if self.accept is True: - return call_result.GetConfigurationPayload( - configuration_key=[ - {"key": key[0], "readonly": False, "value": "60"} - ] - ) - else: - return call_result.GetConfigurationPayload( - unknown_key=["WebSocketPingInterval"] - ) - if key[0] == ConfigurationKey.meter_values_sampled_data.value: - return call_result.GetConfigurationPayload( - configuration_key=[ - { - "key": key[0], - "readonly": False, - "value": "Energy.Active.Import.Register", - } - ] - ) - if key[0] == ConfigurationKey.meter_value_sample_interval.value: - if self.accept is True: - return call_result.GetConfigurationPayload( - configuration_key=[ - {"key": key[0], "readonly": False, "value": "60"} - ] - ) - else: - return call_result.GetConfigurationPayload( - configuration_key=[{"key": key[0], "readonly": True, "value": "60"}] - ) - if ( - key[0] - == ConfigurationKey.charging_schedule_allowed_charging_rate_unit.value - ): - return call_result.GetConfigurationPayload( - configuration_key=[ - {"key": key[0], "readonly": False, "value": "Current"} - ] - ) - if key[0] == ConfigurationKey.authorize_remote_tx_requests.value: - if self.accept is True: - return call_result.GetConfigurationPayload( - configuration_key=[ - {"key": key[0], "readonly": False, "value": "false"} - ] - ) - else: - return call_result.GetConfigurationPayload(unknown_key=[key[0]]) - if key[0] == ConfigurationKey.charge_profile_max_stack_level.value: - return call_result.GetConfigurationPayload( - configuration_key=[{"key": key[0], "readonly": False, "value": "3"}] - ) - return call_result.GetConfigurationPayload( - configuration_key=[{"key": key[0], "readonly": False, "value": ""}] - ) - - @on(Action.ChangeConfiguration) - def on_change_configuration(self, **kwargs): - """Handle a get configuration request.""" - if self.accept is True: - return call_result.ChangeConfigurationPayload(ConfigurationStatus.accepted) - else: - return call_result.ChangeConfigurationPayload(ConfigurationStatus.rejected) - - @on(Action.ChangeAvailability) - def on_change_availability(self, **kwargs): - """Handle change availability request.""" - if self.accept is True: - return call_result.ChangeAvailabilityPayload(AvailabilityStatus.accepted) - else: - return call_result.ChangeAvailabilityPayload(AvailabilityStatus.rejected) - - @on(Action.UnlockConnector) - def on_unlock_connector(self, **kwargs): - """Handle unlock request.""" - if self.accept is True: - return call_result.UnlockConnectorPayload(UnlockStatus.unlocked) - else: - return call_result.UnlockConnectorPayload(UnlockStatus.unlock_failed) - - @on(Action.Reset) - def on_reset(self, **kwargs): - """Handle change availability request.""" - if self.accept is True: - return call_result.ResetPayload(ResetStatus.accepted) - else: - return call_result.ResetPayload(ResetStatus.rejected) - - @on(Action.RemoteStartTransaction) - def on_remote_start_transaction(self, **kwargs): - """Handle remote start request.""" - if self.accept is True: - asyncio.create_task(self.send_start_transaction()) - return call_result.RemoteStartTransactionPayload( - RemoteStartStopStatus.accepted - ) - else: - return call_result.RemoteStopTransactionPayload( - RemoteStartStopStatus.rejected - ) - - @on(Action.RemoteStopTransaction) - def on_remote_stop_transaction(self, **kwargs): - """Handle remote stop request.""" - if self.accept is True: - return call_result.RemoteStopTransactionPayload( - RemoteStartStopStatus.accepted - ) - else: - return call_result.RemoteStopTransactionPayload( - RemoteStartStopStatus.rejected - ) - - @on(Action.SetChargingProfile) - def on_set_charging_profile(self, **kwargs): - """Handle set charging profile request.""" - if self.accept is True: - return call_result.SetChargingProfilePayload(ChargingProfileStatus.accepted) - else: - return call_result.SetChargingProfilePayload(ChargingProfileStatus.rejected) - - @on(Action.ClearChargingProfile) - def on_clear_charging_profile(self, **kwargs): - """Handle clear charging profile request.""" - if self.accept is True: - return call_result.ClearChargingProfilePayload( - ClearChargingProfileStatus.accepted - ) - else: - return call_result.ClearChargingProfilePayload( - ClearChargingProfileStatus.unknown - ) - - @on(Action.TriggerMessage) - def on_trigger_message(self, **kwargs): - """Handle trigger message request.""" - if self.accept is True: - return call_result.TriggerMessagePayload(TriggerMessageStatus.accepted) - else: - return call_result.TriggerMessagePayload(TriggerMessageStatus.rejected) - - @on(Action.UpdateFirmware) - def on_update_firmware(self, **kwargs): - """Handle update firmware request.""" - return call_result.UpdateFirmwarePayload() - - @on(Action.GetDiagnostics) - def on_get_diagnostics(self, **kwargs): - """Handle get diagnostics request.""" - return call_result.GetDiagnosticsPayload() - - @on(Action.DataTransfer) - def on_data_transfer(self, **kwargs): - """Handle get data transfer request.""" - if self.accept is True: - return call_result.DataTransferPayload(DataTransferStatus.accepted) - else: - return call_result.DataTransferPayload(DataTransferStatus.rejected) - - async def send_boot_notification(self): - """Send a boot notification.""" - request = call.BootNotificationPayload( - charge_point_model="Optimus", charge_point_vendor="The Mobility House" - ) - resp = await self.call(request) - assert resp.status == RegistrationStatus.accepted - - async def send_heartbeat(self): - """Send a heartbeat.""" - request = call.HeartbeatPayload() - resp = await self.call(request) - assert len(resp.current_time) > 0 - - async def send_authorize(self): - """Send an authorize request.""" - request = call.AuthorizePayload(id_tag="test_cp") - resp = await self.call(request) - assert resp.id_tag_info["status"] == AuthorizationStatus.accepted - - async def send_firmware_status(self): - """Send a firmware status notification.""" - request = call.FirmwareStatusNotificationPayload( - status=FirmwareStatus.downloaded - ) - resp = await self.call(request) - assert resp is not None - - async def send_diagnostics_status(self): - """Send a diagnostics status notification.""" - request = call.DiagnosticsStatusNotificationPayload( - status=DiagnosticsStatus.uploaded - ) - resp = await self.call(request) - assert resp is not None - - async def send_data_transfer(self): - """Send a data transfer.""" - request = call.DataTransferPayload( - vendor_id="The Mobility House", - message_id="Test123", - data="Test data transfer", - ) - resp = await self.call(request) - assert resp.status == DataTransferStatus.accepted - - async def send_start_transaction(self, meter_start: int = 12345): - """Send a start transaction notification.""" - request = call.StartTransactionPayload( - connector_id=1, - id_tag="test_cp", - meter_start=meter_start, - timestamp=datetime.now(tz=timezone.utc).isoformat(), - ) - resp = await self.call(request) - self.active_transactionId = resp.transaction_id - assert resp.id_tag_info["status"] == AuthorizationStatus.accepted.value - - async def send_status_notification(self): - """Send a status notification.""" - request = call.StatusNotificationPayload( - connector_id=0, - error_code=ChargePointErrorCode.no_error, - status=ChargePointStatus.suspended_ev, - timestamp=datetime.now(tz=timezone.utc).isoformat(), - info="Test info", - vendor_id="The Mobility House", - vendor_error_code="Test error", - ) - resp = await self.call(request) - request = call.StatusNotificationPayload( - connector_id=1, - error_code=ChargePointErrorCode.no_error, - status=ChargePointStatus.charging, - timestamp=datetime.now(tz=timezone.utc).isoformat(), - info="Test info", - vendor_id="The Mobility House", - vendor_error_code="Test error", - ) - resp = await self.call(request) - request = call.StatusNotificationPayload( - connector_id=2, - error_code=ChargePointErrorCode.no_error, - status=ChargePointStatus.available, - timestamp=datetime.now(tz=timezone.utc).isoformat(), - info="Test info", - vendor_id="The Mobility House", - vendor_error_code="Available", - ) - resp = await self.call(request) - - assert resp is not None - - async def send_meter_periodic_data(self): - """Send periodic meter data notification.""" - while self.active_transactionId == 0: - await asyncio.sleep(1) - request = call.MeterValuesPayload( - connector_id=1, - transaction_id=self.active_transactionId, - meter_value=[ - { - "timestamp": "2021-06-21T16:15:09Z", - "sampledValue": [ - { - "value": "1305590.000", - "context": "Sample.Periodic", - "measurand": "Energy.Active.Import.Register", - "location": "Outlet", - "unit": "Wh", - }, - { - "value": "20.000", - "context": "Sample.Periodic", - "measurand": "Current.Import", - "location": "Outlet", - "unit": "A", - "phase": "L1", - }, - { - "value": "0.000", - "context": "Sample.Periodic", - "measurand": "Current.Import", - "location": "Outlet", - "unit": "A", - "phase": "L2", - }, - { - "value": "0.000", - "context": "Sample.Periodic", - "measurand": "Current.Import", - "location": "Outlet", - "unit": "A", - "phase": "L3", - }, - { - "value": "16.000", - "context": "Sample.Periodic", - "measurand": "Current.Offered", - "location": "Outlet", - "unit": "A", - }, - { - "value": "50.010", - "context": "Sample.Periodic", - "measurand": "Frequency", - "location": "Outlet", - }, - { - "value": "0.000", - "context": "Sample.Periodic", - "measurand": "Power.Active.Import", - "location": "Outlet", - "unit": "kW", - }, - { - "value": "0.000", - "context": "Sample.Periodic", - "measurand": "Power.Active.Import", - "location": "Outlet", - "unit": "W", - "phase": "L1", - }, - { - "value": "0.000", - "context": "Sample.Periodic", - "measurand": "Power.Active.Import", - "location": "Outlet", - "unit": "W", - "phase": "L2", - }, - { - "value": "0.000", - "context": "Sample.Periodic", - "measurand": "Power.Active.Import", - "location": "Outlet", - "unit": "W", - "phase": "L3", - }, - { - "value": "0.000", - "context": "Sample.Periodic", - "measurand": "Power.Factor", - "location": "Outlet", - }, - { - "value": "38.500", - "context": "Sample.Periodic", - "measurand": "Temperature", - "location": "Body", - "unit": "Celsius", - }, - { - "value": "228.000", - "context": "Sample.Periodic", - "measurand": "Voltage", - "location": "Outlet", - "unit": "V", - "phase": "L1-N", - }, - { - "value": "228.000", - "context": "Sample.Periodic", - "measurand": "Voltage", - "location": "Outlet", - "unit": "V", - "phase": "L2-N", - }, - { - "value": "0.000", - "context": "Sample.Periodic", - "measurand": "Voltage", - "location": "Outlet", - "unit": "V", - "phase": "L3-N", - }, - { - "value": "89.00", - "context": "Sample.Periodic", - "measurand": "Power.Reactive.Import", - "unit": "W", - }, - { - "value": "0.010", - "context": "Transaction.Begin", - "unit": "kWh", - }, - { - "value": "1305570.000", - }, - ], - } - ], - ) - resp = await self.call(request) - assert resp is not None - - async def send_meter_line_voltage(self): - """Send line voltages.""" - while self.active_transactionId == 0: - await asyncio.sleep(1) - request = call.MeterValuesPayload( - connector_id=1, - transaction_id=self.active_transactionId, - meter_value=[ - { - "timestamp": "2021-06-21T16:15:09Z", - "sampledValue": [ - { - "value": "395.900", - "context": "Sample.Periodic", - "measurand": "Voltage", - "location": "Outlet", - "unit": "V", - "phase": "L1-L2", - }, - { - "value": "396.300", - "context": "Sample.Periodic", - "measurand": "Voltage", - "location": "Outlet", - "unit": "V", - "phase": "L2-L3", - }, - { - "value": "398.900", - "context": "Sample.Periodic", - "measurand": "Voltage", - "location": "Outlet", - "unit": "V", - "phase": "L3-L1", - }, - ], - } - ], - ) - resp = await self.call(request) - assert resp is not None - - async def send_meter_err_phases(self): - """Send erroneous voltage phase.""" - while self.active_transactionId == 0: - await asyncio.sleep(1) - request = call.MeterValuesPayload( - connector_id=1, - transaction_id=self.active_transactionId, - meter_value=[ - { - "timestamp": "2021-06-21T16:15:09Z", - "sampledValue": [ - { - "value": "230", - "context": "Sample.Periodic", - "measurand": "Voltage", - "location": "Outlet", - "unit": "V", - "phase": "L1", - }, - { - "value": "23", - "context": "Sample.Periodic", - "measurand": "Current.Import", - "location": "Outlet", - "unit": "A", - "phase": "L1-N", - }, - ], - } - ], - ) - resp = await self.call(request) - assert resp is not None - - async def send_main_meter_clock_data(self): - """Send periodic main meter value. Main meter values dont have transaction_id.""" - while self.active_transactionId == 0: - await asyncio.sleep(1) - request = call.MeterValuesPayload( - connector_id=1, - meter_value=[ - { - "timestamp": "2021-06-21T16:15:09Z", - "sampledValue": [ - { - "value": "67230012", - "context": "Sample.Clock", - "format": "Raw", - "measurand": "Energy.Active.Import.Register", - "location": "Inlet", - }, - ], - } - ], - ) - resp = await self.call(request) - assert resp is not None - - async def send_meter_clock_data(self): - """Send periodic meter data notification.""" - self.active_transactionId = 0 - request = call.MeterValuesPayload( - connector_id=1, - transaction_id=self.active_transactionId, - meter_value=[ - { - "timestamp": "2021-06-21T16:15:09Z", - "sampledValue": [ - { - "measurand": "Voltage", - "context": "Sample.Clock", - "unit": "V", - "value": "228.490", - }, - { - "measurand": "Power.Active.Import", - "context": "Sample.Clock", - "unit": "W", - "value": "0.000", - }, - { - "measurand": "Energy.Active.Import.Register", - "context": "Sample.Clock", - "unit": "kWh", - "value": "1101.452", - }, - { - "measurand": "Current.Import", - "context": "Sample.Clock", - "unit": "A", - "value": "0.054", - }, - { - "measurand": "Frequency", - "context": "Sample.Clock", - "value": "50.000", - }, - ], - }, - ], - ) - resp = await self.call(request) - assert resp is not None - - async def send_stop_transaction(self, delay: int = 0): - """Send a stop transaction notification.""" - # add delay to allow meter data to be processed - await asyncio.sleep(delay) - while self.active_transactionId == 0: - await asyncio.sleep(1) - request = call.StopTransactionPayload( - meter_stop=54321, - timestamp=datetime.now(tz=timezone.utc).isoformat(), - transaction_id=self.active_transactionId, - reason="EVDisconnected", - id_tag="test_cp", - ) - resp = await self.call(request) - assert resp.id_tag_info["status"] == AuthorizationStatus.accepted.value - - async def send_security_event(self): - """Send a security event notification.""" - request = call.SecurityEventNotificationPayload( - type="SettingSystemTime", - timestamp="2022-09-29T20:58:29Z", - tech_info="BootNotification", - ) - await self.call(request) diff --git a/tests/test_charge_point_core.py b/tests/test_charge_point_core.py new file mode 100644 index 00000000..951fc39b --- /dev/null +++ b/tests/test_charge_point_core.py @@ -0,0 +1,330 @@ +"""Test various chargepoint core functions/exceptions.""" + +import asyncio +import math +from types import SimpleNamespace +from unittest.mock import patch +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from websockets.protocol import State + +from homeassistant.setup import async_setup_component + +from custom_components.ocpp.chargepoint import ( + ChargePoint, + OcppVersion, + Metric, + _ConnectorAwareMetrics as CAM, + MeasurandValue, +) +from custom_components.ocpp.const import ( + DOMAIN, + CentralSystemSettings, + ChargerSystemSettings, + DEFAULT_ENERGY_UNIT, + DEFAULT_POWER_UNIT, + HA_ENERGY_UNIT, + HA_POWER_UNIT, +) +from custom_components.ocpp.enums import ( + HAChargerDetails as cdet, + HAChargerSession as csess, +) +from ocpp.messages import CallError +from ocpp.charge_point import ChargePoint as LibCP +from ocpp.exceptions import NotImplementedError as OcppNotImplementedError + +from .const import CONF_SSL_CERTFILE_PATH, CONF_SSL_KEYFILE_PATH + + +# ----------------------------- +# Helpers to build a CP instance +# ----------------------------- +def _mk_entry_data(): + return { + "host": "127.0.0.1", + "port": 0, + "csid": "cs", + "cpids": [{"CP_A": {"cpid": "test_cpid"}}], + "subprotocols": ["ocpp1.6"], + "websocket_close_timeout": 5, + "ssl": False, + # required ping fields: + "websocket_ping_interval": 0.0, + "websocket_ping_timeout": 0.01, + "websocket_ping_tries": 0, + "ssl_certfile_path": CONF_SSL_CERTFILE_PATH, + "ssl_keyfile_path": CONF_SSL_KEYFILE_PATH, + } + + +def _mk_cp(hass, *, version=OcppVersion.V201): + entry = MockConfigEntry(domain=DOMAIN, data=_mk_entry_data()) + centr = CentralSystemSettings(**entry.data) + chg = ChargerSystemSettings( + cpid="test_cpid", + max_current=32.0, + idle_interval=60, + meter_interval=60, + monitored_variables="", + monitored_variables_autoconfig=False, + skip_schema_validation=False, + force_smart_charging=False, + ) + # Minimal fake connection + conn = SimpleNamespace(state=State.CLOSED, close=lambda: asyncio.sleep(0)) + cp = ChargePoint("CP_A", conn, version, hass, entry, centr, chg) + cp._metrics[(0, csess.meter_start.value)].value = None + return cp + + +def test_connector_aware_metrics_core(): + """Test _ConnectorAwareMetrics API.""" + m = CAM() + + # set/get flat + m["Voltage"] = Metric(230.0, "V") + assert isinstance(m["Voltage"], Metric) + assert m["Voltage"].value == 230.0 + + # set/get per connector + m[(2, "Voltage")] = Metric(231.0, "V") + assert m[(2, "Voltage")].value == 231.0 + + # connector mapping view + assert isinstance(m[2], dict) + assert "Voltage" in m[2] + + # __contains__ for tuple/int/str keys + assert "Voltage" in m + assert (2, "Voltage") in m + assert 2 in m + + # type checks + with pytest.raises(TypeError): + m[(3, "X")] = ("not", "metric") + with pytest.raises(TypeError): + m[3] = Metric(1, "A") + + +@pytest.mark.asyncio +async def test_get_specific_response_raises_callerror(hass, monkeypatch): + """Test _get_specific_response “unsilence” of CallError.""" + cp = _mk_cp(hass) + + async def fake_super(self, unique_id, timeout): + # Simulate that the lib returns a CallError object + # (which is normally "silenced" in the lib). + return CallError(unique_id, "SomeError", "details") + + # Patch the lib's _get_specific_response so that our wrapper is hit + monkeypatch.setattr(LibCP, "_get_specific_response", fake_super, raising=True) + + with pytest.raises(Exception) as ei: + await cp._get_specific_response("uid-1", 1) + assert "SomeError" in str(ei.value) + + +@pytest.mark.asyncio +async def test_async_update_device_info_updates_metrics_and_registry(hass): + """Test async_update_device_info.""" + await async_setup_component(hass, "device_tracker", {}) + + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="e1", title="e1") + entry.add_to_hass(hass) + await hass.async_block_till_done() + + central = CentralSystemSettings( + csid="cs", + host="127.0.0.1", + port=9999, + subprotocols=["ocpp1.6"], + ssl=False, + ssl_certfile_path="", + ssl_keyfile_path="", + websocket_close_timeout=1, + websocket_ping_interval=0.1, + websocket_ping_timeout=0.1, + websocket_ping_tries=0, + ) + charger = ChargerSystemSettings( + cpid="test_cpid", + max_current=32.0, + idle_interval=60, + meter_interval=60, + monitored_variables="", + monitored_variables_autoconfig=False, + skip_schema_validation=False, + force_smart_charging=False, + ) + + class DummyConn: + """Dummy connection.""" + + state = None + + cp = ChargePoint( + id="CP_ID", + connection=DummyConn(), + version=OcppVersion.V201, + hass=hass, + entry=entry, + central=central, + charger=charger, + ) + + await cp.async_update_device_info( + serial="SER123", + vendor="Acme", + model="Model X", + firmware_version="1.2.3", + ) + + assert cp._metrics[(0, cdet.model.value)].value == "Model X" + assert cp._metrics[(0, cdet.vendor.value)].value == "Acme" + assert cp._metrics[(0, cdet.firmware_version.value)].value == "1.2.3" + assert cp._metrics[(0, cdet.serial.value)].value == "SER123" + + from homeassistant.helpers import device_registry + + dr = device_registry.async_get(hass) + dev = dr.async_get_device({(DOMAIN, "CP_ID"), (DOMAIN, "test_cpid")}) + assert dev is not None + assert dev.manufacturer == "Acme" + assert dev.model == "Model X" + assert dev.sw_version == "1.2.3" + + +def test_get_ha_metric_prefers_exact_entity(hass): + """Test get_ha_metric lookup logic.""" + cp = _mk_cp(hass) + # Seed states + hass.states.async_set("sensor.test_cpid_voltage", "n/a") + hass.states.async_set("sensor.test_cpid_connector_1_voltage", "229.5") + + # With connector_id=1 we should resolve the child entity + assert cp.get_ha_metric("Voltage", connector_id=1) == "229.5" + # With connector_id=None -> root entity + assert cp.get_ha_metric("Voltage", connector_id=None) == "n/a" + + +def _mv(measurand, value, phase=None, unit=None, context=None, location=None): + return MeasurandValue(measurand, value, phase, unit, context, location) + + +def test_process_phases_voltage_and_current_branches(hass): + """Test process_phases: l-l → l-n conversion, summation and unit normalization.""" + cp = _mk_cp(hass) + + # Voltage line-to-line values (should average and divide by sqrt(3)) + bucket = [ + _mv("Voltage", 400.0, phase="L1-L2", unit="V"), + _mv("Voltage", 399.0, phase="L2-L3", unit="V"), + _mv("Voltage", 401.0, phase="L3-L1", unit="V"), + ] + cp.process_phases(bucket, connector_id=1) + v_ln = cp._metrics[(1, "Voltage")].value + assert pytest.approx(v_ln, rel=1e-3) == (400.0 + 399.0 + 401.0) / 3 / math.sqrt(3) + assert cp._metrics[(1, "Voltage")].unit == "V" + + # Power.Active.Import in W should become kW when aggregated + bucket2 = [ + _mv("Power.Active.Import", 1000.0, phase="L1", unit=DEFAULT_POWER_UNIT), + _mv("Power.Active.Import", 2000.0, phase="L2", unit=DEFAULT_POWER_UNIT), + _mv("Power.Active.Import", 3000.0, phase="L3", unit=DEFAULT_POWER_UNIT), + ] + cp.process_phases(bucket2, connector_id=2) + p_kw = cp._metrics[(2, "Power.Active.Import")].value + assert p_kw == (1000 + 2000 + 3000) / 1000 # -> 6 kW + assert cp._metrics[(2, "Power.Active.Import")].unit == HA_POWER_UNIT + + +def test_get_energy_kwh_and_session_derive(hass): + """Test get_energy_kwh + process_measurands path (EAIR Wh → kWh, derive Energy.Session).""" + cp = _mk_cp(hass, version=OcppVersion.V201) # != 1.6 to enable session derive + + # Starting meter (kWh) + cp._metrics[(1, csess.meter_start.value)].value = 10.0 + cp._metrics[(1, csess.meter_start.value)].unit = HA_ENERGY_UNIT + + # Send EAIR in Wh (should normalize to kWh) + mv = _mv( + "Energy.Active.Import.Register", 10500.0, unit=DEFAULT_ENERGY_UNIT + ) # 10.5 kWh + cp.process_measurands([[mv]], is_transaction=True, connector_id=1) + + # EAIR normalized + assert cp._metrics[(1, "Energy.Active.Import.Register")].value == 10.5 + assert cp._metrics[(1, "Energy.Active.Import.Register")].unit == HA_ENERGY_UNIT + + # Session energy derived = EAIR - meter_start + assert cp._metrics[(1, csess.session_energy.value)].value == pytest.approx( + 0.5, 1e-12 + ) + assert cp._metrics[(1, csess.session_energy.value)].unit == HA_ENERGY_UNIT + + +@pytest.mark.asyncio +async def test_handle_call_wraps_notimplementederror_and_sends(hass): + """Test _handle_call Path: NotImplementedError → _send(...).""" + central = CentralSystemSettings( + csid="cs", + host="127.0.0.1", + port=9999, + subprotocols=["ocpp1.6"], + ssl=False, + ssl_certfile_path="", + ssl_keyfile_path="", + websocket_close_timeout=1, + websocket_ping_interval=0.1, + websocket_ping_timeout=0.1, + websocket_ping_tries=0, + ) + charger = ChargerSystemSettings( + cpid="test_cpid", + max_current=32.0, + idle_interval=60, + meter_interval=60, + monitored_variables="", + monitored_variables_autoconfig=False, + skip_schema_validation=False, + force_smart_charging=False, + ) + + conn = SimpleNamespace(state=State.OPEN, close=lambda: None) + + cp = ChargePoint( + "CP_ID", + conn, + OcppVersion.V201, + hass, + SimpleNamespace(entry_id="e1", data={}), + central, + charger, + ) + + # Patch the PARENT _handle_call to raise the OCPP NotImplementedError + async def parent_raises(self, msg): + raise OcppNotImplementedError("nope") + + sent = {} + + async def fake_send(payload): + sent["payload"] = payload + + class DummyMsg: + """Dummy message class.""" + + def create_call_error(self, exc): + """Create call error.""" + assert isinstance(exc, OcppNotImplementedError) + return SimpleNamespace(to_json=lambda: "ERR_JSON") + + with ( + patch.object(LibCP, "_handle_call", parent_raises, create=True), + patch.object(cp, "_send", fake_send), + ): + # Wrapper should CATCH the OCPP NotImplementedError and send CallError JSON + await cp._handle_call(DummyMsg()) + + assert sent.get("payload") == "ERR_JSON" diff --git a/tests/test_charge_point_v16.py b/tests/test_charge_point_v16.py new file mode 100644 index 00000000..cdecc538 --- /dev/null +++ b/tests/test_charge_point_v16.py @@ -0,0 +1,5533 @@ +"""Implement a test by a simulating an OCPP 1.6 chargepoint.""" + +import asyncio +import contextlib +from datetime import datetime, UTC # timedelta, +import inspect +import logging +import re +import time +from types import SimpleNamespace + +import pytest +from homeassistant.exceptions import HomeAssistantError +import websockets + +from custom_components.ocpp.api import CentralSystem +from custom_components.ocpp.button import BUTTONS +from custom_components.ocpp.chargepoint import Metric as M +from custom_components.ocpp.const import ( + DOMAIN as OCPP_DOMAIN, + CONF_CPIDS, + CONF_CPID, + CONF_NUM_CONNECTORS, +) +from custom_components.ocpp.enums import ( + ConfigurationKey as ckey, + HAChargerDetails as cdet, + HAChargerServices as csvcs, + HAChargerSession as csess, + HAChargerStatuses as cstat, + Profiles as prof, +) +from custom_components.ocpp.number import NUMBERS +from custom_components.ocpp.switch import SWITCHES +from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP +from ocpp.routing import on +from ocpp.v16 import ChargePoint as cpclass, call, call_result +from ocpp.v16.enums import ( + Action, + AuthorizationStatus, + AvailabilityStatus, + AvailabilityType, + ChargePointErrorCode, + ChargePointStatus, + ChargingProfileStatus, + ClearChargingProfileStatus, + ConfigurationStatus, + DataTransferStatus, + DiagnosticsStatus, + FirmwareStatus, + Phase, + RegistrationStatus, + RemoteStartStopStatus, + ResetStatus, + TriggerMessageStatus, + UnlockStatus, +) + +from .const import ( + MOCK_CONFIG_CP_APPEND, +) +from .charge_point_test import ( + set_switch, + press_button, + set_number, + wait_ready, +) + + +SERVICES = [ + csvcs.service_update_firmware, + csvcs.service_configure, + csvcs.service_get_configuration, + csvcs.service_get_diagnostics, + csvcs.service_trigger_custom_message, + csvcs.service_clear_profile, + csvcs.service_data_transfer, + csvcs.service_set_charge_rate, +] + + +SERVICES_ERROR = [ + csvcs.service_configure, + csvcs.service_get_configuration, + csvcs.service_trigger_custom_message, + csvcs.service_clear_profile, + csvcs.service_data_transfer, + csvcs.service_set_charge_rate, +] + + +async def wait_for_num_connectors( + hass, cp_id: str, expected: int, timeout: float = 5.0 +): + """Wait until server side CP has num_connectors == expected. + + Returns the actual CentralSystem instance (after possible reload). + """ + deadline = time.monotonic() + timeout + last_seen = None + + while time.monotonic() < deadline: + entry = hass.config_entries._entries.get_entries_for_domain(OCPP_DOMAIN)[0] + cs = hass.data[OCPP_DOMAIN][entry.entry_id] + + srv = cs.charge_points.get(cp_id) + if srv is not None: + last_seen = getattr(srv, "num_connectors", None) + if last_seen == expected: + return cs + + for item in entry.data.get(CONF_CPIDS, []): + if isinstance(item, dict) and cp_id in item: + last_seen = item[cp_id].get(CONF_NUM_CONNECTORS) + if last_seen == expected: + return cs + + await asyncio.sleep(0.05) + + raise AssertionError( + f"num_connectors never became {expected} (last seen: {last_seen})" + ) + + +async def test_switches(hass, cpid, socket_enabled): + """Test switch operations.""" + for switch in SWITCHES: + await set_switch(hass, cpid, switch.key, True) + await asyncio.sleep(1) + await set_switch(hass, cpid, switch.key, False) + + +test_switches.__test__ = False + + +async def test_buttons(hass, cpid, socket_enabled): + """Test button operations.""" + for button in BUTTONS: + await press_button(hass, cpid, button.key) + + +test_buttons.__test__ = False + + +async def test_services(hass, cpid, serv_list, socket_enabled): + """Test service operations.""" + + for service in serv_list: + data = {"devid": cpid} + if service == csvcs.service_update_firmware: + data.update({"firmware_url": "http://www.charger.com/firmware.bin"}) + if service == csvcs.service_configure: + data.update({"ocpp_key": "WebSocketPingInterval", "value": "60"}) + await hass.services.async_call( + OCPP_DOMAIN, + service.value, + service_data=data, + blocking=True, + return_response=True, + ) + break + if service == csvcs.service_get_configuration: + data.update({"ocpp_key": "UnknownKeyTest"}) + await hass.services.async_call( + OCPP_DOMAIN, + service.value, + service_data=data, + blocking=True, + return_response=True, + ) + break + if service == csvcs.service_get_diagnostics: + data.update({"upload_url": "https://webhook.site/abc"}) + if service == csvcs.service_data_transfer: + data.update({"vendor_id": "ABC"}) + if service == csvcs.service_set_charge_rate: + data.update({"limit_amps": 30}) + if service == csvcs.service_trigger_custom_message: + data.update({"requested_message:": "StatusNotification"}) + + await hass.services.async_call( + OCPP_DOMAIN, + service.value, + service_data=data, + blocking=True, + ) + # test additional set charge rate options + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_set_charge_rate, + service_data={"devid": cpid, "limit_watts": 3000}, + blocking=True, + ) + # test custom charge profile for advanced use + prof = { + "chargingProfileId": 8, + "stackLevel": 6, + "chargingProfileKind": "Relative", + "chargingProfilePurpose": "ChargePointMaxProfile", + "chargingSchedule": { + "chargingRateUnit": "A", + "chargingSchedulePeriod": [{"startPeriod": 0, "limit": 16.0}], + }, + } + data = {"devid": cpid, "custom_profile": str(prof)} + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_set_charge_rate, + service_data=data, + blocking=True, + ) + # test custom message request for MeterValues + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_trigger_custom_message, + service_data={"devid": cpid, "requested_message": "MeterValues"}, + blocking=True, + ) + + for number in NUMBERS: + # test setting value of number slider + await set_number(hass, cpid, number.key, 10) + + +test_services.__test__ = False + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9001, "cp_id": "CP_1_nosub", "cms": "cms_nosub"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_nosub"]) +@pytest.mark.parametrize("port", [9001]) +async def test_cms_responses_nosub_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to a charger with no subprotocol.""" + + # no subprotocol central system assumes ocpp1.6 charge point + # NB each new config entry will trigger async_update_entry + # if the charger measurands differ from the config entry + # which causes the websocket server to close/restart with a + # ConnectionClosedOK exception, hence it needs to be passed/suppressed + + async with ( + websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", # this is the charger cp_id ie CP_1_nosub in the cs + ) as ws2 + ): + assert ws2.subprotocol is None + # Note this mocks a real charger and is not the charger representation in the cs, which is accessed by cp_id + cp2 = ChargePoint( + f"{cp_id}_client", ws2 + ) # uses a different id for debugging, would normally be cp_id + with contextlib.suppress( + asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK + ): + await asyncio.wait_for( + asyncio.gather( + cp2.start(), + cp2.send_boot_notification(), + cp2.send_authorize(), + cp2.send_heartbeat(), + cp2.send_status_notification(), + cp2.send_firmware_status(), + cp2.send_data_transfer(), + cp2.send_start_transaction(), + cp2.send_stop_transaction(), + cp2.send_meter_periodic_data(), + ), + timeout=10, + ) + await ws2.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9002, "cp_id": "CP_1_unsup", "cms": "cms_unsup"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_unsup"]) +@pytest.mark.parametrize("port", [9002]) +async def test_cms_responses_unsupp_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system unsupported protocol.""" + + # unsupported subprotocol raises websockets exception + with pytest.raises(websockets.exceptions.InvalidStatus): + await websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp0.0"], + ) + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9003, "cp_id": "CP_1_restore_values", "cms": "cms_restore_values"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_restore_values"]) +@pytest.mark.parametrize("port", [9003]) +async def test_cms_responses_restore_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system restoring values for a charger.""" + + cs = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + # use a different id for debugging + cp = ChargePoint(f"{cp_id}_client", ws) + cp.active_transactionId = None + # send None values + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for( + asyncio.gather( + cp.start(), + cp.send_meter_periodic_data(), + ), + timeout=3, + ) + # cpid set in cs after websocket connection + cpid = cs.charge_points[cp_id].settings.cpid + + # check if None + assert cs.get_metric(cpid, "Energy.Meter.Start") is None + assert cs.get_metric(cpid, "Transaction.Id") is None + + # send new data + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for( + asyncio.gather( + cp.start(), + cp.send_boot_notification(), + cp.send_start_transaction(12344), + cp.send_meter_periodic_data(), + ), + timeout=3, + ) + + # save for reference the values for meter_start and transaction_id + saved_meter_start = int(cs.get_metric(cpid, "Energy.Meter.Start")) + saved_transactionId = int(cs.get_metric(cpid, "Transaction.Id")) + + # delete current values from api memory + cs.del_metric(cpid, "Energy.Meter.Start") + cs.del_metric(cpid, "Transaction.Id") + # send new data + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for( + asyncio.gather( + cp.start(), + cp.send_boot_notification(), + cp.send_meter_periodic_data(), + ), + timeout=3, + ) + await ws.close() + + # check if restored old values from HA when api have lost the values, i.e. simulated reboot of HA + assert int(cs.get_metric(cpid, "Energy.Meter.Start")) == saved_meter_start + assert int(cs.get_metric(cpid, "Transaction.Id")) == saved_transactionId + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9004, "cp_id": "CP_1_norm", "cms": "cms_norm"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_norm"]) +@pytest.mark.parametrize("port", [9004]) +async def test_cms_responses_normal_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to a charger under normal operation.""" + + cs = setup_config_entry + + # test ocpp messages sent from charger to cms + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.5", "ocpp1.6"], + ) as ws: + # use a different id for debugging + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + await cp.send_boot_notification() + await cp.send_authorize() + await cp.send_heartbeat() + await cp.send_status_notification() + await cp.send_security_event() + await cp.send_firmware_status() + await cp.send_data_transfer() + await cp.send_start_transaction(12345) + await cp.send_meter_err_phases() + await cp.send_meter_line_voltage() + await cp.send_meter_periodic_data() + # add delay to allow meter data to be processed + await cp.send_stop_transaction(1) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + cpid = cs.charge_points[cp_id].settings.cpid + assert int(cs.get_metric(cpid, "Energy.Active.Import.Register")) == int( + 1305570 / 1000 + ) + assert int(cs.get_metric(cpid, "Energy.Session")) == int((54321 - 12345) / 1000) + assert int(cs.get_metric(cpid, "Current.Import")) == 0 + # assert int(cs.get_metric(cpid, "Voltage")) == 228 + assert cs.get_unit(cpid, "Energy.Active.Import.Register") == "kWh" + assert cs.get_ha_unit(cpid, "Power.Reactive.Import") == "var" + assert cs.get_unit(cpid, "Power.Reactive.Import") == "var" + assert cs.get_metric("unknown_cpid", "Energy.Active.Import.Register") is None + assert cs.get_unit("unknown_cpid", "Energy.Active.Import.Register") is None + assert cs.get_extra_attr("unknown_cpid", "Energy.Active.Import.Register") is None + assert int(cs.get_supported_features("unknown_cpid")) == 0 + assert ( + await asyncio.wait_for( + cs.set_max_charge_rate_amps("unknown_cpid", 0), timeout=1 + ) + is False + ) + assert ( + await asyncio.wait_for( + cs.set_charger_state("unknown_cpid", csvcs.service_clear_profile, False), + timeout=1, + ) + is False + ) + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9005, "cp_id": "CP_1_services", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_services"]) +@pytest.mark.parametrize("port", [9005]) +async def test_cms_responses_actions_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to actions and multi charger under normal operation.""" + # start clean entry for services + cs = setup_config_entry + + # test ocpp messages sent from cms to charger, through HA switches/services + # should reconnect as already started above + # test processing of clock aligned meter data + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + with contextlib.suppress(asyncio.TimeoutError): + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + # Confirm charger completed post_connect before running services + await asyncio.wait_for( + asyncio.gather( + cp.send_meter_clock_data(), + # cs.charge_points[cp_id].trigger_boot_notification(), + # cs.charge_points[cp_id].trigger_status_notification(), + test_switches( + hass, + cs.charge_points[cp_id].settings.cpid, + socket_enabled, + ), + test_services( + hass, + cs.charge_points[cp_id].settings.cpid, + SERVICES, + socket_enabled, + ), + test_buttons( + hass, + cs.charge_points[cp_id].settings.cpid, + socket_enabled, + ), + ), + timeout=10, + ) + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + # cpid set in cs after websocket connection + cpid = cs.charge_points[cp_id].settings.cpid + + assert int(cs.get_metric(cpid, "Frequency")) == 50 + assert float(cs.get_metric(cpid, "Energy.Active.Import.Register")) == 1101.452 + + # add new charger to config entry + cp_id = "CP_1_non_er_3.9" + entry = hass.config_entries._entries.get_entries_for_domain(OCPP_DOMAIN)[0] + entry.data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) + entry.data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "cpid2" + # reload required to setup new charger in HA, normally happens with discovery flow + assert await hass.config_entries.async_reload(entry.entry_id) + cs = hass.data[OCPP_DOMAIN][entry.entry_id] + + # test ocpp messages sent from charger that don't support errata 3.9 + # i.e. "Energy.Meter.Start" starts from 0 for each session and "Energy.Active.Import.Register" + # reports starting from 0 Wh for every new transaction id. Total main meter values are without transaction id. + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + # use a different id for debugging + cp = ChargePoint(f"{cp_id}_client", ws) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for( + asyncio.gather( + cp.start(), + cp.send_boot_notification(), + cp.send_start_transaction(0), + cp.send_meter_periodic_data(), + cp.send_main_meter_clock_data(), + # add delay to allow meter data to be processed + cp.send_stop_transaction(2), + ), + timeout=5, + ) + await ws.close() + + cpid = cs.charge_points[cp_id].settings.cpid + # Last sent "Energy.Active.Import.Register" value without transaction id should be here. + assert int(cs.get_metric(cpid, "Energy.Active.Import.Register")) == int( + 67230012 / 1000 + ) + assert cs.get_unit(cpid, "Energy.Active.Import.Register") == "kWh" + + # Last sent "Energy.Active.Import.Register" value with transaction id should be here. + # Meter value sent with stop transaction should not be used to calculate session energy + assert int(cs.get_metric(cpid, "Energy.Session")) == int(1305570 / 1000) + assert cs.get_unit(cpid, "Energy.Session") == "kWh" + + # test ocpp messages sent from charger that don't support errata 3.9 with meter values with kWh as energy unit + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + with contextlib.suppress( + asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK + ): + # use a different id for debugging + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await cp.send_start_transaction(0) + await cp.send_meter_energy_kwh() + await cp.send_meter_clock_data() + # add delay to allow meter data to be processed + await asyncio.sleep(0.05) + await cp.send_stop_transaction(2) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + assert int(cs.get_metric(cpid, "Energy.Active.Import.Register")) == 1101 + assert int(cs.get_metric(cpid, "Energy.Session")) == 11 + assert cs.get_unit(cpid, "Energy.Active.Import.Register") == "kWh" + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9006, "cp_id": "CP_1_error", "cms": "cms_error"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_error"]) +@pytest.mark.parametrize("port", [9006]) +async def test_cms_responses_errors_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to actions and multi charger under error operation.""" + # start clean entry for services + cs = setup_config_entry + + # test ocpp rejection messages sent from charger to cms + # use SERVICES_ERROR as only Core and Smart profiles enabled + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + with contextlib.suppress( + asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK + ): + cp = ChargePoint(f"{cp_id}_client", ws) + cp.accept = False + # Allow charger time to connect before running services + await asyncio.wait_for( + cp.start(), + timeout=5, + ) + await ws.close() + # if monitored variables differ cs will restart and charger needs to reconnect + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + with contextlib.suppress( + asyncio.TimeoutError, websockets.exceptions.ConnectionClosedOK + ): + cp = ChargePoint(f"{cp_id}_client", ws) + cp.accept = False + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + # Confirm charger completed post_connect before running services + await asyncio.wait_for( + asyncio.gather( + cs.charge_points[cp_id].trigger_boot_notification(), + cs.charge_points[cp_id].trigger_status_notification(), + test_switches( + hass, + cs.charge_points[cp_id].settings.cpid, + socket_enabled, + ), + test_services( + hass, + "xxx", # Test with incorrect devid supplied + SERVICES_ERROR, + socket_enabled, + ), + test_buttons( + hass, + cs.charge_points[cp_id].settings.cpid, + socket_enabled, + ), + ), + timeout=10, + ) + await cs.charge_points[cp_id].stop() + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + # test services when charger is unavailable + with pytest.raises(HomeAssistantError): + await test_services( + hass, cs.charge_points[cp_id].settings.cpid, SERVICES_ERROR, socket_enabled + ) + + +@pytest.mark.timeout(40) # Set timeout for this test +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9007, "cp_id": "CP_1_norm_mc", "cms": "cms_norm"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_norm_mc"]) +@pytest.mark.parametrize("port", [9007]) +async def test_cms_responses_normal_multiple_connectors_v16( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test central system responses to a charger. + + Normal operation with multiple connectors. + """ + + cs = setup_config_entry + num_connectors = 2 + + # test ocpp messages sent from charger to cms + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.5", "ocpp1.6"], + ) as ws: + # use a different id for debugging + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=num_connectors) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + cs = await wait_for_num_connectors(hass, cp_id, expected=num_connectors) + await cp.send_boot_notification() + await cp.send_authorize() + await cp.send_heartbeat() + await cp.send_status_notification() + await cp.send_security_event() + await cp.send_firmware_status() + await cp.send_data_transfer() + await cp.send_start_transaction(12345) + await cp.send_meter_err_phases() + await cp.send_meter_line_voltage() + await cp.send_meter_periodic_data() + # add delay to allow meter data to be processed + await cp.send_stop_transaction(1) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + cpid = cs.charge_points[cp_id].settings.cpid + + assert int( + cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + ) == int(1305570 / 1000) + assert int(cs.get_metric(cpid, "Energy.Session", connector_id=1)) == int( + (54321 - 12345) / 1000 + ) + assert int(cs.get_metric(cpid, "Current.Import", connector_id=1)) == 0 + # assert int(cs.get_metric(cpid, "Voltage")) == 228 + assert cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) == "kWh" + assert cs.get_ha_unit(cpid, "Power.Reactive.Import", connector_id=1) == "var" + assert cs.get_unit(cpid, "Power.Reactive.Import", connector_id=1) == "var" + assert cs.get_metric("unknown_cpid", "Energy.Active.Import.Register") is None + assert cs.get_unit("unknown_cpid", "Energy.Active.Import.Register") is None + assert cs.get_extra_attr("unknown_cpid", "Energy.Active.Import.Register") is None + assert int(cs.get_supported_features("unknown_cpid")) == 0 + assert ( + await asyncio.wait_for( + cs.set_max_charge_rate_amps("unknown_cpid", 0), timeout=1 + ) + is False + ) + assert ( + await asyncio.wait_for( + cs.set_charger_state("unknown_cpid", csvcs.service_clear_profile, False), + timeout=1, + ) + is False + ) + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9009, "cp_id": "CP_1_clear", "cms": "cms_clear"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_clear"]) +@pytest.mark.parametrize("port", [9009]) +async def test_clear_profile_v16(hass, socket_enabled, cp_id, port, setup_config_entry): + """Verify that HA's clear_profile service triggers OCPP 1.6 ClearChargingProfile.""" + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + # Make CP ready so HA can run services + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + cpid = cs.charge_points[cp_id].settings.cpid + + # Minimal clear: no filters -> clears any CS/CP max profiles + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_clear_profile.value, + service_data={"devid": cpid}, + blocking=True, + ) + + # Let the request propagate + await asyncio.sleep(0.05) + + # Assert the CP handler was called + assert cp.last_clear_profile_kwargs is not None + # Common default: empty dict (no id/purpose/stack/connector filters) + assert isinstance(cp.last_clear_profile_kwargs, dict) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +async def set_report_session_energyreport( + cs: CentralSystem, cp_id: str, should_report: bool +): + """Set report session energy report True/False.""" + cs.charge_points[cp_id]._charger_reports_session_energy = should_report + + +set_report_session_energyreport.__test__ = False + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9022, "cp_id": "CP_1_stop_paths_b", "cms": "cms_stop_paths_b"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths_b"]) +@pytest.mark.parametrize("port", [9022]) +async def test_stop_transaction_paths_v16_b( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise all branches of ocppv16.on_stop_transaction.""" + cs: CentralSystem = setup_config_entry + + # + # SCENARIO B: charger reports session energy BUT SessionEnergy already set → do not overwrite. + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + cs.charge_points[cp_id]._charger_reports_session_energy = True + await cp.send_start_transaction(meter_start=0) + + m = cs.charge_points[cp_id]._metrics + # Pre-set SessionEnergy (should remain unchanged) + m[(1, "Energy.Session")].value = 7.777 + m[(1, "Energy.Session")].unit = "kWh" + + # Set EAIR to a different value to ensure we would notice an overwrite + m[(1, "Energy.Active.Import.Register")].value = 999999 + m[(1, "Energy.Active.Import.Register")].unit = "Wh" + + await cp.send_stop_transaction(delay=0) + + cpid = cs.charge_points[cp_id].settings.cpid + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert round(sess, 3) == 7.777 # unchanged + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9023, "cp_id": "CP_1_stop_paths_c", "cms": "cms_stop_paths_c"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_stop_paths_c"]) +@pytest.mark.parametrize("port", [9023]) +async def test_stop_transaction_paths_v16_c( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise all branches of ocppv16.on_stop_transaction.""" + cs: CentralSystem = setup_config_entry + + # + # SCENARIO C: _charger_reports_session_energy = False -> compute from meter_stop - meter_start + # + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + await cp.send_start_transaction(12345) + await set_report_session_energyreport(cs, cp_id, False) + await cp.send_stop_transaction(1) + + cpid = cs.charge_points[cp_id].settings.cpid + + # Expect session = 54.321 - 12.345 = 41.976 kWh + sess = float(cs.get_metric(cpid, "Energy.Session")) + assert round(sess, 3) == round(54.321 - 12.345, 3) + assert cs.get_unit(cpid, "Energy.Session") == "kWh" + + # After stop, these measurands must be zeroed + for meas in [ + "Current.Import", + "Power.Active.Import", + "Power.Reactive.Import", + "Current.Export", + "Power.Active.Export", + "Power.Reactive.Export", + ]: + assert float(cs.get_metric(cpid, meas)) == 0.0 + + # Optional: stop reason captured + assert cs.get_metric(cpid, "Stop.Reason") is not None + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9018, "cp_id": "CP_1_mv_restore", "cms": "cms_mv_restore"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_mv_restore"]) +@pytest.mark.parametrize("port", [9018]) +async def test_on_meter_values_restore_paths_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Cover both restore branches in on_meter_values. + + - restored (meter_start) is not None + - restored_tx (transaction_id) is not None + Then verify SessionEnergy behavior. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Ensure the metric slots look "missing" so both restore branches run. + srv._metrics[(1, "Energy.Meter.Start")].value = None + srv._metrics[(1, "Transaction.Id")].value = None + + # Patch get_ha_metric so both restores succeed. + def fake_get_ha_metric(name: str, connector_id: int | None = None): + if name == "Energy.Meter.Start" and connector_id == 1: + return "12.5" # kWh + if name == "Transaction.Id" and connector_id == 1: + return "123456" + return None + + monkeypatch.setattr(srv, "get_ha_metric", fake_get_ha_metric, raising=True) + + # (1) Send a MeterValues WITHOUT transaction_id -> updates aggregate EAIR (conn 0) + mv_no_tx = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "15000", # Wh -> 15.0 kWh + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Inlet", + "context": "Sample.Clock", + } + ], + } + ], + ) + resp = await cp.call(mv_no_tx) + assert resp is not None + + # Verify both restore branches happened. + assert srv._metrics[(1, "Energy.Meter.Start")].value == 12.5 + assert srv._metrics[(1, "Transaction.Id")].value == 123456 + assert srv._active_tx.get(1, 0) == 123456 + + # EAIR (connector 1) updated to 15.0 kWh with attrs. + assert srv._metrics[(1, "Energy.Active.Import.Register")].value == 15.0 + assert srv._metrics[(1, "Energy.Active.Import.Register")].unit == "kWh" + assert ( + srv._metrics[(1, "Energy.Active.Import.Register")].extra_attr.get( + "location" + ) + == "Inlet" + ) + assert ( + srv._metrics[(1, "Energy.Active.Import.Register")].extra_attr.get("context") + == "Sample.Clock" + ) + + # (2) Send a MeterValues WITH matching transaction_id and EAIR=16.0 kWh + mv_with_tx = call.MeterValues( + connector_id=1, + transaction_id=123456, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "16.0", + "measurand": "Energy.Active.Import.Register", + "unit": "kWh", + "context": "Sample.Periodic", + } + ], + } + ], + ) + resp2 = await cp.call(mv_with_tx) + assert resp2 is not None + + # SessionEnergy = 16.0 − 12.5 = 3.5 kWh + sess = float(cs.get_metric(cpid, "Energy.Session", connector_id=1)) + assert pytest.approx(sess, rel=1e-6) == 3.5 + assert cs.get_unit(cpid, "Energy.Session", connector_id=1) == "kWh" + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9013, "cp_id": "CP_1_extra", "cms": "cms_extra"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_extra"]) +@pytest.mark.parametrize("port", [9013]) +async def test_api_get_extra_attr_paths( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise CentralSystem.get_extra_attr() without driving full post-connect. + + We connect briefly to ensure the CS has a server-side CP object, then we + seed _metrics extra_attr directly and verify lookup order: + - explicit connector_id returns that connector's attrs, + - no connector_id prefers aggregate (conn 0), + - if conn 0 is missing, fallback to conn 1 succeeds. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + # Start a minimal CP so CS creates/keeps the server-side object + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Grab server-side CP and seed metrics directly + cp_srv = cs.charge_points[cp_id] + cpid = cp_srv.settings.cpid + + meas = "Energy.Active.Import.Register" + + # Seed aggregate (connector 0) extra_attr + cp_srv._metrics[(0, meas)].extra_attr = { + "location": "Inlet", + "context": "Sample.Clock", + } + + # (A) No connector_id -> prefers aggregate (0) + attrs = cs.get_extra_attr(cpid, measurand=meas) + assert attrs == {"location": "Inlet", "context": "Sample.Clock"} + + # (B) Explicit connector 1 -> returns that connector's attrs + cp_srv._metrics[(1, meas)].extra_attr = {"custom": "c1", "context": "Override"} + attrs_c1 = cs.get_extra_attr(cpid, measurand=meas, connector_id=1) + assert attrs_c1 == {"custom": "c1", "context": "Override"} + + # (C) Fallback order when aggregate is missing -> falls back to connector 1 + cp_srv._metrics[(0, meas)].extra_attr = None + attrs_fallback = cs.get_extra_attr(cpid, measurand=meas) + assert attrs_fallback == {"custom": "c1", "context": "Override"} + + # Clean up + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9014, "cp_id": "CP_1_fw_ok", "cms": "cms_fw_ok"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_ok"]) +@pytest.mark.parametrize("port", [9014]) +async def test_update_firmware_supported_valid_url_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """FW supported + valid URL -> returns True and RPC is sent with correct payload.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + # Enable FW bit + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) | prof.FW + ) + + url = "https://example.com/fw.bin" + caplog.set_level(logging.INFO) + + ok = await server_cp.update_firmware(url, wait_time=0) + assert ok is True + + # Assert the client actually received an UpdateFirmware call with expected data + # retrieveDate format: YYYY-mm-ddTHH:MM:SSZ + assert cp.last_update_firmware is not None + assert cp.last_update_firmware.get("location") == url + rd = cp.last_update_firmware.get("retrieve_date") + assert isinstance(rd, str) and re.match( + r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$", rd + ) + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9015, "cp_id": "CP_1_fw_badurl", "cms": "cms_fw_badurl"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_badurl"]) +@pytest.mark.parametrize("port", [9015]) +async def test_update_firmware_supported_invalid_url_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """FW supported + invalid URL -> returns False and no RPC is sent.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) | prof.FW + ) + + bad_url = "not-a-valid-url" + caplog.set_level(logging.WARNING) + + ok = await server_cp.update_firmware(bad_url, wait_time=1) + assert ok is False + # Should warn about invalid URL + assert any("Failed to parse url" in rec.message for rec in caplog.records) + # Client must not have received any UpdateFirmware + assert getattr(cp, "last_update_firmware", None) is None + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9016, "cp_id": "CP_1_fw_nosupport", "cms": "cms_fw_nosupport"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_nosupport"]) +@pytest.mark.parametrize("port", [9016]) +async def test_update_firmware_not_supported_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """FW not supported -> returns False; no RPC.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + # Ensure FW bit is NOT set + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) & ~prof.FW + ) + + caplog.set_level(logging.WARNING) + ok = await server_cp.update_firmware("https://example.com/fw.bin", wait_time=0) + assert ok is False + assert any( + "does not support OCPP firmware updating" in rec.message + for rec in caplog.records + ) + assert getattr(cp, "last_update_firmware", None) is None + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9017, "cp_id": "CP_1_fw_rpcfail", "cms": "cms_fw_rpcfail"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_fw_rpcfail"]) +@pytest.mark.parametrize("port", [9017]) +async def test_update_firmware_rpc_failure_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog, monkeypatch +): + """FW supported but self.call raises -> returns False and logs error.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + server_cp = cs.charge_points[cp_id] + server_cp._attr_supported_features = ( + int(server_cp._attr_supported_features or 0) | prof.FW + ) + + # Make the server-side call() fail + async def boom(_req): + raise RuntimeError("boom") + + monkeypatch.setattr(server_cp, "call", boom, raising=True) + + caplog.set_level(logging.ERROR) + ok = await server_cp.update_firmware("https://example.com/fw.bin", wait_time=0) + assert ok is False + assert any("UpdateFirmware failed" in rec.message for rec in caplog.records) + # No successful RPC reached the client + assert getattr(cp, "last_update_firmware", None) is None + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(40) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9020, "cp_id": "CP_1_unit_fallback", "cms": "cms_unit_fallback"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_unit_fallback"]) +@pytest.mark.parametrize("port", [9020]) +async def test_api_get_unit_fallback_to_later_connectors( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """get_unit() should fall back to connectors >=2 when (0) and (1) have no unit.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + # IMPORTANT: advertise 3 connectors so the CS learns n_connectors >= 3 + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=3) + cp_task = asyncio.create_task(cp.start()) + + # Boot + wait for server-side post_connect to complete (fetches number_of_connectors) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + cs = await wait_for_num_connectors(hass, cp_id, expected=3) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + meas = "Power.Active.Import" + + # Ensure no flat-key unit short-circuits the fallback + if meas in srv._metrics: + srv._metrics[meas].unit = None + + # Seed (0) and (1) with metrics but no unit… + srv._metrics[(0, meas)] = srv._metrics.get((0, meas), M(0.0, None)) + srv._metrics[(0, meas)].unit = None + srv._metrics[(1, meas)] = srv._metrics.get((1, meas), M(0.0, None)) + srv._metrics[(1, meas)].unit = None + + # …and (2) with a concrete unit the fallback should discover. + srv._metrics[(2, meas)] = M(0.0, "kW") + + unit = cs.get_unit(cpid, measurand=meas) + assert unit == "kW" + + # Cleanup + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [ + { + "port": 9019, + "cp_id": "CP_1_extra_fallback", + "cms": "cms_extra_fallback", + } + ], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_extra_fallback"]) +@pytest.mark.parametrize("port", [9019]) +async def test_api_get_extra_attr_fallback_to_later_connectors( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Ensure get_extra_attr() falls back. + + To connectors >=2 when (0), flat-key, (1) and (2) have no attrs (extra_attr=None), so connector 3 is returned. + """ + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=3) + cp_task = asyncio.create_task(cp.start()) + + # Boot + wait for server-side post_connect to complete (fetches number_of_connectors) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + cs = await wait_for_num_connectors(hass, cp_id, expected=3) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + from custom_components.ocpp.chargepoint import Metric as M + + meas = "Energy.Active.Import.Register" + + # (1) Force early checks to return None (NOT {}): + # - Access the flat key via __getitem__ to create the exact object the API will read, + # then set its extra_attr to None. + srv._metrics[(0, meas)] = M(0.0, None) + srv._metrics[(0, meas)].extra_attr = None + + _flat = srv._metrics[meas] # <-- pre-touch the flat key + _flat.extra_attr = None # <-- ensure it returns None, not {} + + srv._metrics[(1, meas)] = M(0.0, None) + srv._metrics[(1, meas)].extra_attr = None + + srv._metrics[(2, meas)] = M(0.0, None) + srv._metrics[(2, meas)].extra_attr = None + + # (2) Seed connector 3 with the only non-empty attrs. + expected = {"source": "conn3", "context": "Sample.Clock"} + srv._metrics[(3, meas)] = M(0.0, None) + srv._metrics[(3, meas)].extra_attr = expected + + # (3) Now the API should fall through to connector 3. + got = cs.get_extra_attr(cpid, measurand=meas) + assert got == expected + + # Cleanup + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +# @pytest.mark.skip(reason="skip") +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9008, "cp_id": "CP_1_diag_dt", "cms": "cms_diag_dt"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_diag_dt"]) +@pytest.mark.parametrize("port", [9008]) +async def test_get_diagnostics_and_data_transfer_v16( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """Ensure HA services trigger correct OCPP 1.6 calls with expected payload. + + including DataTransfer rejected path and get_diagnostics error/feature branches. + """ + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + # Bring charger to ready state (boot + post_connect) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Resolve HA device id (cpid) + cpid = cs.charge_points[cp_id].settings.cpid + + # --- get_diagnostics: happy path with valid URL --- + upload_url = "https://example.test/diag" + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": upload_url}, + blocking=True, + ) + + # --- data_transfer: Accepted path --- + vendor_id = "VendorX" + message_id = "Msg42" + payload = '{"hello":"world"}' + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_data_transfer.value, + service_data={ + "devid": cpid, + "vendor_id": vendor_id, + "message_id": message_id, + "data": payload, + }, + blocking=True, + ) + + # Give event loop a tick to flush ws calls + await asyncio.sleep(0.05) + + # Assert CP handlers received expected fields (as captured by the fake CP) + assert cp.last_diag_location == upload_url + assert cp.last_data_transfer == (vendor_id, message_id, payload) + + # --- data_transfer: Rejected path (flip cp.accept -> False) --- + cp.accept = False + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_data_transfer.value, + service_data={ + "devid": cpid, + "vendor_id": "VendorX", + "message_id": "MsgRejected", + "data": "nope", + }, + blocking=True, + ) + await asyncio.sleep(0.05) + + # --- get_diagnostics: invalid URL triggers vol.MultipleInvalid warning --- + caplog.clear() + caplog.set_level(logging.WARNING) + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": "not-a-valid-url"}, + blocking=True, + ) + assert any( + "Failed to parse url" in rec.message for rec in caplog.records + ), "Expected warning for invalid diagnostics upload_url not found" + + # --- get_diagnostics: FW profile NOT supported branch --- + # Simulate that FirmwareManagement profile is not supported by the CP + cpobj = cs.charge_points[cp_id] + original_features = getattr(cpobj, "_attr_supported_features", None) + + # Try to blank out features regardless of type (set/list/tuple/int) + try: + tp = type(original_features) + if isinstance(original_features, set | list | tuple): + new_val = tp() # empty same container type + else: + new_val = 0 # fall back to "no features" + setattr(cpobj, "_attr_supported_features", new_val) + except Exception: + setattr(cpobj, "_attr_supported_features", 0) + + # Valid URL, but without FW support the handler should skip/return gracefully + await hass.services.async_call( + OCPP_DOMAIN, + csvcs.service_get_diagnostics.value, + service_data={"devid": cpid, "upload_url": "https://example.com/diag2"}, + blocking=True, + ) + + # Restore original features to avoid impacting other tests + if original_features is not None: + setattr(cpobj, "_attr_supported_features", original_features) + + # Cleanup + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9024, "cp_id": "CP_1_monconn", "cms": "cms_monconn"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_monconn"]) +@pytest.mark.parametrize("port", [9024]) +async def test_monitor_connection_timeout_branch( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Exercise TimeoutError branch in chargepoint.monitor_connection and ensure it raises after exceeded tries.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + + from custom_components.ocpp import chargepoint as cp_mod + + async def noop_task(_coro): + return None + + monkeypatch.setattr(srv_cp.hass, "async_create_task", noop_task, raising=True) + + async def fast_sleep(_): + return None # skip the initial sleep(10) and interval sleeps + + monkeypatch.setattr(cp_mod.asyncio, "sleep", fast_sleep, raising=True) + + # First wait_for returns a never-finishing "pong waiter", + # second wait_for raises TimeoutError -> hits the except branch + calls = {"n": 0} + + async def fake_wait_for(awaitable, timeout): + calls["n"] += 1 + if inspect.iscoroutine(awaitable): + awaitable.close() + if calls["n"] == 1: + + class _NeverFinishes: + def __await__(self): + fut = asyncio.get_event_loop().create_future() + return fut.__await__() + + return _NeverFinishes() + raise TimeoutError + + monkeypatch.setattr(cp_mod.asyncio, "wait_for", fake_wait_for, raising=True) + + # Make the code raise on first timeout + srv_cp.cs_settings.websocket_ping_interval = 0.0 + srv_cp.cs_settings.websocket_ping_timeout = 0.01 + srv_cp.cs_settings.websocket_ping_tries = 0 # => > tries -> raise + + srv_cp.post_connect_success = True + + async def noop(): + return None + + monkeypatch.setattr(srv_cp, "post_connect", noop, raising=True) + monkeypatch.setattr(srv_cp, "set_availability", noop, raising=True) + + with pytest.raises(TimeoutError): + await srv_cp.monitor_connection() + + assert calls["n"] >= 2 # both wait_for calls were exercised + + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9025, "cp_id": "CP_1_authlist", "cms": "cms_authlist"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_authlist"]) +@pytest.mark.parametrize("port", [9025]) +async def test_get_authorization_status_with_auth_list( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Exercise ChargePoint.get_authorization_status() when an auth_list is configured.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.const import ( + DOMAIN, + CONFIG, + CONF_DEFAULT_AUTH_STATUS, + CONF_AUTH_LIST, + CONF_ID_TAG, + CONF_AUTH_STATUS, + ) + + # Start a minimal client so the server-side CP is registered. + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + # We only needed a boot to register the CP; close the socket cleanly. + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + srv_cp = cs.charge_points[cp_id] + + # Configure default + auth_list in HA config dict + hass.data[DOMAIN][CONFIG][CONF_DEFAULT_AUTH_STATUS] = ( + AuthorizationStatus.blocked.value + ) + hass.data[DOMAIN][CONFIG][CONF_AUTH_LIST] = [ + { + CONF_ID_TAG: "TAG_PRESENT", + CONF_AUTH_STATUS: AuthorizationStatus.expired.value, + }, + {CONF_ID_TAG: "TAG_NO_STATUS"}, # should fall back to default + ] + + # 1) Early return path: remote id tag + srv_cp._remote_id_tag = "REMOTE123" + assert ( + srv_cp.get_authorization_status("REMOTE123") + == AuthorizationStatus.accepted.value + ) + + # 2) Match in auth_list with explicit status + assert ( + srv_cp.get_authorization_status("TAG_PRESENT") + == AuthorizationStatus.expired.value + ) + + # 3) Match in auth_list without explicit status -> default + assert ( + srv_cp.get_authorization_status("TAG_NO_STATUS") + == AuthorizationStatus.blocked.value + ) + + # 4) Not found in auth_list -> default + assert ( + srv_cp.get_authorization_status("UNKNOWN") == AuthorizationStatus.blocked.value + ) + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [ + { + "port": 9026, + "cp_id": "CP_1_sess_single", + "cms": "cms_sess_single", + "num_connectors": 1, + } + ], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_sess_single"]) +@pytest.mark.parametrize("port", [9026]) +async def test_session_metrics_single_connector_backward_compat( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Single-connector: connector_id=None should transparently read connector 1 session metrics.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.5", "ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=1) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Server-side handle + CPID + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Seed connector 1 session value directly + meas = "Energy.Session" + srv._metrics[(1, meas)] = srv._metrics.get((1, meas), M(None, None)) + srv._metrics[(1, meas)].value = 3.2 + srv._metrics[(1, meas)].unit = "kWh" + + # Backward-compat read: connector_id=None must resolve to connector 1 for single-connector + val_none = cs.get_metric(cpid, measurand=meas, connector_id=None) + assert val_none == 3.2 + + # Cleanly close the socket + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [ + { + "port": 9027, + "cp_id": "CP_1_sess_multi", + "cms": "cms_sess_multi", + "num_connectors": 2, + } + ], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_1_sess_multi"]) +@pytest.mark.parametrize("port", [9027]) +async def test_session_metrics_multi_connector_isolated( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Multi-connector: values on connector 1 and 2 are distinct.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", + subprotocols=["ocpp1.5", "ocpp1.6"], + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws, no_connectors=2) + cp_task = asyncio.create_task(cp.start()) + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + meas = "Energy.Session" + # Seed distinct values per connector + for conn, val in [(1, 1.0), (2, 2.0)]: + srv._metrics[(conn, meas)] = srv._metrics.get((conn, meas), M(None, None)) + srv._metrics[(conn, meas)].value = val + srv._metrics[(conn, meas)].unit = "kWh" + + # Verify isolation + assert cs.get_metric(cpid, measurand=meas, connector_id=1) == 1.0 + assert cs.get_metric(cpid, measurand=meas, connector_id=2) == 2.0 + + # Cleanly close the socket + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9071, "cp_id": "CP_ST_SU", "cms": "cms_st_su"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_ST_SU"]) +@pytest.mark.parametrize("port", [9071]) +async def test_start_transaction_accept_and_reject( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """start_transaction returns True on accepted, False on reject and notifies HA.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] # server-side CP + + # 1) Accepted -> True + async def call_ok(req): + return SimpleNamespace(status=RemoteStartStopStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", call_ok, raising=True) + ok = await srv_cp.start_transaction(connector_id=2) + assert ok is True + + # 2) Rejected -> False and notify_ha called + notes = [] + + async def fake_notify(msg, title="Ocpp integration"): + notes.append((msg, title)) + return True + + async def call_bad(req): + return SimpleNamespace(status=RemoteStartStopStatus.rejected) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr(srv_cp, "call", call_bad, raising=True) + bad = await srv_cp.start_transaction(connector_id=1) + assert bad is False + assert notes and "Start transaction failed" in notes[0][0] + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9072, "cp_id": "CP_STOP", "cms": "cms_stop"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_STOP"]) +@pytest.mark.parametrize("port", [9072]) +async def test_stop_transaction_paths( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """stop_transaction: early True when no active tx; accepted True; reject False + notify.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Case A: no active tx anywhere -> returns True without calling cp + srv_cp.active_transaction_id = 0 + # ocppv16 uses _active_tx dict; ensure it's empty/falsey + setattr(srv_cp, "_active_tx", {}) # or defaultdict if lib uses that + called = {"n": 0} + + async def should_not_call(_req): + called["n"] += 1 + return SimpleNamespace(status=RemoteStartStopStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", should_not_call, raising=True) + early = await srv_cp.stop_transaction() + assert early is True + assert called["n"] == 0 # verify we didn't call into charger + + # Case B: active tx id present -> accepted -> True + srv_cp.active_transaction_id = 42 + + async def call_ok(req): + return SimpleNamespace(status=RemoteStartStopStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", call_ok, raising=True) + ok = await srv_cp.stop_transaction() + assert ok is True + + # Case C: active tx but reject -> False and notify_ha + notes = [] + + async def fake_notify(msg, title="Ocpp integration"): + notes.append(msg) + return True + + async def call_bad(req): + return SimpleNamespace(status=RemoteStartStopStatus.rejected) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr(srv_cp, "call", call_bad, raising=True) + srv_cp.active_transaction_id = 99 + bad = await srv_cp.stop_transaction() + assert bad is False + assert notes and "Stop transaction failed" in notes[0] + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9073, "cp_id": "CP_UNLOCK", "cms": "cms_unlock"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_UNLOCK"]) +@pytest.mark.parametrize("port", [9073]) +async def test_unlock_accept_and_fail( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """unlock: unlocked -> True; otherwise False + notify.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Success + async def call_ok(req): + return SimpleNamespace(status=UnlockStatus.unlocked) + + monkeypatch.setattr(srv_cp, "call", call_ok, raising=True) + ok = await srv_cp.unlock(connector_id=2) + assert ok is True + + # Failure → notify + notes = [] + + async def fake_notify(msg, title="Ocpp integration"): + notes.append(msg) + return True + + async def call_fail(req): + # pick a non-success status + return SimpleNamespace(status=UnlockStatus.unlock_failed) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr(srv_cp, "call", call_fail, raising=True) + bad = await srv_cp.unlock(connector_id=1) + assert bad is False + assert notes and "Unlock failed" in notes[0] + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9074, "cp_id": "CP_NUM_CONN", "cms": "cms_num_conn"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_NUM_CONN"]) +@pytest.mark.parametrize("port", [9074]) +async def test_get_number_of_connectors_variants( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Exercise all branches of get_number_of_connectors().""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Case A: valid configurationKey with correct value + async def call_good(req): + return SimpleNamespace( + configuration_key=[ + SimpleNamespace(key="NumberOfConnectors", value="3") + ] + ) + + monkeypatch.setattr(srv_cp, "call", call_good) + n = await srv_cp.get_number_of_connectors() + assert n == 3 + + # Case B: resp is list[tuple] with dict inside ("configurationKey") + async def call_tuple(req): + return [ + "ignored", + "ignored", + {"configurationKey": [{"key": "NumberOfConnectors", "value": "4"}]}, + ] + + monkeypatch.setattr(srv_cp, "call", call_tuple) + n = await srv_cp.get_number_of_connectors() + assert n == 4 + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9076, "cp_id": "CP_diag", "cms": "cms_diag"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_diag"]) +@pytest.mark.parametrize("port", [9076]) +async def test_on_diagnostics_status_notification( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test on_diagnostics_status. + + - replies with DiagnosticsStatusNotification + - schedules notify_ha with expected message + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp: ServerCP = cs.charge_points[cp_id] + + captured = {"called": 0, "msg": None} + + async def fake_notify(msg: str, title: str = "Ocpp integration"): + # record the message; return True like the real notifier + captured["msg"] = msg + return True + + def fake_async_create_task(coro): + # actually schedule the coroutine so fake_notify runs + captured["called"] += 1 + return asyncio.create_task(coro) + + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + monkeypatch.setattr( + srv_cp.hass, "async_create_task", fake_async_create_task, raising=True + ) + + # trigger server handler + req = call.DiagnosticsStatusNotification(status="Uploaded") + resp = await cp.call(req) + assert resp is not None # server replied + + # ensure notify_ha ran and message content is correct + # give the task a tick to run + await asyncio.sleep(0) + assert captured["called"] == 1 + assert captured["msg"] == "Diagnostics upload status: Uploaded" + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9077, "cp_id": "CP_phases", "cms": "cms_phases"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_phases"]) +@pytest.mark.parametrize("port", [9077]) +@pytest.mark.parametrize("num_connectors", [1, 2]) +async def test_current_import_phase_extra_attrs_single_and_multi_connector( + hass, socket_enabled, cp_id, port, setup_config_entry, num_connectors +): + """Verify that phase extra attributes (L1/L2/L3) for Current.Import are populated. + + - with 1 connector: reading without connector_id should resolve via fallback. + - with 2 connectors: each connector returns its own phase set. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + + try: + # Boot and wait until server is ready to receive MeterValues + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Server-side CP instance + srv_cp: ServerCP = cs.charge_points[cp_id] + # Force connector count for this test parameterization + srv_cp.num_connectors = num_connectors + + # Helper to send a MeterValues frame with phase currents + async def send_current_import_phases( + connector_id: int, l1: float, l2: float, l3: float + ): + ts = datetime.now(UTC).isoformat() + req = call.MeterValues( + connector_id=connector_id, + meter_value=[ + { + "timestamp": ts, + "sampledValue": [ + { + "measurand": "Current.Import", + "phase": Phase.l1.value, + "unit": "A", + "value": str(l1), + }, + { + "measurand": "Current.Import", + "phase": Phase.l2.value, + "unit": "A", + "value": str(l2), + }, + { + "measurand": "Current.Import", + "phase": Phase.l3.value, + "unit": "A", + "value": str(l3), + }, + ], + } + ], + ) + # Send to server + await cp.call(req) + + # Send phases for connector 1 + await send_current_import_phases(1, 5.0, 7.0, 8.0) + + # If two connectors, send different phases for connector 2 + if num_connectors == 2: + await send_current_import_phases(2, 11.0, 13.0, 17.0) + + # Let server handlers run + await asyncio.sleep(0) + + # Assertions + if num_connectors == 1: + # Without connector_id -> should resolve (fallback) to connector 1 + attrs = cs.get_extra_attr(cp_id, "Current.Import", connector_id=None) + assert ( + attrs is not None + ), "Expected extra_attr dict for single-connector" + assert attrs.get("L1") == 5.0 + assert attrs.get("L2") == 7.0 + assert attrs.get("L3") == 8.0 + + # Explicit connector_id=1 also works + attrs1 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=1) + assert attrs1 is not None + assert attrs1.get("L1") == 5.0 + assert attrs1.get("L2") == 7.0 + assert attrs1.get("L3") == 8.0 + + else: + # Two connectors: verify separation + attrs1 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=1) + attrs2 = cs.get_extra_attr(cp_id, "Current.Import", connector_id=2) + + assert ( + attrs1 is not None and attrs2 is not None + ), "Expected extra_attr dicts for both connectors" + + # Connector 1 values + assert attrs1.get("L1") == 5.0 + assert attrs1.get("L2") == 7.0 + assert attrs1.get("L3") == 8.0 + + # Connector 2 values + assert attrs2.get("L1") == 11.0 + assert attrs2.get("L2") == 13.0 + assert attrs2.get("L3") == 17.0 + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +class _ExplosiveStatus: + """A status object that raises on equality checks, but can be stringified.""" + + def __str__(self) -> str: + return "ExplosiveStatus" + + def __repr__(self) -> str: + return "ExplosiveStatus" + + # Cause 'status in (Accepted, Scheduled)' to raise inside try: + def __eq__(self, other): + raise RuntimeError("eq() boom on status comparison") + + +class _RespWithExplosiveStatus: + def __init__(self): + self.status = _ExplosiveStatus() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9078, "cp_id": "CP_avail", "cms": "cms_avail"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail"]) +@pytest.mark.parametrize("port", [9078]) +async def test_set_availability_timeout_branch( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test set_availability timeout branch.""" + + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp: ServerCP = cs.charge_points[cp_id] + + async def fake_call_timeout(req): + raise TimeoutError("simulated timeout") + + monkeypatch.setattr(srv_cp, "call", fake_call_timeout, raising=True) + + ok = await srv_cp.set_availability(state=True, connector_id=1) + assert ok is False # timeout-grenen ska returnera False + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9079, "cp_id": "CP_avail2", "cms": "cms_avail2"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail2"]) +@pytest.mark.parametrize("port", [9079]) +async def test_set_availability_exception_branch( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test set_availability exception branch.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + async def fake_call_error(req): + raise RuntimeError("generic error") + + monkeypatch.setattr(srv_cp, "call", fake_call_error, raising=True) + + ok = await srv_cp.set_availability(state=False, connector_id=2) + assert ok is False + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9090, "cp_id": "CP_avail3", "cms": "cms_avail3"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail3"]) +@pytest.mark.parametrize("port", [9090]) +async def test_set_availability_final_try_exception_path_with_notify( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Trigger the last try/except-branch. + + - resp.status exists but the comparison 'status in (...)' throws (via __eq__). + - Expect: warning + notify_ha and return False. + """ + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + async def fake_call_ok(req): + # Returnera ett objekt där status-jämförelsen spränger inne i try-blocket + return _RespWithExplosiveStatus() + + captured = {"msg": None} + + async def fake_notify(msg: str, title: str = "Ocpp integration"): + captured["msg"] = msg + return True + + monkeypatch.setattr(srv_cp, "call", fake_call_ok, raising=True) + monkeypatch.setattr(srv_cp, "notify_ha", fake_notify, raising=True) + + ok = await srv_cp.set_availability(state=True, connector_id=1) + assert ok is False + # Kontrollera att notify_ha kördes med rätt innehåll + assert ( + captured["msg"] + == "Warning: Set availability failed with response ExplosiveStatus" + ) + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9091, "cp_id": "CP_avail4", "cms": "cms_avail4"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail4"]) +@pytest.mark.parametrize("port", [9091]) +@pytest.mark.parametrize( + "status,expected", + [(AvailabilityStatus.accepted, True), (AvailabilityStatus.scheduled, True)], +) +async def test_set_availability_happy_paths( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch, status, expected +): + """Test set_availability happy paths.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + async def fake_call_ok(req): + assert isinstance(req, call.ChangeAvailability) + assert req.type in ( + AvailabilityType.operative, + AvailabilityType.inoperative, + ) + return SimpleNamespace(status=status) + + monkeypatch.setattr(srv_cp, "call", fake_call_ok, raising=True) + + ok = await srv_cp.set_availability(state=True, connector_id=1) + assert ok is expected + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9092, "cp_id": "CP_avail5", "cms": "cms_avail5"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail5"]) +@pytest.mark.parametrize("port", [9092]) +async def test_set_availability_connector_id_parse_error_falls_back_to_zero( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Send non-int connector_id; should fallback to conn=0 and still work.""" + cs: CentralSystem = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + captured = {"seen_conn": None} + + async def fake_call_capture(req): + assert isinstance(req, call.ChangeAvailability) + captured["seen_conn"] = req.connector_id + return SimpleNamespace(status=AvailabilityStatus.accepted) + + monkeypatch.setattr(srv_cp, "call", fake_call_capture, raising=True) + + ok = await srv_cp.set_availability(state=True, connector_id="not-an-int") + assert ok is True + assert captured["seen_conn"] == 0 # fallback to 0 + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9096, "cp_id": "CP_setrate_4", "cms": "cms_setrate_4"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_setrate_4"]) +@pytest.mark.parametrize("port", [9096]) +async def test_set_charge_rate_units_none_fallback_to_amps_and_accept_first( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """get_configuration returns None -> fallback to Amps; first attempt returns Accepted -> True.""" + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + cp_task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv_cp: ServerCP = cs.charge_points[cp_id] + + # Force fallback path + async def fake_get_conf(key): + return None + + monkeypatch.setattr( + srv_cp, "get_configuration", fake_get_conf, raising=True + ) + + async def fake_call(req): + if isinstance(req, call.ClearChargingProfile): + return SimpleNamespace(status="Accepted") + if isinstance(req, call.SetChargingProfile): + # Accept immediately (CPMax on connector 0) + return SimpleNamespace(status=ChargingProfileStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.set_charge_rate(limit_amps=20, conn_id=0) + assert ok is True + + finally: + cp_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cp_task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9097, "cp_id": "CP_eair_no_tx", "cms": "cms_eair_no_tx"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_eair_no_tx"]) +@pytest.mark.parametrize("port", [9097]) +async def test_on_meter_values_no_tx_aggregate_ignores_begin_and_converts_wh( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test that Transaction.Begin is ignored when Periodic also in the same message.""" + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # Both Periodic (4369 Wh) and Begin (0) in the same bucket + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "4369", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Transaction.Begin", + "unit": "Wh", + "value": "0", + }, + ], + } + ], + ) + await cp.call(req) + + val = cs.get_metric(cp_id, "Energy.Active.Import.Register") + assert val == pytest.approx(4.369, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9098, "cp_id": "CP_eair_tx", "cms": "cms_eair_tx"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_eair_tx"]) +@pytest.mark.parametrize("port", [9098]) +async def test_on_meter_values_tx_updates_connector_and_session_energy( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test that values with txId writes to connector and updates Energy.Session from the best EAIR.""" + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + req1 = call.MeterValues( + connector_id=1, + transaction_id=111, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "1000", + } + ], + } + ], + ) + await cp.call(req1) + + v1 = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=1) + s1 = cs.get_metric(cp_id, "Energy.Session", connector_id=1) + assert v1 == pytest.approx(1.0, rel=1e-6) + assert s1 == pytest.approx(0.0, rel=1e-6) + + req2 = call.MeterValues( + connector_id=1, + transaction_id=111, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "kWh", + "value": "1.5", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Transaction.Begin", + "unit": "Wh", + "value": "0", + }, + ], + } + ], + ) + await cp.call(req2) + + v2 = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=1) + s2 = cs.get_metric(cp_id, "Energy.Session", connector_id=1) + assert v2 == pytest.approx(1.5, rel=1e-6) + assert s2 == pytest.approx(0.5, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9099, "cp_id": "CP_eair_prio", "cms": "cms_eair_prio"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_eair_prio"]) +@pytest.mark.parametrize("port", [9099]) +async def test_on_meter_values_priority_end_over_periodic( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test that Transaction.End wins over Sample.Periodic (no matter the order).""" + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + req = call.MeterValues( + connector_id=2, + transaction_id=222, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "kWh", + "value": "10.0", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Transaction.End", + "unit": "kWh", + "value": "10.5", + }, + ], + } + ], + ) + await cp.call(req) + + v = cs.get_metric(cp_id, "Energy.Active.Import.Register", connector_id=2) + assert v == pytest.approx(10.5, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9101, "cp_id": "CP_eair_prio_vs_value", "cms": "cms_eair_prio_vs_value"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_eair_prio_vs_value"]) +@pytest.mark.parametrize("port", [9101]) +async def test_on_meter_values_priority_beats_raw_value( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Test that prio beats raw value.""" + + cs: CentralSystem = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + # "Other" context has prio 0; Periodic has prio 2 -> Periodic should be picked even if the value is lower. + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Other", + "unit": "kWh", + "value": "2.0", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "kWh", + "value": "1.0", + }, + ], + } + ], + ) + await cp.call(req) + + v = cs.get_metric(cp_id, "Energy.Active.Import.Register") + assert v == pytest.approx(1.0, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9111, "cp_id": "CP_trig_single_ok", "cms": "cms_trig_single_ok"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_single_ok"]) +@pytest.mark.parametrize("port", [9111]) +async def test_trigger_status_single_accepts( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=1: should NOT try connectorId=0; only cid=1; accepted -> True.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + # force single connector + srv_cp._metrics[0][cdet.connectors.value].value = 1 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is True + assert attempts == [1] + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9112, "cp_id": "CP_trig_multi_ok", "cms": "cms_trig_multi_ok"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_multi_ok"]) +@pytest.mark.parametrize("port", [9112]) +async def test_trigger_status_multi_all_accepts( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=2: multi-connector happy path. Should return True and not reduce connector count.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 2 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is True + assert attempts == [0, 1, 2] + assert srv_cp._metrics[0][cdet.connectors.value].value == 2 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9113, "cp_id": "CP_trig_reject_zero_continue", "cms": "cms_trig_r0"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_reject_zero_continue"]) +@pytest.mark.parametrize("port", [9113]) +async def test_trigger_status_reject_zero_but_accept_rest( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=2: cid=0 -> Rejected (ignored), 1 & 2 accepted -> True; connector count unchanged.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 2 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + if req.connector_id == 0: + return SimpleNamespace(status="Rejected") + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is True + assert attempts == [0, 1, 2] + # should not downgrade connector count because only cid=0 rejected + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 2 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9114, "cp_id": "CP_trig_reject_nonzero_adjusts", "cms": "cms_trig_rnz"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_reject_nonzero_adjusts"]) +@pytest.mark.parametrize("port", [9114]) +async def test_trigger_status_reject_nonzero_adjusts_and_stops( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=3: 0 & 1 accepted; 2 rejected -> set connectors to 1 (cid-1), return False, stop before 3.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 3 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + if req.connector_id == 2: + return SimpleNamespace(status="Rejected") + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is False + assert attempts == [0, 1, 2] + # reduced to cid-1 => 1 + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9115, "cp_id": "CP_trig_timeout_zero_continue", "cms": "cms_trig_t0"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_timeout_zero_continue"]) +@pytest.mark.parametrize("port", [9115]) +async def test_trigger_status_timeout_on_zero_continues( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=2: cid=0 raises TimeoutError (ignored), others accepted -> True; count unchanged.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 2 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + if req.connector_id == 0: + raise TimeoutError("simulated") + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is True + assert attempts == [0, 1, 2] + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 2 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9116, "cp_id": "CP_trig_timeout_nonzero_adjusts", "cms": "cms_trig_tnz"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_trig_timeout_nonzero_adjusts"]) +@pytest.mark.parametrize("port", [9116]) +async def test_trigger_status_timeout_on_nonzero_adjusts_and_stops( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """n=2: cid=2 raises TimeoutError -> set connectors to 1, return False, stop.""" + cs: CentralSystem = setup_config_entry + attempts = [] + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + await cp.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv_cp = cs.charge_points[cp_id] + srv_cp._metrics[0][cdet.connectors.value].value = 2 + + async def fake_call(req): + if isinstance(req, call.TriggerMessage): + attempts.append(req.connector_id) + if req.connector_id == 2: + raise TimeoutError("simulated") + return SimpleNamespace(status=TriggerMessageStatus.accepted) + return SimpleNamespace() + + monkeypatch.setattr(srv_cp, "call", fake_call, raising=True) + + ok = await srv_cp.trigger_status_notification() + assert ok is False + # Should stop after the failing connector + assert attempts == [0, 1, 2] + assert int(srv_cp._metrics[0][cdet.connectors.value].value) == 1 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(30) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9120, "cp_id": "CP_postconn_ex_1", "cms": "cms_postconn_ex_1"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_1"]) +@pytest.mark.parametrize("port", [9120]) +async def test_post_connect_fetch_supported_features_raises( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """fetch_supported_features raises inside post_connect -> swallowed; post_connect_success stays False.""" + cs: CentralSystem = setup_config_entry + + # Patch before connecting so our call to post_connect() hits the boom. + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def boom(self): + raise RuntimeError("fetch boom") + + monkeypatch.setattr(ServerCP, "fetch_supported_features", boom, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + # client test CP + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + # Vänta bara tills servern registrerat CP-objektet + # (ingen BootNotification -> ingen auto post_connect) + await asyncio.sleep(0.05) + srv_cp = cs.charge_points[cp_id] + + # Säkerställ definierat initialt värde + setattr(srv_cp, "post_connect_success", False) + + # Kör post_connect() – ska svälja exception och inte sätta success=True + await srv_cp.post_connect() + + assert getattr(srv_cp, "post_connect_success", False) is not True + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9121, "cp_id": "CP_postconn_ex_2", "cms": "cms_postconn_ex_2"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_2"]) +@pytest.mark.parametrize("port", [9121]) +async def test_post_connect_set_availability_error_swallowed_and_REM_triggers_called( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Inner try around set_availability: generic Exception swallowed, and REM triggers still called.""" + cs: CentralSystem = setup_config_entry + + # Patch server CP methods before connecting. + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + async def ok_set_std(self): + return None + + async def nope_avail(self): + raise ValueError("availability failed") + + called = {"boot": 0, "status": 0} + + async def fake_boot(self): + called["boot"] += 1 + + async def fake_status(self): + called["status"] += 1 + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + monkeypatch.setattr( + ServerCP, "set_standard_configuration", ok_set_std, raising=True + ) + monkeypatch.setattr(ServerCP, "set_availability", nope_avail, raising=True) + monkeypatch.setattr(ServerCP, "trigger_boot_notification", fake_boot, raising=True) + monkeypatch.setattr( + ServerCP, "trigger_status_notification", fake_status, raising=True + ) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + # wait until server registered CP + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + + # enable REM and force boot path + srv_cp._attr_supported_features = {prof.REM} + srv_cp.received_boot_notification = False + setattr(srv_cp, "post_connect_success", False) + + await srv_cp.post_connect() + + assert getattr(srv_cp, "post_connect_success", False) is True + assert called["boot"] == 1 + assert called["status"] == 1 + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9122, "cp_id": "CP_postconn_ex_3", "cms": "cms_postconn_ex_3"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_3"]) +@pytest.mark.parametrize("port", [9122]) +async def test_post_connect_set_availability_cancelled_bubbles( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Inner try around set_availability: asyncio.CancelledError must be re-raised (not swallowed).""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + async def ok_set_std(self): + return None + + async def cancelled(self): + raise asyncio.CancelledError() + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + monkeypatch.setattr( + ServerCP, "set_standard_configuration", ok_set_std, raising=True + ) + monkeypatch.setattr(ServerCP, "set_availability", cancelled, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + srv_cp._attr_supported_features = {prof.REM} + srv_cp.received_boot_notification = False + + with pytest.raises(asyncio.CancelledError): + await srv_cp.post_connect() + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9123, "cp_id": "CP_postconn_ex_4", "cms": "cms_postconn_ex_4"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_4"]) +@pytest.mark.parametrize("port", [9123]) +async def test_post_connect_trigger_boot_notification_raises_outer_caught( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Outer try: trigger_boot_notification raises -> swallowed; post_connect_success already True.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + async def ok_set_std(self): + return None + + async def ok_avail(self): + return None + + async def boom_boot(self): + raise RuntimeError("boot fail") + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + monkeypatch.setattr( + ServerCP, "set_standard_configuration", ok_set_std, raising=True + ) + monkeypatch.setattr(ServerCP, "set_availability", ok_avail, raising=True) + monkeypatch.setattr(ServerCP, "trigger_boot_notification", boom_boot, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + srv_cp._attr_supported_features = {prof.REM} + srv_cp.received_boot_notification = False + setattr(srv_cp, "post_connect_success", False) + + await srv_cp.post_connect() + + assert getattr(srv_cp, "post_connect_success", False) is True + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9124, "cp_id": "CP_postconn_ex_5", "cms": "cms_postconn_ex_5"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_5"]) +@pytest.mark.parametrize("port", [9124]) +async def test_post_connect_trigger_status_notification_raises_outer_caught( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Outer try: trigger_status_notification raises -> swallowed; post_connect_success already True.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + async def ok_set_std(self): + return None + + async def ok_avail(self): + return None + + async def ok_boot(self): + return None + + async def boom_status(self): + raise RuntimeError("status fail") + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + monkeypatch.setattr( + ServerCP, "set_standard_configuration", ok_set_std, raising=True + ) + monkeypatch.setattr(ServerCP, "set_availability", ok_avail, raising=True) + monkeypatch.setattr(ServerCP, "trigger_boot_notification", ok_boot, raising=True) + monkeypatch.setattr( + ServerCP, "trigger_status_notification", boom_status, raising=True + ) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + srv_cp._attr_supported_features = {prof.REM} + srv_cp.received_boot_notification = False + setattr(srv_cp, "post_connect_success", False) + + await srv_cp.post_connect() + assert getattr(srv_cp, "post_connect_success", False) is True + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9125, "cp_id": "CP_postconn_ex_6", "cms": "cms_postconn_ex_6"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_6"]) +@pytest.mark.parametrize("port", [9125]) +async def test_post_connect_update_entry_raises_outer_caught( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Outer try: async_update_entry raises -> swallowed, success flag not set.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + + def boom_update_entry(entry, data=None): + raise RuntimeError("update failed") + + monkeypatch.setattr( + srv_cp.hass.config_entries, + "async_update_entry", + boom_update_entry, + raising=True, + ) + + await srv_cp.post_connect() + assert getattr(srv_cp, "post_connect_success", False) is not True + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9126, "cp_id": "CP_postconn_ex_7", "cms": "cms_postconn_ex_7"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_7"]) +@pytest.mark.parametrize("port", [9126]) +async def test_post_connect_set_standard_configuration_raises_outer_caught( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Outer try: set_standard_configuration raises -> swallowed, success flag not set.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def ok_get_n(self): + return 1 + + async def ok_hb(self): + return 300 + + async def ok_meas(self): + return "Voltage" + + async def boom_std(self): + raise RuntimeError("std cfg fail") + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", ok_get_n, raising=True) + monkeypatch.setattr(ServerCP, "get_heartbeat_interval", ok_hb, raising=True) + monkeypatch.setattr(ServerCP, "get_supported_measurands", ok_meas, raising=True) + monkeypatch.setattr(ServerCP, "set_standard_configuration", boom_std, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + + await srv_cp.post_connect() + assert getattr(srv_cp, "post_connect_success", False) is not True + finally: + task.cancel() + + +# --------------------------------------------------------------------------- + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9127, "cp_id": "CP_postconn_ex_8", "cms": "cms_postconn_ex_8"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_postconn_ex_8"]) +@pytest.mark.parametrize("port", [9127]) +async def test_post_connect_number_of_connectors_raises_outer_caught( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Outer try: get_number_of_connectors raises -> swallowed, success flag not set.""" + cs: CentralSystem = setup_config_entry + + from custom_components.ocpp.ocppv16 import ChargePoint as ServerCP + + async def ok_fetch(self): + return None + + async def boom_n(self): + raise RuntimeError("n fail") + + monkeypatch.setattr(ServerCP, "fetch_supported_features", ok_fetch, raising=True) + monkeypatch.setattr(ServerCP, "get_number_of_connectors", boom_n, raising=True) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + from tests.test_charge_point_v16 import ChargePoint + + cp = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(cp.start()) + try: + for _ in range(100): + if cp_id in cs.charge_points: + break + await asyncio.sleep(0.02) + srv_cp = cs.charge_points[cp_id] + + await srv_cp.post_connect() + assert getattr(srv_cp, "post_connect_success", False) is not True + finally: + task.cancel() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9341, "cp_id": "CP_cov_ctx_priority", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_ctx_priority"]) +@pytest.mark.parametrize("port", [9341]) +async def test_eair_context_priority_in_bucket( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Ensure EAIR context priority per bucket: Transaction.End > Sample.Periodic > Sample.Clock.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Bucket 1: include three EAIR candidates with different contexts. + # Expect: Transaction.End (13000 Wh) wins -> 13.0 kWh. + mv_bucket1 = call.MeterValues( + connector_id=1, + transaction_id=555, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "11000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Clock", + }, + { + "value": "12000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + }, + { + "value": "13000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Transaction.End", + }, + # Some unrelated measurand in the same bucket + { + "value": "230", + "measurand": "Voltage", + "unit": "V", + "location": "Outlet", + "context": "Sample.Periodic", + }, + ], + } + ], + ) + resp1 = await client.call(mv_bucket1) + assert resp1 is not None + + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + == "kWh" + ) + assert cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=1 + ) == pytest.approx(13.0, rel=1e-6) + + # Bucket 2: No Transaction.End; Sample.Periodic should beat Sample.Clock. + # Expect: 13100 Wh -> 13.1 kWh. + mv_bucket2 = call.MeterValues( + connector_id=1, + transaction_id=555, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "13090", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Clock", + }, + { + "value": "13100", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + }, + ], + } + ], + ) + resp2 = await client.call(mv_bucket2) + assert resp2 is not None + + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + == "kWh" + ) + assert cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=1 + ) == pytest.approx(13.1, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9342, "cp_id": "CP_eair_monotonic", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_eair_monotonic"]) +@pytest.mark.parametrize("port", [9342]) +async def test_eair_monotonic_increments_single_connector( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Ensure EAIR monotonically increases during a normal charging session on a single-connector charger.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Start with a transaction-bound EAIR (Wh → kWh conversion should apply) + # Bucket 1: 1000 Wh → 1.0 kWh + mv1 = call.MeterValues( + connector_id=1, + transaction_id=777, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + } + ], + } + ], + ) + resp1 = await client.call(mv1) + assert resp1 is not None + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + == "kWh" + ) + v1 = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v1 == pytest.approx(1.0, rel=1e-6) + + # Bucket 2: 1500 Wh → 1.5 kWh (increase) + mv2 = call.MeterValues( + connector_id=1, + transaction_id=777, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1500", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + } + ], + } + ], + ) + resp2 = await client.call(mv2) + assert resp2 is not None + v2 = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v2 == pytest.approx(1.5, rel=1e-6) + assert v2 >= v1 + + # Bucket 3: two EAIR candidates in the same bucket. + # Sample.Clock = 1.60 kWh, Sample.Periodic = 1.55 kWh → Periodic should win → 1.55 kWh + mv3 = call.MeterValues( + connector_id=1, + transaction_id=777, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1600", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Clock", + }, + { + "value": "1550", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + }, + ], + } + ], + ) + resp3 = await client.call(mv3) + assert resp3 is not None + v3 = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v3 == pytest.approx(1.55, rel=1e-6) + assert v3 >= v2 + + # Bucket 4: kWh sample directly (unit already kWh): 1.80 kWh → stays 1.80 kWh + mv4 = call.MeterValues( + connector_id=1, + transaction_id=777, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "1.80", + "measurand": "Energy.Active.Import.Register", + "unit": "kWh", + "location": "Outlet", + "context": "Sample.Periodic", + } + ], + } + ], + ) + resp4 = await client.call(mv4) + assert resp4 is not None + v4 = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v4 == pytest.approx(1.80, rel=1e-6) + assert v4 >= v3 + + # Bucket 5: Include a Transaction.Begin(0) alongside a higher Periodic. + # Begin must be ignored; Periodic wins → 1.90 kWh + mv5 = call.MeterValues( + connector_id=1, + transaction_id=777, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "value": "0", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Transaction.Begin", + }, + { + "value": "1900", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh", + "location": "Outlet", + "context": "Sample.Periodic", + }, + ], + } + ], + ) + resp5 = await client.call(mv5) + assert resp5 is not None + v5 = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v5 == pytest.approx(1.90, rel=1e-6) + assert v5 >= v4 + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9351, "cp_id": "CP_set_rate_active_tx", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_set_rate_active_tx"]) +@pytest.mark.parametrize("port", [9351]) +async def test_set_charge_rate_with_active_transaction( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Ensure set_charge_rate uses TxProfile for ongoing session and also attempts TxDefaultProfile.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Start a transaction on connector 1 so that _active_tx[1] is set. + await client.send_start_transaction(0) + + # Mock get_configuration so set_charge_rate doesn't hit srv.call for these + async def fake_get_configuration(key: str = "") -> str: + # units: pretend charger supports Amps + if key == ckey.charging_schedule_allowed_charging_rate_unit.value: + return "A" # same as om.current.value + # stack level + if key == ckey.charge_profile_max_stack_level.value: + return "2" + return "" + + calls = [] + + async def fake_call(req): + calls.append(req) + # Reject CP-max (connector_id == 0) so code proceeds to TxProfile + TxDefault + if getattr(req, "connector_id", None) == 0: + return SimpleNamespace(status=ChargingProfileStatus.rejected) + # Accept TxProfile and TxDefaultProfile + return SimpleNamespace(status=ChargingProfileStatus.accepted) + + monkeypatch.setattr(srv, "get_configuration", fake_get_configuration) + + # Intercept outgoing SetChargingProfile calls + monkeypatch.setattr(srv, "call", fake_call) + + ok = await srv.set_charge_rate(limit_amps=16, conn_id=1) + assert ok is True + + # We expect 3 calls: CP-max (rejected), TxProfile (accepted), TxDefault (accepted) + assert len(calls) == 3 + assert getattr(calls[0], "connector_id", None) == 0 + # The rest should target connector 1 + assert getattr(calls[1], "connector_id", None) == 1 + assert getattr(calls[2], "connector_id", None) == 1 + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9352, "cp_id": "CP_set_rate_exceptions", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_set_rate_exceptions"]) +@pytest.mark.parametrize("port", [9352]) +async def test_set_charge_rate_exception_paths( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Drive the exception handlers in set_charge_rate: CP-max, TxProfile, TxDefault, and custom-profile branch.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Make sure there is an active transaction on connector 1 + await client.send_start_transaction(0) + + # Case A: CP-max raises, TxProfile raises, TxDefault succeeds → overall True + call_count = 0 + + async def fake_call_case_a(req): + nonlocal call_count + call_count += 1 + # 1st call (CP-max) → raise + if call_count == 1: + raise RuntimeError("cp-max boom") + # 2nd call (TxProfile) → raise + if call_count == 2: + raise RuntimeError("tx-profile boom") + # 3rd call (TxDefault) → accept + return SimpleNamespace(status=ChargingProfileStatus.accepted) + + # Ensure smart charging available + srv._attr_supported_features = {prof.SMART} + + async def fake_get_configuration(key: str = "") -> str: + if key == ckey.charging_schedule_allowed_charging_rate_unit.value: + return "A" + if key == ckey.charge_profile_max_stack_level.value: + return "2" + return "" + + monkeypatch.setattr(srv, "get_configuration", fake_get_configuration) + + monkeypatch.setattr(srv, "call", fake_call_case_a) + ok_a = await srv.set_charge_rate(limit_amps=10, conn_id=1) + assert ok_a is True + assert call_count == 3 # hit all branches + + # Case B: CP-max raises, TxProfile raises, TxDefault raises → overall False + call_count_b = 0 + + async def fake_call_case_b(req): + nonlocal call_count_b + call_count_b += 1 + raise RuntimeError(f"boom-{call_count_b}") + + monkeypatch.setattr(srv, "call", fake_call_case_b) + ok_b = await srv.set_charge_rate(limit_amps=12, conn_id=1) + assert ok_b is False + assert call_count_b >= 2 # at least CP-max + TxProfile tried + + # Case C: Custom profile branch raises → returns False + async def fake_call_custom(req): + raise RuntimeError("custom-profile boom") + + monkeypatch.setattr(srv, "call", fake_call_custom) + ok_c = await srv.set_charge_rate( + conn_id=1, + profile={ + # Minimal shape; actual content irrelevant since we stub .call + "chargingProfileId": 4242, + "stackLevel": 2, + "chargingProfileKind": "Relative", + "chargingProfilePurpose": "TxDefaultProfile", + "chargingSchedule": { + "chargingRateUnit": "A", + "chargingSchedulePeriod": [{"startPeriod": 0, "limit": 10}], + }, + }, + ) + assert ok_c is False + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9361, "cp_id": "CP_tx_restore", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_tx_restore"]) +@pytest.mark.parametrize("port", [9361]) +async def test_meter_values_restores_transaction_on_restart( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """If a MeterValues arrives with a txId and we have no active tx recorded, adopt it (no warning).""" + cs = setup_config_entry + caplog.set_level(logging.WARNING) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Ensure single-connector semantics before first MeterValues arrives + srv.num_connectors = 1 + + # Simulate "after restart": no StartTransaction; we just receive MeterValues with txId. + req = call.MeterValues( + connector_id=1, + transaction_id=1111, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "1000", + } + ], + } + ], + ) + await client.call(req) + + # The tx should be adopted (restored) without warning + tx_metric = cs.get_metric(cpid, "Transaction.Id", connector_id=1) + assert int(tx_metric) == 1111 + + # Active map should reflect it as well + assert int(srv._active_tx.get(1, 0) or 0) == 1111 + + # On single-connector chargers, legacy field may be mirrored; tolerate either default or mirrored + # (Do not fail test if multi-connector) + try: + n = int(getattr(srv, "num_connectors", 1) or 1) + except Exception: + n = 1 + if n == 1: + assert int(getattr(srv, "active_transaction_id", 0) or 0) == 1111 + + # No "Unknown transaction" warning should be present + assert not any( + "Unknown transaction detected on conn 1 with id=1111" in rec.message + for rec in caplog.records + ) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9362, "cp_id": "CP_tx_mismatch", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_tx_mismatch"]) +@pytest.mark.parametrize("port", [9362]) +async def test_meter_values_logs_warning_on_tx_mismatch( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """If a different tx is already active on the connector, log 'Unknown transaction' warning.""" + cs = setup_config_entry + caplog.set_level(logging.WARNING) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Establish an active tx on connector 1 (use helper that triggers StartTransaction) + await client.send_start_transaction(0) # typical helper targets connector 1 + + # Sanity: we should now have a recorded active tx id + recorded = int(srv._active_tx.get(1, 0) or 0) + assert recorded != 0 + + # Send MeterValues with a different transactionId to trigger mismatch + req = call.MeterValues( + connector_id=1, + transaction_id=recorded + 999, # different id + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "2000", + } + ], + } + ], + ) + await client.call(req) + + # Metric should NOT be overwritten by the mismatched txId + tx_metric = cs.get_metric(cpid, "Transaction.Id", connector_id=1) + assert int(tx_metric) == recorded + + # Warning should be logged + assert any( + f"Unknown transaction detected on conn 1 with id={recorded + 999}" + in rec.message + for rec in caplog.records + ) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +class BadInt: + """Object that raises when coerced to int; truthy so `value or 0` picks it.""" + + def __bool__(self): + """Bool function.""" + + return True + + def __int__(self): + """Bad int function.""" + + raise ValueError("bad int") + + +class FlakyTxMetric(M): + """Metric subclass whose .value raises int() the first time, then behaves like 0. + + Setter stores values normally. + """ + + def __init__(self): + """Init function.""" + + # unit can be None; extra_attr for safety + super().__init__(value=None, unit=None) + self._hits = 0 + self._forced = False + self._stored = None + self.extra_attr = {} + + @property + def value(self): + """Value prop method.""" + + if self._forced: + return self._stored + self._hits += 1 + return BadInt() if self._hits == 1 else 0 + + @value.setter + def value(self, v): + self._forced = True + self._stored = v + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9371, "cp_id": "CP_exc_paths", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_exc_paths"]) +@pytest.mark.parametrize("port", [9371]) +async def test_on_meter_values_exception_branches_are_handled( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """Exercise the three `except Exception:` branches in on_meter_values. + + 1) Warm-up: int(self._metrics[tx_key].value or 0) raises -> set 0 + 2) int(num_connectors) raises -> n_con = 1 + 3) int(active_transaction_id) raises -> legacy = 0 + """ + cs = setup_config_entry + caplog.set_level(logging.DEBUG) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + conn = 1 + + # 1) Install a Metric subclass that trips the first int(...) then returns 0 + tx_key = (conn, csess.transaction_id.value) + with contextlib.suppress(Exception): + del srv._active_tx[conn] + srv._metrics[tx_key] = FlakyTxMetric() + + # 2) Make int(num_connectors) raise -> n_con = 1 in except + srv.num_connectors = BadInt() + + # 3) Make int(active_transaction_id) raise -> legacy = 0 in except + srv.active_transaction_id = BadInt() + + # Send MeterValues with txId so paths are exercised + req = call.MeterValues( + connector_id=conn, + transaction_id=3333, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "500", + } + ], + } + ], + ) + await client.call(req) + + # Assertions: function returned, active_tx entry exists, + # Transaction.Id is int-coercible, and legacy field coercion doesn't raise. + assert conn in srv._active_tx + val = cs.get_metric(cpid, "Transaction.Id", connector_id=conn) + assert int(val or 0) in (0, 3333) + + ok = True + try: + int(getattr(srv, "active_transaction_id", 0) or 0) + except Exception: + ok = False + assert ok is True + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9372, "cp_id": "CP_exc_paths_2", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_exc_paths_2"]) +@pytest.mark.parametrize("port", [9372]) +async def test_on_meter_values_exception_branches_with_restore( + hass, socket_enabled, cp_id, port, setup_config_entry, caplog +): + """Same as above, but check we can end up with an adopted txId (3333) after the exception branches ran.""" + + cs = setup_config_entry + caplog.set_level(logging.DEBUG) + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + conn = 1 + + with contextlib.suppress(Exception): + del srv._active_tx[conn] + srv._metrics[(conn, csess.transaction_id.value)] = FlakyTxMetric() + srv.num_connectors = BadInt() + srv.active_transaction_id = BadInt() + + req = call.MeterValues( + connector_id=conn, + transaction_id=3333, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "800", + } + ], + } + ], + ) + await client.call(req) + + # Either 0 (if not adopted) or 3333 (adopted); both prove branch ran without crash. + assert int(srv._active_tx.get(conn, 0) or 0) in (0, 3333) + val = cs.get_metric(cpid, "Transaction.Id", connector_id=conn) + assert int(val or 0) in (0, 3333) + + # Legacy coercion no longer raises (except branch set legacy=0 path) + ok = True + try: + int(getattr(srv, "active_transaction_id", 0) or 0) + except Exception: + ok = False + assert ok + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9381, "cp_id": "CP_avail_reject_fallback", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_avail_reject_fallback"]) +@pytest.mark.parametrize("port", [9381]) +async def test_change_availability_conn0_rejected_falls_back_to_conn1( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Test if ChangeAvailability(0, ...) is Rejected on a single-connector charger. + + The implementation should retry with connectorId=1 and return True on Accepted. + """ + cs = setup_config_entry + + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + + srv = cs.charge_points[cp_id] + # Ensure single-connector semantics so fallback path is eligible + srv.num_connectors = 1 + + calls = [] + + async def fake_call(req): + """Reject station-level request (conn=0), accept connector 1.""" + calls.append(req) + if isinstance(req, call.ChangeAvailability): + if getattr(req, "connector_id", None) == 0: + return SimpleNamespace(status=AvailabilityStatus.rejected) + if getattr(req, "connector_id", None) == 1: + return SimpleNamespace(status=AvailabilityStatus.accepted) + # Default: don't break other calls + return SimpleNamespace() + + # Intercept outgoing RPC calls + monkeypatch.setattr(srv, "call", fake_call) + + # Act: try to set station Unavailable via connectorId=0 + ok = await srv.set_availability(state=False, connector_id=0) + assert ok is True, "Fallback to connector 1 should make the call succeed" + + # Verify call sequence: first on 0 (Rejected), then on 1 (Accepted) + conn_ids = [ + getattr(c, "connector_id", None) + for c in calls + if isinstance(c, call.ChangeAvailability) + ] + assert conn_ids[:2] == [0, 1], f"Unexpected call order/targets: {conn_ids}" + + # Optionally assert that no pending marker was set for this Accepted outcome + m = srv._metrics.get((1, cstat.status_connector.value)) + if m is not None: + assert "availability_pending" not in getattr(m, "extra_attr", {}) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9391, "cp_id": "CP_pf_avg", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_pf_avg"]) +@pytest.mark.parametrize("port", [9391]) +async def test_process_phases_power_factor_averages_l123( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Power.Factor with phases L1/L2/L3 is averaged (not summed).""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # L1/L2/L3 values: 0.95, 0.97, 0.93 -> expected avg = 0.95 + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.95", + "phase": "L1", + }, + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.97", + "phase": "L2", + }, + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.93", + "phase": "L3", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Power.Factor", connector_id=1) + assert v == pytest.approx((0.95 + 0.97 + 0.93) / 3, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9392, "cp_id": "CP_pf_single", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_pf_single"]) +@pytest.mark.parametrize("port", [9392]) +async def test_process_phases_power_factor_single_phase_passthrough( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Power.Factor with only a single phase (e.g., L1-L2) is passed through.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Only one phase (line-to-line), so it should hit the single-pass branch. + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.88", + "phase": "L1-L2", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Power.Factor", connector_id=1) + assert v == pytest.approx(0.88, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9393, "cp_id": "CP_power_sum_kW", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_power_sum_kW"]) +@pytest.mark.parametrize("port", [9393]) +async def test_process_phases_power_active_import_sum_and_convert_to_kw( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Power.Active.Import over phases is summed and unit W -> kW.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Three phase powers in W: 1200W + 800W + 0W -> 2000W -> 2.0 kW + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "1200", + "phase": "L1", + }, + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "800", + "phase": "L2", + }, + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "0", + "phase": "L3", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Power.Active.Import", connector_id=1) + u = cs.get_unit(cpid, "Power.Active.Import", connector_id=1) + assert v == pytest.approx(2.0, rel=1e-6) # 2000 W -> 2.0 kW + assert u == "kW" + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9394, "cp_id": "CP_energy_sum_kwh", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_energy_sum_kwh"]) +@pytest.mark.parametrize("port", [9394]) +async def test_process_phases_energy_sum_and_convert_to_kwh( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Energy.Active.Import.Register over phases is summed and unit Wh -> kWh.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Three phase energies in Wh: 1000 + 500 + 0 -> 1500 Wh -> 1.5 kWh + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "1000", + "phase": "L1", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "500", + "phase": "L2", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Periodic", + "unit": "Wh", + "value": "0", + "phase": "L3", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Energy.Active.Import.Register", connector_id=1) + u = cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=1) + assert v == pytest.approx(1.5, rel=1e-6) # 1500 Wh -> 1.5 kWh + assert u == "kWh" + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9395, "cp_id": "CP_pf_avg_ltn", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_pf_avg_ltn"]) +@pytest.mark.parametrize("port", [9395]) +async def test_process_phases_power_factor_avg_line_to_neutral( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Power.Factor with L1-N/L2-N/L3-N must use average-of-nonzero (hits line 779).""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # Include a zero to ensure "average of non-zero" behavior + # Non-zero values: 0.96 and 0.90 -> expected avg = (0.96 + 0.90) / 2 = 0.93 + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.96", + "phase": "L1-N", + }, + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.00", + "phase": "L2-N", + }, + { + "measurand": "Power.Factor", + "context": "Sample.Periodic", + "value": "0.90", + "phase": "L3-N", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Power.Factor", connector_id=1) + assert v == pytest.approx((0.96 + 0.90) / 2.0, rel=1e-6) + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9396, "cp_id": "CP_power_sum_ltn", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_power_sum_ltn"]) +@pytest.mark.parametrize("port", [9396]) +async def test_process_phases_power_active_import_sum_line_to_neutral_w_to_kw( + hass, socket_enabled, cp_id, port, setup_config_entry +): + """Power.Active.Import with L1-N/L2-N/L3-N is summed and W→kW (hits lines 793–794 and 806).""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + cpid = srv.settings.cpid + + # 900 W + 600 W + 500 W = 2000 W -> 2.0 kW expected + req = call.MeterValues( + connector_id=1, + meter_value=[ + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "sampledValue": [ + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "900", + "phase": "L1-N", + }, + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "600", + "phase": "L2-N", + }, + { + "measurand": "Power.Active.Import", + "context": "Sample.Periodic", + "unit": "W", + "value": "500", + "phase": "L3-N", + }, + ], + } + ], + ) + await client.call(req) + + v = cs.get_metric(cpid, "Power.Active.Import", connector_id=1) + u = cs.get_unit(cpid, "Power.Active.Import", connector_id=1) + assert v == pytest.approx(2.0, rel=1e-6) # 2000 W -> 2.0 kW + assert u == "kW" + + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +class ChargePoint(cpclass): + """Representation of real client Charge Point.""" + + def __init__(self, id, connection, response_timeout=30, no_connectors=1): + """Init extra variables for testing.""" + super().__init__(id, connection) + self.no_connectors = int(no_connectors) + self.active_transactionId: int = 0 + self.accept: bool = True + self.task = None # reused for background triggers + self._tasks: set[asyncio.Task] = set() + self.last_diag_location: str | None = None + self.last_data_transfer: tuple[str | None, str | None, str | None] | None = None + self.last_clear_profile_kwargs: dict | None = None + self.last_update_firmware: dict | None = None + + @on(Action.get_configuration) + def on_get_configuration(self, key, **kwargs): + """Handle a get configuration requests.""" + if key[0] == ckey.supported_feature_profiles.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + { + "key": key[0], + "readonly": False, + "value": "Core,FirmwareManagement,LocalAuthListManagement,Reservation,SmartCharging,RemoteTrigger,Dummy", + } + ] + ) + else: + # use to test TypeError handling + return call_result.GetConfiguration(unknown_key=[key[0]]) + if key[0] == ckey.heartbeat_interval.value: + return call_result.GetConfiguration( + configuration_key=[{"key": key[0], "readonly": False, "value": "300"}] + ) + if key[0] == ckey.number_of_connectors.value: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": f"{self.no_connectors}"} + ] + ) + if key[0] == ckey.web_socket_ping_interval.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": "60"} + ] + ) + else: + return call_result.GetConfiguration(unknown_key=[key[0]]) + if key[0] == ckey.meter_values_sampled_data.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + { + "key": key[0], + "readonly": False, + "value": "Energy.Active.Import.Register", + } + ] + ) + else: + pass + if key[0] == ckey.meter_value_sample_interval.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": "60"} + ] + ) + else: + return call_result.GetConfiguration( + configuration_key=[{"key": key[0], "readonly": True, "value": "60"}] + ) + if key[0] == ckey.charging_schedule_allowed_charging_rate_unit.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": "Current"} + ] + ) + else: + return call_result.GetConfiguration(unknown_key=[key[0]]) + if key[0] == ckey.authorize_remote_tx_requests.value: + if self.accept is True: + return call_result.GetConfiguration( + configuration_key=[ + {"key": key[0], "readonly": False, "value": "false"} + ] + ) + else: + return call_result.GetConfiguration(unknown_key=[key[0]]) + if key[0] == ckey.charge_profile_max_stack_level.value: + return call_result.GetConfiguration( + configuration_key=[{"key": key[0], "readonly": False, "value": "3"}] + ) + return call_result.GetConfiguration( + configuration_key=[{"key": key[0], "readonly": False, "value": ""}] + ) + + @on(Action.change_configuration) + def on_change_configuration(self, key, **kwargs): + """Handle a get configuration request.""" + if self.accept is True: + if key == ckey.meter_values_sampled_data.value: + return call_result.ChangeConfiguration( + ConfigurationStatus.reboot_required + ) + else: + return call_result.ChangeConfiguration(ConfigurationStatus.accepted) + else: + return call_result.ChangeConfiguration(ConfigurationStatus.rejected) + + @on(Action.change_availability) + def on_change_availability(self, **kwargs): + """Handle change availability request.""" + if self.accept is True: + return call_result.ChangeAvailability(AvailabilityStatus.accepted) + else: + return call_result.ChangeAvailability(AvailabilityStatus.rejected) + + @on(Action.unlock_connector) + def on_unlock_connector(self, **kwargs): + """Handle unlock request.""" + if self.accept is True: + return call_result.UnlockConnector(UnlockStatus.unlocked) + else: + return call_result.UnlockConnector(UnlockStatus.unlock_failed) + + @on(Action.reset) + def on_reset(self, **kwargs): + """Handle reset request.""" + if self.accept is True: + return call_result.Reset(ResetStatus.accepted) + else: + return call_result.Reset(ResetStatus.rejected) + + @on(Action.remote_start_transaction) + def on_remote_start_transaction(self, **kwargs): + """Handle remote start request.""" + if self.accept is True: + self.task = asyncio.create_task(self.send_start_transaction()) + return call_result.RemoteStartTransaction(RemoteStartStopStatus.accepted) + else: + return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) + + @on(Action.remote_stop_transaction) + def on_remote_stop_transaction(self, **kwargs): + """Handle remote stop request.""" + if self.accept is True: + return call_result.RemoteStopTransaction(RemoteStartStopStatus.accepted) + else: + return call_result.RemoteStopTransaction(RemoteStartStopStatus.rejected) + + @on(Action.set_charging_profile) + def on_set_charging_profile(self, **kwargs): + """Handle set charging profile request.""" + if self.accept is True: + return call_result.SetChargingProfile(ChargingProfileStatus.accepted) + else: + return call_result.SetChargingProfile(ChargingProfileStatus.rejected) + + @on(Action.clear_charging_profile) + def on_clear_charging_profile(self, **kwargs): + """Handle clear charging profile request.""" + # keep what was requested so the test can assert + self.last_clear_profile_kwargs = dict(kwargs) if kwargs else {} + if self.accept is True: + return call_result.ClearChargingProfile(ClearChargingProfileStatus.accepted) + else: + return call_result.ClearChargingProfile(ClearChargingProfileStatus.unknown) + + @on(Action.trigger_message) + def on_trigger_message(self, **kwargs): + """Handle trigger message request.""" + if self.accept is True: + return call_result.TriggerMessage(TriggerMessageStatus.accepted) + else: + return call_result.TriggerMessage(TriggerMessageStatus.rejected) + + @on(Action.update_firmware) + def on_update_firmware(self, **kwargs): + """Handle update firmware request.""" + self.last_update_firmware = dict(kwargs) + return call_result.UpdateFirmware() + + @on(Action.get_diagnostics) + def on_get_diagnostics(self, **kwargs): + """Handle get diagnostics request.""" + # OCPP 1.6 GetDiagnostics request uses 'location' + self.last_diag_location = kwargs.get("location") + return call_result.GetDiagnostics() + + @on(Action.data_transfer) + def on_data_transfer(self, **kwargs): + """Handle get data transfer request.""" + # OCPP 1.6 DataTransfer request uses 'vendor_id', 'message_id', 'data' + self.last_data_transfer = ( + kwargs.get("vendor_id"), + kwargs.get("message_id"), + kwargs.get("data"), + ) + if self.accept is True: + return call_result.DataTransfer(DataTransferStatus.accepted) + else: + return call_result.DataTransfer(DataTransferStatus.rejected) + + async def send_boot_notification(self): + """Send a boot notification.""" + request = call.BootNotification( + charge_point_model="Optimus", charge_point_vendor="The Mobility House" + ) + resp = await self.call(request) + assert resp.status == RegistrationStatus.accepted + + async def send_heartbeat(self): + """Send a heartbeat.""" + request = call.Heartbeat() + resp = await self.call(request) + assert len(resp.current_time) > 0 + + async def send_authorize(self): + """Send an authorize request.""" + request = call.Authorize(id_tag="test_cp") + resp = await self.call(request) + assert resp.id_tag_info["status"] == AuthorizationStatus.accepted + + async def send_firmware_status(self): + """Send a firmware status notification.""" + request = call.FirmwareStatusNotification(status=FirmwareStatus.downloaded) + resp = await self.call(request) + assert resp is not None + + async def send_diagnostics_status(self): + """Send a diagnostics status notification.""" + request = call.DiagnosticsStatusNotification(status=DiagnosticsStatus.uploaded) + resp = await self.call(request) + assert resp is not None + + async def send_data_transfer(self): + """Send a data transfer.""" + request = call.DataTransfer( + vendor_id="The Mobility House", + message_id="Test123", + data="Test data transfer", + ) + resp = await self.call(request) + assert resp.status == DataTransferStatus.accepted + + async def send_start_transaction(self, meter_start: int = 12345): + """Send a start transaction notification.""" + request = call.StartTransaction( + connector_id=1, + id_tag="test_cp", + meter_start=meter_start, + timestamp=datetime.now(tz=UTC).isoformat(), + ) + resp = await self.call(request) + self.active_transactionId = resp.transaction_id + assert resp.id_tag_info["status"] == AuthorizationStatus.accepted.value + + async def send_status_notification(self): + """Send a status notification.""" + request = call.StatusNotification( + connector_id=0, + error_code=ChargePointErrorCode.no_error, + status=ChargePointStatus.suspended_ev, + timestamp=datetime.now(tz=UTC).isoformat(), + info="Test info", + vendor_id="The Mobility House", + vendor_error_code="Test error", + ) + resp = await self.call(request) + request = call.StatusNotification( + connector_id=1, + error_code=ChargePointErrorCode.no_error, + status=ChargePointStatus.charging, + timestamp=datetime.now(tz=UTC).isoformat(), + info="Test info", + vendor_id="The Mobility House", + vendor_error_code="Test error", + ) + resp = await self.call(request) + request = call.StatusNotification( + connector_id=2, + error_code=ChargePointErrorCode.no_error, + status=ChargePointStatus.available, + timestamp=datetime.now(tz=UTC).isoformat(), + info="Test info", + vendor_id="The Mobility House", + vendor_error_code="Available", + ) + resp = await self.call(request) + + assert resp is not None + + async def send_status_for_all_connectors(self): + """Send StatusNotification for all connectors.""" + await self.send_status_notification() + + async def send_meter_periodic_data(self, connector_id: int = 1): + """Send periodic meter data notification for a given connector.""" + n = 0 + while self.active_transactionId == 0 and n < 2: + await asyncio.sleep(1) + n += 1 + request = call.MeterValues( + connector_id=connector_id, + transaction_id=self.active_transactionId, + meter_value=[ + { + "timestamp": "2021-06-21T16:15:09Z", + "sampledValue": [ + { + "value": "1305590.000", + "context": "Sample.Periodic", + "measurand": "Energy.Active.Import.Register", + "location": "Outlet", + "unit": "Wh", + }, + { + "value": "20.000", + "context": "Sample.Periodic", + "measurand": "Current.Import", + "location": "Outlet", + "unit": "A", + "phase": "L1", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Current.Import", + "location": "Outlet", + "unit": "A", + "phase": "L2", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Current.Import", + "location": "Outlet", + "unit": "A", + "phase": "L3", + }, + { + "value": "16.000", + "context": "Sample.Periodic", + "measurand": "Current.Offered", + "location": "Outlet", + "unit": "A", + }, + { + "value": "50.010", + "context": "Sample.Periodic", + "measurand": "Frequency", + "location": "Outlet", + }, + { + "value": "", + "context": "Sample.Periodic", + "measurand": "Power.Active.Import", + "location": "Outlet", + "unit": "kW", + }, + { + "value": "", + "context": "Sample.Periodic", + "measurand": "Power.Active.Import", + "location": "Outlet", + "unit": "W", + "phase": "L1", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Power.Active.Import", + "location": "Outlet", + "unit": "W", + "phase": "L2", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Power.Active.Import", + "location": "Outlet", + "unit": "W", + "phase": "L3", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Power.Factor", + "location": "Outlet", + }, + { + "value": "38.500", + "context": "Sample.Periodic", + "measurand": "Temperature", + "location": "Body", + "unit": "Celsius", + }, + { + "value": "228.000", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L1-N", + }, + { + "value": "228.000", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L2-N", + }, + { + "value": "0.000", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L3-N", + }, + { + "value": "89.00", + "context": "Sample.Periodic", + "measurand": "Power.Reactive.Import", + "unit": "var", + }, + { + "value": "0.010", + "context": "Transaction.Begin", + "unit": "kWh", + }, + { + "value": "1305570.000", + }, + ], + } + ], + ) + resp = await self.call(request) + assert resp is not None + + async def send_meter_line_voltage(self, connector_id: int = 1): + """Send line voltages for a given connector.""" + while self.active_transactionId == 0: + await asyncio.sleep(1) + request = call.MeterValues( + connector_id=connector_id, + transaction_id=self.active_transactionId, + meter_value=[ + { + "timestamp": "2021-06-21T16:15:09Z", + "sampledValue": [ + { + "value": "395.900", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L1-L2", + }, + { + "value": "396.300", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L2-L3", + }, + { + "value": "398.900", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L3-L1", + }, + ], + } + ], + ) + resp = await self.call(request) + assert resp is not None + + async def send_meter_err_phases(self, connector_id: int = 1): + """Send erroneous voltage phase for a given connector.""" + while self.active_transactionId == 0: + await asyncio.sleep(1) + request = call.MeterValues( + connector_id=connector_id, + transaction_id=self.active_transactionId, + meter_value=[ + { + "timestamp": "2021-06-21T16:15:09Z", + "sampledValue": [ + { + "value": "230", + "context": "Sample.Periodic", + "measurand": "Voltage", + "location": "Outlet", + "unit": "V", + "phase": "L1", + }, + { + "value": "23", + "context": "Sample.Periodic", + "measurand": "Current.Import", + "location": "Outlet", + "unit": "A", + "phase": "L1-N", + }, + ], + } + ], + ) + resp = await self.call(request) + assert resp is not None + + async def send_meter_energy_kwh(self, connector_id: int = 1): + """Send periodic energy meter value with kWh unit for a given connector.""" + while self.active_transactionId == 0: + await asyncio.sleep(1) + request = call.MeterValues( + connector_id=connector_id, + transaction_id=self.active_transactionId, + meter_value=[ + { + "timestamp": "2021-06-21T16:15:09Z", + "sampledValue": [ + { + "unit": "kWh", + "value": "11", + "context": "Sample.Periodic", + "format": "Raw", + "measurand": "Energy.Active.Import.Register", + }, + ], + } + ], + ) + resp = await self.call(request) + assert resp is not None + + async def send_main_meter_clock_data(self, connector_id: int = 1): + """Send periodic main meter value (no transaction_id) for a given connector.""" + while self.active_transactionId == 0: + await asyncio.sleep(1) + request = call.MeterValues( + connector_id=connector_id, + meter_value=[ + { + "timestamp": "2021-06-21T16:15:09Z", + "sampledValue": [ + { + "value": "67230012", + "context": "Sample.Clock", + "format": "Raw", + "measurand": "Energy.Active.Import.Register", + "location": "Inlet", + }, + ], + } + ], + ) + resp = await self.call(request) + assert resp is not None + + async def send_meter_clock_data(self, connector_id: int = 1): + """Send periodic meter data (clock) for a given connector.""" + self.active_transactionId = 0 + request = call.MeterValues( + connector_id=connector_id, + transaction_id=self.active_transactionId, + meter_value=[ + { + "timestamp": "2021-06-21T16:15:09Z", + "sampledValue": [ + { + "measurand": "Voltage", + "context": "Sample.Clock", + "unit": "V", + "value": "228.490", + }, + { + "measurand": "Power.Active.Import", + "context": "Sample.Clock", + "unit": "W", + "value": "0.000", + }, + { + "measurand": "Energy.Active.Import.Register", + "context": "Sample.Clock", + "unit": "kWh", + "value": "1101.452", + }, + { + "measurand": "Current.Import", + "context": "Sample.Clock", + "unit": "A", + "value": "0.054", + }, + { + "measurand": "Frequency", + "context": "Sample.Clock", + "value": "50.000", + }, + ], + }, + ], + ) + resp = await self.call(request) + assert resp is not None + + async def send_stop_transaction(self, delay: int = 0): + """Send a stop transaction notification.""" + # add delay to allow meter data to be processed + await asyncio.sleep(delay) + n = 0 + while self.active_transactionId == 0 and n < 2: + await asyncio.sleep(1) + n += 1 + request = call.StopTransaction( + meter_stop=54321, + timestamp=datetime.now(tz=UTC).isoformat(), + transaction_id=self.active_transactionId, + reason="EVDisconnected", + id_tag="test_cp", + ) + resp = await self.call(request) + assert resp.id_tag_info["status"] == AuthorizationStatus.accepted.value + + async def send_security_event(self): + """Send a security event notification.""" + request = call.SecurityEventNotification( + type="SettingSystemTime", + timestamp="2022-09-29T20:58:29Z", + tech_info="BootNotification", + ) + await self.call(request) diff --git a/tests/test_charge_point_v201.py b/tests/test_charge_point_v201.py new file mode 100644 index 00000000..34e90ab8 --- /dev/null +++ b/tests/test_charge_point_v201.py @@ -0,0 +1,1319 @@ +"""Implement a test by a simulating an OCPP 2.0.1 chargepoint.""" + +import asyncio +from datetime import datetime, timedelta, UTC + +from homeassistant.core import HomeAssistant, ServiceResponse +from homeassistant.exceptions import HomeAssistantError +from ocpp.v16.enums import Measurand + +from custom_components.ocpp.const import CONF_CPIDS, CONF_CPID, DOMAIN +from custom_components.ocpp import CentralSystem +from custom_components.ocpp.enums import ( + HAChargerDetails as cdet, + HAChargerServices as csvcs, + HAChargerSession as csess, + HAChargerStatuses as cstat, + Profiles, +) +from .charge_point_test import ( + set_switch, + set_number, + press_button, + create_configuration, + run_charge_point_test, + remove_configuration, + wait_ready, +) +from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_3, MOCK_CONFIG_CP_APPEND +from custom_components.ocpp.const import ( + DEFAULT_METER_INTERVAL, + DOMAIN as OCPP_DOMAIN, + CONF_PORT, + MEASURANDS, +) +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +import ocpp +from ocpp.routing import on +import ocpp.exceptions +from ocpp.v201 import ChargePoint as cpclass, call, call_result +from ocpp.v201.datatypes import ( + ComponentType, + EVSEType, + GetVariableResultType, + SetVariableResultType, + VariableType, + VariableAttributeType, + VariableCharacteristicsType, + ReportDataType, +) +from ocpp.v201.enums import ( + Action, + AuthorizationStatusEnumType, + BootReasonEnumType, + ChangeAvailabilityStatusEnumType, + ChargingProfileKindEnumType, + ChargingProfilePurposeEnumType, + ChargingProfileStatusEnumType, + ChargingRateUnitEnumType, + ChargingStateEnumType, + ClearChargingProfileStatusEnumType, + ConnectorStatusEnumType, + DataEnumType, + FirmwareStatusEnumType, + GenericDeviceModelStatusEnumType, + GetVariableStatusEnumType, + IdTokenEnumType, + MeasurandEnumType, + MutabilityEnumType, + OperationalStatusEnumType, + PhaseEnumType, + ReadingContextEnumType, + RegistrationStatusEnumType, + ReportBaseEnumType, + RequestStartStopStatusEnumType, + ResetStatusEnumType, + ResetEnumType, + SetVariableStatusEnumType, + ReasonEnumType, + TransactionEventEnumType, + MessageTriggerEnumType, + TriggerMessageStatusEnumType, + TriggerReasonEnumType, + UpdateFirmwareStatusEnumType, +) +from ocpp.v16.enums import ChargePointStatus as ChargePointStatusv16 + + +supported_measurands = [ + measurand + for measurand in MEASURANDS + if (measurand != Measurand.rpm.value) and (measurand != Measurand.temperature.value) +] + + +class ChargePoint(cpclass): + """Representation of real client Charge Point.""" + + remote_starts: list[call.RequestStartTransaction] = [] + remote_stops: list[str] = [] + task: asyncio.Task | None = None + remote_start_tx_id: str = "remotestart" + operative: bool | None = None + tx_updated_interval: int | None = None + tx_updated_measurands: list[str] | None = None + tx_start_time: datetime | None = None + component_instance_used: str | None = None + variable_instance_used: str | None = None + charge_profiles_set: list[call.SetChargingProfile] = [] + charge_profiles_cleared: list[call.ClearChargingProfile] = [] + accept_reset: bool = True + resets: list[call.Reset] = [] + + @on(Action.get_base_report) + def _on_base_report(self, request_id: int, report_base: str, **kwargs): + assert report_base == ReportBaseEnumType.full_inventory.value + self.task = asyncio.create_task(self._send_full_inventory(request_id)) + return call_result.GetBaseReport( + GenericDeviceModelStatusEnumType.accepted.value + ) + + @on(Action.request_start_transaction) + def _on_remote_start( + self, id_token: dict, remote_start_id: int, **kwargs + ) -> call_result.RequestStartTransaction: + self.remote_starts.append( + call.RequestStartTransaction(id_token, remote_start_id, *kwargs) + ) + self.task = asyncio.create_task( + self._start_transaction_remote_start(id_token, remote_start_id) + ) + return call_result.RequestStartTransaction( + RequestStartStopStatusEnumType.accepted.value + ) + + @on(Action.request_stop_transaction) + def _on_remote_stop(self, transaction_id: str, **kwargs): + assert transaction_id == self.remote_start_tx_id + self.remote_stops.append(transaction_id) + return call_result.RequestStopTransaction( + RequestStartStopStatusEnumType.accepted.value + ) + + @on(Action.set_variables) + def _on_set_variables(self, set_variable_data: list[dict], **kwargs): + result: list[SetVariableResultType] = [] + for input in set_variable_data: + if (input["component"] == {"name": "SampledDataCtrlr"}) and ( + input["variable"] == {"name": "TxUpdatedInterval"} + ): + self.tx_updated_interval = int(input["attribute_value"]) + if (input["component"] == {"name": "SampledDataCtrlr"}) and ( + input["variable"] == {"name": "TxUpdatedMeasurands"} + ): + self.tx_updated_measurands = input["attribute_value"].split(",") + + attr_result: SetVariableStatusEnumType + if input["variable"] == {"name": "RebootRequired"}: + attr_result = SetVariableStatusEnumType.reboot_required + elif input["variable"] == {"name": "BadVariable"}: + attr_result = SetVariableStatusEnumType.unknown_variable + elif input["variable"] == {"name": "VeryBadVariable"}: + raise ocpp.exceptions.InternalError() + else: + attr_result = SetVariableStatusEnumType.accepted + self.component_instance_used = input["component"].get("instance", None) + self.variable_instance_used = input["variable"].get("instance", None) + + result.append( + SetVariableResultType( + attr_result, + ComponentType(input["component"]["name"]), + VariableType(input["variable"]["name"]), + ) + ) + return call_result.SetVariables(result) + + @on(Action.get_variables) + def _on_get_variables(self, get_variable_data: list[dict], **kwargs): + result: list[GetVariableResultType] = [] + for input in get_variable_data: + value: str | None = None + if (input["component"] == {"name": "SampledDataCtrlr"}) and ( + input["variable"] == {"name": "TxUpdatedInterval"} + ): + value = str(self.tx_updated_interval) + elif input["variable"]["name"] == "TestInstance": + value = ( + input["component"]["instance"] + "," + input["variable"]["instance"] + ) + elif input["variable"] == {"name": "VeryBadVariable"}: + raise ocpp.exceptions.InternalError() + result.append( + GetVariableResultType( + GetVariableStatusEnumType.accepted + if value is not None + else GetVariableStatusEnumType.unknown_variable, + ComponentType(input["component"]["name"]), + VariableType(input["variable"]["name"]), + attribute_value=value, + ) + ) + return call_result.GetVariables(result) + + @on(Action.change_availability) + def _on_change_availability(self, operational_status: str, **kwargs): + if operational_status == OperationalStatusEnumType.operative.value: + self.operative = True + elif operational_status == OperationalStatusEnumType.inoperative.value: + self.operative = False + else: + assert False + return call_result.ChangeAvailability( + ChangeAvailabilityStatusEnumType.accepted.value + ) + + @on(Action.set_charging_profile) + def _on_set_charging_profile(self, evse_id: int, charging_profile: dict, **kwargs): + self.charge_profiles_set.append( + call.SetChargingProfile(evse_id, charging_profile) + ) + unit = charging_profile["charging_schedule"][0]["charging_rate_unit"] + limit = charging_profile["charging_schedule"][0]["charging_schedule_period"][0][ + "limit" + ] + if (unit == ChargingRateUnitEnumType.amps.value) and (limit < 6): + return call_result.SetChargingProfile( + ChargingProfileStatusEnumType.rejected.value + ) + return call_result.SetChargingProfile( + ChargingProfileStatusEnumType.accepted.value + ) + + @on(Action.clear_charging_profile) + def _on_clear_charging_profile(self, **kwargs): + self.charge_profiles_cleared.append( + call.ClearChargingProfile( + kwargs.get("charging_profile_id", None), + kwargs.get("charging_profile_criteria", None), + ) + ) + return call_result.ClearChargingProfile( + ClearChargingProfileStatusEnumType.accepted.value + ) + + @on(Action.reset) + def _on_reset(self, type: str, **kwargs): + self.resets.append(call.Reset(type, kwargs.get("evse_id", None))) + return call_result.Reset( + ResetStatusEnumType.accepted.value + if self.accept_reset + else ResetStatusEnumType.rejected.value + ) + + async def _start_transaction_remote_start( + self, id_token: dict, remote_start_id: int + ): + # As if AuthorizeRemoteStart is set + authorize_resp: call_result.Authorize = await self.call( + call.Authorize(id_token) + ) + assert ( + authorize_resp.id_token_info["status"] + == AuthorizationStatusEnumType.accepted.value + ) + + self.tx_start_time = datetime.now(tz=UTC) + request = call.TransactionEvent( + TransactionEventEnumType.started.value, + self.tx_start_time.isoformat(), + TriggerReasonEnumType.remote_start.value, + 0, + transaction_info={ + "transaction_id": self.remote_start_tx_id, + "remote_start_id": remote_start_id, + }, + meter_value=[ + { + "timestamp": self.tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 0, + "measurand": Measurand.power_active_import.value, + "unit_of_measure": {"unit": "W"}, + }, + ], + }, + ], + id_token=id_token, + ) + await self.call(request) + + async def _send_full_inventory(self, request_id: int): + # Cannot send all at once because of a bug in python ocpp module + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 0, + [ + ReportDataType( + ComponentType("SmartChargingCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ) + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 1, + [ + ReportDataType( + ComponentType("ReservationCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 2, + [ + ReportDataType( + ComponentType("LocalAuthListCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 3, + [ + ReportDataType( + ComponentType("EVSE", evse=EVSEType(1)), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 4, + [ + ReportDataType( + ComponentType("Connector", evse=EVSEType(1, connector_id=1)), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + ], + tbc=True, + ) + ) + await self.call( + call.NotifyReport( + request_id, + datetime.now(tz=UTC).isoformat(), + 5, + [ + ReportDataType( + ComponentType("SampledDataCtrlr"), + VariableType("TxUpdatedMeasurands"), + [VariableAttributeType(value="", persistent=True)], + VariableCharacteristicsType( + DataEnumType.member_list, + False, + values_list=",".join(supported_measurands), + ), + ), + ], + ) + ) + + +async def _test_transaction(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + + await set_switch(hass, cpid, "charge_control", True) + assert len(cp.remote_starts) == 1 + assert cp.remote_starts[0].id_token == { + "id_token": cs.charge_points[cs.cpids[cpid]]._remote_id_tag, + "type": IdTokenEnumType.central.value, + } + while cs.get_metric(cpid, csess.transaction_id.value) is None: + await asyncio.sleep(0.1) + assert cs.get_metric(cpid, csess.transaction_id.value) == cp.remote_start_tx_id + + tx_start_time = cp.tx_start_time + await cp.call( + call.StatusNotification( + tx_start_time.isoformat(), ConnectorStatusEnumType.occupied, 1, 1 + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.preparing + ) + + await cp.call( + call.TransactionEvent( + TransactionEventEnumType.updated.value, + tx_start_time.isoformat(), + TriggerReasonEnumType.cable_plugged_in.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + }, + ) + ) + await cp.call( + call.TransactionEvent( + TransactionEventEnumType.updated.value, + tx_start_time.isoformat(), + TriggerReasonEnumType.charging_state_changed.value, + 2, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateEnumType.charging.value, + }, + meter_value=[ + { + "timestamp": tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 0, + "measurand": Measurand.current_export.value, + "phase": PhaseEnumType.l1.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 0, + "measurand": Measurand.current_export.value, + "phase": PhaseEnumType.l2.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 0, + "measurand": Measurand.current_export.value, + "phase": PhaseEnumType.l3.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 1.1, + "measurand": Measurand.current_import.value, + "phase": PhaseEnumType.l1.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 2.2, + "measurand": Measurand.current_import.value, + "phase": PhaseEnumType.l2.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 3.3, + "measurand": Measurand.current_import.value, + "phase": PhaseEnumType.l3.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 12.1, + "measurand": Measurand.current_offered.value, + "phase": PhaseEnumType.l1.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 12.2, + "measurand": Measurand.current_offered.value, + "phase": PhaseEnumType.l2.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 12.3, + "measurand": Measurand.current_offered.value, + "phase": PhaseEnumType.l3.value, + "unit_of_measure": {"unit": "A"}, + }, + { + "value": 0, + "measurand": Measurand.energy_active_export_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + { + "value": 0.1, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "Wh", "multiplier": 3}, + }, + { + "value": 0, + "measurand": Measurand.energy_reactive_export_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + { + "value": 0, + "measurand": Measurand.energy_reactive_import_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + { + "value": 50, + "measurand": Measurand.frequency.value, + "unit_of_measure": {"unit": "Hz"}, + }, + { + "value": 0, + "measurand": Measurand.power_active_export.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 1518, + "measurand": Measurand.power_active_import.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 8418, + "measurand": Measurand.power_offered.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 1, + "measurand": Measurand.power_factor.value, + }, + { + "value": 0, + "measurand": Measurand.power_reactive_export.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 0, + "measurand": Measurand.power_reactive_import.value, + "unit_of_measure": {"unit": "W"}, + }, + { + "value": 69, + "measurand": Measurand.soc.value, + "unit_of_measure": {"unit": "percent"}, + }, + { + "value": 229.9, + "measurand": Measurand.voltage.value, + "phase": PhaseEnumType.l1_n.value, + "unit_of_measure": {"unit": "V"}, + }, + { + "value": 230, + "measurand": Measurand.voltage.value, + "phase": PhaseEnumType.l2_n.value, + "unit_of_measure": {"unit": "V"}, + }, + { + "value": 230.4, + "measurand": Measurand.voltage.value, + "phase": PhaseEnumType.l3_n.value, + "unit_of_measure": {"unit": "V"}, + }, + { + # Not among enabled measurands, will be ignored + "value": 1111, + "measurand": MeasurandEnumType.energy_active_net.value, + "unit_of_measure": {"unit": "Wh"}, + }, + ], + } + ], + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.charging + ) + assert cs.get_metric(cpid, Measurand.current_export.value) == 0 + assert cs.get_metric(cpid, Measurand.current_import.value) == pytest.approx(2.2) + assert cs.get_metric(cpid, Measurand.current_offered.value) == pytest.approx(12.2) + assert cs.get_metric(cpid, Measurand.energy_active_export_register.value) == 0 + assert cs.get_metric(cpid, Measurand.energy_active_import_register.value) == 0.1 + assert cs.get_metric(cpid, Measurand.energy_reactive_export_register.value) == 0 + assert cs.get_metric(cpid, Measurand.energy_reactive_import_register.value) == 0 + assert cs.get_metric(cpid, Measurand.frequency.value) == 50 + assert cs.get_metric(cpid, Measurand.power_active_export.value) == 0 + assert cs.get_metric(cpid, Measurand.power_active_import.value) == 1.518 + assert cs.get_metric(cpid, Measurand.power_offered.value) == 8.418 + assert cs.get_metric(cpid, Measurand.power_reactive_export.value) == 0 + assert cs.get_metric(cpid, Measurand.power_reactive_import.value) == 0 + assert cs.get_metric(cpid, Measurand.soc.value) == 69 + assert cs.get_metric(cpid, Measurand.voltage.value) == 230.1 + assert cs.get_metric(cpid, csess.session_energy) == 0 + assert cs.get_metric(cpid, csess.session_time) == 0 + + await cp.call( + call.TransactionEvent( + TransactionEventEnumType.updated.value, + (tx_start_time + timedelta(seconds=60)).isoformat(), + TriggerReasonEnumType.meter_value_periodic.value, + 3, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateEnumType.charging.value, + }, + meter_value=[ + { + "timestamp": (tx_start_time + timedelta(seconds=60)).isoformat(), + "sampled_value": [ + { + "value": 256, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + ], + } + ], + ) + ) + assert cs.get_metric(cpid, csess.session_energy) == 0.156 + assert cs.get_metric(cpid, csess.session_time) == 1 + + await set_switch(hass, cpid, "charge_control", False) + assert len(cp.remote_stops) == 1 + + await cp.call( + call.TransactionEvent( + TransactionEventEnumType.ended.value, + (tx_start_time + timedelta(seconds=120)).isoformat(), + TriggerReasonEnumType.remote_stop.value, + 4, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateEnumType.ev_connected.value, + "stopped_reason": ReasonEnumType.remote.value, + }, + meter_value=[ + { + "timestamp": (tx_start_time + timedelta(seconds=120)).isoformat(), + "sampled_value": [ + { + "value": 333, + "context": ReadingContextEnumType.transaction_end, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "Wh"}, + }, + ], + } + ], + ) + ) + assert cs.get_metric(cpid, Measurand.current_import.value) == 0 + assert cs.get_metric(cpid, Measurand.current_offered.value) == 0 + assert cs.get_metric(cpid, Measurand.energy_active_import_register.value) == 0.333 + assert cs.get_metric(cpid, Measurand.frequency.value) == 0 + assert cs.get_metric(cpid, Measurand.power_active_import.value) == 0 + assert cs.get_metric(cpid, Measurand.power_offered.value) == 0 + assert cs.get_metric(cpid, Measurand.power_reactive_import.value) == 0 + assert cs.get_metric(cpid, Measurand.soc.value) == 0 + assert cs.get_metric(cpid, Measurand.voltage.value) == 0 + assert cs.get_metric(cpid, csess.session_energy) == 0.233 + assert cs.get_metric(cpid, csess.session_time) == 2 + + # Now with energy reading in Started transaction event + await cp.call( + call.TransactionEvent( + TransactionEventEnumType.started.value, + tx_start_time.isoformat(), + TriggerReasonEnumType.cable_plugged_in.value, + 0, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateEnumType.ev_connected.value, + }, + meter_value=[ + { + "timestamp": tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 1000, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "kWh", "multiplier": -3}, + }, + ], + }, + ], + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.preparing + ) + await cp.call( + call.TransactionEvent( + TransactionEventEnumType.updated.value, + tx_start_time.isoformat(), + TriggerReasonEnumType.charging_state_changed.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateEnumType.charging.value, + }, + meter_value=[ + { + "timestamp": tx_start_time.isoformat(), + "sampled_value": [ + { + "value": 1234, + "measurand": Measurand.energy_active_import_register.value, + "unit_of_measure": {"unit": "kWh", "multiplier": -3}, + }, + ], + }, + ], + ) + ) + assert cs.get_metric(cpid, csess.session_energy) == 0.234 + + await cp.call( + call.TransactionEvent( + TransactionEventEnumType.updated.value, + tx_start_time.isoformat(), + TriggerReasonEnumType.charging_state_changed.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateEnumType.suspended_ev.value, + }, + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.suspended_ev + ) + + await cp.call( + call.TransactionEvent( + TransactionEventEnumType.updated.value, + tx_start_time.isoformat(), + TriggerReasonEnumType.charging_state_changed.value, + 1, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateEnumType.suspended_evse.value, + }, + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.suspended_evse + ) + + await cp.call( + call.TransactionEvent( + TransactionEventEnumType.ended.value, + tx_start_time.isoformat(), + TriggerReasonEnumType.ev_communication_lost.value, + 2, + transaction_info={ + "transaction_id": cp.remote_start_tx_id, + "charging_state": ChargingStateEnumType.idle.value, + "stopped_reason": ReasonEnumType.ev_disconnected.value, + }, + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ChargePointStatusv16.available + ) + + +async def _set_variable( + hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint, key: str, value: str +) -> tuple[ServiceResponse, HomeAssistantError]: + response: ServiceResponse | None = None + error: HomeAssistantError | None = None + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + try: + response = await hass.services.async_call( + DOMAIN, + csvcs.service_configure, + service_data={"devid": cpid, "ocpp_key": key, "value": value}, + blocking=True, + return_response=True, + ) + except HomeAssistantError as e: + error = e + return response, error + + +async def _get_variable( + hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint, key: str +) -> tuple[ServiceResponse, HomeAssistantError]: + response: ServiceResponse | None = None + error: HomeAssistantError | None = None + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + try: + response = await hass.services.async_call( + DOMAIN, + csvcs.service_get_configuration, + service_data={"devid": cpid, "ocpp_key": key}, + blocking=True, + return_response=True, + ) + except HomeAssistantError as e: + error = e + return response, error + + +async def _test_services(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): + service_response: ServiceResponse + error: HomeAssistantError + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/TxUpdatedInterval", "17" + ) + assert service_response == {"reboot_required": False} + assert cp.tx_updated_interval == 17 + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/RebootRequired", "17" + ) + assert service_response == {"reboot_required": True} + + service_response, error = await _set_variable( + hass, cs, cp, "TestComponent(CompInstance)/TestVariable(VarInstance)", "17" + ) + assert service_response == {"reboot_required": False} + assert cp.component_instance_used == "CompInstance" + assert cp.variable_instance_used == "VarInstance" + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/BadVariable", "17" + ) + assert error is not None + assert str(error).startswith("Failed to set variable") + + service_response, error = await _set_variable( + hass, cs, cp, "SampledDataCtrlr/VeryBadVariable", "17" + ) + assert error is not None + assert str(error).startswith("OCPP call failed: InternalError") + + service_response, error = await _set_variable( + hass, cs, cp, "does not compute", "17" + ) + assert error is not None + assert str(error) == "Invalid OCPP key" + + service_response, error = await _get_variable( + hass, cs, cp, "SampledDataCtrlr/TxUpdatedInterval" + ) + assert service_response == {"value": "17"} + + service_response, error = await _get_variable( + hass, cs, cp, "TestComponent(CompInstance)/TestInstance(VarInstance)" + ) + assert service_response == {"value": "CompInstance,VarInstance"} + + service_response, error = await _get_variable( + hass, cs, cp, "SampledDataCtrlr/BadVariale" + ) + assert error is not None + assert str(error).startswith("Failed to get variable") + + service_response, error = await _get_variable( + hass, cs, cp, "SampledDataCtrlr/VeryBadVariable" + ) + assert error is not None + assert str(error).startswith("OCPP call failed: InternalError") + + +async def _set_charge_rate_service( + hass: HomeAssistant, data: dict +) -> HomeAssistantError: + try: + await hass.services.async_call( + DOMAIN, + csvcs.service_set_charge_rate, + service_data=data, + blocking=True, + ) + except HomeAssistantError as e: + return e + return None + + +async def _test_charge_profiles( + hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint +): + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + + error: HomeAssistantError = await _set_charge_rate_service( + hass, {"devid": cpid, "limit_watts": 3000} + ) + + assert error is None + assert len(cp.charge_profiles_set) == 1 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 3000}], + "charging_rate_unit": ChargingRateUnitEnumType.watts.value, + }, + ], + } + + error = await _set_charge_rate_service(hass, {"devid": cpid, "limit_amps": 16}) + assert error is None + assert len(cp.charge_profiles_set) == 2 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 16}], + "charging_rate_unit": ChargingRateUnitEnumType.amps.value, + }, + ], + } + + error = await _set_charge_rate_service( + hass, + { + "devid": cpid, + "custom_profile": """{ + 'id': 2, + 'stack_level': 1, + 'charging_profile_purpose': 'TxProfile', + 'charging_profile_kind': 'Relative', + 'charging_schedule': [{ + 'id': 1, + 'charging_rate_unit': 'A', + 'charging_schedule_period': [{'start_period': 0, 'limit': 6}] + }] + }""", + }, + ) + assert error is None + assert len(cp.charge_profiles_set) == 3 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 2, + "stack_level": 1, + "charging_profile_purpose": ChargingProfilePurposeEnumType.tx_profile.value, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 6}], + "charging_rate_unit": ChargingRateUnitEnumType.amps.value, + }, + ], + } + + await set_number(hass, cpid, "maximum_current", 12) + assert len(cp.charge_profiles_set) == 4 + assert cp.charge_profiles_set[-1].evse_id == 0 + assert cp.charge_profiles_set[-1].charging_profile == { + "id": 1, + "stack_level": 0, + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value, + "charging_profile_kind": ChargingProfileKindEnumType.relative.value, + "charging_schedule": [ + { + "id": 1, + "charging_schedule_period": [{"start_period": 0, "limit": 12}], + "charging_rate_unit": ChargingRateUnitEnumType.amps.value, + }, + ], + } + + error = await _set_charge_rate_service(hass, {"devid": cpid, "limit_amps": 5}) + assert error is not None + assert str(error).startswith("Failed to set variable: Rejected") + + assert len(cp.charge_profiles_cleared) == 0 + await set_number(hass, cpid, "maximum_current", 32) + assert len(cp.charge_profiles_cleared) == 1 + assert cp.charge_profiles_cleared[-1].charging_profile_id is None + assert cp.charge_profiles_cleared[-1].charging_profile_criteria == { + "charging_profile_purpose": ChargingProfilePurposeEnumType.charging_station_max_profile.value + } + + +async def _run_test(hass: HomeAssistant, cs: CentralSystem, cp: ChargePoint): + boot_res: call_result.BootNotification = await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonEnumType.power_up.value, + ) + ) + assert boot_res.status == RegistrationStatusEnumType.accepted.value + assert boot_res.status_info is None + datetime.fromisoformat(boot_res.current_time) + await cp.call( + call.StatusNotification( + datetime.now(tz=UTC).isoformat(), ConnectorStatusEnumType.available, 1, 1 + ) + ) + + heartbeat_resp: call_result.Heartbeat = await cp.call(call.Heartbeat()) + datetime.fromisoformat(heartbeat_resp.current_time) + + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + + await wait_ready(cs.charge_points[cp_id]) + + # Junk report to be ignored + await cp.call(call.NotifyReport(2, datetime.now(tz=UTC).isoformat(), 0)) + + assert cs.get_metric(cpid, cdet.serial.value, connector_id=0) == "SERIAL" + assert cs.get_metric(cpid, cdet.model.value, connector_id=0) == "MODEL" + assert cs.get_metric(cpid, cdet.vendor.value, connector_id=0) == "VENDOR" + assert cs.get_metric(cpid, cdet.firmware_version.value, connector_id=0) == "VERSION" + assert ( + cs.get_metric(cpid, cdet.features.value, connector_id=0) + == Profiles.CORE | Profiles.SMART | Profiles.RES | Profiles.AUTH + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ConnectorStatusEnumType.available.value + ) + assert cp.tx_updated_interval == DEFAULT_METER_INTERVAL + assert cp.tx_updated_measurands == supported_measurands + + while cp.operative is None: + await asyncio.sleep(0.1) + assert cp.operative + + await _test_transaction(hass, cs, cp) + await _test_services(hass, cs, cp) + await _test_charge_profiles(hass, cs, cp) + + await press_button(hass, cpid, "reset") + assert len(cp.resets) == 1 + assert cp.resets[0].type == ResetEnumType.immediate.value + assert cp.resets[0].evse_id is None + + error: HomeAssistantError = None + cp.accept_reset = False + try: + await press_button(hass, cpid, "reset") + except HomeAssistantError as e: + error = e + assert error is not None + assert str(error) == "OCPP call failed: Rejected" + + await set_switch(hass, cpid, "availability", False) + assert not cp.operative + await cp.call( + call.StatusNotification( + datetime.now(tz=UTC).isoformat(), ConnectorStatusEnumType.unavailable, 1, 1 + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ConnectorStatusEnumType.unavailable.value + ) + + await cp.call( + call.StatusNotification( + datetime.now(tz=UTC).isoformat(), ConnectorStatusEnumType.faulted, 1, 1 + ) + ) + assert ( + cs.get_metric(cpid, cstat.status_connector.value) + == ConnectorStatusEnumType.faulted.value + ) + + await cp.call( + call.FirmwareStatusNotification(FirmwareStatusEnumType.installed.value) + ) + + +class ChargePointAllFeatures(ChargePoint): + """A charge point which also supports UpdateFirmware and TriggerMessage.""" + + triggered_status_notification: list[EVSEType] = [] + + @on(Action.update_firmware) + def _on_update_firmware(self, request_id: int, firmware: dict, **kwargs): + return call_result.UpdateFirmware(UpdateFirmwareStatusEnumType.rejected.value) + + @on(Action.trigger_message) + def _on_trigger_message(self, requested_message: str, **kwargs): + if (requested_message == MessageTriggerEnumType.status_notification) and ( + "evse" in kwargs + ): + self.triggered_status_notification.append( + EVSEType(kwargs["evse"]["id"], kwargs["evse"]["connector_id"]) + ) + return call_result.TriggerMessage(TriggerMessageStatusEnumType.rejected.value) + + +async def _extra_features_test( + hass: HomeAssistant, + cs: CentralSystem, + cp: ChargePointAllFeatures, +): + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + + await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonEnumType.power_up.value, + ) + ) + await wait_ready(cs.charge_points[cp_id]) + + assert ( + cs.get_metric(cpid, cdet.features.value, connector_id=0) + == Profiles.CORE + | Profiles.SMART + | Profiles.RES + | Profiles.AUTH + | Profiles.FW + | Profiles.REM + ) + + while len(cp.triggered_status_notification) < 1: + await asyncio.sleep(0.1) + assert cp.triggered_status_notification[0].id == 1 + assert cp.triggered_status_notification[0].connector_id == 1 + + +class ChargePointReportUnsupported(ChargePointAllFeatures): + """A charge point which does not support GetBaseReport.""" + + @on(Action.get_base_report) + def _on_base_report(self, request_id: int, report_base: str, **kwargs): + raise ocpp.exceptions.NotImplementedError("This is not implemented") + + +class ChargePointReportFailing(ChargePointAllFeatures): + """A charge point which keeps failing GetBaseReport.""" + + @on(Action.get_base_report) + def _on_base_report(self, request_id: int, report_base: str, **kwargs): + raise ocpp.exceptions.InternalError("Test failure") + + +async def _unsupported_base_report_test( + hass: HomeAssistant, + cs: CentralSystem, + cp: ChargePoint, +): + cp_id = cp.id[:-7] + cpid = cs.charge_points[cp_id].settings.cpid + + await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonEnumType.power_up.value, + ) + ) + await wait_ready(cs.charge_points[cp_id]) + assert ( + cs.get_metric(cpid, cdet.features.value, connector_id=0) + == Profiles.CORE | Profiles.REM | Profiles.FW + ) + + +@pytest.mark.timeout(150) +async def test_cms_responses_v201(hass, socket_enabled): + """Test central system responses to a charger.""" + + # Should not have to do this ideally, however web socket in the CSMS + # restarts if measurands reported by the charger differ from the list + # from the configuration, which a real charger can deal with but this + # test cannot + # config_data[CONF_MONITORED_VARIABLES] = ",".join(supported_measurands) + cp_id = "CP_2" + config_data = MOCK_CONFIG_DATA.copy() + config_data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) + config_data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "test_v201_cpid" + + config_data[CONF_PORT] = 9080 + + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=config_data, + entry_id="test_v201_cms", + title="test_v201_cms", + version=2, + minor_version=0, + ) + cs: CentralSystem = await create_configuration(hass, config_entry) + # threading in async validation causes tests to fail + ocpp.messages.ASYNC_VALIDATION = False + await run_charge_point_test( + config_entry, + cp_id, + ["ocpp2.0.1"], + lambda ws: ChargePoint("CP_2_client", ws), + [lambda cp: _run_test(hass, cs, cp)], + ) + + # add v2.1 charger to config entry + entry = hass.config_entries._entries.get_entries_for_domain(OCPP_DOMAIN)[0] + cp_id3 = "CP_2_1_allfeatures" + entry.data[CONF_CPIDS].append({cp_id3: MOCK_CONFIG_CP_APPEND.copy()}) + entry.data[CONF_CPIDS][-1][cp_id3][CONF_CPID] = "test_v21_cpid3" + # need to reload to setup sensors etc for new charger + await hass.config_entries.async_reload(entry.entry_id) + cs = hass.data[DOMAIN][entry.entry_id] + + await run_charge_point_test( + config_entry, + cp_id3, + ["ocpp2.1"], + lambda ws: ChargePointAllFeatures("CP_2_1_allfeatures_client", ws), + [lambda cp: _extra_features_test(hass, cs, cp)], + ) + + await remove_configuration(hass, config_entry) + + cp_id = "CP_2_noreport" + config_data = MOCK_CONFIG_DATA_3.copy() + config_data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) + config_data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "test_v201_cpid" + + config_data[CONF_PORT] = 9011 + + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=config_data, + entry_id="test_v201_cms", + title="test_v201_cms", + version=2, + minor_version=0, + ) + cs = await create_configuration(hass, config_entry) + + await run_charge_point_test( + config_entry, + cp_id, + ["ocpp2.0.1"], + lambda ws: ChargePointReportUnsupported("CP_2_noreport_client", ws), + [lambda cp: _unsupported_base_report_test(hass, cs, cp)], + ) + + cp_id2 = "CP_2_report_fail" + entry = hass.config_entries._entries.get_entries_for_domain(OCPP_DOMAIN)[0] + entry.data[CONF_CPIDS].append({cp_id2: MOCK_CONFIG_CP_APPEND.copy()}) + entry.data[CONF_CPIDS][-1][cp_id2][CONF_CPID] = "test_v201_cpid2" + # need to reload to setup sensors etc for new charger + await hass.config_entries.async_reload(entry.entry_id) + cs = hass.data[DOMAIN][entry.entry_id] + + await run_charge_point_test( + config_entry, + cp_id2, + ["ocpp2.0.1"], + lambda ws: ChargePointReportFailing("CP_2_report_fail_client", ws), + [lambda cp: _unsupported_base_report_test(hass, cs, cp)], + ) + + await remove_configuration(hass, config_entry) diff --git a/tests/test_charge_point_v201_multi.py b/tests/test_charge_point_v201_multi.py new file mode 100644 index 00000000..f5902950 --- /dev/null +++ b/tests/test_charge_point_v201_multi.py @@ -0,0 +1,372 @@ +"""Implement a test by a simulating an OCPP 2.0.1 chargepoint.""" + +import asyncio +from datetime import datetime, UTC + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.ocpp.const import CONF_CPIDS, CONF_CPID +from custom_components.ocpp.const import ( + DOMAIN as OCPP_DOMAIN, +) +from custom_components.ocpp.enums import ( + HAChargerServices as csvcs, +) +import ocpp +from ocpp.routing import on +import ocpp.exceptions +from ocpp.v201 import ChargePoint as cpclass, call, call_result +from ocpp.v201.enums import ( + Action, + BootReasonEnumType, + ChangeAvailabilityStatusEnumType, + ChargingStateEnumType, + ConnectorStatusEnumType, + DataEnumType, + GenericDeviceModelStatusEnumType, + MutabilityEnumType, + OperationalStatusEnumType, + RegistrationStatusEnumType, + ReportBaseEnumType, + SetVariableStatusEnumType, + TransactionEventEnumType, + TriggerMessageStatusEnumType, + TriggerReasonEnumType, + UpdateFirmwareStatusEnumType, +) +from ocpp.v201.datatypes import ( + ComponentType, + EVSEType, + VariableType, + VariableAttributeType, + VariableCharacteristicsType, + ReportDataType, + SetVariableResultType, +) + +from .const import MOCK_CONFIG_DATA, MOCK_CONFIG_CP_APPEND + +from .charge_point_test import ( + create_configuration, + run_charge_point_test, + remove_configuration, + wait_ready, +) + + +class MultiConnectorChargePoint(cpclass): + """Minimal OCPP 2.0.1 client that reports 2 EVSE (1: two connectors, 2: one).""" + + def __init__(self, cp_id, ws): + """Initialize.""" + super().__init__(cp_id, ws) + self.inventory_done = asyncio.Event() + self.last_start_evse_id = None + self._tasks: set[asyncio.Task] = set() + + @on(Action.get_base_report) + async def on_get_base_report(self, request_id: int, report_base: str, **kwargs): + """Get base report.""" + assert report_base in (ReportBaseEnumType.full_inventory, "FullInventory") + task = asyncio.create_task(self._send_full_inventory(request_id)) + self._tasks.add(task) + return call_result.GetBaseReport( + GenericDeviceModelStatusEnumType.accepted.value + ) + + @on(Action.trigger_message) + async def on_trigger_message(self, requested_message: str, **kwargs): + """Handle trigger message.""" + self.last_trigger = (requested_message, kwargs.get("evse")) + return call_result.TriggerMessage(TriggerMessageStatusEnumType.accepted.value) + + @on(Action.update_firmware) + async def on_update_firmware(self, request_id: int, firmware: dict, **kwargs): + """Handle update firmware.""" + return call_result.UpdateFirmware(UpdateFirmwareStatusEnumType.rejected.value) + + @on(Action.set_variables) + async def on_set_variables(self, set_variable_data: list[dict], **kwargs): + """Handle SetVariables.""" + results: list[SetVariableResultType] = [] + for item in set_variable_data: + comp = item.get("component", {}) + var = item.get("variable", {}) + results.append( + SetVariableResultType( + SetVariableStatusEnumType.accepted, + ComponentType( + comp.get("name"), + instance=comp.get("instance"), + evse=comp.get("evse"), + ), + VariableType( + var.get("name"), + instance=var.get("instance"), + ), + ) + ) + return call_result.SetVariables(results) + + async def _send_full_inventory(self, request_id: int): + ts = datetime.now(UTC).isoformat() + report_data = [ + # EVSE 1 + ReportDataType( + ComponentType("EVSE", evse=EVSEType(1)), + VariableType("Status"), + [VariableAttributeType(value="OK")], + ), + # EVSE 2 + ReportDataType( + ComponentType("EVSE", evse=EVSEType(2)), + VariableType("Status"), + [VariableAttributeType(value="OK")], + ), + # Connector(1,1) + ReportDataType( + ComponentType("Connector", evse=EVSEType(1, connector_id=1)), + VariableType("Enabled"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # Connector(1,2) + ReportDataType( + ComponentType("Connector", evse=EVSEType(1, connector_id=2)), + VariableType("Enabled"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # Connector(2,1) + ReportDataType( + ComponentType("Connector", evse=EVSEType(2, connector_id=1)), + VariableType("Enabled"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # SmartChargingCtrlr.Available + ReportDataType( + ComponentType("SmartChargingCtrlr"), + VariableType("Available"), + [ + VariableAttributeType( + value="true", mutability=MutabilityEnumType.read_only + ) + ], + ), + # SampledDataCtrlr.TxUpdatedMeasurands + ReportDataType( + ComponentType("SampledDataCtrlr"), + VariableType("TxUpdatedMeasurands"), + [VariableAttributeType(value="", persistent=True)], + VariableCharacteristicsType( + DataEnumType.member_list, + False, + values_list="Energy.Active.Import.Register,Current.Import,Voltage", + ), + ), + ] + + req = call.NotifyReport( + request_id=request_id, + generated_at=ts, + seq_no=0, + report_data=report_data, + tbc=False, + ) + await self.call(req) + self.inventory_done.set() + + @on(Action.change_availability) + async def on_change_availability(self, operational_status: str, **kwargs): + """Handle change availability.""" + self.operative = operational_status == OperationalStatusEnumType.operative.value + return call_result.ChangeAvailability( + ChangeAvailabilityStatusEnumType.accepted.value + ) + + @on(Action.request_start_transaction) + async def on_request_start_transaction( + self, evse_id: int, id_token: dict, **kwargs + ): + """Handle request for start transaction.""" + self.last_start_evse_id = evse_id + return call_result.RequestStartTransaction(status="Accepted") + + @on(Action.request_stop_transaction) + async def on_request_stop_transaction(self, transaction_id: str, **kwargs): + """Handle request for stop transaction.""" + return call_result.RequestStopTransaction(status="Accepted") + + async def send_status( + self, evse_id: int, connector_id: int, status: ConnectorStatusEnumType + ): + """Send status.""" + await self.call( + call.StatusNotification( + timestamp=datetime.now(UTC).isoformat(), + connector_status=status.value, + evse_id=evse_id, + connector_id=connector_id, + ) + ) + + async def send_tx_started_eair_wh( + self, evse_id: int, connector_id: int, tx_id: str, eair_wh: int + ): + """Send EAIR on transaction started.""" + await self.call( + call.TransactionEvent( + event_type=TransactionEventEnumType.started, + timestamp=datetime.now(UTC).isoformat(), + trigger_reason=TriggerReasonEnumType.authorized, + seq_no=0, + transaction_info={ + "transaction_id": tx_id, + "charging_state": ChargingStateEnumType.charging, + }, + evse={"id": evse_id, "connector_id": connector_id}, + meter_value=[ + { + "timestamp": datetime.now(UTC).isoformat(), + "sampled_value": [ + { + "measurand": "Energy.Active.Import.Register", + "value": eair_wh, + "unit_of_measure": {"unit": "Wh"}, + } + ], + } + ], + ) + ) + + async def send_tx_updated_eair_wh( + self, evse_id: int, connector_id: int, tx_id: str, eair_wh: int + ): + """Send EAIR on transaction updated.""" + await self.call( + call.TransactionEvent( + event_type=TransactionEventEnumType.updated, + timestamp=datetime.now(UTC).isoformat(), + trigger_reason=TriggerReasonEnumType.meter_value_periodic, + seq_no=1, + transaction_info={ + "transaction_id": tx_id, + "charging_state": ChargingStateEnumType.charging, + }, + evse={"id": evse_id, "connector_id": connector_id}, + meter_value=[ + { + "timestamp": datetime.now(UTC).isoformat(), + "sampled_value": [ + { + "measurand": "Energy.Active.Import.Register", + "value": eair_wh, + "unit_of_measure": {"unit": "Wh"}, + } + ], + } + ], + ) + ) + + +@pytest.mark.timeout(150) +async def test_v201_multi_connectors_per_evse(hass, socket_enabled): + """Test multi connector per EVSE functionality.""" + cp_id = "CP_v201_multi" + + config_data = MOCK_CONFIG_DATA.copy() + config_data[CONF_CPIDS].append({cp_id: MOCK_CONFIG_CP_APPEND.copy()}) + config_data[CONF_CPIDS][-1][cp_id][CONF_CPID] = "test_v201_cpid" + + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=config_data, + entry_id="test_v201_multi", + title="test_v201_multi", + version=2, + minor_version=0, + ) + + cs = await create_configuration(hass, config_entry) + ocpp.messages.ASYNC_VALIDATION = False + + async def _scenario(hass, cs, cp: MultiConnectorChargePoint): + boot = await cp.call( + call.BootNotification( + { + "serial_number": "SERIAL", + "model": "MODEL", + "vendor_name": "VENDOR", + "firmware_version": "VERSION", + }, + BootReasonEnumType.power_up.value, + ) + ) + assert boot.status == RegistrationStatusEnumType.accepted.value + + await wait_ready(cs.charge_points["CP_v201_multi"]) + + await asyncio.wait_for(cp.inventory_done.wait(), timeout=5) + + cp_srv = cs.charge_points["CP_v201_multi"] + for _ in range(50): + if getattr(cp_srv, "num_connectors", 0) == 3: + break + await asyncio.sleep(0.1) + assert cp_srv.num_connectors == 3 + + cpid = cp_srv.settings.cpid + + await cp.send_status(1, 1, ConnectorStatusEnumType.available) + await cp.send_status(1, 2, ConnectorStatusEnumType.occupied) + await cp.send_status(2, 1, ConnectorStatusEnumType.unavailable) + await asyncio.sleep(0.05) + + assert cs.get_metric(cpid, "Status.Connector", connector_id=1) == "Available" + assert cs.get_metric(cpid, "Status.Connector", connector_id=2) == "Occupied" + assert cs.get_metric(cpid, "Status.Connector", connector_id=3) == "Unavailable" + + await cp.send_tx_started_eair_wh(1, 2, "TX-1", 10_000) + await cp.send_tx_updated_eair_wh(1, 2, "TX-1", 10_500) + await asyncio.sleep(0.05) + + assert cs.get_metric( + cpid, "Energy.Active.Import.Register", connector_id=2 + ) == pytest.approx(10.5) + assert cs.get_metric(cpid, "Energy.Session", connector_id=2) == pytest.approx( + 0.5 + ) + assert ( + cs.get_unit(cpid, "Energy.Active.Import.Register", connector_id=2) == "kWh" + ) + assert cs.get_unit(cpid, "Energy.Session", connector_id=2) == "kWh" + + ok = await cs.set_charger_state( + cpid, csvcs.service_charge_start.name, True, connector_id=3 + ) + assert ok is True + await asyncio.sleep(0.05) + assert cp.last_start_evse_id == 2 + + await run_charge_point_test( + config_entry, + "CP_v201_multi", + ["ocpp2.0.1"], + lambda ws: MultiConnectorChargePoint("CP_v201_multi_client", ws), + [lambda cp: _scenario(hass, cs, cp)], + ) + + await remove_configuration(hass, config_entry) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 62b42a6f..c27539b5 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,16 +1,26 @@ """Test ocpp config flow.""" + from unittest.mock import patch +from pytest_homeassistant_custom_component.common import MockConfigEntry from homeassistant import config_entries, data_entry_flow +from homeassistant.data_entry_flow import InvalidData import pytest -from custom_components.ocpp.const import ( # BINARY_SENSOR,; PLATFORMS,; SENSOR,; SWITCH, +from custom_components.ocpp.const import ( + CONF_NUM_CONNECTORS, + DEFAULT_NUM_CONNECTORS, DOMAIN, ) -from .const import MOCK_CONFIG, MOCK_CONFIG_2, MOCK_CONFIG_DATA - -# from pytest_homeassistant_custom_component.common import MockConfigEntry +from .const import ( + MOCK_CONFIG_CS, + MOCK_CONFIG_CP, + MOCK_CONFIG_FLOW, + CONF_CPIDS, + CONF_MONITORED_VARIABLES_AUTOCONFIG, + DEFAULT_MONITORED_VARIABLES, +) # This fixture bypasses the actual setup of the integration @@ -19,9 +29,15 @@ @pytest.fixture(autouse=True) def bypass_setup_fixture(): """Prevent setup.""" - with patch("custom_components.ocpp.async_setup", return_value=True,), patch( - "custom_components.ocpp.async_setup_entry", - return_value=True, + with ( + patch( + "custom_components.ocpp.async_setup", + return_value=True, + ), + patch( + "custom_components.ocpp.async_setup_entry", + return_value=True, + ), ): yield @@ -37,54 +53,162 @@ async def test_successful_config_flow(hass, bypass_get_data): ) # Check that the config flow shows the user form as the first step - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - # If a user were to enter `test_username` for username and `test_password` - # for password, it would result in this function call + # Remove cpids key as it gets added in flow + config = MOCK_CONFIG_CS.copy() + config.pop(CONF_CPIDS) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_CONFIG + result["flow_id"], user_input=config + ) + + # Check that the config flow is complete and a new entry is created with + # the input data + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "test_csid_flow" + assert result["data"] == MOCK_CONFIG_CS + assert result["result"] + + +async def test_successful_discovery_flow(hass, bypass_get_data): + """Test a discovery config flow.""" + # Mock the config flow for the central system + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_CS, + entry_id="test_cms_disc", + title="test_cms_disc", + version=2, + minor_version=0, + ) + # Need to ensure data entry exists as skipped init.py setup + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] + info = {"cp_id": "test_cp_id", "entry": entry} + # data here is discovery_info not user_input + result_disc = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=info, ) # Check that the config flow shows the user form as the first step - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "measurands" + assert result_disc["type"] == data_entry_flow.FlowResultType.FORM + assert result_disc["step_id"] == "cp_user" + result_disc["discovery_info"] = info - # Call again for step_id == "measurands" with default measurand - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_CONFIG_2 + # Switch to manual measurand selection to test full flow + cp_input = MOCK_CONFIG_CP.copy() + cp_input[CONF_MONITORED_VARIABLES_AUTOCONFIG] = False + result_cp = await hass.config_entries.flow.async_configure( + result_disc["flow_id"], user_input=cp_input + ) + + measurand_input = dict.fromkeys(DEFAULT_MONITORED_VARIABLES.split(","), True) + result_meas = await hass.config_entries.flow.async_configure( + result_cp["flow_id"], user_input=measurand_input ) # Check that the config flow is complete and a new entry is created with # the input data - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "test_csid" - assert result["data"] == MOCK_CONFIG_DATA - assert result["result"] + flow_output = MOCK_CONFIG_FLOW.copy() + flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_MONITORED_VARIABLES_AUTOCONFIG] = ( + False + ) + flow_output[CONF_CPIDS][-1]["test_cp_id"][CONF_NUM_CONNECTORS] = ( + DEFAULT_NUM_CONNECTORS + ) + + assert result_meas["type"] == data_entry_flow.FlowResultType.ABORT + entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] + assert entry.data == flow_output + + # Test different CP IDs are allowed + info2 = {"cp_id": "different_cp_id", "entry": entry} + result2_disc = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=info2, + ) + # Check that the config flow shows the user form as the first step + assert result2_disc["type"] == data_entry_flow.FlowResultType.FORM + assert result2_disc["step_id"] == "cp_user" + result2_disc["discovery_info"] = info2 + + cp2_input = MOCK_CONFIG_CP.copy() + result_cp2 = await hass.config_entries.flow.async_configure( + result2_disc["flow_id"], user_input=cp2_input + ) + + assert result_cp2["type"] == data_entry_flow.FlowResultType.ABORT + # Check there are 2 cpid entries + assert len(entry.data[CONF_CPIDS]) == 2 + + +async def test_duplicate_cpid_discovery_flow(hass, bypass_get_data): + """Test discovery flow with duplicate CP ID.""" + # Setup first charger + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_CS, + entry_id="test_cms_disc", + title="test_cms_disc", + version=2, + ) + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Try to add same CP ID twice + entry = hass.config_entries._entries.get_entries_for_domain(DOMAIN)[0] + info = {"cp_id": "test_cp_id", "entry": entry} + + # First discovery should succeed + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=info, + ) + assert result1["type"] == data_entry_flow.FlowResultType.FORM + + # Second discovery with same CP ID should abort + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=info, + ) + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "already_in_progress" + + +async def test_failed_config_flow(hass, error_on_get_data): + """Test failed config flow scenarios.""" + # Test invalid central system configuration + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + # Test with invalid input data, includes cpids + invalid_config = MOCK_CONFIG_CS.copy() + + with pytest.raises(InvalidData): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=invalid_config + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM -# In this case, we want to simulate a failure during the config flow. -# We use the `error_on_get_data` mock instead of `bypass_get_data` -# (note the function parameters) to raise an Exception during -# validation of the input config. -# async def test_failed_config_flow(hass, error_on_get_data): -# """Test a failed config flow due to credential validation failure.""" -# -# result = await hass.config_entries.flow.async_init( -# DOMAIN, context={"source": config_entries.SOURCE_USER} -# ) -# -# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM -# assert result["step_id"] == "user" -# -# result = await hass.config_entries.flow.async_configure( -# result["flow_id"], user_input=MOCK_CONFIG -# ) -# -# assert result["type"] == data_entry_flow.RESULT_TYPE_FORM -# assert result["errors"] == {"base": "auth"} -# -# # # Our config flow also has an options flow, so we must test it as well. # async def test_options_flow(hass): # """Test an options flow.""" diff --git a/tests/test_connector_aware_metrics.py b/tests/test_connector_aware_metrics.py new file mode 100644 index 00000000..e39cebfd --- /dev/null +++ b/tests/test_connector_aware_metrics.py @@ -0,0 +1,258 @@ +"""Test connector-aware metrics handling.""" + +import pytest + +from custom_components.ocpp.chargepoint import _ConnectorAwareMetrics, Metric + + +def M(v=None, unit=None): + """Help to create a Metric with a value and a None timestamp.""" + return Metric(v, unit) + + +def test_flat_set_get_contains_len_iter(): + """Flat access (connector 0) should work and be independent from per-connector.""" + m = _ConnectorAwareMetrics() + + # Flat = connector 0 + m["Power.Active.Import"] = M(1.5) + assert "Power.Active.Import" in m + assert len(m) == 1 + assert list(iter(m)) == ["Power.Active.Import"] + assert m["Power.Active.Import"].value == 1.5 + + # Per-connector should not affect flat view length/keys + m[(2, "Power.Active.Import")] = M(7.0) + assert len(m) == 1 # len() reports flat/conn-0 size only + assert "Power.Active.Import" in m + assert m[(2, "Power.Active.Import")].value == 7.0 + assert m["Power.Active.Import"].value == 1.5 + + +def test_get_whole_connector_mapping_and_assign_full_mapping(): + """Accessing m[conn_id] returns the dict for that connector; setting dict replaces it.""" + m = _ConnectorAwareMetrics() + + # When first accessed, connector dict exists (created by defaultdict) + conn1_map = m[1] + assert isinstance(conn1_map, dict) + assert conn1_map == {} + + # Replace entire mapping for connector 1 + m[1] = {"Voltage": M(229.9), "Current.Import": M(6.0)} + assert m[(1, "Voltage")].value == 229.9 + assert m[(1, "Current.Import")].value == 6.0 + + # Flat (connector 0) remains independent — do NOT access m["Voltage"] (would create) + assert "Voltage" not in m + assert "Current.Import" not in m + assert len(m) == 0 + + +def test_delete_per_connector_and_flat(): + """Deleting per-connector keys must not touch flat; deleting flat must not touch others.""" + m = _ConnectorAwareMetrics() + + m["A"] = M(10) # flat + m[(2, "A")] = M(20) # connector 2 + + # Delete per-connector key (check via the connector dict, not tuple get) + del m[(2, "A")] + assert "A" not in m[2] # still present flat + assert m["A"].value == 10 + + # Delete flat key (verify flat keys(), avoid m["A"] which would recreate) + del m["A"] + assert "A" not in m + + # Recreate per-connector and verify it stays independent + m[(2, "A")] = M(99) + assert m[(2, "A")].value == 99 + assert "A" not in m # flat untouched + + +def test_keys_values_items_are_flat_only(): + """keys()/values()/items() only reflect connector 0 (flat) mapping.""" + m = _ConnectorAwareMetrics() + m["k0"] = M(0) + m[(1, "k1")] = M(1) + m[(2, "k2")] = M(2) + + assert list(m.keys()) == ["k0"] + assert [v.value for v in m.values()] == [0] + items = list(m.items()) + assert items == [("k0", m["k0"])] + + +def test_type_checks_on_setitem(): + """__setitem__ must enforce types for flat, per-connector tuple, and connector dict.""" + m = _ConnectorAwareMetrics() + + # Flat must be Metric + with pytest.raises(TypeError): + m["foo"] = 123 # not a Metric + + # Per-connector must be Metric + with pytest.raises(TypeError): + m[(1, "foo")] = 123 # not a Metric + + # Connector mapping must be dict[str, Metric] + with pytest.raises(TypeError): + m[1] = 123 + + # Correct types should pass + m["ok_flat"] = M(1) + m[(1, "ok_pc")] = M(2) + m[2] = {"ok_map": M(3)} + + assert m["ok_flat"].value == 1 + assert m[(1, "ok_pc")].value == 2 + assert m[(2, "ok_map")].value == 3 + + +def test_clear_and_contains_tuple_semantics(): + """clear() empties everything; __contains__ tuple logic works without creating entries.""" + m = _ConnectorAwareMetrics() + m["flat"] = M(1) + m[(1, "pc")] = M(2) + + # Membership checks + assert "flat" in m + assert "pc" in m[1] # check in the connector dict + + m.clear() + + # After clear, no flat keys and no connector dicts + assert len(list(m.keys())) == 0 + + +def test_get_variants_and_contains_behavior(): + """Exercise get() for flat keys, per-connector keys, connector dicts, and defaults.""" + m = _ConnectorAwareMetrics() + + # Seed some values + m["Voltage"] = M(230.0, "V") # flat (connector 0) + m[(2, "Voltage")] = M(231.0, "V") # connector 2 + + # 1) get() existing flat key + got = m.get("Voltage") + assert isinstance(got, Metric) + assert got.value == 230.0 + assert "Voltage" in m + + # 2) get() missing flat key -> default is returned + default_metric = M(0.0, "V") + got_default = m.get("Nope", default_metric) + assert isinstance(got_default, Metric) + assert got_default is default_metric + assert got_default.value == 0.0 + assert "V" not in m + + # 3) get() existing tuple key + got_c2 = m.get((2, "Voltage")) + assert isinstance(got_c2, Metric) + assert got_c2.value == 231.0 + assert 2 in m + + # 4) get() missing tuple key -> also inserts default, not the missing key + missing_default = M(7.0) + got_missing = m.get((2, "Missing"), missing_default) + assert isinstance(got_missing, Metric) + assert got_missing is missing_default + assert got_missing.value == 7.0 + assert 2 in m and "Missing" not in m[2] + + +def test_delitem_all_paths_and_errors(): + """Cover deletion of flat keys, per-connector keys, and entire connector maps.""" + m = _ConnectorAwareMetrics() + + # Seed: + m["Voltage"] = M(230.0, "V") # flat (connector 0) + m[(2, "Voltage")] = M(231.0, "V") + m[(2, "Current.Import")] = M(6.0, "A") + + # A) Delete a flat key + del m["Voltage"] + assert "Voltage" not in m + # Accessing again returns a fresh Metric(None, None) due to defaultdict + after_del_flat = m["Voltage"] + assert isinstance(after_del_flat, Metric) + assert after_del_flat.value is None + + # B) Delete a (conn, meas) key + del m[(2, "Current.Import")] + assert "Current.Import" not in m[2] + # Accessing again creates a fresh Metric(None, None) + after_del_tuple = m[(2, "Current.Import")] + assert isinstance(after_del_tuple, Metric) + assert after_del_tuple.value is None + # Remaining key for connector 2 is still there + assert "Voltage" in m[2] + + # C) Delete an entire connector map + del m[2] + assert 2 not in m + # Accessing m[2] recreates an empty mapping via top-level defaultdict + recreated = m[2] + assert isinstance(recreated, dict) + assert 2 in m and recreated == {} + + # D) Deleting a missing flat key raises KeyError and does not create it + with pytest.raises(KeyError): + del m["DoesNotExist"] + assert "DoesNotExist" not in m + + # E) Deleting a missing (conn, meas) raises KeyError, but creates the connector id + with pytest.raises(KeyError): + del m[(3, "NoSuchMeas")] + assert 3 in m and m[3] == {} + + # F) Deleting a non-existent connector id raises KeyError and does not create it + with pytest.raises(KeyError): + del m[42] + assert 42 not in m + + +def test_get_returns_default_when_inner_is_plain_dict(): + """Ensure get() returns provided default if inner mapping is a plain dict that raises KeyError.""" + m = _ConnectorAwareMetrics() + + # Replace connector 1 map with a plain dict (no defaultdict semantics) + m[1] = {"Voltage": M(230.0, "V")} + + # Missing measurand under connector 1 -> __getitem__ would raise KeyError, + # so get() must return the supplied default (and not insert anything). + default_metric = M(99.0, "V") + got = m.get((1, "Missing"), default_metric) + assert got is default_metric + + # Confirm we didn't insert the missing key as a side effect + assert "Missing" not in m[1] + # And tuple __contains__ is still False + assert (1, "Missing") not in m + + +def test_contains_tuple_semantics_true_false_and_missing_connector(): + """Exercise __contains__ for (connector, measurand) tuples.""" + m = _ConnectorAwareMetrics() + + # Seed values on different connectors + m[(2, "Voltage")] = M(231.0, "V") + m[(3, "Current.Import")] = M(6.0, "A") + + # Present tuple -> True + assert (2, "Voltage") in m + assert (3, "Current.Import") in m + + # Wrong measurand on existing connector -> False + assert (2, "Current.Import") not in m + assert (3, "Voltage") not in m + + # Missing connector entirely -> False (uses .get(conn, {}) in __contains__) + assert 99 not in m # connector 99 doesn't exist yet + assert (99, "Voltage") not in m # tuple contains should be False as well + + # After creating empty map for 99 (via direct access), measurand still absent -> False + _ = m[99] # creates empty mapping for connector 99 + assert (99, "Voltage") not in m diff --git a/tests/test_init.py b/tests/test_init.py index bd2088e6..77143ed7 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,17 +1,21 @@ """Test ocpp setup process.""" + # from homeassistant.exceptions import ConfigEntryNotReady # import pytest +from collections.abc import AsyncGenerator + +from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.ocpp import ( - CentralSystem, - async_reload_entry, - async_setup_entry, - async_unload_entry, -) -from custom_components.ocpp.const import DOMAIN +from custom_components.ocpp import CentralSystem +from custom_components.ocpp.const import DOMAIN, CONF_CPID -from .const import MOCK_CONFIG_DATA +from .const import ( + MOCK_CONFIG_DATA, + MOCK_CONFIG_DATA_1, + MOCK_CONFIG_MIGRATION_FLOW, + MOCK_CONFIG_DATA_1_MC, +) # We can pass fixtures as defined in conftest.py to tell pytest to use the fixture @@ -19,27 +23,117 @@ # Home Assistant using the pytest_homeassistant_custom_component plugin. # Assertions allow you to verify that the return value of whatever is on the left # side of the assertion matches with the right side. -async def test_setup_unload_and_reload_entry(hass, bypass_get_data): +async def test_setup_unload_and_reload_entry( + hass: AsyncGenerator[HomeAssistant, None], bypass_get_data: None +): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test" + domain=DOMAIN, + data=MOCK_CONFIG_DATA_1, + entry_id="test_cms1", + title="test_cms1", + version=2, + minor_version=0, ) + config_entry.add_to_hass(hass) + await hass.async_block_till_done() # Set up the entry and assert that the values set during setup are where we expect # them to be. Because we have patched the ocppDataUpdateCoordinator.async_get_data # call, no code from custom_components/ocpp/api.py actually runs. - assert await async_setup_entry(hass, config_entry) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.data + assert config_entry.entry_id in hass.data[DOMAIN] + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem + + # Reload the entry and assert that the data from above is still there + assert await hass.config_entries.async_reload(config_entry.entry_id) assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert type(hass.data[DOMAIN][config_entry.entry_id]) == CentralSystem + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem + + # Unload the entry and verify that the data has been removed + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_setup_unload_and_reload_entry_multiple_connectors( + hass: AsyncGenerator[HomeAssistant, None], bypass_get_data: None +): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_DATA_1_MC, + entry_id="test_cms1_mc", + title="test_cms1_mc", + version=2, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + # Set up the entry and assert that the values set during setup are where we expect + # them to be. Because we have patched the ocppDataUpdateCoordinator.async_get_data + # call, no code from custom_components/ocpp/api.py actually runs. + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.data + assert config_entry.entry_id in hass.data[DOMAIN] + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem # Reload the entry and assert that the data from above is still there - assert await async_reload_entry(hass, config_entry) is None + assert await hass.config_entries.async_reload(config_entry.entry_id) assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] - assert type(hass.data[DOMAIN][config_entry.entry_id]) == CentralSystem + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem + + # Unload the entry and verify that the data has been removed + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_migration_entry( + hass: AsyncGenerator[HomeAssistant, None], bypass_get_data: None +): + """Test entry migration.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_MIGRATION_FLOW, + entry_id="test_migration", + title="test_migration", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + # Ensure cp id is present in state machine to trigger migration flow. This simulates the condition where a user has a sensor entity in HA with the cp_id as the state value, which is used to identify the entry to migrate. If this value is not present, the migration flow will not be triggered and the test will fail. + hass.states.async_set( + f"sensor.{MOCK_CONFIG_MIGRATION_FLOW[CONF_CPID].lower()}_id", + MOCK_CONFIG_MIGRATION_FLOW[CONF_CPID], + ) + + # Set up the entry and assert that the values set during setup are where we expect + # them to be. Because we have patched the ocppDataUpdateCoordinator.async_get_data + # call, no code from custom_components/ocpp/api.py actually runs. + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.data + assert config_entry.entry_id in hass.data[DOMAIN] + assert type(hass.data[DOMAIN][config_entry.entry_id]) is CentralSystem + # check migration has created new entry with correct keys + assert config_entry.data.keys() == MOCK_CONFIG_DATA.keys() + # check versions match + assert config_entry.version == 2 + assert config_entry.minor_version == 1 # Unload the entry and verify that the data has been removed - assert await async_unload_entry(hass, config_entry) + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.entry_id not in hass.data[DOMAIN] @@ -48,6 +142,7 @@ async def test_setup_unload_and_reload_entry(hass, bypass_get_data): # config_entry = MockConfigEntry( # domain=DOMAIN, data=MOCK_CONFIG_DATA, entry_id="test" # ) +# config_entry.add_to_hass(config_entry) # # # In this case we are testing the condition where async_setup_entry raises # # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates diff --git a/tests/test_more_coverage_chargepoint.py b/tests/test_more_coverage_chargepoint.py new file mode 100644 index 00000000..9e379e48 --- /dev/null +++ b/tests/test_more_coverage_chargepoint.py @@ -0,0 +1,661 @@ +"""Test additional chargepoint paths.""" + +import asyncio +import contextlib +from types import SimpleNamespace + +import pytest +import websockets +from websockets.protocol import State + + +from custom_components.ocpp.chargepoint import ChargePoint as BaseCP, MeasurandValue +from custom_components.ocpp.ocppv16 import ChargePoint as CPv16 +from custom_components.ocpp.const import DEFAULT_MEASURAND +from unittest.mock import MagicMock, AsyncMock +from ocpp.v16.enums import Measurand, Phase + +# Reuse the client helpers & fixtures from your main v16 test module. +from .test_charge_point_v16 import wait_ready, ChargePoint + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9410, "cp_id": "CP_cov_base_defaults", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_base_defaults"]) +@pytest.mark.parametrize("port", [9410]) +async def test_base_default_methods_return_values( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Covers 299, 307, 315, 413, 417, 425, 429, 433, 451–453, 455–457: base defaults & no-op behaviors.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Use the base-class implementations explicitly to cover the base lines, + # even though v16 overrides some of these. + assert ( + await BaseCP.get_number_of_connectors(srv) == srv.num_connectors + ) # L299 + assert await BaseCP.get_supported_measurands(srv) == "" # L307 + assert ( + await BaseCP.get_supported_features(srv) == 0 + ) # L315 (prof.NONE is 0) + + assert await BaseCP.set_availability(srv, True) is False # L413 + assert await BaseCP.start_transaction(srv, 1) is False # L417 + assert await BaseCP.stop_transaction(srv) is False # L425 + assert await BaseCP.reset(srv) is False # L429 + assert await BaseCP.unlock(srv, 1) is False # L433 + + assert await BaseCP.get_configuration(srv, "Foo") is None # L451–453 + assert await BaseCP.configure(srv, "Foo", "Bar") is None # L455–457 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9413, "cp_id": "CP_cov_handle_call", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_handle_call"]) +@pytest.mark.parametrize("port", [9413]) +async def test_handle_call_notimplemented_sends_call_error( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Covers 520–526: wrapper catches ocpp.exceptions.NotImplementedError and sends CallError JSON.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Patch the exact base alias used by the subclass, and raise the *OCPP* NotImplementedError. + import custom_components.ocpp.chargepoint as cp_mod + from ocpp.exceptions import NotImplementedError as OcppNotImplementedError + + async def boom(self, msg): + # Raise the OCPP exception class that the wrapper actually catches. + raise OcppNotImplementedError(details={"cause": "nyi"}) + + captured = {"payload": None} + + async def fake_send(self, payload): + # _handle_call builds a JSON string via to_json(), then calls _send(...) + captured["payload"] = ( + payload.to_json() if hasattr(payload, "to_json") else payload + ) + + # Patch on the cp alias (the base class your subclass imports as `cp`). + monkeypatch.setattr(cp_mod.cp, "_handle_call", boom, raising=True) + monkeypatch.setattr(cp_mod.cp, "_send", fake_send, raising=True) + + class Msg: + """Minimal message stub compatible with msg.create_call_error(e).""" + + def create_call_error(self, *_, **__): + from types import SimpleNamespace + + # Return an object with to_json() so wrapper turns it into a JSON string. + return SimpleNamespace(to_json=lambda: '{"error":"NotImplemented"}') + + # Invoke: the wrapper should catch the OCPP NotImplementedError and call _send with JSON. + await srv._handle_call(Msg()) + + assert captured["payload"] == '{"error":"NotImplemented"}' + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9414, "cp_id": "CP_cov_run_paths", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_run_paths"]) +@pytest.mark.parametrize("port", [9414]) +async def test_run_handles_timeout_and_other_exception( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Covers 537 and 540–541: run() swallows TimeoutError and logs other exceptions, then stops.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + stopped = {"count": 0} + + async def fake_stop(): + stopped["count"] += 1 + + monkeypatch.setattr(srv, "stop", fake_stop, raising=True) + + async def raises_timeout(): + await asyncio.sleep(0) + raise TimeoutError("simulated") + + async def raises_other(): + await asyncio.sleep(0) + raise ValueError("simulated") + + # TimeoutError path -> should be swallowed (L537) and then stop() called. + await srv.run([raises_timeout()]) + assert stopped["count"] >= 1 + + # Other exception path -> should be logged via L540–541 and then stop() called again. + await srv.run([raises_other()]) + assert stopped["count"] >= 2 + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9415, "cp_id": "CP_cov_update_early", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_update_early"]) +@pytest.mark.parametrize("port", [9415]) +async def test_update_returns_early_when_root_device_missing( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Covers 602: update() returns early if the root device cannot be found in the device registry.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Fake registries: no device returned. + import custom_components.ocpp.chargepoint as mod + + class FakeDR: + """Fake DR.""" + + def async_get_device(self, identifiers): + return None + + def async_clear_config_entry(self, config_entry_id): + return None + + @property + def devices(self): + """Fake devices.""" + return {} + + def async_update_device(self, *args, **kwargs): + return None + + def async_get_or_create(self, *args, **kwargs): + return SimpleNamespace(id="dummy") + + class FakeER: + """Fake ER.""" + + def async_clear_config_entry(self, config_entry_id): + return None + + def fake_entries_for_device(_er, _dev_id): + # No entities to update; the loop is exercised anyway. + return [] + + # Patch HA helpers & dispatcher. + monkeypatch.setattr( + mod.device_registry, "async_get", lambda _: FakeDR(), raising=True + ) + monkeypatch.setattr( + mod.entity_registry, "async_get", lambda _: FakeER(), raising=True + ) + + monkeypatch.setattr( + mod.entity_registry, + "async_entries_for_device", + fake_entries_for_device, + raising=True, + ) + monkeypatch.setattr( + mod, "async_dispatcher_send", lambda *args, **kw: None, raising=True + ) + + # Should exit early without error (L602). + await srv.update(srv.settings.cpid) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +@pytest.mark.parametrize( + "setup_config_entry", + [{"port": 9416, "cp_id": "CP_cov_update_walk", "cms": "cms_services"}], + indirect=True, +) +@pytest.mark.parametrize("cp_id", ["CP_cov_update_walk"]) +@pytest.mark.parametrize("port", [9416]) +async def test_update_traverses_children_and_skips_visited( + hass, socket_enabled, cp_id, port, setup_config_entry, monkeypatch +): + """Covers 612 and 623–624: skips already visited IDs and appends children discovered via via_device_id.""" + cs = setup_config_entry + async with websockets.connect( + f"ws://127.0.0.1:{port}/{cp_id}", subprotocols=["ocpp1.6"] + ) as ws: + client = ChargePoint(f"{cp_id}_client", ws) + task = asyncio.create_task(client.start()) + try: + await client.send_boot_notification() + await wait_ready(cs.charge_points[cp_id]) + srv = cs.charge_points[cp_id] + + # Build a tiny fake device graph: + # root -> child (twice in the values() list to create a duplicate push) + import custom_components.ocpp.chargepoint as mod + + class Dev: + """Fake Dev.""" + + def __init__(self, id, via=None): + self.id = id + self.via_device_id = via + + root = Dev("root", via=None) + child = Dev("child", via="root") + + class FakeDR: + """Fake DR.""" + + def async_clear_config_entry(self, config_entry_id): + return None + + def async_update_device(self, *args, **kwargs): + return None + + def async_get_or_create(self, *args, **kwargs): + return SimpleNamespace(id="dummy") + + def async_get_device(self, identifiers): + return root + + @property + def devices(self): + # Duplicate the child to force the same ID to be appended twice -> will hit continue (L612) + class Container: + def values(self_inner): + return [root, child, child] + + return Container() + + class FakeER: + """Fake ER.""" + + def async_clear_config_entry(self, config_entry_id): + return None + + def fake_entries_for_device(_er, _dev_id): + # No entities to update; the loop is exercised anyway. + return [] + + # Patch HA helpers & dispatcher. + monkeypatch.setattr( + mod.device_registry, "async_get", lambda _: FakeDR(), raising=True + ) + monkeypatch.setattr( + mod.entity_registry, "async_get", lambda _: FakeER(), raising=True + ) + monkeypatch.setattr( + mod.entity_registry, + "async_entries_for_device", + fake_entries_for_device, + raising=True, + ) + monkeypatch.setattr( + mod, "async_dispatcher_send", lambda *args, **kw: None, raising=True + ) + + # No exceptions expected; internal traversal will append 'child' twice, + # so on second pop it will be in 'visited' and trigger L612 'continue'. + await srv.update(srv.settings.cpid) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + await ws.close() + + +@pytest.mark.timeout(5) +async def test_process_measurands_defaults_and_session_energy_v2x(hass, monkeypatch): + """Covers 830–832, 836, 881–887: default EAIR measurand/unit and Energy.Session handling for 2.x.""" + # Minimal CP instance not bound to a real socket. + version = SimpleNamespace(value="2.0.1") + fake_hass = SimpleNamespace( + async_create_task=lambda c: asyncio.create_task(c), + helpers=SimpleNamespace( + entity_component=SimpleNamespace(async_update_entity=lambda eid: None) + ), + ) + fake_entry = SimpleNamespace(entry_id="dummy") + fake_central = SimpleNamespace( + websocket_ping_interval=0, + websocket_ping_timeout=0, + websocket_ping_tries=0, + ) + fake_settings = SimpleNamespace(cpid="cpid_dummy") + fake_conn = SimpleNamespace(state=State.CLOSED) + + srv = BaseCP( + "cp_dummy", + fake_conn, + version, + fake_hass, + fake_entry, + fake_central, + fake_settings, + ) + srv._ocpp_version = "2.0.1" # ensure 2.x path + + # 1) Missing measurand -> defaults to EAIR; missing unit -> defaults to Wh then normalized to kWh. + samples1 = [[MeasurandValue(None, 12345.0, None, None, None, None)]] + srv.process_measurands( + samples1, is_transaction=True, connector_id=1 + ) # <-- no await + + eair = srv._metrics[(1, "Energy.Active.Import.Register")] + assert eair.unit == "kWh" + assert pytest.approx(eair.value, rel=1e-6) == 12.345 + + esess = srv._metrics[(1, "Energy.Session")] + assert esess.unit == "kWh" + assert (esess.value or 0.0) == 0.0 + + # 2) Next periodic EAIR sample increases by 100 Wh -> session delta = 0.1 kWh. + samples2 = [[MeasurandValue(None, 12445.0, None, None, None, None)]] + srv.process_measurands( + samples2, is_transaction=True, connector_id=1 + ) # <-- no await + + eair2 = srv._metrics[(1, "Energy.Active.Import.Register")] + esess2 = srv._metrics[(1, "Energy.Session")] + assert pytest.approx(eair2.value, rel=1e-6) == 12.445 + assert pytest.approx(esess2.value or 0.0, rel=1e-6) == 0.1 + + +@pytest.mark.timeout(5) +async def test_session_and_lifetime_eair_distinction(hass): + """Ensure correct EAIR handling for session vs lifetime readings. + + When the charger reports session energy directly in EAIR: + - an in-transaction EAIR reading (5000 Wh => 5.0 kWh) updates Energy.Session but NOT the lifetime EAIR metric, + - a separate non-transaction EAIR reading (123450 Wh => 123.45 kWh) updates the lifetime EAIR metric. + + When the charger does NOT report session energy in EAIR: + - both an in-transaction EAIR reading and a non-transaction EAIR reading write the lifetime EAIR metric. + - a single-phase EAIR reading tagged with "L1" has its phase stripped to derive session energy correctly. + """ + + # ------ First scenario: charger reports session energy ------ + # Minimal CP instance not bound to a real socket. + version = SimpleNamespace(value="1.6") + fake_hass = SimpleNamespace( + async_create_task=lambda c: asyncio.create_task(c), + helpers=SimpleNamespace( + entity_component=SimpleNamespace(async_update_entity=lambda eid: None) + ), + ) + fake_entry = SimpleNamespace(entry_id="dummy") + fake_central = SimpleNamespace( + websocket_ping_interval=0, + websocket_ping_timeout=0, + websocket_ping_tries=0, + ) + fake_settings = SimpleNamespace(cpid="cpid_dummy") + fake_conn = SimpleNamespace(state=State.CLOSED) + + srv = BaseCP( + "cp_dummy", + fake_conn, + version, + fake_hass, + fake_entry, + fake_central, + fake_settings, + ) + srv._ocpp_version = "1.6" + + # Simulate charger that reports session energy directly (meter_start == 0). + srv._charger_reports_session_energy = True + + # 1) In-transaction EAIR reading (5000 Wh -> 5.0 kWh). + tx_samples = [[MeasurandValue(None, 5000.0, None, None, None, None)]] + srv.process_measurands(tx_samples, is_transaction=True, connector_id=1) + + # Session must be updated to 5.0 kWh, main EAIR must NOT be overwritten by this call. + sess = srv._metrics[(1, "Energy.Session")] + main_after_tx = srv._metrics[(1, "Energy.Active.Import.Register")].value + assert pytest.approx(sess.value, rel=1e-6) == 5.0 + # main might be None or unchanged; ensure it was NOT set to 5.0 by the tx call + assert pytest.approx(main_after_tx, rel=1e-6) != 5.0 + + # 2) Non-transaction EAIR reading representing lifetime meter (123450 Wh -> 123.45 kWh). + life_samples = [[MeasurandValue(None, 123450.0, None, None, None, None)]] + srv.process_measurands(life_samples, is_transaction=False, connector_id=1) + + # Now the lifetime EAIR metric must equal 123.45 kWh, and session must still be 5.0 kWh. + main = srv._metrics[(1, "Energy.Active.Import.Register")] + sess = srv._metrics[(1, "Energy.Session")] + + assert pytest.approx(main.value, rel=1e-6) == 123.45 + assert main.unit == "kWh" + assert pytest.approx(sess.value, rel=1e-6) == 5.0 + assert sess.unit == "kWh" + + # ------ Second scenario: charger does NOT report session energy ------ + # New CP instance (clean metrics) to test the other branch. + srv2 = BaseCP( + "cp_dummy2", + fake_conn, + version, + fake_hass, + fake_entry, + fake_central, + fake_settings, + ) + srv2._ocpp_version = "1.6" + srv2._charger_reports_session_energy = False + + # 1) In-transaction EAIR reading (5000 Wh -> 5.0 kWh) should write lifetime EAIR metric. + tx_samples2 = [[MeasurandValue(None, 5000.0, None, None, None, None)]] + srv2.process_measurands(tx_samples2, is_transaction=True, connector_id=1) + + main_after_tx2 = srv2._metrics[(1, "Energy.Active.Import.Register")].value + # Because charger does NOT report session energy, main should be written with 5.0 kWh. + assert pytest.approx(main_after_tx2, rel=1e-6) == 5.0 + + # 2) Non-transaction EAIR reading representing lifetime meter (123450 Wh -> 123.45 kWh). + life_samples2 = [[MeasurandValue(None, 123450.0, None, None, None, None)]] + srv2.process_measurands(life_samples2, is_transaction=False, connector_id=1) + + main_after_life2 = srv2._metrics[(1, "Energy.Active.Import.Register")].value + # Lifetime EAIR should be updated to 123.45 kWh. + assert pytest.approx(main_after_life2, rel=1e-6) == 123.45 + + # ------ Third scenario: single-phase charger sending L1 tag on main EAIR ------ + # New CP instance to test the L1 stripping logic. + srv3 = BaseCP( + "cp_dummy3", + fake_conn, + version, + fake_hass, + fake_entry, + fake_central, + fake_settings, + ) + srv3._ocpp_version = "1.6" + srv3._charger_reports_session_energy = False + + # 1) Set baseline meter_start (Car plugged in at 10.0 kWh) + # The correct string key is "Energy.Meter.Start" + srv3._metrics[(1, "Energy.Meter.Start")].value = 10.0 + srv3._metrics[(1, "Energy.Meter.Start")].unit = "kWh" + + # 2) Send in-transaction EAIR reading with an isolated L1 phase (10500 Wh -> 10.5 kWh) + l1_samples = [ + [ + MeasurandValue( + measurand="Energy.Active.Import.Register", + value=10500.0, + phase="L1", + unit="Wh", + context=None, + location=None, + ) + ] + ] + srv3.process_measurands(l1_samples, is_transaction=True, connector_id=1) + + # 3) Lifetime EAIR should update to 10.5 kWh + main_l1 = srv3._metrics[(1, "Energy.Active.Import.Register")] + assert pytest.approx(main_l1.value, rel=1e-6) == 10.5 + + # 4) Session Energy should derive to 0.5 kWh (10.5 - 10.0) + sess_l1 = srv3._metrics[(1, "Energy.Session")] + assert sess_l1.value == pytest.approx(0.5, rel=1e-6) + + +@pytest.mark.asyncio +async def test_process_phases_neutral_shield(): + """Test that isolated Neutral (N) phases do not overwrite main sensors with 0.0.""" + from custom_components.ocpp.chargepoint import ChargePoint as BaseCP, MeasurandValue + from unittest.mock import MagicMock + + # 1. Setup Mock ChargePoint + cp = MagicMock(spec=BaseCP) + + class MockMetric: + def __init__(self): + self.value = None + self.unit = None + self.extra_attr = {} + + # Pre-populate the main Voltage bucket + cp._metrics = {(1, Measurand.voltage.value): MockMetric()} + + # 2. PASS 1: The Valid L1-N Payload (Positional format: measurand, value, phase, unit, context, location) + payload_l1n = [ + MeasurandValue( + Measurand.voltage.value, + 241.5, + Phase.l1_n.value, + "V", + None, + None, + ) + ] + BaseCP.process_phases(cp, payload_l1n, connector_id=1) + + assert cp._metrics[(1, Measurand.voltage.value)].value == 241.5 + + # 3. PASS 2: The Isolated "N" Payload (The Saboteur) + payload_n = [ + MeasurandValue( + Measurand.voltage.value, + 2.1, + Phase.n.value, + "V", + None, + None, + ) + ] + BaseCP.process_phases(cp, payload_n, connector_id=1) + + # Shield should protect the 241.5 value! + assert cp._metrics[(1, Measurand.voltage.value)].value == 241.5 + + +@pytest.mark.timeout(5) +async def test_ocppv16_clean_measurands_logic(): + """Test that get_supported_measurands strips illegal phases and drops garbage.""" + + # 1. Setup Mock v1.6 ChargePoint + cp = MagicMock(spec=CPv16) + cp.id = "test_charger" + cp.settings = MagicMock() + # Force the non-autodetect path to keep the test fast and isolated + cp.settings.monitored_variables = "" + cp.settings.monitored_variables_autoconfig = False + cp.configure = AsyncMock() + + # Scenario A: Real-world messy data from a broken firmware charger + dirty_string = ( + "Voltage.L1-N, Voltage.N, Temperature, Current.Offered.L1, " + "Current.Import.L1, Power.Active.Import.L1, , " + "Energy.Active.Import.Register.L1, GarbageText.L2, " + "Current.Export.L2, Current.Export.L3, " + ) + cp.get_configuration = AsyncMock(return_value=dirty_string) + + result_a = await CPv16.get_supported_measurands(cp) + + result_set = set(result_a.split(",")) + assert result_set == { + "Voltage", + "Temperature", + "Current.Offered", + "Current.Import", + "Power.Active.Import", + "Energy.Active.Import.Register", + "Current.Export", + } + assert "GarbageText" not in result_set + + # Scenario B: Complete garbage (Should fall back to DEFAULT_MEASURAND) + cp.get_configuration = AsyncMock(return_value="TotalGarbage.L1, Random.String.N") + result_b = await CPv16.get_supported_measurands(cp) + + assert result_b == DEFAULT_MEASURAND + + # Scenario C: Empty string / None + cp.get_configuration = AsyncMock(return_value="") + result_c = await CPv16.get_supported_measurands(cp) + + assert result_c == "" diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 00000000..928a0abd --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,119 @@ +"""Test sensor for ocpp integration.""" + +import asyncio +import websockets +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from homeassistant.const import ATTR_DEVICE_CLASS +from homeassistant.components.sensor.const import ( + SensorDeviceClass, + SensorStateClass, + ATTR_STATE_CLASS, +) + +from custom_components.ocpp.const import CONF_NUM_CONNECTORS, DOMAIN as OCPP_DOMAIN + +from .const import ( + MOCK_CONFIG_DATA, + CONF_CPIDS, + MOCK_CONFIG_CP_APPEND, + CONF_PORT, + CONF_CPID, +) +from .charge_point_test import create_configuration, remove_configuration + + +async def test_sensor(hass, socket_enabled): + """Test sensor.""" + + cp_id = "CP_1_sens" + cpid = "test_cpid_sens" + data = MOCK_CONFIG_DATA.copy() + cp_data = MOCK_CONFIG_CP_APPEND.copy() + cp_data[CONF_CPID] = cpid + data[CONF_CPIDS].append({cp_id: cp_data}) + data[CONF_PORT] = 9015 + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=data, + entry_id="test_cms_sens", + title="test_cms_sens", + version=2, + minor_version=0, + ) + + # start clean entry for server + await create_configuration(hass, config_entry) + + # connect to websocket to trigger charger setup + async with websockets.connect( + f"ws://127.0.0.1:{data[CONF_PORT]}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + # Wait for setup to complete + await asyncio.sleep(1) + # Test reactive power sensor + state = hass.states.get(f"sensor.{cpid}_power_reactive_import") + assert ( + state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.REACTIVE_POWER + ) + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + # Test reactive energy sensor, not having own device class yet + state = hass.states.get(f"sensor.{cpid}_energy_reactive_import_register") + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_STATE_CLASS) is None + + await ws.close() + + await remove_configuration(hass, config_entry) + + +async def test_sensor_entities_per_connector_created(hass, socket_enabled): + """Create separate entities per connector when num_connectors=2.""" + + cp_id = "CP_1_sens_mc" + cpid = "test_cpid_sens_mc" + + data = MOCK_CONFIG_DATA.copy() + cp_data = MOCK_CONFIG_CP_APPEND.copy() + cp_data[CONF_CPID] = cpid + cp_data[CONF_NUM_CONNECTORS] = 2 # ensure two connectors up front + data[CONF_CPIDS].append({cp_id: cp_data}) + data[CONF_PORT] = 9050 + + config_entry = MockConfigEntry( + domain=OCPP_DOMAIN, + data=data, + entry_id="test_cms_sens_mc", + title="test_cms_sens_mc", + version=2, + minor_version=0, + ) + + await create_configuration(hass, config_entry) + + # Open a ws once to trigger platform setup; entities are created during setup_entry + async with websockets.connect( + f"ws://127.0.0.1:{data[CONF_PORT]}/{cp_id}", + subprotocols=["ocpp1.6"], + ) as ws: + # Give HA a tick to register entities + await asyncio.sleep(0.5) + + # Per-connector entities should include in the entity_id + s1 = hass.states.get(f"sensor.{cpid}_connector_1_status_connector") + s2 = hass.states.get(f"sensor.{cpid}_connector_2_status_connector") + assert s1 is not None, "missing sensor for connector 1" + assert s2 is not None, "missing sensor for connector 2" + + # There must not be any entity for a non-existent connector 3 + s3 = hass.states.get(f"sensor.{cpid}_connector_3_status_connector") + assert s3 is None, "unexpected sensor for connector 3" + + # Root-level sensor still includes + root = hass.states.get(f"sensor.{cpid}_connectors") + assert root is not None, "missing root-level 'connectors' sensor" + + await ws.close() + + await remove_configuration(hass, config_entry) diff --git a/tests/test_set_charge_rate_v16.py b/tests/test_set_charge_rate_v16.py new file mode 100644 index 00000000..5050fdb4 --- /dev/null +++ b/tests/test_set_charge_rate_v16.py @@ -0,0 +1,187 @@ +"""Tests for the simplified OCPP 1.6 set_charge_rate implementation. + +These tests use the production ChargePoint class (v1.6) and monkeypatch only +the collaborators set_charge_rate depends on: +- get_configuration(...) +- call(...) +- notify_ha(...) + +They avoid any parallel/dummy implementation of ChargePoint. +""" + +from types import SimpleNamespace + +import pytest + +from custom_components.ocpp.ocppv16 import ChargePoint as ChargePointv16 +from custom_components.ocpp.enums import ( + Profiles as prof, + ConfigurationKey as ckey, +) +from ocpp.v16.enums import ( + ChargingProfileStatus, + ChargingProfilePurposeType, + ChargingProfileKindType, + ChargingRateUnitType, +) + + +@pytest.fixture +def cp_v16(): + """Provide a minimally-initialized v1.6 ChargePoint instance. + + We bypass __init__ and set only the attributes used by set_charge_rate. + """ + cp = object.__new__(ChargePointv16) # type: ignore[misc] + # What set_charge_rate reads: + cp._attr_supported_features = prof.SMART # can be overridden in tests + cp._ocpp_version = "1.6" + cp.active_transaction_id = 0 + cp._active_tx = {} + # set_charge_rate calls these (we’ll monkeypatch per-test): + # - cp.get_configuration(key) + # - cp.call(req) + # - cp.notify_ha(msg) + return cp + + +@pytest.mark.asyncio +async def test_custom_profile_path_exception_triggers_notify_and_returns_false( + cp_v16, monkeypatch +): + """1) When a custom profile is provided and the CP call raises, return False and notify HA.""" + # notify capture + notices = [] + + async def fake_notify(msg, title="Ocpp integration"): + notices.append(msg) + return True + + async def fake_call(_req): + raise RuntimeError("boom") + + # get_configuration shouldn't be touched in this path + async def fake_get_conf(_key): + pytest.fail("get_configuration should not be called for custom profile") + + monkeypatch.setattr(cp_v16, "notify_ha", fake_notify) + monkeypatch.setattr(cp_v16, "call", fake_call) + monkeypatch.setattr(cp_v16, "get_configuration", fake_get_conf) + + profile = { + "chargingProfileId": 123, + "stackLevel": 1, + "chargingProfileKind": ChargingProfileKindType.relative.value, + "chargingProfilePurpose": ChargingProfilePurposeType.charge_point_max_profile.value, + "chargingSchedule": { + "chargingRateUnit": ChargingRateUnitType.amps.value, + "chargingSchedulePeriod": [{"startPeriod": 0, "limit": 16}], + }, + } + + ok = await cp_v16.set_charge_rate(profile=profile, conn_id=2) + assert ok is False + assert len(notices) == 1 + assert "Set charging profile failed" in notices[0] + + +@pytest.mark.asyncio +async def test_smart_charging_not_supported_returns_false_no_notify( + cp_v16, monkeypatch +): + """2) If the charger doesn't advertise SMART profile, return False without notifications.""" + cp_v16._attr_supported_features = prof.NONE + + notices = [] + + async def fake_notify(msg, title="Ocpp integration"): + notices.append(msg) + return True + + # get_configuration and call should not be called + async def fake_get_conf(_key): + pytest.fail("get_configuration should not be called when SMART not supported") + + async def fake_call(_req): + pytest.fail("call should not be called when SMART not supported") + + monkeypatch.setattr(cp_v16, "notify_ha", fake_notify) + monkeypatch.setattr(cp_v16, "get_configuration", fake_get_conf) + monkeypatch.setattr(cp_v16, "call", fake_call) + + ok = await cp_v16.set_charge_rate(limit_amps=16, conn_id=2) + assert ok is False + assert notices == [] + + +@pytest.mark.asyncio +async def test_cpmax_exception_falls_back_to_txdefault_accepted_returns_true( + cp_v16, monkeypatch +): + """3) CPMax path raises -> fallback to TxDefault which is accepted -> return True.""" + + # Allow both A and stack level + async def fake_get_conf(key: str): + if key == ckey.charging_schedule_allowed_charging_rate_unit.value: + return "Current" # supports Amps + if key == ckey.charge_profile_max_stack_level.value: + return "2" + pytest.fail(f"Unexpected get_configuration key: {key}") + + # First SetChargingProfile (CPMax connectorId=0) raises, second (TxDefault connectorId=2) accepted + async def fake_call(req): + purpose = req.cs_charging_profiles["chargingProfilePurpose"] + if purpose == ChargingProfilePurposeType.charge_point_max_profile.value: + raise RuntimeError("transport error") + if purpose == ChargingProfilePurposeType.tx_default_profile.value: + return SimpleNamespace(status=ChargingProfileStatus.accepted) + return SimpleNamespace(status=ChargingProfileStatus.rejected) + + notices = [] + + async def fake_notify(msg, title="Ocpp integration"): + notices.append(msg) + return True + + monkeypatch.setattr(cp_v16, "get_configuration", fake_get_conf) + monkeypatch.setattr(cp_v16, "call", fake_call) + monkeypatch.setattr(cp_v16, "notify_ha", fake_notify) + + ok = await cp_v16.set_charge_rate(limit_amps=16, conn_id=2) + assert ok is True + # No user-facing warning necessary when fallback succeeds + assert notices == [] + + +@pytest.mark.asyncio +async def test_cpmax_rejected_txdefault_accepted_returns_true(cp_v16, monkeypatch): + """4) CPMax rejected -> TxDefault accepted -> return True.""" + + async def fake_get_conf(key: str): + if key == ckey.charging_schedule_allowed_charging_rate_unit.value: + return "Current" + if key == ckey.charge_profile_max_stack_level.value: + return "3" + pytest.fail(f"Unexpected get_configuration key: {key}") + + async def fake_call(req): + purpose = req.cs_charging_profiles["chargingProfilePurpose"] + if purpose == ChargingProfilePurposeType.charge_point_max_profile.value: + return SimpleNamespace(status=ChargingProfileStatus.rejected) + if purpose == ChargingProfilePurposeType.tx_default_profile.value: + return SimpleNamespace(status=ChargingProfileStatus.accepted) + return SimpleNamespace(status=ChargingProfileStatus.rejected) + + notices = [] + + async def fake_notify(msg, title="Ocpp integration"): + notices.append(msg) + return True + + monkeypatch.setattr(cp_v16, "get_configuration", fake_get_conf) + monkeypatch.setattr(cp_v16, "call", fake_call) + monkeypatch.setattr(cp_v16, "notify_ha", fake_notify) + + ok = await cp_v16.set_charge_rate(limit_amps=10, conn_id=2) + assert ok is True + assert notices == []