diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 984d90570..4683a4fb4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -4,9 +4,10 @@ name: Latest commit env: - CACHE_VERSION: 12 + CACHE_VERSION: 14 DEFAULT_PYTHON: "3.13" PRE_COMMIT_HOME: ~/.cache/pre-commit + VENV: venv on: schedule: @@ -16,10 +17,12 @@ on: # pull_request: jobs: - # Prepare default python version environment - prepare: + # Determine cache key once + cache: runs-on: ubuntu-latest - name: Prepare + name: Cache identify + outputs: + cache-key: ${{ steps.set-key.outputs.cache-key }} steps: - name: Check out committed code uses: actions/checkout@v4 @@ -28,73 +31,48 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }}-${{ - hashFiles('setup.py') }} - restore-keys: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements_test.txt') }}-${{ hashFiles('setup.py') }}- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements_test.txt') }} - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}- - - name: Create Python virtual environment - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - pip install virtualenv --upgrade - python -m venv venv - . venv/bin/activate - pip install uv - uv pip install -U pip setuptools wheel - uv pip install -r requirements_test.txt -r requirements_commit.txt - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v4 + - name: Fetch HA pyproject + id: core-version + run: wget -O ha_pyproject.toml "https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/pyproject.toml" + - name: Compute cache key + id: set-key + run: echo "cache-key=${{ runner.os }}-venv-cache-${{ env.CACHE_VERSION }}-${{ steps.python.outputs.python-version }}-${{ hashFiles('pyproject.toml', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> "$GITHUB_OUTPUT" + + # Prepare default python version environment + prepare: + runs-on: ubuntu-latest + needs: cache + name: Prepare + steps: + - name: Prepare code checkout and python/pre-commit setup + id: cache-reuse + uses: plugwise/gh-actions/prepare-python-and-code@v1 with: - path: ${{ env.PRE_COMMIT_HOME }} - key: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} - restore-keys: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit- - - name: Install pre-commit dependencies - if: steps.cache-precommit.outputs.cache-hit != 'true' - run: | - . venv/bin/activate - pre-commit install-hooks + cache-key: ${{ needs.cache.outputs.cache-key }} + fail-on-miss: false # First time create cache (if not already exists) + python-version: ${{ env.DEFAULT_PYTHON }} + venv-dir: ${{ env.VENV }} + precommit-home: ${{ env.PRE_COMMIT_HOME }} ruff: runs-on: ubuntu-latest name: Ruff check and force - needs: prepare + needs: + - cache + - prepare steps: - name: Check out committed code uses: actions/checkout@v4 with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - id: python - uses: actions/setup-python@v5 + - name: Restore cached environment + id: cache-reuse + uses: plugwise/gh-actions/restore-venv@v1 with: + cache-key: ${{ needs.cache.outputs.cache-key }} python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }}-${{ - hashFiles('setup.py') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python ${{ env.DEFAULT_PYTHON }} virtual environment from cache" - exit 1 + venv-dir: ${{ env.VENV }} + precommit-home: ${{ env.PRE_COMMIT_HOME }} - name: Ruff (with fix) run: | . venv/bin/activate @@ -115,44 +93,22 @@ jobs: runs-on: ubuntu-latest name: Check commit needs: + - cache + - prepare - ruff - shellcheck - dependencies_check steps: - name: Check out committed code uses: actions/checkout@v4 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - id: python - uses: actions/setup-python@v5 + - name: Restore cached environment + id: cache-reuse + uses: plugwise/gh-actions/restore-venv@v1 with: + cache-key: ${{ needs.cache.outputs.cache-key }} python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }}-${{ - hashFiles('setup.py') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python ${{ env.DEFAULT_PYTHON }} virtual environment from cache" - exit 1 - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache@v4 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} - - name: Fail job if cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore pre-commit environment from cache" - exit 1 + venv-dir: ${{ env.VENV }} + precommit-home: ${{ env.PRE_COMMIT_HOME }} - name: Verify commit run: | . venv/bin/activate @@ -167,76 +123,27 @@ jobs: . venv/bin/activate pre-commit run --show-diff-on-failure --color=always --all-files --hook-stage manual markdownlint - prepare-test-cache: - runs-on: ubuntu-latest - name: Create pytest cache for Python ${{ matrix.python-version }} - needs: commitcheck - strategy: - matrix: - python-version: ["3.13", "3.12"] - steps: - - name: Check out committed code - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - id: python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Restore full Python ${{ matrix.python-version }} virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-venv-${{ - matrix.python-version }}-${{ hashFiles('requirements_test.txt') - }}-${{ hashFiles('setup.py') }} - restore-keys: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-venv-${{ matrix.python-version }}-${{ hashFiles('requirements_test.txt') }}-${{ hashFiles('setup.py') }} - ${{ env.CACHE_VERSION}}-${{ runner.os }}-venv-${{ matrix.python-version }}-${{ hashFiles('requirements_test.txt') }} - ${{ env.CACHE_VERSION}}-${{ runner.os }}-venv-${{ matrix.python-version }}- - - name: Create full Python ${{ matrix.python-version }} virtual environment - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - python -m venv venv - . venv/bin/activate - pip install uv - uv pip install -U pip setuptools wheel - #pip install -r requirements_test.txt - # 20220124 Mimic setup_test.sh - uv pip install --upgrade -r requirements_test.txt -c https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/package_constraints.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test_pre_commit.txt - uv pip install --upgrade pytest-asyncio - pytest: runs-on: ubuntu-latest name: Run pytest using Python ${{ matrix.python-version }} - needs: prepare-test-cache + needs: + - cache + - prepare + - commitcheck strategy: matrix: - python-version: ["3.13", "3.12"] - + python-version: ["3.13"] steps: - name: Check out committed code uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - id: python - uses: actions/setup-python@v5 + - name: Restore cached environment + id: cache-reuse + uses: plugwise/gh-actions/restore-venv@v1 with: - python-version: ${{ matrix.python-version }} - - name: Restore full Python ${{ matrix.python-version }} virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-venv-${{ - matrix.python-version }}-${{ hashFiles('requirements_test.txt') - }}-${{ hashFiles('setup.py') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 + cache-key: ${{ needs.cache.outputs.cache-key }} + python-version: ${{ env.DEFAULT_PYTHON }} + venv-dir: ${{ env.VENV }} + precommit-home: ${{ env.PRE_COMMIT_HOME }} - name: Run all tests run: | . venv/bin/activate @@ -252,32 +159,23 @@ jobs: mypy: runs-on: ubuntu-latest name: Run mypy - needs: pytest + needs: + - cache + - prepare + - pytest steps: - name: Check out committed code uses: actions/checkout@v4 with: persist-credentials: false - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - id: python - uses: actions/setup-python@v5 + - name: Restore cached environment + id: cache-reuse + uses: plugwise/gh-actions/restore-venv@v1 with: + cache-key: ${{ needs.cache.outputs.cache-key }} python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }}-${{ - hashFiles('setup.py') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python ${{ env.DEFAULT_PYTHON }} virtual environment from cache" - exit 1 + venv-dir: ${{ env.VENV }} + precommit-home: ${{ env.PRE_COMMIT_HOME }} - name: Run mypy run: | . venv/bin/activate @@ -307,30 +205,22 @@ jobs: coverage: name: Process test coverage runs-on: ubuntu-latest - needs: pytest + needs: + - cache + - prepare + - pytest + - mypy steps: - name: Check out committed code uses: actions/checkout@v4 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - id: python - uses: actions/setup-python@v5 + - name: Restore cached environment + id: cache-reuse + uses: plugwise/gh-actions/restore-venv@v1 with: + cache-key: ${{ needs.cache.outputs.cache-key }} python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }}-${{ - hashFiles('setup.py') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 + venv-dir: ${{ env.VENV }} + precommit-home: ${{ env.PRE_COMMIT_HOME }} - name: Download all coverage artifacts uses: actions/download-artifact@v4 - name: Combine coverage results @@ -347,38 +237,30 @@ jobs: test-publishing: name: Build and publish Python 🐍 distributions 📦 to TestPyPI runs-on: ubuntu-latest - needs: [coverage, mypy] + needs: + - cache + - prepare + - coverage + - mypy steps: - name: Check out committed code uses: actions/checkout@v4 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - id: python - uses: actions/setup-python@v5 + - name: Restore cached environment + id: cache-reuse + uses: plugwise/gh-actions/restore-venv@v1 with: + cache-key: ${{ needs.cache.outputs.cache-key }} python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }}-${{ - hashFiles('setup.py') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 + venv-dir: ${{ env.VENV }} + precommit-home: ${{ env.PRE_COMMIT_HOME }} - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user + run: | + . venv/bin/activate + uv pip install build - name: Build a binary wheel and a source tarball - run: python3 -m build + run: | + . venv/bin/activate + python3 -m build - name: Publish distribution 📦 to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 continue-on-error: true @@ -390,30 +272,21 @@ jobs: complexity: name: Process test complexity runs-on: ubuntu-latest - needs: coverage + needs: + - cache + - prepare + - coverage steps: - name: Check out committed code uses: actions/checkout@v4 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - id: python - uses: actions/setup-python@v5 + - name: Restore cached environment + id: cache-reuse + uses: plugwise/gh-actions/restore-venv@v1 with: + cache-key: ${{ needs.cache.outputs.cache-key }} python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base Python ${{ env.DEFAULT_PYTHON }} virtual environment - id: cache-venv - uses: actions/cache@v4 - with: - path: venv - key: >- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ - steps.python.outputs.python-version }}-${{ - hashFiles('requirements_test.txt') }}-${{ - hashFiles('setup.py') }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 + venv-dir: ${{ env.VENV }} + precommit-home: ${{ env.PRE_COMMIT_HOME }} - name: Run complexity report (click to view details) run: | . venv/bin/activate diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e804df1bb..9ff73a7b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ default_language_version: repos: # Run manually in CI skipping the branch checks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.7 + rev: v0.12.0 hooks: - id: ruff name: "Ruff check" @@ -24,20 +24,24 @@ repos: args: - --branch=main - repo: https://github.com/asottile/pyupgrade - rev: v3.19.1 + rev: v3.20.0 hooks: - id: pyupgrade name: "Check Py upgrade" args: [--py311-plus] - # Moved codespell configuration to setup.cfg as per 'all-files' issues not reading args - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell - name: "Check spelling" + name: "Check Code Spelling" + args: + - --ignore-words-list=aiport,astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn + - --skip="./.*,*.csv,*.json,*.ambr" + - --quiet-level=2 exclude_types: [csv, json] + exclude: ^userdata/|^fixtures/ - repo: https://github.com/PyCQA/bandit - rev: 1.8.3 + rev: 1.8.5 hooks: - id: bandit name: "Bandit checking" @@ -47,12 +51,19 @@ repos: - --configfile=tests/bandit.yaml files: ^(plugwise|tests)/.+\.py$ - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.0 + rev: v1.37.1 hooks: - id: yamllint name: "YAML linting" + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + name: "Shell checking" + args: + - --external-sources - repo: https://github.com/cdce8p/python-typing-update - rev: v0.7.1 + rev: v0.7.2 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. @@ -109,6 +120,6 @@ repos: entry: ./tmp/biome check fixtures/ plugwise/ tests/ --files-ignore-unknown=true --no-errors-on-unmatched --json-formatter-indent-width=2 --json-formatter-indent-style=space language: script - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.44.0 + rev: v0.45.0 hooks: - id: markdownlint diff --git a/CHANGELOG.md b/CHANGELOG.md index 660a53a25..5d47c3971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v1.7.6 + +- Maintenance chores (mostly reworking Github CI Actions) backporting from efforts on Python Plugwise [USB: #264](https://github.com/plugwise/python-plugwise-usb/pull/264) after porting our progress using [USB: #263](https://github.com/plugwise/python-plugwise-usb/pull/263) +- Don't raise an error when a locked switch is being toggled, and other switch-related improvements via [#755](https://github.com/plugwise/python-plugwise/pull/755) + +## v1.7.5 + +- Maintenance chores +- Deprecating python 3.12 + +## v1.7.4 + +- Maintenance chores + ## v1.7.3 - Improve readability of xml-data in POST/PUT requests via [#707](https://github.com/plugwise/python-plugwise/pull/707), [#708](https://github.com/plugwise/python-plugwise/pull/708) and [#715](https://github.com/plugwise/python-plugwise/pull/715) diff --git a/fixtures/adam_multiple_devices_per_zone/data.json b/fixtures/adam_multiple_devices_per_zone/data.json index d3e13a175..8937cd465 100644 --- a/fixtures/adam_multiple_devices_per_zone/data.json +++ b/fixtures/adam_multiple_devices_per_zone/data.json @@ -540,6 +540,19 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A11" }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "02cf28bfec924855854c544690a609ef", + "4a810418d5394b3f82727340b91ba740" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, "f1fee6043d3642a9b0a65297455f008e": { "available": true, "binary_sensors": { diff --git a/fixtures/m_adam_multiple_devices_per_zone/data.json b/fixtures/m_adam_multiple_devices_per_zone/data.json index 7c38b1b21..06459a117 100644 --- a/fixtures/m_adam_multiple_devices_per_zone/data.json +++ b/fixtures/m_adam_multiple_devices_per_zone/data.json @@ -531,6 +531,19 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A11" }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "02cf28bfec924855854c544690a609ef", + "4a810418d5394b3f82727340b91ba740" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, "f1fee6043d3642a9b0a65297455f008e": { "available": true, "binary_sensors": { diff --git a/plugwise/__init__.py b/plugwise/__init__.py index 35250323d..9c56faa6a 100644 --- a/plugwise/__init__.py +++ b/plugwise/__init__.py @@ -15,6 +15,8 @@ MODULES, NONE, SMILES, + STATE_OFF, + STATE_ON, STATUS, SYSTEM, GwEntityData, @@ -398,10 +400,21 @@ async def set_temperature_offset(self, dev_id: str, offset: float) -> None: async def set_switch_state( self, appl_id: str, members: list[str] | None, model: str, state: str - ) -> None: - """Set the given State of the relevant Switch.""" + ) -> bool: + """Set the given State of the relevant Switch. + + Return the result: + - True when switched to state on, + - False when switched to state off, + - the unchanged state when the switch is for instance locked. + """ + if state not in (STATE_OFF, STATE_ON): + raise PlugwiseError("Invalid state supplied to set_switch_state") + try: - await self._smile_api.set_switch_state(appl_id, members, model, state) + return await self._smile_api.set_switch_state( + appl_id, members, model, state + ) except ConnectionFailedError as exc: raise ConnectionFailedError( f"Failed to set switch state: {str(exc)}" diff --git a/plugwise/constants.py b/plugwise/constants.py index f12c8bbd9..4e1becd56 100644 --- a/plugwise/constants.py +++ b/plugwise/constants.py @@ -23,6 +23,8 @@ PRESET_AWAY: Final = "away" PRESSURE_BAR: Final = "bar" SIGNAL_STRENGTH_DECIBELS_MILLIWATT: Final = "dBm" +STATE_OFF: Final = "off" +STATE_ON: Final = "on" TEMP_CELSIUS: Final = "°C" TEMP_KELVIN: Final = "°K" TIME_MILLISECONDS: Final = "ms" diff --git a/plugwise/legacy/smile.py b/plugwise/legacy/smile.py index eedd360f7..fc4f3a2c5 100644 --- a/plugwise/legacy/smile.py +++ b/plugwise/legacy/smile.py @@ -18,6 +18,8 @@ OFF, REQUIRE_APPLIANCES, RULES, + STATE_OFF, + STATE_ON, GwEntityData, ThermoLoc, ) @@ -195,7 +197,7 @@ async def set_schedule_state( Determined from - DOMAIN_OBJECTS. Used in HA Core to set the hvac_mode: in practice switch between schedule on - off. """ - if state not in ("on", "off"): + if state not in (STATE_OFF, STATE_ON): raise PlugwiseError("Plugwise: invalid schedule state.") # Handle no schedule-name / Off-schedule provided @@ -214,7 +216,7 @@ async def set_schedule_state( ) # pragma: no cover new_state = "false" - if state == "on": + if state == STATE_ON: new_state = "true" locator = f'.//*[@id="{schedule_rule_id}"]/template' @@ -234,13 +236,16 @@ async def set_schedule_state( async def set_switch_state( self, appl_id: str, members: list[str] | None, model: str, state: str - ) -> None: + ) -> bool: """Set the given state of the relevant switch. For individual switches, sets the state directly. For group switches, sets the state for each member in the group separately. For switch-locks, sets the lock state using a different data format. + Return the requested state when succesful, the current state otherwise. """ + current_state = self.gw_entities[appl_id]["switches"]["relay"] + requested_state = state == STATE_ON switch = Munch() switch.actuator = "actuator_functionalities" switch.func_type = "relay_functionality" @@ -250,7 +255,7 @@ async def set_switch_state( # Handle switch-lock if model == "lock": - state = "false" if state == "off" else "true" + state = "true" if state == STATE_ON else "false" appliance = self._appliances.find(f'appliance[@id="{appl_id}"]') appl_name = appliance.find("name").text appl_type = appliance.find("type").text @@ -269,37 +274,45 @@ async def set_switch_state( "" ) await self.call_request(APPLIANCES, method="post", data=data) - return + return requested_state # Handle group of switches data = f"<{switch.func_type}>{state}" if members is not None: return await self._set_groupswitch_member_state( - data, members, state, switch + appl_id, data, members, state, switch ) # Handle individual relay switches uri = f"{APPLIANCES};id={appl_id}/relay" - if model == "relay": - locator = ( - f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock' - ) + if model == "relay" and self.gw_entities[appl_id]["switches"]["lock"]: # Don't bother switching a relay when the corresponding lock-state is true - if self._appliances.find(locator).text == "true": - raise PlugwiseError("Plugwise: the locked Relay was not switched.") + return current_state await self.call_request(uri, method="put", data=data) + return requested_state async def _set_groupswitch_member_state( - self, data: str, members: list[str], state: str, switch: Munch - ) -> None: + self, appl_id: str, data: str, members: list[str], state: str, switch: Munch + ) -> bool: """Helper-function for set_switch_state(). - Set the given State of the relevant Switch (relay) within a group of members. + Set the requested state of the relevant switch within a group of switches. + Return the current group-state when none of the switches has changed its state, the requested state otherwise. """ + current_state = self.gw_entities[appl_id]["switches"]["relay"] + requested_state = state == STATE_ON + switched = 0 for member in members: - uri = f"{APPLIANCES};id={member}/relay" - await self.call_request(uri, method="put", data=data) + if not self.gw_entities[member]["switches"]["lock"]: + uri = f"{APPLIANCES};id={member}/relay" + await self.call_request(uri, method="put", data=data) + switched += 1 + + if switched > 0: + return requested_state + + return current_state # pragma: no cover async def set_temperature(self, _: str, items: dict[str, float]) -> None: """Set the given Temperature on the relevant Thermostat.""" @@ -310,7 +323,7 @@ async def set_temperature(self, _: str, items: dict[str, float]) -> None: if setpoint is None: raise PlugwiseError( "Plugwise: failed setting temperature: no valid input provided" - ) # pragma: no cover" + ) # pragma: no cover temperature = str(setpoint) data = ( diff --git a/plugwise/smile.py b/plugwise/smile.py index f3b259ff8..0947401d4 100644 --- a/plugwise/smile.py +++ b/plugwise/smile.py @@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable import datetime as dt -from typing import Any +from typing import Any, cast from plugwise.constants import ( ADAM, @@ -22,7 +22,10 @@ NOTIFICATIONS, OFF, RULES, + STATE_OFF, + STATE_ON, GwEntityData, + SwitchType, ThermoLoc, ) from plugwise.data import SmileData @@ -309,12 +312,12 @@ async def set_schedule_state( Used in HA Core to set the hvac_mode: in practice switch between schedule on - off. """ # Input checking - if new_state not in ("on", "off"): + if new_state not in (STATE_OFF, STATE_ON): raise PlugwiseError("Plugwise: invalid schedule state.") # Translate selection of Off-schedule-option to disabling the active schedule if name == OFF: - new_state = "off" + new_state = STATE_OFF # Handle no schedule-name / Off-schedule provided if name is None or name == OFF: @@ -367,18 +370,27 @@ def determine_contexts( subject = f'' subject = etree.fromstring(subject) - if state == "off": + if state == STATE_OFF: self._last_active[loc_id] = name contexts.remove(subject) - if state == "on": + if state == STATE_ON: contexts.append(subject) return str(etree.tostring(contexts, encoding="unicode").rstrip()) async def set_switch_state( self, appl_id: str, members: list[str] | None, model: str, state: str - ) -> None: - """Set the given State of the relevant Switch.""" + ) -> bool: + """Set the given state of the relevant Switch. + + For individual switches, sets the state directly. + For group switches, sets the state for each member in the group separately. + For switch-locks, sets the lock state using a different data format. + Return the requested state when succesful, the current state otherwise. + """ + model_type = cast(SwitchType, model) + current_state = self.gw_entities[appl_id]["switches"][model_type] + requested_state = state == STATE_ON switch = Munch() switch.actuator = "actuator_functionalities" switch.device = "relay" @@ -396,10 +408,18 @@ async def set_switch_state( if model == "lock": switch.func = "lock" - state = "false" if state == "off" else "true" + state = "true" if state == STATE_ON else "false" + + data = ( + f"<{switch.func_type}>" + f"<{switch.func}>{state}" + f"" + ) if members is not None: - return await self._set_groupswitch_member_state(members, state, switch) + return await self._set_groupswitch_member_state( + appl_id, data, members, state, switch + ) locator = f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}' found = self._domain_objects.findall(locator) @@ -412,39 +432,42 @@ async def set_switch_state( else: # actuators with a single item like relay_functionality switch_id = item.attrib["id"] - data = ( - f"<{switch.func_type}>" - f"<{switch.func}>{state}" - f"" - ) uri = f"{APPLIANCES};id={appl_id}/{switch.device};id={switch_id}" if model == "relay": - locator = ( - f'appliance[@id="{appl_id}"]/{switch.actuator}/{switch.func_type}/lock' - ) - # Don't bother switching a relay when the corresponding lock-state is true - if self._domain_objects.find(locator).text == "true": - raise PlugwiseError("Plugwise: the locked Relay was not switched.") + lock_blocked = self.gw_entities[appl_id]["switches"].get("lock") + if lock_blocked or lock_blocked is None: + # Don't switch a relay when its corresponding lock-state is true or no + # lock is present. That means the relay can't be controlled by the user. + return current_state await self.call_request(uri, method="put", data=data) + return requested_state async def _set_groupswitch_member_state( - self, members: list[str], state: str, switch: Munch - ) -> None: + self, appl_id: str, data: str, members: list[str], state: str, switch: Munch + ) -> bool: """Helper-function for set_switch_state(). - Set the given State of the relevant Switch within a group of members. + Set the requested state of the relevant switch within a group of switches. + Return the current group-state when none of the switches has changed its state, the requested state otherwise. """ + current_state = self.gw_entities[appl_id]["switches"]["relay"] + requested_state = state == STATE_ON + switched = 0 for member in members: locator = f'appliance[@id="{member}"]/{switch.actuator}/{switch.func_type}' switch_id = self._domain_objects.find(locator).attrib["id"] uri = f"{APPLIANCES};id={member}/{switch.device};id={switch_id}" - data = ( - f"<{switch.func_type}>" - f"<{switch.func}>{state}" - f"" - ) - await self.call_request(uri, method="put", data=data) + lock_blocked = self.gw_entities[member]["switches"].get("lock") + # Assume Plugs under Plugwise control are not part of a group + if lock_blocked is not None and not lock_blocked: + await self.call_request(uri, method="put", data=data) + switched += 1 + + if switched > 0: + return requested_state + + return current_state async def set_temperature(self, loc_id: str, items: dict[str, float]) -> None: """Set the given Temperature on the relevant Thermostat.""" diff --git a/pyproject.toml b/pyproject.toml index ddf1ba51a..ede4e456a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools~=79.0"] +requires = ["setuptools~=80.0"] build-backend = "setuptools.build_meta" [project] name = "plugwise" -version = "1.7.4" +version = "1.7.6" license = "MIT" description = "Plugwise Smile (Adam/Anna/P1) and Stretch module for Python 3." readme = "README.md" @@ -13,7 +13,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Home Automation", ] @@ -24,7 +23,7 @@ maintainers = [ { name = "bouwew"}, { name = "CoMPaTech" } ] -requires-python = ">=3.12.0" +requires-python = ">=3.13" dependencies = [ "aiohttp", "defusedxml", @@ -52,7 +51,7 @@ include = ["plugwise*"] # 20241208: W0201 / attribute-defined-outside-init # 20241208: R1702 / too-many-nested-blocks # too many nested blocks in test_init 8/5 # 20241208: R6102 / consider-using-tuple -# 20241208: Recommended disabling => "implicit-str-concat", # ISC001 - 2 occurances! +# 20241208: Recommended disabling => "implicit-str-concat", # ISC001 - 2 occurrences! ## [tool.pylint.MAIN] @@ -466,7 +465,6 @@ lint.select = [ "S317", # suspicious-xml-sax-usage "S318", # suspicious-xml-mini-dom-usage "S319", # suspicious-xml-pull-dom-usage - "S320", # suspicious-xmle-tree-usage "S601", # paramiko-call "S602", # subprocess-popen-with-shell-equals-true "S604", # call-with-shell-equals-true diff --git a/requirements_test.txt b/requirements_test.txt index 788f4c116..bd6bb3177 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,3 +5,4 @@ pytest-asyncio radon==6.0.1 types-python-dateutil uv +pytest-cov diff --git a/scripts/complexity.sh b/scripts/complexity.sh index fbae27090..8089723c9 100755 --- a/scripts/complexity.sh +++ b/scripts/complexity.sh @@ -3,18 +3,23 @@ set -eu my_path=$(git rev-parse --show-toplevel) -# shellcheck disable=SC1091 -source "${my_path}/scripts/python-venv.sh" - -# shellcheck disable=SC2154 -if [ -f "${my_venv}/bin/activate" ]; then - # shellcheck disable=SC1091 - . "${my_venv}/bin/activate" - echo "-----------------------------" - echo "Running cyclomatic complexity" - echo "-----------------------------" - PYTHONPATH=$(pwd) radon cc plugwise/ tests/ -s -nc --no-assert +if [ -n "${VIRTUAL_ENV-}" ] && [ -f "${VIRTUAL_ENV}/bin/activate" ]; then + # shellcheck disable=SC1091 # ingesting virtualenv + . "${VIRTUAL_ENV}/bin/activate" else - echo "Virtualenv available, bailing out" - exit 2 + # other common virtualenvs + my_path=$(git rev-parse --show-toplevel) + + for venv in venv .venv .; do + if [ -f "${my_path}/${venv}/bin/activate" ]; then + # shellcheck disable=SC1090 # ingesting virtualenv + . "${my_path}/${venv}/bin/activate" + break + fi + done fi + +echo "-----------------------------" +echo "Running cyclomatic complexity" +echo "-----------------------------" +PYTHONPATH=$(pwd) radon cc plugwise/ tests/ -s -nc --no-assert diff --git a/scripts/manual_fixtures.py b/scripts/manual_fixtures.py index 8cdc4fb24..aae0a2cb9 100755 --- a/scripts/manual_fixtures.py +++ b/scripts/manual_fixtures.py @@ -36,8 +36,12 @@ def json_writer(manual_name: str, output: dict) -> None: adam_multiple_devices_per_zone = base.copy() # Change schedule to not present for "446ac08dd04d4eff8ac57489757b7314" -adam_multiple_devices_per_zone["446ac08dd04d4eff8ac57489757b7314"].pop("available_schedules") -adam_multiple_devices_per_zone["446ac08dd04d4eff8ac57489757b7314"].pop("select_schedule") +adam_multiple_devices_per_zone["446ac08dd04d4eff8ac57489757b7314"].pop( + "available_schedules" +) +adam_multiple_devices_per_zone["446ac08dd04d4eff8ac57489757b7314"].pop( + "select_schedule" +) json_writer("m_adam_multiple_devices_per_zone", adam_multiple_devices_per_zone) @@ -73,24 +77,12 @@ def json_writer(manual_name: str, output: dict) -> None: m_adam_cooling.pop("10016900610d4c7481df78c89606ef22") # Correct setpoint for device "ad4838d7d35c4d6ea796ee12ae5aedf8" and zone "f2bf9048bef64cc5b6d5110154e33c81" -m_adam_cooling["ad4838d7d35c4d6ea796ee12ae5aedf8"]["sensors"][ - "setpoint" -] = 23.5 -m_adam_cooling["ad4838d7d35c4d6ea796ee12ae5aedf8"]["sensors"][ - "temperature" -] = 25.8 -m_adam_cooling["f2bf9048bef64cc5b6d5110154e33c81"]["thermostat"][ - "setpoint" -] = 23.5 -m_adam_cooling["f2bf9048bef64cc5b6d5110154e33c81"]["sensors"][ - "temperature" -] = 25.8 -m_adam_cooling["f2bf9048bef64cc5b6d5110154e33c81"][ - "select_schedule" -] = "off" -m_adam_cooling["f2bf9048bef64cc5b6d5110154e33c81"][ - "control_state" -] = "cooling" +m_adam_cooling["ad4838d7d35c4d6ea796ee12ae5aedf8"]["sensors"]["setpoint"] = 23.5 +m_adam_cooling["ad4838d7d35c4d6ea796ee12ae5aedf8"]["sensors"]["temperature"] = 25.8 +m_adam_cooling["f2bf9048bef64cc5b6d5110154e33c81"]["thermostat"]["setpoint"] = 23.5 +m_adam_cooling["f2bf9048bef64cc5b6d5110154e33c81"]["sensors"]["temperature"] = 25.8 +m_adam_cooling["f2bf9048bef64cc5b6d5110154e33c81"]["select_schedule"] = "off" +m_adam_cooling["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = "cooling" m_adam_cooling["f2bf9048bef64cc5b6d5110154e33c81"]["climate_mode"] = "cool" # Add new key available @@ -105,39 +97,23 @@ def json_writer(manual_name: str, output: dict) -> None: m_adam_cooling.pop("854f8a9b0e7e425db97f1f110e1ce4b3") # Go for 1772 -m_adam_cooling["1772a4ea304041adb83f357b751341ff"]["sensors"][ - "temperature" -] = 21.6 +m_adam_cooling["1772a4ea304041adb83f357b751341ff"]["sensors"]["temperature"] = 21.6 # Go for e2f4 -m_adam_cooling["e2f4322d57924fa090fbbc48b3a140dc"]["sensors"][ - "setpoint" -] = 23.5 -m_adam_cooling["e2f4322d57924fa090fbbc48b3a140dc"]["sensors"][ - "temperature" -] = 23.9 -m_adam_cooling["f871b8c4d63549319221e294e4f88074"]["thermostat"][ - "setpoint" -] = 25.0 -m_adam_cooling["f871b8c4d63549319221e294e4f88074"]["sensors"][ - "temperature" -] = 23.9 -m_adam_cooling["f871b8c4d63549319221e294e4f88074"][ - "control_state" -] = "cooling" +m_adam_cooling["e2f4322d57924fa090fbbc48b3a140dc"]["sensors"]["setpoint"] = 23.5 +m_adam_cooling["e2f4322d57924fa090fbbc48b3a140dc"]["sensors"]["temperature"] = 23.9 +m_adam_cooling["f871b8c4d63549319221e294e4f88074"]["thermostat"]["setpoint"] = 25.0 +m_adam_cooling["f871b8c4d63549319221e294e4f88074"]["sensors"]["temperature"] = 23.9 +m_adam_cooling["f871b8c4d63549319221e294e4f88074"]["control_state"] = "cooling" m_adam_cooling["f871b8c4d63549319221e294e4f88074"]["climate_mode"] = "auto" # Go for da22 -m_adam_cooling["da224107914542988a88561b4452b0f6"][ - "select_regulation_mode" -] = "cooling" -m_adam_cooling["da224107914542988a88561b4452b0f6"][ - "regulation_modes" -].append("cooling") -m_adam_cooling["da224107914542988a88561b4452b0f6"]["sensors"][ - "outdoor_temperature" -] = 29.65 +m_adam_cooling["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" +m_adam_cooling["da224107914542988a88561b4452b0f6"]["regulation_modes"].append("cooling") +m_adam_cooling["da224107914542988a88561b4452b0f6"]["sensors"]["outdoor_temperature"] = ( + 29.65 +) # Go for 056e m_adam_cooling["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ @@ -146,12 +122,12 @@ def json_writer(manual_name: str, output: dict) -> None: m_adam_cooling["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "heating_state" ] = False -m_adam_cooling["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "flame_state" -] = False -m_adam_cooling["056ee145a816487eaa69243c3280f8bf"]["sensors"][ - "water_temperature" -] = 19.0 +m_adam_cooling["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["flame_state"] = ( + False +) +m_adam_cooling["056ee145a816487eaa69243c3280f8bf"]["sensors"]["water_temperature"] = ( + 19.0 +) m_adam_cooling["056ee145a816487eaa69243c3280f8bf"]["sensors"][ "intended_boiler_temperature" ] = 17.5 @@ -163,56 +139,30 @@ def json_writer(manual_name: str, output: dict) -> None: m_adam_heating = m_adam_cooling.copy() # Correct setpoint for "ad4838d7d35c4d6ea796ee12ae5aedf8" -m_adam_heating["f2bf9048bef64cc5b6d5110154e33c81"]["thermostat"][ - "setpoint" -] = 20.0 -m_adam_heating["ad4838d7d35c4d6ea796ee12ae5aedf8"]["sensors"][ - "setpoint" -] = 20.0 -m_adam_heating["f2bf9048bef64cc5b6d5110154e33c81"]["sensors"][ - "temperature" -] = 19.1 -m_adam_heating["ad4838d7d35c4d6ea796ee12ae5aedf8"]["sensors"][ - "temperature" -] = 19.1 +m_adam_heating["f2bf9048bef64cc5b6d5110154e33c81"]["thermostat"]["setpoint"] = 20.0 +m_adam_heating["ad4838d7d35c4d6ea796ee12ae5aedf8"]["sensors"]["setpoint"] = 20.0 +m_adam_heating["f2bf9048bef64cc5b6d5110154e33c81"]["sensors"]["temperature"] = 19.1 +m_adam_heating["ad4838d7d35c4d6ea796ee12ae5aedf8"]["sensors"]["temperature"] = 19.1 -m_adam_heating["f2bf9048bef64cc5b6d5110154e33c81"][ - "control_state" -] = "preheating" +m_adam_heating["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = "preheating" m_adam_heating["f2bf9048bef64cc5b6d5110154e33c81"]["climate_mode"] = "heat" # Go for 1772 -m_adam_heating["1772a4ea304041adb83f357b751341ff"]["sensors"][ - "temperature" -] = 18.6 +m_adam_heating["1772a4ea304041adb83f357b751341ff"]["sensors"]["temperature"] = 18.6 # Related zone temperature is set below # Go for e2f4 -m_adam_heating["f871b8c4d63549319221e294e4f88074"]["thermostat"][ - "setpoint" -] = 15.0 -m_adam_heating["e2f4322d57924fa090fbbc48b3a140dc"]["sensors"][ - "setpoint" -] = 15.0 -m_adam_heating["f871b8c4d63549319221e294e4f88074"]["sensors"][ - "temperature" -] = 17.9 -m_adam_heating["e2f4322d57924fa090fbbc48b3a140dc"]["sensors"][ - "temperature" -] = 17.9 +m_adam_heating["f871b8c4d63549319221e294e4f88074"]["thermostat"]["setpoint"] = 15.0 +m_adam_heating["e2f4322d57924fa090fbbc48b3a140dc"]["sensors"]["setpoint"] = 15.0 +m_adam_heating["f871b8c4d63549319221e294e4f88074"]["sensors"]["temperature"] = 17.9 +m_adam_heating["e2f4322d57924fa090fbbc48b3a140dc"]["sensors"]["temperature"] = 17.9 m_adam_heating["f871b8c4d63549319221e294e4f88074"]["climate_mode"] = "auto" -m_adam_heating["f871b8c4d63549319221e294e4f88074"][ - "control_state" -] = "idle" +m_adam_heating["f871b8c4d63549319221e294e4f88074"]["control_state"] = "idle" # Go for da22 -m_adam_heating["da224107914542988a88561b4452b0f6"][ - "select_regulation_mode" -] = "heating" -m_adam_heating["da224107914542988a88561b4452b0f6"][ - "regulation_modes" -].remove("cooling") +m_adam_heating["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" +m_adam_heating["da224107914542988a88561b4452b0f6"]["regulation_modes"].remove("cooling") m_adam_heating["da224107914542988a88561b4452b0f6"]["sensors"][ "outdoor_temperature" ] = -1.25 @@ -224,12 +174,12 @@ def json_writer(manual_name: str, output: dict) -> None: m_adam_heating["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "heating_state" ] = True -m_adam_heating["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "flame_state" -] = False -m_adam_heating["056ee145a816487eaa69243c3280f8bf"]["sensors"][ - "water_temperature" -] = 37.0 +m_adam_heating["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["flame_state"] = ( + False +) +m_adam_heating["056ee145a816487eaa69243c3280f8bf"]["sensors"]["water_temperature"] = ( + 37.0 +) m_adam_heating["056ee145a816487eaa69243c3280f8bf"]["sensors"][ "intended_boiler_temperature" ] = 38.1 @@ -252,18 +202,18 @@ def json_writer(manual_name: str, output: dict) -> None: m_anna_heatpump_cooling = base.copy() # Go for 1cbf -m_anna_heatpump_cooling["1cbf783bb11e4a7c8a6843dee3a86927"][ - "model" -] = "Generic heater/cooler" -m_anna_heatpump_cooling["1cbf783bb11e4a7c8a6843dee3a86927"][ - "binary_sensors" -]["cooling_enabled"] = True -m_anna_heatpump_cooling["1cbf783bb11e4a7c8a6843dee3a86927"][ - "binary_sensors" -]["heating_state"] = False -m_anna_heatpump_cooling["1cbf783bb11e4a7c8a6843dee3a86927"][ - "binary_sensors" -]["cooling_state"] = True +m_anna_heatpump_cooling["1cbf783bb11e4a7c8a6843dee3a86927"]["model"] = ( + "Generic heater/cooler" +) +m_anna_heatpump_cooling["1cbf783bb11e4a7c8a6843dee3a86927"]["binary_sensors"][ + "cooling_enabled" +] = True +m_anna_heatpump_cooling["1cbf783bb11e4a7c8a6843dee3a86927"]["binary_sensors"][ + "heating_state" +] = False +m_anna_heatpump_cooling["1cbf783bb11e4a7c8a6843dee3a86927"]["binary_sensors"][ + "cooling_state" +] = True m_anna_heatpump_cooling["1cbf783bb11e4a7c8a6843dee3a86927"]["sensors"][ "water_temperature" @@ -344,9 +294,9 @@ def json_writer(manual_name: str, output: dict) -> None: # Go for 3cb7 m_anna_heatpump_idle["3cb70739631c4d17a86b8b12e8a5161b"]["control_state"] = "idle" -m_anna_heatpump_idle["3cb70739631c4d17a86b8b12e8a5161b"]["sensors"][ - "temperature" -] = 23.0 +m_anna_heatpump_idle["3cb70739631c4d17a86b8b12e8a5161b"]["sensors"]["temperature"] = ( + 23.0 +) m_anna_heatpump_idle["3cb70739631c4d17a86b8b12e8a5161b"]["sensors"][ "cooling_activation_outdoor_temperature" ] = 25.0 diff --git a/scripts/python-venv.sh b/scripts/python-venv.sh deleted file mode 100755 index bef499779..000000000 --- a/scripts/python-venv.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -set -eu - -pyversions=(3.13 3.12) -my_path=$(git rev-parse --show-toplevel) -my_venv=${my_path}/venv - -# Ensures a python virtualenv is available at the highest available python3 version -for pv in "${pyversions[@]}"; do - if [ "$(which "python$pv")" ]; then - # If not (yet) available instantiate python virtualenv - if [ ! -d "${my_venv}" ]; then - "python${pv}" -m venv "${my_venv}" - # Ensure wheel is installed (preventing local issues) - # shellcheck disable=SC1091 - . "${my_venv}/bin/activate" - pip install wheel - fi - break - fi -done - -# Failsafe -if [ ! -d "${my_venv}" ]; then - echo "Unable to instantiate venv, check your base python3 version and if you have python3-venv installed" - exit 1 -fi diff --git a/scripts/run-in-env.sh b/scripts/run-in-env.sh index 624d5ea95..946cbb3e5 100755 --- a/scripts/run-in-env.sh +++ b/scripts/run-in-env.sh @@ -1,19 +1,33 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh +# 20250613 Copied from HA-Core (unchanged) set -eu -my_path=$(git rev-parse --show-toplevel) +# Used in venv activate script. +# Would be an error if undefined. +OSTYPE="${OSTYPE-}" -# shellcheck disable=SC1091 -. "${my_path}/scripts/python-venv.sh" +# Activate pyenv and virtualenv if present, then run the specified command -# shellcheck disable=SC2154 -if [ -f "${my_venv}/bin/activate" ]; then - set +o nounset # Workaround https://github.com/pypa/virtualenv/issues/150 for nodeenv - # shellcheck disable=SC1091 - . "${my_venv}/bin/activate" - set -o nounset - exec "$@" +# pyenv, pyenv-virtualenv +if [ -s .python-version ]; then + PYENV_VERSION=$(head -n 1 .python-version) + export PYENV_VERSION +fi + +if [ -n "${VIRTUAL_ENV-}" ] && [ -f "${VIRTUAL_ENV}/bin/activate" ]; then + # shellcheck disable=SC1091 # ingesting virtualenv + . "${VIRTUAL_ENV}/bin/activate" else - echo "Virtualenv available, bailing out" - exit 2 + # other common virtualenvs + my_path=$(git rev-parse --show-toplevel) + + for venv in venv .venv .; do + if [ -f "${my_path}/${venv}/bin/activate" ]; then + # shellcheck disable=SC1090 # ingesting virtualenv + . "${my_path}/${venv}/bin/activate" + break + fi + done fi + +exec "$@" diff --git a/scripts/setup.sh b/scripts/setup.sh index add58c35b..00073f8d6 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,23 +1,23 @@ #!/usr/bin/env bash -set -eu +# 20250613 Copied from HA-core and shell-check adjusted and modified for local use +set -e -my_path=$(git rev-parse --show-toplevel) - -# shellcheck disable=SC1091 -. "${my_path}/scripts/python-venv.sh" +if [ -z "$VIRTUAL_ENV" ]; then + if [ -x "$(command -v uv)" ]; then + uv venv venv + else + python3 -m venv venv + fi + # shellcheck disable=SC1091 # ingesting virtualenv + source venv/bin/activate +fi -# shellcheck disable=SC2154 -if [ -f "${my_venv}/bin/activate" ]; then - set +o nounset # Workaround https://github.com/pypa/virtualenv/issues/150 for nodeenv - # shellcheck disable=SC1091 - . "${my_venv}/bin/activate" - set -o nounset - # Install commit requirements - pip install wheel uv - uv pip install --upgrade -e . -r requirements_commit.txt -c https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/package_constraints.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test_pre_commit.txt - # Install pre-commit hook - "${my_venv}/bin/pre-commit" install -else - echo "Virtualenv available, bailing out" - exit 2 +if ! [ -x "$(command -v uv)" ]; then + python3 -m pip install uv fi + +# Install commit requirements +uv pip install --upgrade -e . -r requirements_commit.txt -c https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/package_constraints.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test_pre_commit.txt + +# Install pre-commit hook +pre-commit install diff --git a/scripts/setup_test.sh b/scripts/setup_test.sh index a117c7011..2120fe14c 100755 --- a/scripts/setup_test.sh +++ b/scripts/setup_test.sh @@ -1,39 +1,42 @@ #!/usr/bin/env bash -set -eu +# 20250613 Copied from HA-core and shell-check adjusted and modified for local use +set -e my_path=$(git rev-parse --show-toplevel) -# shellcheck disable=SC1091 -. "${my_path}/scripts/python-venv.sh" - -# shellcheck disable=SC2154 -if [ -f "${my_venv}/bin/activate" ]; then - set +o nounset # Workaround https://github.com/pypa/virtualenv/issues/150 for nodeenv - # shellcheck disable=SC1091 - . "${my_venv}/bin/activate" - mkdir -p ./tmp - set -o nounset - # Install test requirements - uv pip install --upgrade -e . -r requirements_test.txt -c https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/package_constraints.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test_pre_commit.txt - # Prepare biomejs - echo "Fetching/updating biome cli" - if uname -a | grep -q arm64; then - curl -sL "https://github.com/biomejs/biome/releases/latest/download/biome-darwin-arm64" -o "${my_path}/tmp/biome" - elif uname -a | grep -q x86_64; then - curl -sL "https://github.com/biomejs/biome/releases/latest/download/biome-linux-x64" -o "${my_path}/tmp/biome" - else - echo "Unable to determine processor and as such to install packaged biome cli version, bailing out" - exit 2 - fi - - # Make biome executable (if necessary) - chmod +x "${my_path}/tmp/biome" - - # Install pre-commit hook unless running from within pre-commit - if [ "$#" -eq 0 ]; then - "${my_venv}/bin/pre-commit" install - fi -else - echo "Virtualenv available, bailing out" - exit 2 +if [ -z "$VIRTUAL_ENV" ]; then + if [ -x "$(command -v uv)" ]; then + uv venv venv + else + python3 -m venv venv + fi + # shellcheck disable=SC1091 # ingesting virtualenv + source venv/bin/activate +fi + +if ! [ -x "$(command -v uv)" ]; then + python3 -m pip install uv +fi + +mkdir -p ./tmp + +# Install test requirements +uv pip install --upgrade -e . -r requirements_test.txt -c https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/package_constraints.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test.txt -r https://raw.githubusercontent.com/home-assistant/core/dev/requirements_test_pre_commit.txt + +# Prepare biomejs +echo "Fetching/updating biome cli" +arch=$(uname -m) +case "$arch" in + aarch64|arm64) use_arch="darwin-arm64" ;; + x86_64) use_arch="linux-x64" ;; + *) echo "Unsupported arch for biome cli version: $arch"; exit 2 ;; +esac +curl -sL "https://github.com/biomejs/biome/releases/latest/download/biome-${use_arch}" -o "${my_path}/tmp/biome" + +# Make biome executable (if necessary) +chmod +x "${my_path}/tmp/biome" + +# Install pre-commit hook unless running from within pre-commit +if [ "$#" -eq 0 ]; then + pre-commit install fi diff --git a/scripts/tests_and_coverage.sh b/scripts/tests_and_coverage.sh index 73be7e825..f4a1b0742 100755 --- a/scripts/tests_and_coverage.sh +++ b/scripts/tests_and_coverage.sh @@ -1,24 +1,40 @@ #!/usr/bin/env bash +# 20250613 Copied from HA-Core: run-in-env.sh set -eu -my_path=$(git rev-parse --show-toplevel) +# Used in venv activate script. +# Would be an error if undefined. +OSTYPE="${OSTYPE-}" -# shellcheck disable=SC1091 -. "${my_path}/scripts/python-venv.sh" +# Activate pyenv and virtualenv if present, then run the specified command -# shellcheck disable=SC2154 -if [ -f "${my_venv}/bin/activate" ]; then - set +o nounset # Workaround https://github.com/pypa/virtualenv/issues/150 for nodeenv - # shellcheck disable=SC1091 - . "${my_venv}/bin/activate" - set -o nounset - if [ ! "$(which pytest)" ]; then - echo "Unable to find pytest, run setup_test.sh before this script" - exit 1 - fi +# pyenv, pyenv-virtualenv +if [ -s .python-version ]; then + PYENV_VERSION=$(head -n 1 .python-version) + export PYENV_VERSION +fi + +if [ -n "${VIRTUAL_ENV-}" ] && [ -f "${VIRTUAL_ENV}/bin/activate" ]; then + # shellcheck disable=SC1091 # ingesting virtualenv + . "${VIRTUAL_ENV}/bin/activate" else - echo "Virtualenv available, bailing out" - exit 2 + # other common virtualenvs + my_path=$(git rev-parse --show-toplevel) + + for venv in venv .venv .; do + if [ -f "${my_path}/${venv}/bin/activate" ]; then + # shellcheck disable=SC1090 # ingesting virtualenv + . "${my_path}/${venv}/bin/activate" + break + fi + done +fi + +# 20250613 End of copy + +if ! command -v pytest >/dev/null; then + echo "Unable to find pytest, run setup_test.sh before this script" + exit 1 fi handle_command_error() { @@ -36,7 +52,6 @@ biome_format() { # Install/update dependencies pre-commit install pre-commit install-hooks -pip install uv uv pip install -r requirements_test.txt -r requirements_commit.txt set +u diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 2c3d87f13..000000000 --- a/setup.cfg +++ /dev/null @@ -1,8 +0,0 @@ -# Added Codespell since pre-commit doesn't process args correctly (and python3.11 and toml prevent using pyproject.toml) check #277 (/#278) for details - -[codespell] -# Most of the ignores from HA-Core upstream -# Self-added: leeg -ignore-words-list = additionals,alle,alot,ba,bre,bund,currenty,datas,dof,dur,ether,farenheit,falsy,fo,haa,hass,hist,iam,iff,iif,incomfort,ines,ist,leeg,lightsensor,mut,nam,nd,pres,pullrequests,referer,resset,rime,ser,serie,sur,te,technik,ue,uint,unsecure,visability,wan,wanna,withing,zar -skip = ./.*,*.csv,*.json -quiet-level = 2 diff --git a/tests/data/adam/adam_multiple_devices_per_zone.json b/tests/data/adam/adam_multiple_devices_per_zone.json index 523f9cfcc..2780afe76 100644 --- a/tests/data/adam/adam_multiple_devices_per_zone.json +++ b/tests/data/adam/adam_multiple_devices_per_zone.json @@ -567,6 +567,19 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A08" }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "02cf28bfec924855854c544690a609ef", + "4a810418d5394b3f82727340b91ba740" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, "fe799307f1624099878210aa0b9f1475": { "binary_sensors": { "plugwise_notification": true diff --git a/tests/test_adam.py b/tests/test_adam.py index 2378d7f41..96b015ce5 100644 --- a/tests/test_adam.py +++ b/tests/test_adam.py @@ -106,14 +106,27 @@ async def test_connect_adam_plus_anna_new(self): smile, "056ee145a816487eaa69243c3280f8bf", model="dhw_cm_switch" ) assert switch_change + # Test relay without lock-attribute switch_change = await self.tinker_switch( - smile, "854f8a9b0e7e425db97f1f110e1ce4b3", model="lock" + smile, + "854f8a9b0e7e425db97f1f110e1ce4b3", ) - assert switch_change + assert not switch_change switch_change = await self.tinker_switch( smile, "2568cc4b9c1e401495d4741a5f89bee1" ) assert not switch_change + switch_change = await self.tinker_switch( + smile, + "2568cc4b9c1e401495d4741a5f89bee1", + model="lock", + ) + assert switch_change + + assert await self.tinker_switch_bad_input( + smile, + "854f8a9b0e7e425db97f1f110e1ce4b3", + ) tinkered = await self.tinker_gateway_mode(smile) assert not tinkered @@ -288,7 +301,7 @@ async def test_connect_adam_multiple_devices_per_zone(self): assert smile._last_active["82fa13f017d240daa0d0ea1775420f24"] == CV_JESSIE assert smile._last_active["08963fec7c53423ca5680aa4cb502c63"] == BADKAMER_SCHEMA assert smile._last_active["446ac08dd04d4eff8ac57489757b7314"] == BADKAMER_SCHEMA - assert self.entity_items == 370 + assert self.entity_items == 375 assert "af82e4ccf9c548528166d38e560662a4" in self.notifications @@ -304,6 +317,14 @@ async def test_connect_adam_multiple_devices_per_zone(self): smile, "675416a629f343c495449970e2ca37b5" ) assert not switch_change + # Test a blocked group-change, both relays are locked. + group_change = await self.tinker_switch( + smile, + "e8ef2a01ed3b4139a53bf749204fe6b4", + ["02cf28bfec924855854c544690a609ef", "4a810418d5394b3f82727340b91ba740"], + ) + assert not group_change + await smile.close_connection() await self.disconnect(server, client) diff --git a/tests/test_init.py b/tests/test_init.py index 0369f6503..c80468ec6 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -684,16 +684,18 @@ async def tinker_switch( """Turn a Switch on and off to test functionality.""" _LOGGER.info("Asserting modifying settings for switch devices:") _LOGGER.info("- Devices (%s):", dev_id) + convert = {"on": True, "off": False} tinker_switch_passed = False - for new_state in ["false", "true", "false"]: + for new_state in ["off", "on", "off"]: _LOGGER.info("- Switching %s", new_state) try: - await smile.set_switch_state(dev_id, members, model, new_state) - tinker_switch_passed = True - _LOGGER.info(" + tinker_switch worked as intended") - except pw_exceptions.PlugwiseError: - _LOGGER.info(" + locked, not switched as expected") - return False + result = await smile.set_switch_state(dev_id, members, model, new_state) + if result == convert[new_state]: + tinker_switch_passed = True + _LOGGER.info(" + tinker_switch worked as intended") + else: + _LOGGER.info(" + tinker_switch failed unexpectedly") + return False except ( pw_exceptions.ConnectionFailedError ): # leave for-loop at connect-error @@ -706,6 +708,20 @@ async def tinker_switch( return tinker_switch_passed + @pytest.mark.asyncio + async def tinker_switch_bad_input( + self, smile, dev_id=None, members=None, model="relay", unhappy=False + ): + """Enter a wrong state as input to toggle a Switch.""" + _LOGGER.info("Test entering bad input set_switch_state:") + _LOGGER.info("- Devices (%s):", dev_id) + new_state = "false" + try: + await smile.set_switch_state(dev_id, members, model, new_state) + except pw_exceptions.PlugwiseError: + _LOGGER.info(" + failed input-check as expected") + return True # test is pass! + @pytest.mark.asyncio async def tinker_thermostat_temp( self, smile, loc_id, block_cooling=False, fail_cooling=False, unhappy=False diff --git a/userdata/adam_multiple_devices_per_zone/core.domain_objects.xml b/userdata/adam_multiple_devices_per_zone/core.domain_objects.xml index e38e3a9b1..9a91994ab 100644 --- a/userdata/adam_multiple_devices_per_zone/core.domain_objects.xml +++ b/userdata/adam_multiple_devices_per_zone/core.domain_objects.xml @@ -1425,7 +1425,9 @@ 2020-03-20T17:44:58.716+01:00 - + + + 2020-03-20T17:30:00+01:00 @@ -3146,7 +3148,9 @@ 2020-03-20T17:39:34.219+01:00 - + + + 2020-03-20T17:28:23.547+01:00 @@ -3517,6 +3521,54 @@ + + Test + + switching + 2021-12-23T08:25:07.571+01:00 + 2023-12-22T16:29:14.088+01:00 + + + + + + + + relay + + + + + + + electricity_produced + W + 2023-12-22T16:29:13.997+01:00 + 2023-08-16T23:58:55.515+02:00 + + + 0.00 + + + + electricity_consumed + W + 2023-12-22T16:29:13.997+01:00 + 2023-08-16T23:58:55.515+02:00 + + + 14.81 + + + + + + + false + single + + +