diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc40635..c11cebe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,31 @@ on: push: branches: - master - - dev - pull_request: ~ + paths: + - 'src/**' + - 'tests/**' + - 'setup.py' + - 'pyproject.toml' + - 'MANIFEST.in' + - '.pre-commit-config.yaml' + - '.pylintrc' + - '.yamllint' + - '.secretlintrc.json' + - '.github/workflows/ci.yml' + - '.github/workflows/matchers/**' + pull_request: + paths: + - 'src/**' + - 'tests/**' + - 'setup.py' + - 'pyproject.toml' + - 'MANIFEST.in' + - '.pre-commit-config.yaml' + - '.pylintrc' + - '.yamllint' + - '.secretlintrc.json' + - '.github/workflows/ci.yml' + - '.github/workflows/matchers/**' env: CACHE_VERSION: 1 @@ -35,11 +58,8 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements.txt') }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('pyproject.toml') }} restore-keys: | - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_test.txt') }}- - ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.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' @@ -47,8 +67,8 @@ jobs: sudo apt-get update && sudo apt-get install -y libxml2-dev libxslt1-dev python3-dev build-essential python -m venv venv . venv/bin/activate - pip install -U "pip<20.3" setuptools - pip install -r requirements.txt -r requirements_test.txt + pip install -U pip setuptools + pip install -e ".[dev]" - name: Restore pre-commit environment from cache id: cache-precommit uses: actions/cache@v4 @@ -84,8 +104,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements.txt') }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('pyproject.toml') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -108,50 +127,6 @@ jobs: . venv/bin/activate pre-commit run --hook-stage manual bandit --all-files --show-diff-on-failure - lint-black: - name: Check black - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base 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.txt') }}-${{ - hashFiles('requirements_test.txt') }} - - 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 - - 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 Python virtual environment from cache" - exit 1 - - name: Run black - run: | - . venv/bin/activate - pre-commit run --hook-stage manual black --all-files --show-diff-on-failure - lint-codespell: name: Check codespell runs-on: ubuntu-latest @@ -172,8 +147,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements.txt') }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('pyproject.toml') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -219,8 +193,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements.txt') }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('pyproject.toml') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -246,97 +219,6 @@ jobs: . venv/bin/activate pre-commit run --hook-stage manual check-executables-have-shebangs --all-files - lint-flake8: - name: Check flake8 - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base 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.txt') }}-${{ - hashFiles('requirements_test.txt') }} - - 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 - - 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 Python virtual environment from cache" - exit 1 - - name: Register flake8 problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/flake8.json" - - name: Run flake8 - run: | - . venv/bin/activate - pre-commit run --hook-stage manual flake8 --all-files - - lint-isort: - name: Check isort - runs-on: ubuntu-latest - needs: prepare-base - steps: - - name: Check out code from GitHub - uses: actions/checkout@v5 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - - name: Restore base 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.txt') }}-${{ - hashFiles('requirements_test.txt') }} - - 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 - - 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 Python virtual environment from cache" - exit 1 - - name: Run isort - run: | - . venv/bin/activate - pre-commit run --hook-stage manual isort --all-files --show-diff-on-failure - lint-json: name: Check JSON runs-on: ubuntu-latest @@ -357,8 +239,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements.txt') }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('pyproject.toml') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -384,8 +265,8 @@ jobs: . venv/bin/activate pre-commit run --hook-stage manual check-json --all-files - lint-pyupgrade: - name: Check pyupgrade + lint-ruff-check: + name: Check ruff runs-on: ubuntu-latest needs: prepare-base steps: @@ -404,8 +285,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements.txt') }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('pyproject.toml') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -423,10 +303,14 @@ jobs: run: | echo "Failed to restore Python virtual environment from cache" exit 1 - - name: Run pyupgrade + - name: Run ruff-check + run: | + . venv/bin/activate + pre-commit run ruff-check --all-files --show-diff-on-failure + - name: Run ruff-format run: | . venv/bin/activate - pre-commit run --hook-stage manual pyupgrade --all-files --show-diff-on-failure + pre-commit run ruff-format --all-files --show-diff-on-failure lint-yaml: name: Check YAML @@ -448,8 +332,7 @@ jobs: key: >- ${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-${{ - hashFiles('requirements.txt') }}-${{ - hashFiles('requirements_test.txt') }} + hashFiles('pyproject.toml') }} - name: Fail job if Python cache restore failed if: steps.cache-venv.outputs.cache-hit != 'true' run: | @@ -489,4 +372,4 @@ jobs: pip install pylint - name: Analysing the code with pylint run: | - python -m pylint --fail-under=10 `find -regextype egrep -regex '(.*.py)$'` + python -m pylint --fail-under=10 `find -regextype egrep -regex '(.*.py)$' -not -path './graphify-out/*'` diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..b5e8cfd --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..1e472ec --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' + diff --git a/.github/workflows/dev-release-pr.yml b/.github/workflows/dev-release-pr.yml new file mode 100644 index 0000000..00dbf52 --- /dev/null +++ b/.github/workflows/dev-release-pr.yml @@ -0,0 +1,69 @@ +name: Open dev -> master release PR (with version bump) + +on: + push: + branches: [dev] + +permissions: + contents: write + pull-requests: write + +jobs: + release-pr: + if: "!contains(github.event.head_commit.message, 'chore: bump version')" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: dev + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Check for existing dev -> master PR + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + existing=$(gh pr list --base master --head dev --state open --json number --jq '.[0].number') + echo "existing=$existing" >> "$GITHUB_OUTPUT" + if [ -n "$existing" ]; then + echo "PR #$existing already open; skipping version bump." + fi + + - name: Bump patch version in pyproject.toml + if: steps.check.outputs.existing == '' + run: | + python - <<'PY' + import re, pathlib + p = pathlib.Path("pyproject.toml") + s = p.read_text() + m = re.search(r'^version\s*=\s*"(\d+)\.(\d+)\.(\d+)"', s, re.MULTILINE) + if not m: + raise SystemExit("version not found in pyproject.toml") + maj, mnr, pat = map(int, m.groups()) + new = f'version = "{maj}.{mnr}.{pat+1}"' + p.write_text(s[:m.start()] + new + s[m.end():]) + print("Bumped to", new) + PY + + - name: Commit bump + if: steps.check.outputs.existing == '' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pyproject.toml + if ! git diff --cached --quiet; then + git commit -m "chore: bump version [skip ci]" + git push + fi + + - name: Create dev -> master PR + if: steps.check.outputs.existing == '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr create \ + --base master \ + --head dev \ + --title "Release: dev -> master" \ + --body "Automated release PR. Contains merged feature PRs and a single version bump." diff --git a/.github/workflows/guard-master.yml b/.github/workflows/guard-master.yml new file mode 100644 index 0000000..f5f98cc --- /dev/null +++ b/.github/workflows/guard-master.yml @@ -0,0 +1,17 @@ +name: Guard master branch + +on: + pull_request: + branches: [master] + +jobs: + source-branch-is-dev: + runs-on: ubuntu-latest + steps: + - name: Ensure PR source is dev + run: | + if [ "${{ github.head_ref }}" != "dev" ]; then + echo "::error::PRs into master must come from 'dev' (got '${{ github.head_ref }}')." + exit 1 + fi + echo "Source branch is dev. OK." diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml deleted file mode 100644 index 28e7455..0000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Python package - -on: - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] - - steps: - - uses: actions/checkout@v5 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors. - flake8 . --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. T - flake8 . --max-line-length=79 --statistics diff --git a/.github/workflows/release-on-master.yml b/.github/workflows/release-on-master.yml new file mode 100644 index 0000000..cac49e2 --- /dev/null +++ b/.github/workflows/release-on-master.yml @@ -0,0 +1,43 @@ +name: Tag and release on master + +on: + push: + branches: [master] + +permissions: + contents: write + +jobs: + tag-and-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read version from pyproject.toml + id: version + run: | + v=$(python -c "import re; m=re.search(r'^version\s*=\s*\"([^\"]+)\"', open('pyproject.toml').read(), re.MULTILINE); print(m.group(1))") + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "tag=v$v" >> "$GITHUB_OUTPUT" + + - name: Check if tag already exists + id: check + run: | + if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag ${{ steps.version.outputs.tag }} already exists; skipping." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + if: steps.check.outputs.exists == 'false' + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} + generateReleaseNotes: true + commit: ${{ github.sha }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release_draft.yml b/.github/workflows/release_draft.yml deleted file mode 100644 index 14d8a93..0000000 --- a/.github/workflows/release_draft.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Release Drafter - -on: - workflow_dispatch: - -jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 80496ef..609d1aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,46 @@ -# general things to ignore +# Build artifacts build/ dist/ *.egg-info/ *.egg +*.spec +*.manifest + +# Python bytecode *.py[cod] +*.pyc +*.pyo +*.pyd +.Python __pycache__/ *.so -*~ -build -dist -pyhiveapi.egg-info -# due to using tox and pytest +# Testing and coverage .tox .cache -test* +htmlcov/ +.coverage +.coverage.* custom_tests/* -# virtual environment folder +# Environment files +.env + +# Virtual environments .venv/ +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store + +# Claude superpowers +superpowers/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5be785f..d85ef23 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,11 @@ +exclude: ^graphify-out/ repos: - - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.12 hooks: - - id: pyupgrade - args: [--py38-plus] - - repo: https://github.com/psf/black - rev: 25.1.0 - hooks: - - id: black - args: - - --safe - - --quiet + - id: ruff-check + args: [--fix] + - id: ruff-format - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: @@ -20,13 +15,6 @@ repos: - --skip="./.*,*.csv,*.json" - --quiet-level=2 exclude_types: [csv, json] - - repo: https://github.com/pycqa/flake8 - rev: 7.3.0 - hooks: - - id: flake8 - additional_dependencies: - - flake8-docstrings==1.7.0 - - pydocstyle==6.3.0 - repo: https://github.com/PyCQA/bandit rev: 1.8.6 hooks: @@ -37,11 +25,6 @@ repos: - --configfile=tests/bandit.yaml additional_dependencies: - pbr - - repo: https://github.com/PyCQA/isort - rev: 6.0.1 - hooks: - - id: isort - args: ["--profile", "black", "-o", "aiohttp", "-o", "apyhiveapi", "-o", "boto3", "-o", "botocore", "-o", "pyquery", "-o", "requests", "-o", "setuptools", "-o", "six", "-o", "urllib3"] - repo: https://github.com/adrienverge/yamllint.git rev: v1.37.1 hooks: @@ -61,11 +44,24 @@ repos: - id: no-commit-to-branch args: - --branch=master + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + args: ["--baseline", ".secrets.baseline"] - repo: https://github.com/pycqa/pylint rev: v3.3.1 hooks: - id: pylint args: [ - "-rn", # Only display messages - "-sn", # Don't display the score + "-rn", + "-sn", ] + - repo: local + hooks: + - id: check-data-pii + name: Block PII in data files + entry: python3 scripts/check_data_pii.py + language: system + files: ^src/data/.*\.json$ + pass_filenames: true diff --git a/.pylintrc b/.pylintrc index 15a09fa..8eac790 100644 --- a/.pylintrc +++ b/.pylintrc @@ -80,7 +80,7 @@ persistent=yes # Minimum Python version to use for version dependent checks. Will default to # the version used to run pylint. -py-version=3.9 +py-version=3.10 # Discover python modules and packages in the file system subtree. recursive=no @@ -391,8 +391,8 @@ preferred-modules= [EXCEPTIONS] # Exceptions that will emit a warning when caught. -overgeneral-exceptions=BaseException, - Exception +#overgeneral-exceptions=builtins.BaseException, +# builtins.Exception [REFACTORING] diff --git a/.secretlintrc.json b/.secretlintrc.json deleted file mode 100644 index 7235f49..0000000 --- a/.secretlintrc.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "rules": [ - { - "id": "@secretlint/secretlint-rule-preset-recommend" - }, - { - "id": "@secretlint/secretlint-rule-basicauth" - }, - { - "id": "@secretlint/secretlint-rule-pattern", - "options": { - "patterns": [ - { - "name": "password=", - "pattern": "password\\s*=\\s*(?[\\w\\d!@#$%^&(){}\\[\\]:\";'<>,.?\/~`_+-=|]{1,256})\\b.*" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..f9ca122 --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,405 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": { + "src/api/hive_auth.py": [ + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "3e619ee0820ecf213c2f38c634e416b53defe3b0", + "is_verified": false, + "line_number": 27 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "b8e0d506d969f09a9af89ce89fd9759b72c63262", + "is_verified": false, + "line_number": 28 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "e97a751edc71e9afbe0c0f63ec94873392833f9f", + "is_verified": false, + "line_number": 29 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "92488c021dd524a2f4e116666b3645308fa0e35c", + "is_verified": false, + "line_number": 30 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "d4571e2f026f458aecd2950b0eb6aec190276177", + "is_verified": false, + "line_number": 31 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "8109d3c2f659f13cb61fc9e71eed574efe8c8fd8", + "is_verified": false, + "line_number": 32 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "08cac7461d7b624b88c53ee47da09cbbb84ea290", + "is_verified": false, + "line_number": 33 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "95523fea7e6136c6148299dcc3077debfa2976b3", + "is_verified": false, + "line_number": 34 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "c978fb77621e86f5e9077653fe5345ac1616b466", + "is_verified": false, + "line_number": 35 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "fc02990268ecf8a35a4912d60dab3754e5f43846", + "is_verified": false, + "line_number": 36 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "2c2c0ca491a73e95c8965b6641731057b65f6462", + "is_verified": false, + "line_number": 37 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "672b25c6be065170206f3fc6346ebb8e84cbb9d3", + "is_verified": false, + "line_number": 38 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "99d02e268ea3ee849fb6e359c6c1b019e4d07efd", + "is_verified": false, + "line_number": 39 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "e677fc4cb09d99e1e0d30af31f2e209e541e380e", + "is_verified": false, + "line_number": 40 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "05b69b06f40cae0c910a15b1ac75b1f7a847eccb", + "is_verified": false, + "line_number": 41 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth.py", + "hashed_secret": "c7f914bac2d66eb3f8ae3888fa47bf1ada6caaf5", + "is_verified": false, + "line_number": 42 + }, + { + "type": "Secret Keyword", + "filename": "src/api/hive_auth.py", + "hashed_secret": "5dc786e32e3a0a4611daaf397721c6ef64cd71b0", + "is_verified": false, + "line_number": 69 + }, + { + "type": "Secret Keyword", + "filename": "src/api/hive_auth.py", + "hashed_secret": "ac9f290e69cee683ba3c63461f1f3fa02765032a", + "is_verified": false, + "line_number": 70 + }, + { + "type": "Secret Keyword", + "filename": "src/api/hive_auth.py", + "hashed_secret": "576956b5291ac38d04ef5f82cc974286a857f0b2", + "is_verified": false, + "line_number": 125 + } + ], + "src/api/hive_auth_async.py": [ + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "3e619ee0820ecf213c2f38c634e416b53defe3b0", + "is_verified": false, + "line_number": 34 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "b8e0d506d969f09a9af89ce89fd9759b72c63262", + "is_verified": false, + "line_number": 35 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "e97a751edc71e9afbe0c0f63ec94873392833f9f", + "is_verified": false, + "line_number": 36 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "92488c021dd524a2f4e116666b3645308fa0e35c", + "is_verified": false, + "line_number": 37 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "d4571e2f026f458aecd2950b0eb6aec190276177", + "is_verified": false, + "line_number": 38 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "8109d3c2f659f13cb61fc9e71eed574efe8c8fd8", + "is_verified": false, + "line_number": 39 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "08cac7461d7b624b88c53ee47da09cbbb84ea290", + "is_verified": false, + "line_number": 40 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "95523fea7e6136c6148299dcc3077debfa2976b3", + "is_verified": false, + "line_number": 41 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "c978fb77621e86f5e9077653fe5345ac1616b466", + "is_verified": false, + "line_number": 42 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "fc02990268ecf8a35a4912d60dab3754e5f43846", + "is_verified": false, + "line_number": 43 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "2c2c0ca491a73e95c8965b6641731057b65f6462", + "is_verified": false, + "line_number": 44 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "672b25c6be065170206f3fc6346ebb8e84cbb9d3", + "is_verified": false, + "line_number": 45 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "99d02e268ea3ee849fb6e359c6c1b019e4d07efd", + "is_verified": false, + "line_number": 46 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "e677fc4cb09d99e1e0d30af31f2e209e541e380e", + "is_verified": false, + "line_number": 47 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "05b69b06f40cae0c910a15b1ac75b1f7a847eccb", + "is_verified": false, + "line_number": 48 + }, + { + "type": "Hex High Entropy String", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "c7f914bac2d66eb3f8ae3888fa47bf1ada6caaf5", + "is_verified": false, + "line_number": 49 + }, + { + "type": "Secret Keyword", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "5dc786e32e3a0a4611daaf397721c6ef64cd71b0", + "is_verified": false, + "line_number": 60 + }, + { + "type": "Secret Keyword", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "ac9f290e69cee683ba3c63461f1f3fa02765032a", + "is_verified": false, + "line_number": 61 + }, + { + "type": "Secret Keyword", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "351b174ccf89601f6f4bd3f3970a4aba7d17c98e", + "is_verified": false, + "line_number": 64 + }, + { + "type": "Secret Keyword", + "filename": "src/api/hive_auth_async.py", + "hashed_secret": "576956b5291ac38d04ef5f82cc974286a857f0b2", + "is_verified": false, + "line_number": 120 + } + ] + }, + "generated_at": "2026-05-02T23:21:57Z" +} diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 34f9736..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Python: Current File", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal", - "justMyCode": true - }, - { - "name": "Python: Debug Tests", - "type": "python", - "request": "launch", - "program": "${file}", - "purpose": ["debug-test"], - "console": "integratedTerminal", - "justMyCode": false - } - ] -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7688f82..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "python.testing.pytestArgs": [], - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true -} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 57e4749..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "tasks": [ - { - "label": "Clean Build Files", - "type": "shell", - "command": "rm -rf build dist *.egg-info pyhive_integration.egg-info && find . -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && find . -type f -name '*.pyc' -delete 2>/dev/null || true", - "problemMatcher": [], - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "shared" - } - } - ] -} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b13789..44448bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1,60 @@ +# Contributing +## Prerequisites + +- Python 3.10+ +- [pre-commit](https://pre-commit.com/) + +## Setup + +```bash +git clone https://github.com/Pyhive/Pyhiveapi.git +cd Pyhiveapi +pip install -e ".[dev]" +pre-commit install +``` + +## Running tests + +```bash +pytest tests/ +``` + +With coverage: + +```bash +pytest tests/ --cov +``` + +## Running linters + +```bash +pre-commit run --all-files +``` + +Individual tools: + +```bash +ruff check src/ # lint +ruff format src/ # format +mypy src/ # type check +``` + +## Generating the sync package + +The `pyhiveapi` (sync) package is auto-generated from the async source in `src/`: + +```bash +python setup.py build_py +``` + +Never edit files under `pyhiveapi/` directly — edit `src/` only. + +## Submitting a PR + +1. Branch off `dev` (not `master`) +2. Make your changes and ensure `pre-commit run --all-files` passes +3. Push and open a PR against `dev` +4. Direct PRs to `master` are blocked + +Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing. diff --git a/MANIFEST.in b/MANIFEST.in index 776f831..fc09818 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,2 @@ recursive-include pyhiveapi * -include requirements.txt -include requirements_test.txt recursive-include data * \ No newline at end of file diff --git a/README.md b/README.md index 22f553b..2218faf 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,185 @@ +# pyhive-integration -![CodeQL](https://github.com/Pyhive/Pyhiveapi/workflows/CodeQL/badge.svg) ![Python Linting](https://github.com/Pyhive/Pyhiveapi/workflows/Python%20package/badge.svg) +![CodeQL](https://github.com/Pyhive/Pyhiveapi/workflows/CodeQL/badge.svg) ![Python Linting](https://github.com/Pyhive/Pyhiveapi/workflows/Python%20package/badge.svg) ![PyPI](https://img.shields.io/pypi/v/pyhive-integration) ![Python](https://img.shields.io/pypi/pyversions/pyhive-integration) ![License](https://img.shields.io/github/license/Pyhive/Pyhiveapi) -# Important -The package name had to be changed and going forward the Pyhiveapi package should no longer be used. all the same code has been moved into a new package called [pyhive-integration](https://pypi.org/project/pyhive-integration/). Nothing changes in how the package functions its just a rename. +A Python library for interfacing with the [Hive](https://www.hivehome.com/) smart home platform. Provides both async (`apyhiveapi`) and sync (`pyhiveapi`) APIs, and is designed primarily for use with [Home Assistant](https://www.home-assistant.io/) — though it works standalone too. -# Introduction -This is a library which interfaces with the Hive smart home platform. -This library is built mainly to integrate with the Home Assistant platform, -but it can also be used independently (See examples below.) +> **Package rename notice:** This package replaces the legacy `pyhiveapi` package. The module names, API, and functionality are identical — only the PyPI distribution name changed. -NOTE: -This integration can only be used with the hive owner account guest accounts are currently not supported. +--- +## Features -## Examples -Here are examples and documentation on how to use the library independently. +- Async-first design with a generated sync wrapper (no asyncio boilerplate needed in sync contexts) +- AWS Cognito SRP authentication with SMS two-factor authentication support +- Automatic token refresh at 90% of token lifetime with silent retry on expiry +- Polling-based device state with a smart cache to avoid stale reads during in-progress polls +- Full device discovery — returns a ready-to-use device list for Home Assistant entity creation +- File-based offline mode for development and testing without live credentials -https://pyhass.github.io/pyhiveapi.docs/ [WIP] +## Supported Devices +| Device Type | Capabilities | +| --- | --- | +| **Heating** (thermostat, TRV) | Current / target temperature, mode (schedule / manual / off), boost on/off, heat-on-demand, min/max range, schedule now/next/later | +| **Hot Water** | Mode (schedule / on / off), boost on/off, state | +| **Lights** | On/off, brightness, colour temperature, full RGB colour, colour mode | +| **Smart Plugs** | On/off, power usage | +| **Sensors** | Motion, contact (open/close), battery level, online status | +| **Hub / Sense** | Smoke, CO, dog bark, glass break detection | +--- + +## Installation + +```bash +pip install pyhive-integration +``` + +Requires Python 3.10+. + +--- + +## Quick Start + +### Async + +```python +import asyncio +from apyhiveapi import Auth, Hive + +async def main(): + auth = Auth(username="user@example.com", password="yourpassword") + tokens = await auth.login() + + # If SMS 2FA is required: + # tokens = await auth.sms_2fa("123456", tokens) + + hive = Hive(username="user@example.com", password="yourpassword") + await hive.startSession({"tokens": tokens}) + + for device in hive.session.data.devices.values(): + print(device) + +asyncio.run(main()) +``` + +### Sync + +```python +from pyhiveapi import Auth, Hive + +auth = Auth(username="user@example.com", password="yourpassword") +tokens = auth.login() + +hive = Hive(username="user@example.com", password="yourpassword") +hive.startSession({"tokens": tokens}) + +for device in hive.session.data.devices.values(): + print(device) +``` + +--- + +## Authentication + +Authentication uses the AWS Cognito SRP flow. If your account has SMS two-factor authentication enabled, `login()` will raise `HiveSmsRequired` — call `sms_2fa(code, tokens)` with the code sent to your phone. + +```python +from apyhiveapi import Auth +from apyhiveapi.helper.hive_exceptions import HiveSmsRequired + +auth = Auth(username="user@example.com", password="yourpassword") +try: + tokens = await auth.login() +except HiveSmsRequired: + code = input("SMS code: ") + tokens = await auth.sms_2fa(code, tokens) +``` + +> **Note:** Only the Hive account owner is supported. Guest accounts cannot be used. + +--- + +## Controlling Devices + +After `startSession`, device modules are available directly on the `Hive` instance: + +```python +# Heating +await hive.heating.set_target_temperature(device, 21.0) +await hive.heating.set_mode(device, "SCHEDULE") +await hive.heating.set_boost_on(device, mins=30, temp=22.0) +await hive.heating.set_boost_off(device) + +# Hot water +await hive.hotwater.set_mode(device, "ON") +await hive.hotwater.set_boost_on(device, mins=60) + +# Lights +await hive.light.set_status_on(device) +await hive.light.set_brightness(device, 80) +await hive.light.set_color_temp(device, 4000) +await hive.light.set_color(device, [255, 100, 0]) + +# Smart plug +await hive.switch.turn_on(device) +await hive.switch.turn_off(device) + +# Force a data refresh +await hive.force_update() +``` + +--- + +## Offline / File-Based Testing + +Set `username="use@file.com"` to load device state from bundled JSON fixtures in `src/data/` instead of making live API calls. Useful for development without real Hive credentials. + +```python +hive = Hive(username="use@file.com", password="") +await hive.startSession({}) +``` + +--- + +## Architecture + +The library exposes two packages built from the same source: + +- **`apyhiveapi`** — async package (source in `src/`) +- **`pyhiveapi`** — sync package (auto-generated from `src/` via `unasync` during build) + +Never edit the generated sync files — edit the async source in `src/` only. + +--- + +## Development + +```bash +# Install dev dependencies +pip install -e ".[dev]" + +# Run linters +pre-commit run --all-files + +# Run tests +pytest tests/ + +# Regenerate sync package +python setup.py build_py +``` + +--- + +## Links + +- [PyPI](https://pypi.org/project/pyhive-integration/) +- [Source](https://github.com/Pyhive/Pyhiveapi) +- [Issue Tracker](https://github.com/Pyhive/Pyhiveapi/issues) + +--- + +## License + +MIT License — see [LICENSE](LICENSE) for details. diff --git a/docs/workflows/README.md b/docs/workflows/README.md new file mode 100644 index 0000000..1380e54 --- /dev/null +++ b/docs/workflows/README.md @@ -0,0 +1,143 @@ +# GitHub Workflow Automation + +This document describes the branching model, branch protection, and the GitHub Actions workflows that automate CI, versioning, releases, and PyPI publishing for `pyhive-integration`. + +--- + +## Branching model + +``` +feature/* ──PR──▶ dev ──PR──▶ master ──tag/release──▶ PyPI +``` + +- **`feature/*`** — All work happens on feature branches. +- **`dev`** — Integration branch. Receives all feature PRs. Holds the next-version code. +- **`master`** — Release branch. Only ever receives merges from `dev`. Each merge produces a tagged GitHub Release and a PyPI publish. + +Direct pushes to `dev` and `master` are blocked via branch protection rules. + +--- + +## Branch protection rules (configured in GitHub Settings) + +### `master` +- Require a pull request before merging. +- Required status checks: + - `source-branch-is-dev` (from `guard-master.yml`) + - All CI jobs from `ci.yml` +- Do not allow bypassing. +- Include administrators. + +### `dev` +- Require a pull request before merging. +- Required status checks: all CI jobs from `ci.yml`. +- Allow any feature branch as PR source. + +--- + +## End-to-end automated flow + +1. Developer opens a PR from `feature/x` into `dev`. CI runs. +2. PR reviewed and merged into `dev`. +3. **`dev-release-pr.yml`** fires: + - If no open `dev → master` PR exists, it bumps the patch version in `setup.py`, commits to `dev` with `[skip ci]`, and opens a `dev → master` PR. + - If an open PR already exists, it does nothing (the PR auto-updates with the new commit). +4. Subsequent feature PRs into `dev` keep updating the same release PR — **no further version bumps**. +5. When ready to release, the maintainer merges the `dev → master` PR. +6. **`guard-master.yml`** ensured the PR's source was `dev`; the merge is allowed. +7. **`release-on-master.yml`** fires on the push to `master`: + - Reads the version from `setup.py`. + - Creates tag `vX.Y.Z` and a GitHub Release with auto-generated notes. +8. **`python-publish.yml`** fires on `release: published`: + - Builds the sdist + wheel. + - Uploads the wheel as a release asset. + - Publishes to PyPI via Trusted Publishing. + +--- + +## Workflow reference + +### `ci.yml` — Continuous Integration +- **Triggers:** all `pull_request` events; `push` to `master`. +- **Purpose:** Lint (bandit, black, codespell, flake8, isort, json, pyupgrade, etc.) and tests. +- **Role:** Gates every PR via required status checks. Provides a final sanity run on `master` after a release PR merges. + +### `guard-master.yml` — Master branch guard +- **Triggers:** `pull_request` targeting `master`. +- **Purpose:** Fails the check if the PR's source branch is anything other than `dev`. +- **Role:** Enforces the "only dev → master" rule. Must be a required status check on `master`. + +### `dev-release-pr.yml` — Release PR + version bump +- **Triggers:** `push` to `dev`. +- **Purpose:** + - Checks for an open `dev → master` PR. + - If none exists: bumps the patch version in `setup.py`, commits with `[skip ci]`, opens the release PR. + - If one exists: no-op. +- **Role:** Guarantees exactly one version bump per release cycle, regardless of how many feature PRs land on `dev`. + +### `release-on-master.yml` — Tag + GitHub Release +- **Triggers:** `push` to `master`. +- **Purpose:** + - Reads the version from `setup.py`. + - Skips if a tag for that version already exists. + - Otherwise creates tag `vX.Y.Z` and a GitHub Release with auto-generated notes. +- **Role:** The bridge between merging the release PR and triggering PyPI publishing. + +### `python-publish.yml` — PyPI publish (release-driven) +- **Triggers:** `release: published`. +- **Purpose:** + - Builds the sdist and wheel with `python -m build`. + - Uploads the wheel as a GitHub Release asset. + - Publishes to PyPI via OIDC Trusted Publishing (`pypi` environment). +- **Role:** Primary production publish path. + +### `dev-publish.yml` — Manual dev/ad-hoc PyPI publish +- **Triggers:** `workflow_dispatch` (manual only). +- **Purpose:** Build and publish to PyPI from a non-master branch on demand. +- **Role:** Used for ad-hoc dev releases when something needs to be pushed to PyPI without going through the full `dev → master` release cycle. Refuses to run on `master`. + +--- + +## Required GitHub configuration + +### Environments +- **`pypi`** — Used by `python-publish.yml` and `dev-publish.yml`. Configure as a Trusted Publisher on PyPI for this repository. + +### Required status checks on `master` +- `source-branch-is-dev` +- All CI job names from `ci.yml`. + +### Required status checks on `dev` +- All CI job names from `ci.yml`. + +### Permissions +The workflows use `GITHUB_TOKEN` with `contents: write` and `pull-requests: write` where needed. No additional PATs required. + +--- + +## Removed (redundant) workflows + +The following workflows were removed during this consolidation: + +- **`python-package.yml`** — Stand-alone flake8 lint duplicated by the `lint-flake8` job in `ci.yml`. +- **`release_draft.yml`** — Manual `release-drafter` workflow superseded by automatic GitHub-native release notes in `release-on-master.yml`. + +--- + +## Versioning + +- Source of truth: `version="X.Y.Z"` in `@/setup.py`. +- Bumped automatically (patch only) by `dev-release-pr.yml` once per release cycle. +- For minor or major bumps, edit `setup.py` manually on `dev` before the first feature PR merges, or amend the bump commit before merging the release PR. + +--- + +## Quick troubleshooting + +| Symptom | Likely cause | +|---|---| +| Release PR didn't open | Push to `dev` was the bot's own bump commit (contains `[skip ci]` and `chore: bump version` — intentionally skipped). | +| Version not bumped | A `dev → master` PR was already open. Bumps happen only when opening a fresh release PR. | +| PyPI publish skipped | Tag for current `setup.py` version already exists; bump the version before merging to `master` again. | +| PR into `master` fails check | Source branch is not `dev`. This is enforced by `guard-master.yml`. | +| Manual dev publish fails on master | Intentional — `dev-publish.yml` refuses to run on `master`. Switch branch or use the standard release flow. | diff --git a/package.json b/package.json deleted file mode 100644 index f6faeac..0000000 --- a/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "husky": { - "hooks": { - "pre-commit": "lint-staged" - } - }, - "lint-staged": { - "*": [ - "secretlint" - ] - } -} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9cde426..7a454ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,87 @@ [build-system] -requires = ["setuptools>=40.6.2", "wheel", "unasync"] -build-backend = "setuptools.build_meta" \ No newline at end of file +requires = ["setuptools>=70", "wheel", "unasync"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyhive-integration" +version = "2.0.0" +description = "A Python library to interface with the Hive API" +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "KJonline24", email = "53_galleys_snark@icloud.com" }] +keywords = ["Hive", "API", "Library", "smart-home", "home-automation", "IoT", "async", "integration"] +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "boto3>=1.16.10", + "botocore>=1.19.10", + "requests", + "aiohttp", + "pyquery", + "loguru", +] + +[project.urls] +Homepage = "https://github.com/Pyhive/pyhiveapi" +Source = "https://github.com/Pyhive/Pyhiveapi" +"Issue Tracker" = "https://github.com/Pyhive/Pyhiveapi/issues" + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "pylint", + "ruff", + "mypy", + "pre-commit", + "graphifyy", +] + +[tool.setuptools] +packages = ["apyhiveapi", "apyhiveapi.api", "apyhiveapi.helper", "pyhive", "pyhive.api", "pyhive.helper"] + +[tool.setuptools.package-dir] +apyhiveapi = "src" +pyhive = "src" + +[tool.setuptools.package-data] +apyhiveapi = ["data/*.json"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B", "PL"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-third-party = [ + "aiohttp", "apyhiveapi", "boto3", "botocore", + "pyquery", "requests", "setuptools", "six", "urllib3", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.coverage.run] +source = ["src"] +omit = ["tests/*"] + +[tool.mypy] +python_version = "3.10" +show_error_codes = true +ignore_errors = true +follow_imports = "silent" +ignore_missing_imports = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f1a5a75..0000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -pre-commit -boto3>=1.16.10 -botocore>=1.19.10 -requests -aiohttp -pyquery -loguru diff --git a/requirements_test.txt b/requirements_test.txt deleted file mode 100644 index e3ff3ab..0000000 --- a/requirements_test.txt +++ /dev/null @@ -1,4 +0,0 @@ -tox -pylint -pytest -pbr \ No newline at end of file diff --git a/scripts/check_data_pii.py b/scripts/check_data_pii.py new file mode 100644 index 0000000..00d0005 --- /dev/null +++ b/scripts/check_data_pii.py @@ -0,0 +1,67 @@ +"""Pre-commit hook: block PII patterns in src/data/*.json files.""" + +import json +import re +import sys +from pathlib import Path + +REAL_UUID_RE = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, +) +EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}") +ALLOWED_EMAILS = {"test@test.com"} +PHONE_RE = re.compile(r"\+[0-9]{10,15}") +UK_POSTCODE_RE = re.compile(r"[A-Z]{1,2}[0-9][0-9A-Z]?\s[0-9][A-Z]{2}") +# First octet 1-255 (our anonymised IP starts with "000." which won't match) +REAL_IP_RE = re.compile(r"\b[1-9][0-9]{0,2}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\b") + +PATTERN_CHECKS = [ + (REAL_UUID_RE, "real UUID", lambda m: True), + (EMAIL_RE, "real email", lambda m: m.group().lower() not in ALLOWED_EMAILS), + (PHONE_RE, "phone number", lambda m: True), + (UK_POSTCODE_RE, "UK postcode", lambda m: True), + (REAL_IP_RE, "real IP address", lambda m: True), +] + +errors = [] + +for filepath in sys.argv[1:]: + path = Path(filepath) + if path.suffix != ".json": + continue + + text = path.read_text(encoding="utf-8") + + # Structure check: file-mode fixture must have {"original": ..., "parsed": {...}} + try: + doc = json.loads(text) + except json.JSONDecodeError as exc: + errors.append(f"{filepath}: invalid JSON — {exc}") + continue + + if "original" not in doc or "parsed" not in doc: + errors.append( + f"{filepath}: missing wrapper — file must have top-level " + f'"original" and "parsed" keys (got: {list(doc.keys())})' + ) + elif not isinstance(doc["parsed"], dict): + errors.append( + f'{filepath}: "parsed" must be a dict, got {type(doc["parsed"]).__name__}' + ) + + # PII pattern checks (run on raw text for speed) + for lineno, line in enumerate(text.splitlines(), 1): + for pattern, label, should_flag in PATTERN_CHECKS: + for match in pattern.finditer(line): + if should_flag(match): + errors.append(f"{filepath}:{lineno}: {label}: {match.group()!r}") + +if errors: + print("Data file check FAILED:") + for e in errors: + print(f" {e}") + sys.exit(1) + +print(f"Data file check passed ({len(sys.argv) - 1} file(s) checked)") +sys.exit(0) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 596c9a0..0000000 --- a/setup.cfg +++ /dev/null @@ -1,64 +0,0 @@ -[metadata] -name = pyhive-integration -description = A Python library to interface with the Hive API -keywords = Hive API Library -license = MIT -author = KJonline24 -author_email = khole_47@icloud.com -url = https://github.com/Pyhive/pyhiveapi -project_urls = - Source = https://github.com/Pyhive/Pyhiveapi - Issue tracker = https://github.com/Pyhive/Pyhiveapi/issues -classifiers = - Development Status :: 5 - Production/Stable - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - -[options] -package_dir = - apyhiveapi = src -packages = find: -python_requires = >=3.10 - -[options.packages.find] -where = . -include = apyhiveapi* - -[build-system] -requires = ["setuptools>=40.6.2", "wheel", "unasync"] -build-backend = "setuptools.build_meta" - -[bdist_wheel] -universal = 1 - -[tool.isort] -profile = "black" -known_third_party = aiohttp,apyhiveapi,boto3,botocore,pyquery,requests,setuptools,six,urllib3 - - -[flake8] -exclude = .git,lib,deps,build,test -doctests = True -# To work with Black -# E501: line too long -# D401: mood imperative -# W503: line break before binary operator -ignore = - E501, - D401, - W503 - - -[mypy] -python_version = 3.10 -show_error_codes = true -ignore_errors = true -follow_imports = silent -ignore_missing_imports = true -warn_incomplete_stub = true -warn_redundant_casts = true -warn_unused_configs = true - diff --git a/setup.py b/setup.py index c072c78..1e0de19 100644 --- a/setup.py +++ b/setup.py @@ -1,27 +1,10 @@ """Setup pyhiveapi package.""" # pylint: skip-file -import os -import re - import unasync from setuptools import setup - -def requirements_from_file(filename="requirements.txt"): - """Get requirements from file.""" - with open(os.path.join(os.path.dirname(__file__), filename)) as r: - reqs = r.read().strip().split("\n") - # Return non empty lines and non comments - return [r for r in reqs if re.match(r"^\w+", r)] - - setup( - version="1.0.9", - packages=["apyhiveapi", "apyhiveapi.api", "apyhiveapi.helper"], - package_dir={"apyhiveapi": "src"}, - package_data={"data": ["*.json"]}, - include_package_data=True, cmdclass={ "build_py": unasync.cmdclass_build_py( rules=[ @@ -36,13 +19,9 @@ def requirements_from_file(filename="requirements.txt"): unasync.Rule( "/apyhiveapi/api/", "/pyhiveapi/api/", - additional_replacements={ - "apyhiveapi": "pyhiveapi", - }, + additional_replacements={"apyhiveapi": "pyhiveapi"}, ), ] ) }, - install_requires=requirements_from_file(), - extras_require={"dev": requirements_from_file("requirements_test.txt")}, ) diff --git a/src/action.py b/src/action.py index 365e62f..cce2abf 100644 --- a/src/action.py +++ b/src/action.py @@ -1,8 +1,10 @@ """Hive Action Module.""" -# pylint: skip-file +import json import logging +from .helper.const import HTTP_OK + _LOGGER = logging.getLogger(__name__) @@ -13,7 +15,7 @@ class HiveAction: object: Return hive action object. """ - actionType = "Actions" + action_type = "Actions" def __init__(self, session: object = None): """Initialise Action. @@ -23,7 +25,7 @@ def __init__(self, session: object = None): """ self.session = session - async def getAction(self, device: dict): + async def get_action(self, device: dict): """Action device to update. Args: @@ -32,37 +34,21 @@ async def getAction(self, device: dict): Returns: dict: Updated device. """ - if self.session.shouldUseCachedData(): - cached = self.session.getCachedDevice(device) + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) if cached is not None: _LOGGER.debug( "Returning cached state for action %s (slow/busy poll).", - device["haName"], + device.ha_name, ) return cached - dev_data = {} - - if device["hiveID"] in self.data["action"]: - dev_data = { - "hiveID": device["hiveID"], - "hiveName": device["hiveName"], - "hiveType": device["hiveType"], - "haName": device["haName"], - "haType": device["haType"], - "status": {"state": await self.getState(device)}, - "power_usage": None, - "deviceData": {}, - "custom": device.get("custom", None), - } - - return self.session.setCachedDevice(device, dev_data) - else: - exists = self.session.data.actions.get("hiveID", False) - if exists is False: - return "REMOVE" - return device - - async def getState(self, device: dict): + if device.hive_id in self.session.data.actions: + device.status = {"state": await self.get_state(device)} + device.device_data = {} + return self.session.set_cached_device(device) + return "REMOVE" + + async def get_state(self, device: dict): """Get action state. Args: @@ -74,61 +60,72 @@ async def getState(self, device: dict): final = None try: - data = self.session.data.actions[device["hiveID"]] + data = self.session.data.actions[device.hive_id] final = data["enabled"] except KeyError as e: _LOGGER.error(e) return final - async def setStatusOn(self, device: dict): - """Set action turn on. + async def _set_action_state(self, device: dict, enabled: bool) -> bool: + """Set action enabled/disabled state. Args: device (dict): Device to set state of. + enabled (bool): True to enable, False to disable. Returns: - boolean: True/False if successful. + bool: True if successful. """ - import json - final = False - if device["hiveID"] in self.session.data.actions: - _LOGGER.debug("Enabling action %s.", device["haName"]) - await self.session.hiveRefreshTokens() - data = self.session.data.actions[device["hiveID"]] - data.update({"enabled": True}) - send = json.dumps(data) - resp = await self.session.api.setAction(device["hiveID"], send) - if resp["original"] == 200: + if device.hive_id in self.session.data.actions: + _LOGGER.debug( + "%s action %s.", + "Enabling" if enabled else "Disabling", + device.ha_name, + ) + await self.session.hive_refresh_tokens() + data = self.session.data.actions[device.hive_id].copy() + data.update({"enabled": enabled}) + resp = await self.session.api.set_action(device.hive_id, json.dumps(data)) + if resp["original"] == HTTP_OK: final = True - await self.session.getDevices(device["hiveID"]) + await self.session.get_devices(device.hive_id) return final - async def setStatusOff(self, device: dict): + async def set_status_on(self, device: dict): + """Set action turn on. + + Args: + device (dict): Device to set state of. + + Returns: + bool: True if successful. + """ + return await self._set_action_state(device, True) + + async def set_status_off(self, device: dict): """Set action to turn off. Args: device (dict): Device to set state of. Returns: - boolean: True/False if successful. + bool: True if successful. """ - import json + return await self._set_action_state(device, False) - final = False + # Backwards-compatible camelCase aliases + async def getAction(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for get_action.""" + return await self.get_action(device) - if device["hiveID"] in self.session.data.actions: - _LOGGER.debug("Disabling action %s.", device["haName"]) - await self.session.hiveRefreshTokens() - data = self.session.data.actions[device["hiveID"]] - data.update({"enabled": False}) - send = json.dumps(data) - resp = await self.session.api.setAction(device["hiveID"], send) - if resp["original"] == 200: - final = True - await self.session.getDevices(device["hiveID"]) + async def setStatusOn(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for set_status_on.""" + return await self.set_status_on(device) - return final + async def setStatusOff(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for set_status_off.""" + return await self.set_status_off(device) diff --git a/src/alarm.py b/src/alarm.py deleted file mode 100644 index f813ae3..0000000 --- a/src/alarm.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Hive Alarm Module.""" - -# pylint: skip-file -import logging - -_LOGGER = logging.getLogger(__name__) - - -class HiveHomeShield: - """Hive homeshield alarm. - - Returns: - object: Hive homeshield - """ - - alarmType = "Alarm" - - async def getMode(self): - """Get current mode of the alarm. - - Returns: - str: Mode if the alarm [armed_home, armed_away, armed_night] - """ - state = None - - try: - data = self.session.data.alarm - state = data["mode"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def getState(self, device: dict): - """Get the alarm triggered state. - - Returns: - boolean: True/False if alarm is triggered. - """ - state = None - - try: - data = self.session.data.devices[device["hiveID"]] - state = data["state"]["alarmActive"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def setMode(self, device: dict, mode: str): - """Set the alarm mode. - - Args: - device (dict): Alarm device. - - Returns: - boolean: True/False if successful. - """ - final = False - - if ( - device["hiveID"] in self.session.data.devices - and device["deviceData"]["online"] - ): - _LOGGER.debug("Setting alarm mode to %s.", mode) - await self.session.hiveRefreshTokens() - resp = await self.session.api.setAlarm(mode=mode) - if resp["original"] == 200: - final = True - await self.session.getAlarm() - - return final - - -class Alarm(HiveHomeShield): - """Home assistant alarm. - - Args: - HiveHomeShield (object): Class object. - """ - - def __init__(self, session: object = None): - """Initialise alarm. - - Args: - session (object, optional): Used to interact with the hive account. Defaults to None. - """ - self.session = session - - async def getAlarm(self, device: dict): - """Get alarm data. - - Args: - device (dict): Device to update. - - Returns: - dict: Updated device. - """ - if self.session.shouldUseCachedData(): - cached = self.session.getCachedDevice(device) - if cached is not None: - _LOGGER.debug( - "Returning cached state for alarm %s (slow/busy poll).", - device["haName"], - ) - return cached - device["deviceData"].update( - {"online": await self.session.attr.onlineOffline(device["device_id"])} - ) - dev_data = {} - - if device["deviceData"]["online"]: - self.session.helper.deviceRecovered(device["device_id"]) - _LOGGER.debug("Updating alarm data for %s.", device["haName"]) - data = self.session.data.devices[device["device_id"]] - dev_data = { - "hiveID": device["hiveID"], - "hiveName": device["hiveName"], - "hiveType": device["hiveType"], - "haName": device["haName"], - "haType": device["haType"], - "device_id": device["device_id"], - "device_name": device["device_name"], - "status": { - "state": await self.getState(device), - "mode": await self.getMode(), - }, - "deviceData": data.get("props", None), - "parentDevice": data.get("parent", None), - "custom": device.get("custom", None), - "attributes": await self.session.attr.stateAttributes( - device["device_id"], device["hiveType"] - ), - } - - return self.session.setCachedDevice(device, dev_data) - else: - await self.session.helper.errorCheck( - device["device_id"], "ERROR", device["deviceData"]["online"] - ) - device.setdefault("status", {"state": None, "mode": None}) - return device diff --git a/src/api/hive_api.py b/src/api/hive_api.py index 6f21c81..6801d3f 100644 --- a/src/api/hive_api.py +++ b/src/api/hive_api.py @@ -1,6 +1,5 @@ """Hive API Module.""" -# pylint: skip-file import json import logging @@ -16,9 +15,8 @@ class HiveApi: """Hive API Code.""" - def __init__(self, hiveSession=None, websession=None, token=None): + def __init__(self, hive_session=None, token=None): """Hive API initialisation.""" - self.cameraBaseUrl = "prod.hcam.bgchtest.info" self.urls = { "properties": "https://sso.hivehome.com/", "login": "https://beekeeper.hivehome.com/1.0/cognito/login", @@ -28,9 +26,6 @@ def __init__(self, hiveSession=None, websession=None, token=None): "weather": "https://weather.prod.bgchprod.info/weather", "holiday_mode": "/holiday-mode", "all": "/nodes/all?products=true&devices=true&actions=true", - "alarm": "/security-lite?homeId=", - "cameraImages": f"https://event-history-service.{self.cameraBaseUrl}/v1/events/cameras?latest=true&cameraId={{0}}", - "cameraRecordings": f"https://event-history-service.{self.cameraBaseUrl}/v1/playlist/cameras/{{0}}/events/{{1}}.m3u8", "devices": "/devices", "products": "/products", "actions": "/actions", @@ -41,43 +36,24 @@ def __init__(self, hiveSession=None, websession=None, token=None): "original": "No response to Hive API request", "parsed": "No response to Hive API request", } - self.session = hiveSession + self.session = hive_session self.token = token + self.headers = { + "content-type": "application/json", + "Accept": "*/*", + "authorization": "", + } - def request(self, type, url, jsc=None, camera=False): + def request(self, http_method, url, jsc=None): """Make API request.""" - _LOGGER.debug("request - Making %s request to: %s", type, url) + _LOGGER.debug("request - Making %s request to: %s", http_method, url) if jsc: _LOGGER.debug("request - Request payload: %s", jsc) if self.session is not None: - if camera: - self.headers = { - "content-type": "application/json", - "Accept": "*/*", - "Authorization": f"Bearer {self.session.tokens.tokenData['token']}", - "x-jwt-token": self.session.tokens.tokenData["token"], - } - else: - self.headers = { - "content-type": "application/json", - "Accept": "*/*", - "authorization": self.session.tokens.tokenData["token"], - } + self.headers["authorization"] = self.session.tokens.token_data["token"] else: - if camera: - self.headers = { - "content-type": "application/json", - "Accept": "*/*", - "Authorization": f"Bearer {self.token}", - "x-jwt-token": self.token, - } - else: - self.headers = { - "content-type": "application/json", - "Accept": "*/*", - "authorization": self.token, - } + self.headers["authorization"] = self.token _LOGGER.debug( "request - Request headers: %s", @@ -85,28 +61,31 @@ def request(self, type, url, jsc=None, camera=False): ) try: - if type == "GET": + if http_method == "GET": return requests.get( url=url, headers=self.headers, data=jsc, timeout=self.timeout ) - if type == "POST": + if http_method == "POST": return requests.post( url=url, headers=self.headers, data=jsc, timeout=self.timeout ) + raise ValueError(f"Unsupported request type: {http_method}") except Exception as e: _LOGGER.error("Request failed: %s", e) raise - def refreshTokens(self, tokens={}): + def refresh_tokens(self, tokens=None): """Get new session tokens - DEPRECATED NOW BY AWS TOKEN MANAGEMENT.""" - _LOGGER.debug("refreshTokens - Attempting token refresh (deprecated method)") + _LOGGER.debug("refresh_tokens - Attempting token refresh (deprecated method)") + if tokens is None: + tokens = {} url = self.urls["refresh"] if self.session is not None: - tokens = self.session.tokens.tokenData + tokens = self.session.tokens.token_data jsc = ( "{" + ",".join( - ('"' + str(i) + '": ' '"' + str(t) + '" ' for i, t in tokens.items()) + ('"' + str(i) + '": "' + str(t) + '" ' for i, t in tokens.items()) ) + "}" ) @@ -115,11 +94,10 @@ def refreshTokens(self, tokens={}): data = json.loads(info.text) if "token" in data and self.session: _LOGGER.debug( - "refreshTokens - Token refresh successful, updating session" + "refresh_tokens - Token refresh successful, updating session" ) - self.session.updateTokens(data) + self.session.update_tokens(data) self.urls.update({"base": data["platform"]["endpoint"]}) - self.urls.update({"camera": data["platform"]["cameraPlatform"]}) self.json_return.update({"original": info.status_code}) self.json_return.update({"parsed": info.json()}) except (OSError, RuntimeError, ZeroDivisionError, json.JSONDecodeError) as e: @@ -128,16 +106,16 @@ def refreshTokens(self, tokens={}): return self.json_return - def getLoginInfo(self): + def get_login_info(self): """Get login properties to make the login request.""" _LOGGER.debug( - "getLoginInfo - Fetching login info from: %s", self.urls["properties"] + "get_login_info - Fetching login info from: %s", self.urls["properties"] ) url = self.urls["properties"] try: data = requests.get(url=url, verify=False, timeout=self.timeout) _LOGGER.debug( - "getLoginInfo - Login info response status: %s", data.status_code + "get_login_info - Login info response status: %s", data.status_code ) html = PyQuery(data.content) json_data = json.loads( @@ -149,12 +127,12 @@ def getLoginInfo(self): + "}" ) - loginData = {} - loginData.update({"UPID": json_data["HiveSSOPoolId"]}) - loginData.update({"CLIID": json_data["HiveSSOPublicCognitoClientId"]}) - loginData.update({"REGION": json_data["HiveSSOPoolId"]}) - _LOGGER.debug("getLoginInfo - Login info extracted successfully") - return loginData + login_data = {} + login_data.update({"UPID": json_data["HiveSSOPoolId"]}) + login_data.update({"CLIID": json_data["HiveSSOPublicCognitoClientId"]}) + login_data.update({"REGION": json_data["HiveSSOPoolId"]}) + _LOGGER.debug("get_login_info - Login info extracted successfully") + return login_data except ( OSError, RuntimeError, @@ -164,10 +142,11 @@ def getLoginInfo(self): ) as e: _LOGGER.error("Failed to get login info: %s", str(e)) self.error() + return None - def getAll(self): + def get_all(self): """Build and query all endpoint.""" - _LOGGER.debug("getAll - Fetching all devices/products/actions from Hive API") + _LOGGER.debug("get_all - Fetching all devices/products/actions from Hive API") json_return = {} url = self.urls["base"] + self.urls["all"] try: @@ -176,7 +155,7 @@ def getAll(self): json_return.update({"original": info.status_code}) json_return.update({"parsed": info.json()}) _LOGGER.debug( - "getAll - All data fetch successful, status: %s", info.status_code + "get_all - All data fetch successful, status: %s", info.status_code ) else: _LOGGER.error("Failed to get response from all endpoint") @@ -186,49 +165,7 @@ def getAll(self): return json_return - def getAlarm(self, homeID=None): - """Build and query alarm endpoint.""" - if self.session is not None: - homeID = self.session.config.homeID - url = self.urls["base"] + self.urls["alarm"] + homeID - try: - info = self.request("GET", url) - self.json_return.update({"original": info.status_code}) - self.json_return.update({"parsed": info.json()}) - except (OSError, RuntimeError, ZeroDivisionError): - self.error() - - return self.json_return - - def getCameraImage(self, device=None, accessToken=None): - """Build and query camera endpoint.""" - json_return = {} - url = self.urls["cameraImages"].format(device["props"]["hardwareIdentifier"]) - try: - info = self.request("GET", url, camera=True) - json_return.update({"original": info.status_code}) - json_return.update({"parsed": info.json()}) - except (OSError, RuntimeError, ZeroDivisionError): - self.error() - - return json_return - - def getCameraRecording(self, device=None, eventId=None): - """Build and query camera endpoint.""" - json_return = {} - url = self.urls["cameraRecordings"].format( - device["props"]["hardwareIdentifier"], eventId - ) - try: - info = self.request("GET", url, camera=True) - json_return.update({"original": info.status_code}) - json_return.update({"parsed": info.text.split("\n")[3]}) - except (OSError, RuntimeError, ZeroDivisionError): - self.error() - - return json_return - - def getDevices(self): + def get_devices(self): """Call the get devices endpoint.""" url = self.urls["base"] + self.urls["devices"] try: @@ -240,7 +177,7 @@ def getDevices(self): return self.json_return - def getProducts(self): + def get_products(self): """Call the get products endpoint.""" url = self.urls["base"] + self.urls["products"] try: @@ -252,7 +189,7 @@ def getProducts(self): return self.json_return - def getActions(self): + def get_actions(self): """Call the get actions endpoint.""" url = self.urls["base"] + self.urls["actions"] try: @@ -264,7 +201,7 @@ def getActions(self): return self.json_return - def motionSensor(self, sensor, fromepoch, toepoch): + def motion_sensor(self, sensor, fromepoch, toepoch): """Call a way to get motion sensor info.""" url = ( self.urls["base"] @@ -287,7 +224,7 @@ def motionSensor(self, sensor, fromepoch, toepoch): return self.json_return - def getWeather(self, weather_url): + def get_weather(self, weather_url): """Call endpoint to get local weather from Hive API.""" t_url = self.urls["weather"] + weather_url url = t_url.replace(" ", "%20") @@ -300,10 +237,10 @@ def getWeather(self, weather_url): return self.json_return - def setState(self, n_type, n_id, **kwargs): + def set_state(self, n_type, n_id, **kwargs): """Set the state of a Device.""" _LOGGER.debug( - "setState - Setting state for device %s (type: %s): %s", + "set_state - Setting state for device %s (type: %s): %s", n_id, n_type, kwargs, @@ -311,7 +248,7 @@ def setState(self, n_type, n_id, **kwargs): jsc = ( "{" + ",".join( - ('"' + str(i) + '": ' '"' + str(t) + '" ' for i, t in kwargs.items()) + ('"' + str(i) + '": "' + str(t) + '" ' for i, t in kwargs.items()) ) + "}" ) @@ -324,7 +261,7 @@ def setState(self, n_type, n_id, **kwargs): self.json_return.update({"original": response.status_code}) self.json_return.update({"parsed": response.json()}) _LOGGER.debug( - "setState - State set successfully for %s, status: %s", + "set_state - State set successfully for %s, status: %s", n_id, response.status_code, ) @@ -342,7 +279,7 @@ def setState(self, n_type, n_id, **kwargs): return self.json_return - def setAction(self, n_id, data): + def set_action(self, n_id, data): """Set the state of a Action.""" jsc = data url = self.urls["base"] + self.urls["actions"] + "/" + n_id diff --git a/src/api/hive_async_api.py b/src/api/hive_async_api.py index 9ff9d18..bc91789 100644 --- a/src/api/hive_async_api.py +++ b/src/api/hive_async_api.py @@ -1,18 +1,16 @@ """Hive API Module.""" -# pylint: skip-file import asyncio import json import logging import time -from typing import Optional import requests import urllib3 from aiohttp import ClientResponse, ClientSession, ClientTimeout, web_exceptions from pyquery import PyQuery -from ..helper.const import HTTP_FORBIDDEN, HTTP_UNAUTHORIZED +from ..helper.const import HTTP_FORBIDDEN, HTTP_OK, HTTP_UNAUTHORIZED from ..helper.hive_exceptions import FileInUse, HiveApiError, HiveAuthError, NoApiToken _LOGGER = logging.getLogger(__name__) @@ -23,23 +21,19 @@ class HiveApiAsync: """Hive API Code.""" - def __init__(self, hiveSession=None, websession: Optional[ClientSession] = None): + def __init__(self, hive_session=None, websession: ClientSession | None = None): """Hive API initialisation.""" - self.baseUrl = "https://beekeeper.hivehome.com/1.0" - self.cameraBaseUrl = "prod.hcam.bgchtest.info" + self.base_url = "https://beekeeper.hivehome.com/1.0" self.urls = { "properties": "https://sso.hivehome.com/", - "login": f"{self.baseUrl}/cognito/login", - "refresh": f"{self.baseUrl}/cognito/refresh-token", - "holiday_mode": f"{self.baseUrl}/holiday-mode", - "all": f"{self.baseUrl}/nodes/all?products=true&devices=true&actions=true", - "alarm": f"{self.baseUrl}/security-lite?homeId=", - "cameraImages": f"https://event-history-service.{self.cameraBaseUrl}/v1/events/cameras?latest=true&cameraId={{0}}", - "cameraRecordings": f"https://event-history-service.{self.cameraBaseUrl}/v1/playlist/cameras/{{0}}/events/{{1}}.m3u8", - "devices": f"{self.baseUrl}/devices", - "products": f"{self.baseUrl}/products", - "actions": f"{self.baseUrl}/actions", - "nodes": f"{self.baseUrl}/nodes/{{0}}/{{1}}", + "login": f"{self.base_url}/cognito/login", + "refresh": f"{self.base_url}/cognito/refresh-token", + "holiday_mode": f"{self.base_url}/holiday-mode", + "all": f"{self.base_url}/nodes/all?products=true&devices=true&actions=true", + "devices": f"{self.base_url}/devices", + "products": f"{self.base_url}/products", + "actions": f"{self.base_url}/actions", + "nodes": f"{self.base_url}/nodes/{{0}}/{{1}}", "long_lived": "https://api.prod.bgchprod.info/omnia/accessTokens", "weather": "https://weather.prod.bgchprod.info/weather", } @@ -48,12 +42,10 @@ def __init__(self, hiveSession=None, websession: Optional[ClientSession] = None) "original": "No response to Hive API request", "parsed": "No response to Hive API request", } - self.session = hiveSession + self.session = hive_session self.websession = ClientSession() if websession is None else websession - async def request( - self, method: str, url: str, camera: bool = False, **kwargs - ) -> ClientResponse: + async def request(self, method: str, url: str, **kwargs) -> ClientResponse: """Make a request.""" _LOGGER.debug("API %s request to %s", method.upper(), url) data = kwargs.get("data", None) @@ -64,24 +56,18 @@ async def request( "User-Agent": "Hive/12.04.0 iOS/18.3.1 Apple", } try: - if camera: - headers["Authorization"] = ( - f"Bearer {self.session.tokens.tokenData['token']}" - ) - headers["x-jwt-token"] = self.session.tokens.tokenData["token"] - else: - headers["Authorization"] = self.session.tokens.tokenData["token"] - except KeyError: + headers["Authorization"] = self.session.tokens.token_data["token"] + except KeyError as exc: if "sso" in url: pass else: - raise NoApiToken + raise NoApiToken from exc auth_token = headers.get("Authorization", "") _LOGGER.debug( "Using token (len=%d, tail=…%s)", len(auth_token), - auth_token[-4:] if len(auth_token) >= 4 else auth_token, + auth_token[-4:] if len(auth_token) >= 4 else auth_token, # noqa: PLR2004 ) timeout = ClientTimeout(total=self.timeout) @@ -103,21 +89,25 @@ async def request( if resp.status in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN): _LOGGER.error( - f"Hive token rejected calling {url} - " - f"HTTP {resp.status} — response: {resp_body[:200]}" + "Hive token rejected calling %s - HTTP %s — response: %s", + url, + resp.status, + resp_body[:200], ) raise HiveAuthError( f"Token expired or forbidden calling {url} — HTTP {resp.status}" ) - elif url is not None and resp.status is not None: + if url is not None and resp.status is not None: _LOGGER.error( - f"Something has gone wrong calling {url} - " - f"HTTP status is - {resp.status} — response: {resp_body[:200]}" + "Something has gone wrong calling %s - HTTP status is - %s — response: %s", + url, + resp.status, + resp_body[:200], ) raise HiveApiError - def getLoginInfo(self): + def get_login_info(self): """Get login properties to make the login request.""" url = "https://sso.hivehome.com/" @@ -132,40 +122,40 @@ def getLoginInfo(self): + "}" ) - loginData = {} - loginData.update({"UPID": json_data["HiveSSOPoolId"]}) - loginData.update({"CLIID": json_data["HiveSSOPublicCognitoClientId"]}) - loginData.update({"REGION": json_data["HiveSSOPoolId"]}) - return loginData + login_data = {} + login_data.update({"UPID": json_data["HiveSSOPoolId"]}) + login_data.update({"CLIID": json_data["HiveSSOPublicCognitoClientId"]}) + login_data.update({"REGION": json_data["HiveSSOPoolId"]}) + return login_data - async def refreshTokens(self): + async def refresh_tokens(self): """Refresh tokens - DEPRECATED NOW BY AWS TOKEN MANAGEMENT.""" url = self.urls["refresh"] if self.session is not None: - tokens = self.session.tokens.tokenData + tokens = self.session.tokens.token_data jsc = ( "{" + ",".join( - ('"' + str(i) + '": ' '"' + str(t) + '" ' for i, t in tokens.items()) + ('"' + str(i) + '": "' + str(t) + '" ' for i, t in tokens.items()) ) + "}" ) try: await self.request("post", url, data=jsc) - if self.json_return["original"] == 200: + if self.json_return["original"] == HTTP_OK: info = self.json_return["parsed"] if "token" in info: - await self.session.updateTokens(info) - self.baseUrl = info["platform"]["endpoint"] - self.cameraBaseUrl = info["platform"]["cameraPlatform"] + await self.session.update_tokens(info) + # pylint: disable-next=invalid-sequence-index + self.base_url = info["platform"]["endpoint"] return True except (ConnectionError, OSError, RuntimeError, ZeroDivisionError): await self.error() return self.json_return - async def getAll(self): + async def get_all(self): """Build and query all endpoint.""" json_return = {} url = self.urls["all"] @@ -181,49 +171,7 @@ async def getAll(self): return json_return - async def getAlarm(self): - """Build and query alarm endpoint.""" - json_return = {} - url = self.urls["alarm"] + self.session.config.homeID - try: - resp = await self.request("get", url) - json_return.update({"original": resp.status}) - json_return.update({"parsed": await resp.json(content_type=None)}) - except (OSError, RuntimeError, ZeroDivisionError): - await self.error() - - return json_return - - async def getCameraImage(self, device): - """Build and query alarm endpoint.""" - json_return = {} - url = self.urls["cameraImages"].format(device["props"]["hardwareIdentifier"]) - try: - resp = await self.request("get", url, True) - json_return.update({"original": resp.status}) - json_return.update({"parsed": await resp.json(content_type=None)}) - except (OSError, RuntimeError, ZeroDivisionError): - await self.error() - - return json_return - - async def getCameraRecording(self, device, eventId): - """Build and query alarm endpoint.""" - json_return = {} - url = self.urls["cameraRecordings"].format( - device["props"]["hardwareIdentifier"], eventId - ) - try: - resp = await self.request("get", url, True) - recUrl = await resp.text() - json_return.update({"original": resp.status}) - json_return.update({"parsed": recUrl.split("\n")[3]}) - except (OSError, RuntimeError, ZeroDivisionError): - await self.error() - - return json_return - - async def getDevices(self): + async def get_devices(self): """Call the get devices endpoint.""" json_return = {} url = self.urls["devices"] @@ -236,7 +184,7 @@ async def getDevices(self): return json_return - async def getProducts(self): + async def get_products(self): """Call the get products endpoint.""" json_return = {} url = self.urls["products"] @@ -249,7 +197,7 @@ async def getProducts(self): return json_return - async def getActions(self): + async def get_actions(self): """Call the get actions endpoint.""" json_return = {} url = self.urls["actions"] @@ -262,7 +210,7 @@ async def getActions(self): return json_return - async def motionSensor(self, sensor, fromepoch, toepoch): + async def motion_sensor(self, sensor, fromepoch, toepoch): """Call a way to get motion sensor info.""" json_return = {} url = ( @@ -286,7 +234,7 @@ async def motionSensor(self, sensor, fromepoch, toepoch): return json_return - async def getWeather(self, weather_url): + async def get_weather(self, weather_url): """Call endpoint to get local weather from Hive API.""" json_return = {} t_url = self.urls["weather"] + weather_url @@ -300,71 +248,43 @@ async def getWeather(self, weather_url): return json_return - async def setState(self, n_type, n_id, **kwargs): + async def set_state(self, n_type, n_id, **kwargs): """Set the state of a Device.""" - _LOGGER.debug("setState - Setting state for %s/%s: %s", n_type, n_id, kwargs) + _LOGGER.debug("set_state - Setting state for %s/%s: %s", n_type, n_id, kwargs) json_return = {} jsc = ( "{" + ",".join( - ('"' + str(i) + '": ' '"' + str(t) + '" ' for i, t in kwargs.items()) + ('"' + str(i) + '": "' + str(t) + '" ' for i, t in kwargs.items()) ) + "}" ) url = self.urls["nodes"].format(n_type, n_id) try: - await self.isFileBeingUsed() - resp = await self.request("post", url, data=jsc) - json_return["original"] = resp.status - json_return["parsed"] = await resp.json(content_type=None) - except (FileInUse, OSError, RuntimeError, ConnectionError) as e: - if e.__class__.__name__ == "FileInUse": - return {"original": "file"} - else: - await self.error() - - return json_return - - async def setAlarm(self, **kwargs): - """Set the state of the alarm.""" - _LOGGER.debug("Setting alarm state: %s", kwargs) - json_return = {} - jsc = ( - "{" - + ",".join( - ('"' + str(i) + '": ' '"' + str(t) + '" ' for i, t in kwargs.items()) - ) - + "}" - ) - - url = f"{self.urls['alarm']}{self.session.config.homeID}" - try: - await self.isFileBeingUsed() + await self.is_file_being_used() resp = await self.request("post", url, data=jsc) json_return["original"] = resp.status json_return["parsed"] = await resp.json(content_type=None) except (FileInUse, OSError, RuntimeError, ConnectionError) as e: if e.__class__.__name__ == "FileInUse": return {"original": "file"} - else: - await self.error() + await self.error() return json_return - async def setAction(self, n_id, data): + async def set_action(self, n_id, data): """Set the state of a Action.""" _LOGGER.debug("Setting action %s", n_id) jsc = data url = self.urls["actions"] + "/" + n_id try: - await self.isFileBeingUsed() + await self.is_file_being_used() await self.request("put", url, data=jsc) except (FileInUse, OSError, RuntimeError, ConnectionError) as e: if e.__class__.__name__ == "FileInUse": return {"original": "file"} - else: - await self.error() + await self.error() return self.json_return @@ -373,7 +293,7 @@ async def error(self): _LOGGER.error("HTTP error occurred during Hive API interaction.") raise web_exceptions.HTTPError - async def isFileBeingUsed(self): + async def is_file_being_used(self): """Check if running in file mode.""" if self.session.config.file: raise FileInUse() diff --git a/src/api/hive_auth.py b/src/api/hive_auth.py index 9ce7d75..faba506 100644 --- a/src/api/hive_auth.py +++ b/src/api/hive_auth.py @@ -71,7 +71,7 @@ class HiveAuth: SMS_MFA_CHALLENGE = "SMS_MFA" DEVICE_VERIFIER_CHALLENGE = "DEVICE_SRP_AUTH" - def __init__( # pylint: disable=too-many-positional-arguments + def __init__( # pylint: disable=too-many-positional-arguments # noqa: PLR0913 self, username: str, password: str, @@ -114,7 +114,7 @@ def __init__( # pylint: disable=too-many-positional-arguments self.use_file = bool(self.username == "use@file.com") self.file_response = {"AuthenticationResult": {"AccessToken": "file"}} self.api = HiveApi() - self.data = self.api.getLoginInfo() + self.data = self.api.get_login_info() self.__pool_id = self.data.get("UPID") self.__client_id = self.data.get("CLIID") self.__region = self.data.get("REGION").split("_")[0] @@ -334,7 +334,7 @@ def process_challenge(self, challenge_parameters: dict): return response - def login(self): + def login(self): # noqa: PLR0912 """Login into a Hive account.""" if self.use_file: return self.file_response @@ -586,7 +586,7 @@ def calculate_u(big_a, big_b): def long_to_hex(long_num): """Convert long number to hex.""" - return "%x" % long_num # pylint: disable=consider-using-f-string + return f"{long_num:x}" def pad_hex(long_int): @@ -601,9 +601,9 @@ def pad_hex(long_int): else: hash_str = long_int if len(hash_str) % 2 == 1: - hash_str = "0%s" % hash_str # pylint: disable=consider-using-f-string + hash_str = f"0{hash_str}" elif hash_str[0] in "89ABCDEFabcdef": - hash_str = "00%s" % hash_str # pylint: disable=consider-using-f-string + hash_str = f"00{hash_str}" return hash_str diff --git a/src/api/hive_auth_async.py b/src/api/hive_auth_async.py index 0cd2f25..668b22f 100644 --- a/src/api/hive_auth_async.py +++ b/src/api/hive_auth_async.py @@ -63,7 +63,7 @@ class HiveAuthAsync: DEVICE_VERIFIER_CHALLENGE = "DEVICE_SRP_AUTH" DEVICE_PASSWORD_CHALLENGE = "DEVICE_PASSWORD_VERIFIER" - def __init__( # pylint: disable=too-many-positional-arguments + def __init__( # pylint: disable=too-many-positional-arguments # noqa: PLR0913 self, username: str, password: str, @@ -106,7 +106,7 @@ def __init__( # pylint: disable=too-many-positional-arguments async def async_init(self): """Initialise async variables.""" - self.data = await self.loop.run_in_executor(None, self.api.getLoginInfo) + self.data = await self.loop.run_in_executor(None, self.api.get_login_info) self.__pool_id = self.data.get("UPID") self.__client_id = self.data.get("CLIID") self.__region = self.data.get("REGION").split("_")[0] @@ -360,7 +360,7 @@ async def process_challenge(self, challenge_parameters): return response - async def login(self): + async def login(self): # noqa: PLR0912 """Login into a Hive account - handles initial SRP auth only.""" if self.use_file: _LOGGER.debug("login - Using file-based authentication.") @@ -807,7 +807,7 @@ def calculate_u(big_a, big_b): def long_to_hex(long_num): """Convert long number to hex.""" - return "%x" % long_num # pylint: disable=consider-using-f-string + return f"{long_num:x}" def pad_hex(long_int): @@ -817,9 +817,9 @@ def pad_hex(long_int): else: hash_str = long_int if len(hash_str) % 2 == 1: - hash_str = "0%s" % hash_str # pylint: disable=consider-using-f-string + hash_str = f"0{hash_str}" elif hash_str[0] in "89ABCDEFabcdef": - hash_str = "00%s" % hash_str # pylint: disable=consider-using-f-string + hash_str = f"00{hash_str}" return hash_str diff --git a/src/camera.py b/src/camera.py deleted file mode 100644 index 4418979..0000000 --- a/src/camera.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Hive Camera Module.""" - -# pylint: skip-file -import logging - -_LOGGER = logging.getLogger(__name__) - - -class HiveCamera: - """Hive camera. - - Returns: - object: Hive camera - """ - - cameraType = "Camera" - - async def getCameraTemperature(self, device: dict): - """Get the camera state. - - Returns: - boolean: True/False if camera is on. - """ - state = None - - try: - data = self.session.data.devices[device["hiveID"]] - state = data["props"]["temperature"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def getCameraState(self, device: dict): - """Get the camera state. - - Returns: - boolean: True/False if camera is on. - """ - state = None - - try: - data = self.session.data.devices[device["hiveID"]] - state = True if data["state"]["mode"] == "ARMED" else False - except KeyError as e: - _LOGGER.error(e) - - return state - - async def getCameraImageURL(self, device: dict): - """Get the camera image url. - - Returns: - str: image url. - """ - state = None - - try: - state = self.session.data.camera[device["hiveID"]]["cameraImage"][ - "thumbnailUrls" - ][0] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def getCameraRecodringURL(self, device: dict): - """Get the camera recording url. - - Returns: - str: image url. - """ - state = None - - try: - state = self.session.data.camera[device["hiveID"]]["cameraRecording"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def setCameraOn(self, device: dict, mode: str): - """Set the camera state to on. - - Args: - device (dict): Camera device. - - Returns: - boolean: True/False if successful. - """ - final = False - - if ( - device["hiveID"] in self.session.data.devices - and device["deviceData"]["online"] - ): - _LOGGER.debug("setCameraOn - Setting camera ON for %s.", device["haName"]) - await self.session.hiveRefreshTokens() - resp = await self.session.api.setState(mode=mode) - if resp["original"] == 200: - final = True - await self.session.getCamera() - - return final - - async def setCameraOff(self, device: dict, mode: str): - """Set the camera state to on. - - Args: - device (dict): Camera device. - - Returns: - boolean: True/False if successful. - """ - final = False - - if ( - device["hiveID"] in self.session.data.devices - and device["deviceData"]["online"] - ): - _LOGGER.debug("setCameraOff - Setting camera OFF for %s.", device["haName"]) - await self.session.hiveRefreshTokens() - resp = await self.session.api.setState(mode=mode) - if resp["original"] == 200: - final = True - await self.session.getCamera() - - return final - - -class Camera(HiveCamera): - """Home assistant camera. - - Args: - HiveCamera (object): Class object. - """ - - def __init__(self, session: object = None): - """Initialise camera. - - Args: - session (object, optional): Used to interact with the hive account. Defaults to None. - """ - self.session = session - - async def getCamera(self, device: dict): - """Get camera data. - - Args: - device (dict): Device to update. - - Returns: - dict: Updated device. - """ - if self.session.shouldUseCachedData(): - cached = self.session.getCachedDevice(device) - if cached is not None: - _LOGGER.debug( - "getCamera - Returning cached state for camera %s (slow/busy poll).", - device["haName"], - ) - return cached - device["deviceData"].update( - {"online": await self.session.attr.onlineOffline(device["device_id"])} - ) - dev_data = {} - - if device["deviceData"]["online"]: - self.session.helper.deviceRecovered(device["device_id"]) - _LOGGER.debug("getCamera - Updating camera data for %s.", device["haName"]) - data = self.session.data.devices[device["device_id"]] - dev_data = { - "hiveID": device["hiveID"], - "hiveName": device["hiveName"], - "hiveType": device["hiveType"], - "haName": device["haName"], - "haType": device["haType"], - "device_id": device["device_id"], - "device_name": device["device_name"], - "status": { - "temperature": await self.getCameraTemperature(device), - "state": await self.getCameraState(device), - "imageURL": await self.getCameraImageURL(device), - "recordingURL": await self.getCameraRecodringURL(device), - }, - "deviceData": data.get("props", None), - "parentDevice": data.get("parent", None), - "custom": device.get("custom", None), - "attributes": await self.session.attr.stateAttributes( - device["device_id"], device["hiveType"] - ), - } - - return self.session.setCachedDevice(device, dev_data) - else: - await self.session.helper.errorCheck( - device["device_id"], "ERROR", device["deviceData"]["online"] - ) - device.setdefault("status", {"state": None}) - return device diff --git a/src/data/alarm.json b/src/data/alarm.json deleted file mode 100644 index 5392dec..0000000 --- a/src/data/alarm.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "mode": "home", - "securitySystemState": "ARMED", - "armingGracePeriod": 60, - "alarmingGracePeriod": 60, - "devices": [ - "keypad-0000-0000-000000000001", - "contact-sensor-0000-0000-000000000001", - "siren-0000-0000-000000000001", - "contact-sensor-0000-0000-000000000002" - ], - "triggers": { - "home": [], - "away": [ - "contact-sensor-0000-0000-000000000001", - "contact-sensor-0000-0000-000000000002" - ], - "asleep": [ - "contact-sensor-0000-0000-000000000002", - "contact-sensor-0000-0000-000000000001" - ] - }, - "actions": { - "away": [ - "keypad-0000-0000-000000000001" - ], - "asleep": [ - "keypad-0000-0000-000000000001" - ], - "sos": [ - "keypad-0000-0000-000000000001" - ] - }, - "monitoringCameras": { - "home": [], - "away": [], - "asleep": [] - }, - "alarmingGraceChirp": { - "away": "ON_GRACE_START", - "asleep": "ON_GRACE_START" - }, - "numberOfTriggersToAlarm": { - "away": 1, - "asleep": 1 - }, - "modeValid": { - "home": true, - "away": true, - "asleep": true - }, - "pinSchedules": {} -} \ No newline at end of file diff --git a/src/data/camera.json b/src/data/camera.json deleted file mode 100644 index 604ffbe..0000000 --- a/src/data/camera.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "cameraImage": { - "parsed": { - "events": [ - { - "thumbnailUrls": [ - "https://test.com/image" - ], - "hasRecording": true - } - ] - } - }, - "camaeraRecording": { - "parsed": "https://test.com/video" - } -} \ No newline at end of file diff --git a/src/data/data.json b/src/data/data.json index 246abf4..9c257a3 100644 --- a/src/data/data.json +++ b/src/data/data.json @@ -21,6 +21,17 @@ "locale": "en-GB", "temperatureUnit": "C" }, + "status": "OK", + "alerts": { + "failuresEmail": false, + "failuresSMS": false, + "warningsEmail": false, + "warningsSMS": false, + "nightAlerts": false + }, + "media": { + "allowAnalyticsSharing": false + }, "products": [ { "id": "hub-0000-0000-0000-000000000001", @@ -73,894 +84,834 @@ } }, { - "id": "heating-0000-0000-0000-000000000001", - "type": "heating", + "id": "hotwater-0000-0000-0000-000000000001", + "type": "hotwater", "sortOrder": 0, - "created": 1294771598031, - "lastSeen": 1574348291098, + "created": 1490945300074, + "lastSeen": 1496048965374, "parent": "parent-0000-0000-0000-000000000002", "props": { "online": true, - "model": "SLR1", - "version": "09134640", - "capabilities": [ - "INFORMATION", - "RENAME", - "HEATING_ALERTS", - "GEOLOCATION", - "HOLIDAY_MODE", - "BOOST", - "OPTIMUM_START" - ], + "model": "SLR2", + "version": "08074640", + "zone": "parent-0000-0000-0000-000000000002", + "maxEvents": 6, "holidayMode": { "enabled": false, - "start": null, - "end": null, - "temperature": 7 + "start": 1494063000000, + "end": 1494167400000 }, - "modelVariant": "SLR1", - "working": true, - "pmz": "OK", + "capabilities": [ + "BOOST", + "HOLIDAY_MODE" + ], "previous": { - "mode": "SCHEDULE", - "target": 19.5 - }, - "scheduleOverride": true, - "temperature": 20.28, - "zone": "parent-0000-0000-0000-000000000002", - "autoBoost": { - "active": false, - "target": 21, - "duration": 30, - "trvs": ["509f4355-83ac-45d0-bce2-0f9727478ed3"] + "mode": "OFF" }, - "readyBy": false + "pmz": "OK" }, "state": { - "name": "Heating", - "boost": 8, - "frostProtection": 7, - "mode": "BOOST", + "name": "Hotwater", + "mode": "SCHEDULE", "schedule": { "monday": [ { "value": { - "target": 20 + "status": "ON" }, - "start": 300 + "start": 405 }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 960 - } - ], - "tuesday": [ + "start": 435 + }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 300 + "start": 720 }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 960 - } - ], - "wednesday": [ + "start": 840 + }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 300 + "start": 960 }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 960 + "start": 1290 } ], - "thursday": [ + "tuesday": [ { "value": { - "target": 20 + "status": "ON" }, - "start": 300 + "start": 495 }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 960 - } - ], - "friday": [ + "start": 525 + }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 300 + "start": 720 }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 960 - } - ], - "saturday": [ + "start": 840 + }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 300 + "start": 960 }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 960 + "start": 1290 } ], - "sunday": [ + "wednesday": [ { "value": { - "target": 20 + "status": "ON" }, - "start": 300 + "start": 405 }, { "value": { - "target": 20 + "status": "OFF" }, - "start": 960 - } - ] - }, - "target": 22, - "optimumStart": true, - "failureStatus": "NORMAL" - } - }, - { - "id": "heating-0000-0000-0000-000000000002", - "type": "nathermostat", - "sortOrder": 1, - "created": 1568080039004, - "lastSeen": 1568085013210, - "parent": "parent-0000-0000-0000-000000000002", - "props": { - "online": true, - "presenceLastChanged": 1568156820531, - "model": "SLT4", - "version": "08000300", - "manufacturer": "Computime", - "upgrade": { - "available": false, - "version": "08000300", - "upgrading": false - }, - "previous": { - "mode": "SCHEDULE", - "heat": "19.5", - "status": true - }, - "minHeat": 7, - "maxHeat": 32, - "state": "HEAT", - "maxScheduleEvents": 6, - "capabilities": [ - "INFORMATION", - "RENAME", - "GEOLOCATION", - "heat", - "humidity-read", - "boost", - "HOLIDAY_MODE" - ], - "humidity": 59, - "lifecycle": "NORMAL", - "temperature": 23.38, - "holidayMode": { - "active": false, - "enabled": false, - "start": 946684800000, - "end": 946684800000 - }, - "zone": "zone2-0000-0000-0000-000000000000", - "pmz": "OK" - }, - "state": { - "name": "US Thermostat", - "zoneName": "US Thermostat", - "heat": 14.44, - "mode": "MANUAL", - "schedule": { - "monday": [ + "start": 435 + }, { - "start": 120, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 720 }, { - "start": 270, "value": { - "heat": 19.5 - } + "status": "OFF" + }, + "start": 840 }, { - "start": 720, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 960 }, { - "start": 1290, "value": { - "heat": 19.5 - } + "status": "OFF" + }, + "start": 1290 } ], - "tuesday": [ - { - "start": 120, - "value": { - "heat": 22 - } - }, + "thursday": [ { - "start": 270, "value": { - "heat": 19.5 - } + "status": "ON" + }, + "start": 405 }, { - "start": 720, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 435 }, { - "start": 1290, - "value": { - "heat": 19.5 - } - } - ], - "wednesday": [ - { - "start": 120, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 720 }, { - "start": 270, "value": { - "heat": 19.5 - } + "status": "OFF" + }, + "start": 840 }, { - "start": 720, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 960 }, { - "start": 1290, "value": { - "heat": 19.5 - } + "status": "OFF" + }, + "start": 1290 } ], - "thursday": [ - { - "start": 150, - "value": { - "heat": 22 - } - }, + "friday": [ { - "start": 300, "value": { - "heat": 19.5 - } + "status": "ON" + }, + "start": 405 }, { - "start": 720, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 435 }, { - "start": 1140, - "value": { - "heat": 19.5 - } - } - ], - "friday": [ - { - "start": 150, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 720 }, { - "start": 300, "value": { - "heat": 19.5 - } + "status": "OFF" + }, + "start": 840 }, { - "start": 720, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 960 }, { - "start": 1140, "value": { - "heat": 19.5 - } + "status": "OFF" + }, + "start": 1290 } ], "saturday": [ { - "start": 150, "value": { - "heat": 22 - } + "status": "ON" + }, + "start": 405 }, { - "start": 300, "value": { - "heat": 19.5 - } + "status": "OFF" + }, + "start": 435 }, { - "start": 720, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 720 }, { - "start": 1140, "value": { - "heat": 19.5 - } - } - ], - "sunday": [ + "status": "OFF" + }, + "start": 840 + }, { - "start": 120, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 960 }, { - "start": 270, "value": { - "heat": 19.5 - } + "status": "OFF" + }, + "start": 1290 + } + ], + "sunday": [ + { + "value": { + "status": "ON" + }, + "start": 495 }, { - "start": 720, "value": { - "heat": 22 - } + "status": "OFF" + }, + "start": 525 }, { - "start": 1290, "value": { - "heat": 19.5 - } + "status": "OFF" + }, + "start": 720 + }, + { + "value": { + "status": "OFF" + }, + "start": 840 + }, + { + "value": { + "status": "OFF" + }, + "start": 960 + }, + { + "value": { + "status": "OFF" + }, + "start": 1290 } ] }, - "status": true - }, - "error": "" + "boost": null, + "status": "OFF" + } }, { - "id": "trv-0000-0000-0000-000000000001", - "type": "trvcontrol", + "id": "heating-0000-0000-0000-000000000001", + "type": "heating", "sortOrder": 1, - "created": 1570649136328, - "parent": "parent-0000-0000-0000-000000000001", + "created": 1498773558071, + "lastSeen": 1761218641848, + "parent": "boilermodule-0000-0000-0000-000000000001", "props": { "online": true, - "version": "00000000", + "model": "SLR1", + "version": "10094640", "upgrade": { "available": false, "upgrading": false }, - "parent": "heating-0000-0000-0000-000000000001", "capabilities": [ + "INFORMATION", + "RENAME", + "HEATING_ALERTS", + "GEOLOCATION", "HOLIDAY_MODE", "BOOST", - "TRV_AUTO_BOOST", + "OPTIMUM_START", "TRV_AUTO_BOOST_READY" ], - "maxEvents": 6, - "pmz": "OK", - "temperature": 21, + "holidayMode": { + "active": false, + "enabled": false, + "start": 1621203120000, + "end": 1801091820000, + "temperature": 7 + }, + "modelVariant": "SLR1", "working": false, - "trvs": ["trv-0000-0000-0000-000000000001"], + "pmz": "OK", "previous": { - "mode": "MANUAL", - "target": 20.5 + "mode": "OFF" }, "scheduleOverride": false, - "zoneName": "TRV 1", + "temperature": 19.2, + "zone": "boilermodule-0000-0000-0000-000000000001", "autoBoost": { "active": false, - "target": 22, "duration": 30, "trvs": [] - } + }, + "readyBy": false }, "state": { - "name": "TRV 1", - "mode": "OFF", - "target": 7, + "name": "Heating Zone 1", + "boost": null, "frostProtection": 7, + "mode": "OFF", "schedule": { "monday": [ { - "start": 420, "value": { - "target": 7 - } + "target": 20 + }, + "start": 330 }, { - "start": 1080, "value": { "target": 7 - } + }, + "start": 510 }, { - "start": 1260, "value": { "target": 20 - } + }, + "start": 900 }, { - "start": 1380, "value": { "target": 7 - } + }, + "start": 1020 } ], "tuesday": [ { - "start": 420, "value": { - "target": 7 - } + "target": 20 + }, + "start": 330 }, { - "start": 1080, "value": { "target": 7 - } + }, + "start": 510 }, { - "start": 1260, "value": { "target": 20 - } + }, + "start": 900 }, { - "start": 1380, "value": { "target": 7 - } + }, + "start": 1020 } ], "wednesday": [ { - "start": 420, "value": { - "target": 7 - } + "target": 20 + }, + "start": 330 }, { - "start": 1080, "value": { "target": 7 - } + }, + "start": 510 }, { - "start": 1260, "value": { "target": 20 - } + }, + "start": 900 }, { - "start": 1380, "value": { "target": 7 - } + }, + "start": 1020 } ], "thursday": [ { - "start": 420, "value": { - "target": 7 - } + "target": 20 + }, + "start": 330 }, { - "start": 1080, "value": { "target": 7 - } + }, + "start": 510 }, { - "start": 1260, "value": { "target": 20 - } + }, + "start": 900 }, { - "start": 1380, "value": { "target": 7 - } + }, + "start": 1020 } ], "friday": [ { - "start": 420, "value": { - "target": 7 - } + "target": 20 + }, + "start": 330 }, { - "start": 1080, "value": { "target": 7 - } + }, + "start": 510 }, { - "start": 1260, "value": { "target": 20 - } + }, + "start": 900 }, { - "start": 1380, "value": { "target": 7 - } + }, + "start": 1020 } ], "saturday": [ { - "start": 420, "value": { - "target": 7 - } + "target": 20 + }, + "start": 330 }, { - "start": 1080, "value": { "target": 7 - } + }, + "start": 510 }, { - "start": 1260, "value": { "target": 20 - } + }, + "start": 900 }, { - "start": 1380, "value": { "target": 7 - } + }, + "start": 1020 } ], "sunday": [ { - "start": 420, "value": { - "target": 7 - } + "target": 20 + }, + "start": 330 }, { - "start": 1080, "value": { "target": 7 - } + }, + "start": 510 }, { - "start": 1260, "value": { "target": 20 - } + }, + "start": 900 }, { - "start": 1380, "value": { "target": 7 - } + }, + "start": 1020 } ] }, - "autoBoost": "DISABLED", - "autoBoostTarget": 22, - "zone": "thermostat-0000-0000-0000-000000000001" + "target": 7, + "optimumStart": false, + "autoBoost": "ENABLED" } }, { - "id": "camera-0000-0000-0000-000000000001", - "type": "hivecamera", - "sortOrder": 0, - "created": 1243477586508, - "lastSeen": 1553324607010, + "id": "trvcontrol-0000-0000-0000-000000000001", + "type": "trvcontrol", + "sortOrder": 1, + "created": 1714990857569, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, - "model": "HCI001", - "version": "V0_0_00_093_svn825", - "manufacturer": "Hive", - "hardwareIdentifier": "51385FC1F5024E748A446456449E9601", - "ipAddress": "000.168.0.00", - "macAddress": "00:0A:11:BB:22:CC", - "temperature": "40", - "hardwareVersion": "H4", + "version": "00000000", + "upgrade": { + "available": false, + "upgrading": false + }, + "parent": "trvcontrol-0000-0000-0000-000000000001", "capabilities": [ - "CAMERA_VIDEO", - "CAMERA_DETECTION", - "CAMERA_DEVICE", - "NOTIFICATIONS", - "INFORMATION", - "CHANGE_WIFI", - "RENAME", - "DELETE" - ] + "HOLIDAY_MODE", + "BOOST", + "TRV_AUTO_BOOST_READY" + ], + "maxEvents": 6, + "pmz": "OK", + "temperature": 17.6, + "working": false, + "trvs": [ + "trv-0000-0000-0000-000000000001" + ], + "consumers": [ + "trv-0000-0000-0000-000000000001" + ], + "previous": {}, + "scheduleOverride": false, + "zoneName": "TRV Zone 1" }, "state": { - "name": "Camera 1", - "resolution": "1080P", - "motionDetection": "ALL", - "audioDetection": "ALL", - "ledDot": false, - "ledRing": true, - "soundAlert": true, - "nightVision": "AUTO", - "invertImage": false, - "cameraAudio": true, - "motionSensitivity": "LOW", - "audioSensitivity": "LOW", - "timeZone": "Europe/London", - "notificationScheduleEnabled": false, - "notificationsMode": "DISABLED", - "frameRate": "30", - "cameraZoom": "1X", - "storage": "CLOUD", - "activityZone": "ALL", - "scheduleEnabled": false, - "mode": "ARMED", - "systemNotificationSettings": { - "connectionStatus": true, - "batteryLevel": true, - "overheating": true - }, + "name": "TRV Zone 1", + "mode": "OFF", + "target": 7, + "frostProtection": 7, "schedule": { "monday": [ { - "start": 390, + "start": 420, "value": { - "mode": "PRIVACY" + "target": 7 } }, { - "start": 510, + "start": 1080, "value": { - "mode": "ARMED" + "target": 7 } }, { - "start": 960, + "start": 1260, "value": { - "mode": "PRIVACY" + "target": 20 } }, { - "start": 1290, + "start": 1380, "value": { - "mode": "ARMED" + "target": 7 } } ], "tuesday": [ { - "start": 390, + "start": 420, "value": { - "mode": "PRIVACY" + "target": 7 } }, { - "start": 510, + "start": 1080, "value": { - "mode": "ARMED" + "target": 7 } }, { - "start": 960, + "start": 1260, "value": { - "mode": "PRIVACY" + "target": 20 } }, { - "start": 1290, + "start": 1380, "value": { - "mode": "ARMED" + "target": 7 } } ], "wednesday": [ { - "start": 390, + "start": 420, "value": { - "mode": "PRIVACY" + "target": 7 } }, { - "start": 510, + "start": 1080, "value": { - "mode": "ARMED" + "target": 7 } }, { - "start": 960, + "start": 1260, "value": { - "mode": "PRIVACY" + "target": 20 } }, { - "start": 1290, + "start": 1380, "value": { - "mode": "ARMED" + "target": 7 } } ], "thursday": [ { - "start": 390, + "start": 420, "value": { - "mode": "PRIVACY" + "target": 7 } }, { - "start": 510, + "start": 1080, "value": { - "mode": "ARMED" + "target": 7 } }, { - "start": 960, + "start": 1260, "value": { - "mode": "PRIVACY" + "target": 20 } }, { - "start": 1290, + "start": 1380, "value": { - "mode": "ARMED" + "target": 7 } } ], "friday": [ { - "start": 390, + "start": 420, "value": { - "mode": "PRIVACY" + "target": 7 } }, { - "start": 510, + "start": 1080, "value": { - "mode": "ARMED" + "target": 7 } }, { - "start": 960, + "start": 1260, "value": { - "mode": "PRIVACY" + "target": 20 } }, { - "start": 1290, + "start": 1380, "value": { - "mode": "ARMED" + "target": 7 } } ], "saturday": [ { - "start": 390, + "start": 420, "value": { - "mode": "PRIVACY" + "target": 7 } }, { - "start": 510, + "start": 1080, "value": { - "mode": "ARMED" + "target": 7 } }, { - "start": 960, + "start": 1260, "value": { - "mode": "PRIVACY" + "target": 20 } }, { - "start": 1290, + "start": 1380, "value": { - "mode": "ARMED" + "target": 7 } } ], "sunday": [ { - "start": 390, + "start": 420, "value": { - "mode": "PRIVACY" + "target": 7 } }, { - "start": 510, + "start": 1080, "value": { - "mode": "ARMED" + "target": 7 } }, { - "start": 960, + "start": 1260, "value": { - "mode": "PRIVACY" + "target": 20 } }, { - "start": 1290, + "start": 1380, "value": { - "mode": "ARMED" + "target": 7 } } ] + } + }, + "isGroup": false + }, + { + "id": "plug-0000-0000-0000-000000000001", + "type": "activeplug", + "sortOrder": 5, + "created": 1619025208161, + "lastSeen": 1745520318574, + "parent": "hub-0000-0000-0000-000000000001", + "props": { + "online": true, + "presenceLastChanged": 1774895317911, + "model": "SLP2b", + "version": "01075700", + "manufacturer": "Computime", + "upgrade": { + "available": false, + "version": "01075700", + "upgrading": false }, - "notificationSchedule": { + "powerConsumption": 0, + "onSince": 1673189770388, + "inUse": false, + "deviceClass": "UNKNOWN", + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "DEVICE_CLASS", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_ACTION" + ] + }, + "state": { + "name": "Plug 1", + "status": "OFF", + "mode": "MANUAL", + "schedule": { "monday": [ { "start": 390, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 510, "value": { - "state": "ENABLE" + "status": "OFF" } }, { "start": 960, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 1290, "value": { - "state": "ENABLE" + "status": "OFF" } } ], @@ -968,25 +919,25 @@ { "start": 390, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 510, "value": { - "state": "ENABLE" + "status": "OFF" } }, { "start": 960, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 1290, "value": { - "state": "ENABLE" + "status": "OFF" } } ], @@ -994,25 +945,25 @@ { "start": 390, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 510, "value": { - "state": "ENABLE" + "status": "OFF" } }, { "start": 960, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 1290, "value": { - "state": "ENABLE" + "status": "OFF" } } ], @@ -1020,25 +971,25 @@ { "start": 390, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 510, "value": { - "state": "ENABLE" + "status": "OFF" } }, { "start": 960, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 1290, "value": { - "state": "ENABLE" + "status": "OFF" } } ], @@ -1046,25 +997,25 @@ { "start": 390, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 510, "value": { - "state": "ENABLE" + "status": "OFF" } }, { "start": 960, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 1290, "value": { - "state": "ENABLE" + "status": "OFF" } } ], @@ -1072,25 +1023,25 @@ { "start": 390, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 510, "value": { - "state": "ENABLE" + "status": "OFF" } }, { "start": 960, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 1290, "value": { - "state": "ENABLE" + "status": "OFF" } } ], @@ -1098,69 +1049,91 @@ { "start": 390, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 510, "value": { - "state": "ENABLE" + "status": "OFF" } }, { "start": 960, "value": { - "state": "DISABLE" + "status": "ON" } }, { "start": 1290, "value": { - "state": "ENABLE" + "status": "OFF" } } ] - } + }, + "deviceClass": "UNKNOWN" } }, { - "id": "plug-0000-0000-0000-000000000001", + "id": "plug-0000-0000-0000-000000000002", "type": "activeplug", - "sortOrder": 0, - "created": 1295844392614, - "lastSeen": 1549431134911, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 5, + "created": 1543675662699, + "lastSeen": 1734281036179, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1774895289031, "model": "SLP2b", - "version": "01045700", + "version": "01075700", "manufacturer": "Computime", - "powerConsumption": 0, - "onSince": 1550266428863, + "upgrade": { + "available": false, + "version": "01075700", + "upgrading": false, + "status": "COMPLETE" + }, + "powerConsumption": 1, + "onSince": 1777762367553, "inUse": false, "deviceClass": "UNKNOWN", - "capabilities": ["INFORMATION", "RENAME", "DELETE"] + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "DEVICE_CLASS", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_ACTION" + ] }, "state": { - "name": "Plug 1", - "status": "OFF", + "name": "Plug 2", + "status": "ON", "mode": "MANUAL", "schedule": { "monday": [ { - "start": 480, + "start": 390, + "value": { + "status": "ON" + } + }, + { + "start": 510, "value": { "status": "OFF" } }, { - "start": 1320, + "start": 960, "value": { "status": "ON" } }, { - "start": 1380, + "start": 1290, "value": { "status": "OFF" } @@ -1168,19 +1141,25 @@ ], "tuesday": [ { - "start": 480, + "start": 390, + "value": { + "status": "ON" + } + }, + { + "start": 510, "value": { "status": "OFF" } }, { - "start": 1320, + "start": 960, "value": { "status": "ON" } }, { - "start": 1380, + "start": 1290, "value": { "status": "OFF" } @@ -1188,19 +1167,25 @@ ], "wednesday": [ { - "start": 480, + "start": 390, + "value": { + "status": "ON" + } + }, + { + "start": 510, "value": { "status": "OFF" } }, { - "start": 1320, + "start": 960, "value": { "status": "ON" } }, { - "start": 1380, + "start": 1290, "value": { "status": "OFF" } @@ -1208,19 +1193,25 @@ ], "thursday": [ { - "start": 480, + "start": 390, + "value": { + "status": "ON" + } + }, + { + "start": 510, "value": { "status": "OFF" } }, { - "start": 1320, + "start": 960, "value": { "status": "ON" } }, { - "start": 1380, + "start": 1290, "value": { "status": "OFF" } @@ -1228,19 +1219,25 @@ ], "friday": [ { - "start": 480, + "start": 390, + "value": { + "status": "ON" + } + }, + { + "start": 510, "value": { "status": "OFF" } }, { - "start": 1320, + "start": 960, "value": { "status": "ON" } }, { - "start": 1380, + "start": 1290, "value": { "status": "OFF" } @@ -1248,19 +1245,25 @@ ], "saturday": [ { - "start": 480, + "start": 390, + "value": { + "status": "ON" + } + }, + { + "start": 510, "value": { "status": "OFF" } }, { - "start": 1320, + "start": 960, "value": { "status": "ON" } }, { - "start": 1380, + "start": 1290, "value": { "status": "OFF" } @@ -1268,351 +1271,343 @@ ], "sunday": [ { - "start": 480, + "start": 390, + "value": { + "status": "ON" + } + }, + { + "start": 510, "value": { "status": "OFF" } }, { - "start": 1320, + "start": 960, "value": { "status": "ON" } }, { - "start": 1380, + "start": 1290, "value": { "status": "OFF" } } ] - } + }, + "deviceClass": "UNKNOWN" } }, { - "id": "plug-0000-0000-0000-000000000002", + "id": "plug-0000-0000-0000-000000000003", "type": "activeplug", - "sortOrder": 0, - "created": 1523679665643, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 5, + "created": 1512759057107, + "lastSeen": 1770200352575, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1774895287386, "model": "SLP2b", - "version": "01035700", + "version": "01075700", "manufacturer": "Computime", + "upgrade": { + "available": false, + "version": "01075700", + "upgrading": false, + "status": "COMPLETE" + }, "powerConsumption": 0, - "onSince": 1553134057795, + "onSince": 1673213848248, "inUse": false, "deviceClass": "UNKNOWN", - "capabilities": ["INFORMATION", "RENAME", "DELETE"] + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "DEVICE_CLASS", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_ACTION" + ] }, "state": { - "name": "Plug 2", + "name": "Plug 3", "status": "OFF", "mode": "MANUAL", "schedule": { "monday": [ { - "start": 390, + "start": 1320, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "ON" } }, { - "start": 510, + "start": 1380, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } - }, + } + ], + "tuesday": [ { - "start": 960, + "start": 1320, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "ON" } }, { - "start": 1290, + "start": 1380, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } ], - "tuesday": [ + "wednesday": [ { - "start": 390, + "start": 1320, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "ON" } }, { - "start": 510, + "start": 1380, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } - }, + } + ], + "thursday": [ { - "start": 960, + "start": 1320, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "ON" } }, { - "start": 1290, + "start": 1380, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } ], - "wednesday": [ + "friday": [ { - "start": 390, + "start": 1320, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "ON" } }, { - "start": 510, + "start": 1380, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } - }, + } + ], + "saturday": [ { - "start": 960, + "start": 1320, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "ON" } }, { - "start": 1290, + "start": 1380, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } ], - "thursday": [ + "sunday": [ { - "start": 390, + "start": 1320, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "ON" } }, { - "start": 510, - "value": { - "status": "OFF" - } - }, - { - "start": 960, - "value": { - "status": "ON" - } - }, - { - "start": 1290, - "value": { - "status": "OFF" - } - } - ], - "friday": [ - { - "start": 390, - "value": { - "status": "ON" - } - }, - { - "start": 510, - "value": { - "status": "OFF" - } - }, - { - "start": 960, - "value": { - "status": "ON" - } - }, - { - "start": 1290, - "value": { - "status": "OFF" - } - } - ], - "saturday": [ - { - "start": 390, - "value": { - "status": "ON" - } - }, - { - "start": 510, - "value": { - "status": "OFF" - } - }, - { - "start": 960, - "value": { - "status": "ON" - } - }, - { - "start": 1290, - "value": { - "status": "OFF" - } - } - ], - "sunday": [ - { - "start": 390, - "value": { - "status": "ON" - } - }, - { - "start": 510, - "value": { - "status": "OFF" - } - }, - { - "start": 960, - "value": { - "status": "ON" - } - }, - { - "start": 1290, + "start": 1380, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } ] - } + }, + "deviceClass": "UNKNOWN" } }, { - "id": "plug-0000-0000-0000-000000000003", - "type": "activeplug", - "sortOrder": 0, - "created": 1512759057107, - "lastSeen": 1549431076054, - "parent": "parent-0000-0000-0000-000000000001", + "id": "light-0000-0000-0000-000000000001", + "type": "warmwhitelight", + "sortOrder": 7, + "created": 1470158558350, + "lastSeen": 1774937545913, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, - "model": "SLP2b", - "version": "01035700", - "manufacturer": "Computime", - "powerConsumption": 0, - "onSince": 1550266371454, - "inUse": false, - "deviceClass": "UNKNOWN", - "capabilities": ["INFORMATION", "RENAME", "DELETE"] + "presenceLastChanged": 1775155587293, + "model": "FWBulb01", + "version": "11500002", + "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11500002", + "upgrading": false, + "status": "COMPLETE" + }, + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "GROUPABLE", + "MIMIC_MODE", + "SCHEDULE", + "IDENTIFY_DEVICE", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "isHue": false }, "state": { - "name": "Plug 3", + "name": "Light 1", "status": "OFF", + "brightness": 100, "mode": "MANUAL", "schedule": { "monday": [ { - "start": 1320, + "start": 1095, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", + "brightness": 5, "status": "ON" } }, { - "start": 1380, + "start": 1350, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } ], "tuesday": [ { - "start": 1320, + "start": 1095, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", + "brightness": 5, "status": "ON" } }, { - "start": 1380, + "start": 1350, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } ], "wednesday": [ { - "start": 1320, + "start": 1095, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", + "brightness": 5, "status": "ON" } }, { - "start": 1380, + "start": 1350, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } ], "thursday": [ { - "start": 1320, + "start": 1095, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", + "brightness": 5, "status": "ON" } }, { - "start": 1380, + "start": 1350, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } ], "friday": [ { - "start": 1320, + "start": 1095, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", + "brightness": 5, "status": "ON" } }, { - "start": 1380, + "start": 1350, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } ], "saturday": [ { - "start": 1320, + "start": 1095, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", + "brightness": 5, "status": "ON" } }, { - "start": 1380, + "start": 1350, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } ], "sunday": [ { - "start": 1320, + "start": 1095, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", + "brightness": 5, "status": "ON" } }, { - "start": 1380, + "start": 1350, "value": { + "actionType": "http://alertme.com/schema/json/configuration/configuration.device.action.generic.v1.json#", "status": "OFF" } } @@ -1621,17 +1616,23 @@ } }, { - "id": "light-0000-0000-0000-000000000003", + "id": "light-0000-0000-0000-000000000002", "type": "warmwhitelight", - "sortOrder": 0, - "created": 1995324452543, - "lastSeen": 1147535994599, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 7, + "created": 1767908246495, + "lastSeen": 1777663934452, + "parent": "hub-0000-0000-0000-000000000001", "props": { - "online": true, + "online": false, + "presenceLastChanged": 1777664294498, "model": "FWBulb01", - "version": "11480002", + "version": "11500002", "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11500002", + "upgrading": false + }, "capabilities": [ "INFORMATION", "RENAME", @@ -1639,12 +1640,17 @@ "GROUPABLE", "MIMIC_MODE", "SCHEDULE", - "IDENTIFY_DEVICE" - ] + "IDENTIFY_DEVICE", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "isHue": false }, "state": { - "name": "Light 3", - "status": "OFF", + "name": "Light 2", + "status": "ON", "brightness": 100, "mode": "MANUAL", "schedule": { @@ -1848,17 +1854,23 @@ } }, { - "id": "light-0000-0000-0000-000000000004", + "id": "light-0000-0000-0000-000000000003", "type": "warmwhitelight", - "sortOrder": 0, - "created": 1495819606370, - "lastSeen": 1549742852622, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 7, + "created": 1636833865349, + "lastSeen": 1771712343506, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1774895290942, "model": "FWBulb01", - "version": "11480002", + "version": "11500002", "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11500002", + "upgrading": false + }, "capabilities": [ "INFORMATION", "RENAME", @@ -1866,12 +1878,17 @@ "GROUPABLE", "MIMIC_MODE", "SCHEDULE", - "IDENTIFY_DEVICE" - ] + "IDENTIFY_DEVICE", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "isHue": false }, "state": { - "name": "Light 4", - "status": "OFF", + "name": "Light 3", + "status": "ON", "brightness": 100, "mode": "MANUAL", "schedule": { @@ -2075,181 +2092,145 @@ } }, { - "id": "light-0000-0000-0000-000000000002", - "type": "warmwhitelight", - "sortOrder": 0, - "created": 1470158558350, - "lastSeen": 1543686750227, - "parent": "parent-0000-0000-0000-000000000001", + "id": "light-0000-0000-0000-000000000004", + "type": "tuneablelight", + "sortOrder": 8, + "created": 1714853620928, + "lastSeen": 1776191743321, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, - "model": "FWBulb01", - "version": "11480002", + "presenceLastChanged": 1776321032975, + "model": "TWBulb01UK", + "version": "11320002", "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11320002", + "upgrading": false + }, "capabilities": [ "INFORMATION", "RENAME", "DELETE", "GROUPABLE", "MIMIC_MODE", + "LIGHT_TUNEABLE", "SCHEDULE", - "IDENTIFY_DEVICE" - ] + "IDENTIFY_DEVICE", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "colourTemperature": { + "min": 2700, + "max": 6535 + }, + "isHue": false }, "state": { - "name": "Light 2", - "status": "ON", + "name": "Light 4", + "status": "OFF", "brightness": 100, "mode": "MANUAL", "schedule": { "monday": [ { - "start": 1095, + "start": 390, "value": { - "brightness": 5, + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1350, + "start": 510, "value": { "status": "OFF" } - } - ], - "tuesday": [ + }, { - "start": 1095, + "start": 960, "value": { - "brightness": 5, + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1350, + "start": 1290, "value": { "status": "OFF" } } ], - "wednesday": [ + "tuesday": [ { - "start": 1095, + "start": 390, "value": { - "brightness": 5, + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1350, + "start": 510, "value": { "status": "OFF" } - } - ], - "thursday": [ + }, { - "start": 1095, + "start": 960, "value": { - "brightness": 5, + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1350, + "start": 1290, "value": { "status": "OFF" } } ], - "friday": [ + "wednesday": [ { - "start": 1095, + "start": 390, "value": { - "brightness": 5, + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1350, + "start": 510, "value": { "status": "OFF" } - } - ], - "saturday": [ + }, { - "start": 1095, + "start": 960, "value": { - "brightness": 5, + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1350, + "start": 1290, "value": { "status": "OFF" } } ], - "sunday": [ - { - "start": 1095, - "value": { - "brightness": 5, - "status": "ON" - } - }, - { - "start": 1350, - "value": { - "status": "OFF" - } - } - ] - } - } - }, - { - "id": "light-0000-0000-0000-000000000001", - "type": "tuneablelight", - "sortOrder": 0, - "created": 1543676094101, - "lastSeen": 1550215521717, - "parent": "parent-0000-0000-0000-000000000001", - "props": { - "online": true, - "model": "TWBulb01UK", - "version": "11300002", - "manufacturer": "Aurora", - "capabilities": [ - "INFORMATION", - "RENAME", - "DELETE", - "GROUPABLE", - "MIMIC_MODE", - "LIGHT_TUNEABLE", - "SCHEDULE", - "IDENTIFY_DEVICE" - ], - "colourTemperature": { - "min": 2700, - "max": 6535 - } - }, - "state": { - "name": "Light 1", - "status": "OFF", - "brightness": 100, - "mode": "MANUAL", - "schedule": { - "monday": [ + "thursday": [ { "start": 390, "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2264,7 +2245,6 @@ "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2275,13 +2255,12 @@ } } ], - "tuesday": [ + "friday": [ { "start": 390, "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2296,7 +2275,6 @@ "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2307,13 +2285,12 @@ } } ], - "wednesday": [ + "saturday": [ { "start": 390, "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2328,7 +2305,6 @@ "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2339,13 +2315,12 @@ } } ], - "thursday": [ + "sunday": [ { "start": 390, "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2360,7 +2335,6 @@ "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2370,14 +2344,62 @@ "status": "OFF" } } - ], - "friday": [ + ] + }, + "colourTemperature": 2703 + } + }, + { + "id": "light-0000-0000-0000-000000000005", + "type": "tuneablelight", + "sortOrder": 8, + "created": 1591207586685, + "lastSeen": 1775761526088, + "parent": "hub-0000-0000-0000-000000000001", + "props": { + "online": true, + "presenceLastChanged": 1775763889464, + "model": "TWBulb01UK", + "version": "11320002", + "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11320002", + "upgrading": false, + "status": "COMPLETE" + }, + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "GROUPABLE", + "MIMIC_MODE", + "LIGHT_TUNEABLE", + "SCHEDULE", + "IDENTIFY_DEVICE", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "colourTemperature": { + "min": 2700, + "max": 6535 + }, + "isHue": false + }, + "state": { + "name": "Light 5", + "status": "ON", + "brightness": 5, + "mode": "MANUAL", + "schedule": { + "monday": [ { "start": 390, "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2392,7 +2414,6 @@ "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2403,13 +2424,12 @@ } } ], - "saturday": [ + "tuesday": [ { "start": 390, "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2424,7 +2444,6 @@ "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2435,13 +2454,12 @@ } } ], - "sunday": [ + "wednesday": [ { "start": 390, "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2456,7 +2474,6 @@ "value": { "brightness": 100, "colourTemperature": 4617.5, - "colourMode": "TUNABLE", "status": "ON" } }, @@ -2466,70 +2483,18 @@ "status": "OFF" } } - ] - }, - "colourTemperature": 2703 - } - }, - { - "id": "light-0000-0000-0000-000000000005", - "type": "tuneablelight", - "sortOrder": 0, - "created": 1543676824201, - "lastSeen": 1548315854100, - "parent": "parent-0000-0000-0000-000000000001", - "props": { - "online": true, - "model": "TWBulb01UK", - "version": "11300002", - "manufacturer": "Aurora", - "capabilities": [ - "INFORMATION", - "RENAME", - "DELETE", - "GROUPABLE", - "MIMIC_MODE", - "LIGHT_TUNEABLE", - "SCHEDULE", - "IDENTIFY_DEVICE" - ], - "colourTemperature": { - "min": 2700, - "max": 6535 - } - }, - "state": { - "name": "Light 5", - "status": "ON", - "brightness": 55, - "mode": "SCHEDULE", - "schedule": { - "monday": [ - { - "start": 0, - "value": { - "status": "OFF" - } - }, + ], + "thursday": [ { - "start": 960, + "start": 390, "value": { - "brightness": 55, - "colourTemperature": 4021.666732788086, - "colourMode": "TUNABLE", + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1080, - "value": { - "status": "OFF" - } - } - ], - "tuesday": [ - { - "start": 0, + "start": 510, "value": { "status": "OFF" } @@ -2537,45 +2502,29 @@ { "start": 960, "value": { - "brightness": 55, - "colourTemperature": 4021.666732788086, - "colourMode": "TUNABLE", + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1080, + "start": 1290, "value": { "status": "OFF" } } ], - "wednesday": [ - { - "start": 0, - "value": { - "status": "OFF" - } - }, + "friday": [ { - "start": 960, + "start": 390, "value": { - "brightness": 55, - "colourTemperature": 4021.666732788086, - "colourMode": "TUNABLE", + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1080, - "value": { - "status": "OFF" - } - } - ], - "thursday": [ - { - "start": 0, + "start": 510, "value": { "status": "OFF" } @@ -2583,22 +2532,29 @@ { "start": 960, "value": { - "brightness": 55, - "colourTemperature": 4021.666732788086, - "colourMode": "TUNABLE", + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1080, + "start": 1290, "value": { "status": "OFF" } } ], - "friday": [ + "saturday": [ + { + "start": 390, + "value": { + "brightness": 100, + "colourTemperature": 4617.5, + "status": "ON" + } + }, { - "start": 0, + "start": 510, "value": { "status": "OFF" } @@ -2606,117 +2562,133 @@ { "start": 960, "value": { - "brightness": 55, - "colourTemperature": 4021.666732788086, - "colourMode": "TUNABLE", + "brightness": 100, + "colourTemperature": 4617.5, "status": "ON" } }, { - "start": 1080, + "start": 1290, "value": { "status": "OFF" } } ], - "saturday": [ + "sunday": [ { - "start": 0, + "start": 390, "value": { - "status": "OFF" + "brightness": 100, + "colourTemperature": 4617.5, + "status": "ON" } }, { - "start": 1080, + "start": 510, "value": { "status": "OFF" } - } - ], - "sunday": [ + }, { - "start": 0, + "start": 960, "value": { - "status": "OFF" + "brightness": 100, + "colourTemperature": 4617.5, + "status": "ON" } }, { - "start": 1080, + "start": 1290, "value": { "status": "OFF" } } ] }, - "colourTemperature": 4022 + "colourTemperature": 2703, + "securityZone": "Security Zone 1" } }, { "id": "light-0000-0000-0000-000000000006", "type": "colourtuneablelight", - "sortOrder": 4, - "created": 1478271219243, - "lastSeen": 1504707448065, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 9, + "created": 1658855487200, + "lastSeen": 1775327751548, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, - "model": "RGBBulb01UK", - "version": "11200002", + "presenceLastChanged": 1775328532795, + "model": "RGBBulb02UK", + "version": "11110002", "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11110002", + "progress": 0, + "upgrading": false, + "status": "COMPLETE" + }, + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "GROUPABLE", + "MIMIC_MODE", + "LIGHT_COLOUR", + "LIGHT_TUNEABLE", + "SCHEDULE", + "IDENTIFY_DEVICE", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], "colourTemperature": { "min": 2700, "max": 6535 - } + }, + "isHue": false }, "state": { "name": "Light 6", - "status": "ON", - "hue": 0, - "saturation": 99, + "status": "OFF", + "hue": 45, + "saturation": 65, "value": 100, "brightness": 100, - "colourMode": "COLOUR", - "colourTemperature": 2703, + "colourMode": "WHITE", + "colourTemperature": 3300, "mode": "MANUAL", "schedule": { "monday": [ { "start": 390, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 510, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } }, { "start": 960, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 1290, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } } @@ -2725,40 +2697,30 @@ { "start": 390, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 510, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } }, { "start": 960, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 1290, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } } @@ -2767,40 +2729,30 @@ { "start": 390, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 510, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } }, { "start": 960, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 1290, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } } @@ -2809,40 +2761,30 @@ { "start": 390, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 510, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } }, { "start": 960, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 1290, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } } @@ -2851,40 +2793,30 @@ { "start": 390, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 510, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } }, { "start": 960, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 1290, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } } @@ -2893,40 +2825,30 @@ { "start": 390, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 510, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } }, { "start": 960, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 1290, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } } @@ -2935,40 +2857,30 @@ { "start": 390, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 510, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } }, { "start": 960, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", + "brightness": 100, + "colourTemperature": 2700, + "colourMode": "WHITE", "status": "ON" } }, { "start": 1290, "value": { - "saturation": 99, - "value": 100, - "hue": 0, - "colourMode": "COLOUR", "status": "OFF" } } @@ -2977,495 +2889,274 @@ } }, { - "id": "motion-sensor-0000-0000-000000000001", + "id": "sensor-0000-0000-0000-000000000004", "type": "motionsensor", - "sortOrder": 0, + "sortOrder": 11, "created": 1495823441915, - "lastSeen": 1546318436728, - "parent": "parent-0000-0000-0000-000000000001", + "lastSeen": 1776942606733, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1777057366558, "model": "MOT003", "version": "05042603", "manufacturer": "HiveHome.com", + "upgrade": { + "available": false, + "version": "05042603", + "upgrading": false, + "status": "COMPLETE" + }, "motion": { "status": false, - "end": 1550320252962 + "end": 1777756606863 }, + "battery": 80, + "temperature": 19.25, "deviceClass": "UNKNOWN", - "statusChanged": 1550320252962, - "capabilities": ["INFORMATION", "RENAME", "DELETE", "MOTION_SENSOR"] + "statusChanged": 1777756906863, + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "MOTION_SENSOR", + "DEVICE_CLASS", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_TRIGGER" + ] }, "state": { - "name": "Motion Sensor 1" + "name": "Motion Sensor 1", + "deviceClass": "UNKNOWN" } }, { - "id": "contact-sensor-0000-0000-000000000001", + "id": "sensor-0000-0000-0000-000000000001", "type": "contactsensor", - "sortOrder": 0, + "sortOrder": 12, "created": 1498308140974, - "lastSeen": 1549430905506, - "parent": "parent-0000-0000-0000-000000000001", + "lastSeen": 1693654330379, + "parent": "hub-0000-0000-0000-000000000001", "props": { - "online": true, + "online": false, + "presenceLastChanged": 1693655830425, "model": "DWS003", "version": "05042603", "manufacturer": "HiveHome.com", - "status": "CLOSED", - "statusChanged": 1550319940352, - "deviceClass": "UNKNOWN", - "capabilities": ["INFORMATION", "RENAME", "DELETE", "CONTACT_SENSOR"] + "upgrade": { + "available": false, + "version": "05042603", + "upgrading": false, + "status": "COMPLETE" + }, + "status": "OPEN", + "statusChanged": 1692868648708, + "battery": 80, + "deviceClass": "OTHER", + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "CONTACT_SENSOR", + "DEVICE_CLASS", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_TRIGGER" + ] }, "state": { - "name": "Contact Sensor 1" + "name": "Contact Sensor 1", + "deviceClass": "OTHER", + "securityZone": "Security Zone 2", + "securityPlacement": "Door" } }, { - "id": "contact-sensor-0000-0000-000000000002", + "id": "sensor-0000-0000-0000-000000000002", "type": "contactsensor", - "sortOrder": 0, - "created": 1543762412780, - "lastSeen": 1550177054944, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 12, + "created": 1693654666634, + "lastSeen": 1713255795610, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1774895438577, "model": "DWS003", "version": "05042603", "manufacturer": "HiveHome.com", + "upgrade": { + "available": false, + "version": "05042603", + "upgrading": false + }, "status": "CLOSED", - "statusChanged": 1550310866196, - "deviceClass": "UNKNOWN", - "capabilities": ["INFORMATION", "RENAME", "DELETE", "CONTACT_SENSOR"] + "statusChanged": 1777744255480, + "battery": 40, + "deviceClass": "OTHER", + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "CONTACT_SENSOR", + "DEVICE_CLASS", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_TRIGGER" + ] }, "state": { - "name": "Contact Sensor 2" + "name": "Contact Sensor 2", + "deviceClass": "OTHER", + "securityZone": "Security Zone 2", + "securityPlacement": "Door" } }, { - "id": "contact-sensor-0000-0000-000000000003", + "id": "sensor-0000-0000-0000-000000000003", "type": "contactsensor", - "sortOrder": 0, + "sortOrder": 12, "created": 1500998859813, - "lastSeen": 1536931887105, - "parent": "parent-0000-0000-0000-000000000001", + "lastSeen": 1769953564769, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": false, + "presenceLastChanged": 1769955064827, "model": "DWS003", - "version": "04002603", + "version": "05042603", "manufacturer": "HiveHome.com", - "status": "OPEN", - "statusChanged": 1547488095058, - "capabilities": ["INFORMATION", "RENAME", "DELETE", "CONTACT_SENSOR"] + "upgrade": { + "available": false, + "version": "05042603", + "upgrading": false, + "status": "COMPLETE" + }, + "status": "CLOSED", + "statusChanged": 1769953396589, + "battery": 60, + "deviceClass": "OTHER", + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "CONTACT_SENSOR", + "DEVICE_CLASS", + "SECURITY_DASHBOARD", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_TRIGGER" + ] }, "state": { - "name": "Contact Sensor 3" + "name": "Contact Sensor 3", + "deviceClass": "OTHER", + "securityZone": "Security Zone 3", + "securityPlacement": "Door" } - }, + } + ], + "devices": [ { - "id": "hotwater-0000-0000-0000-000000000001", - "type": "hotwater", + "id": "hub-0000-0000-0000-000000000001", + "type": "hub", "sortOrder": 0, - "created": 1490945300074, - "lastSeen": 1496048965374, - "parent": "parent-0000-0000-0000-000000000002", + "created": 1466594464511, + "lastSeen": 1777762849944, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, - "model": "SLR2", - "version": "08074640", - "zone": "parent-0000-0000-0000-000000000002", - "maxEvents": 6, - "holidayMode": { + "model": "SENSE", + "version": "1.0.0-7048-MARS36", + "manufacturer": "AlertMe", + "hardwareIdentifier": "HID-000", + "upgrade": { + "available": false, + "version": "1.0.0-7048-MARS36", + "upgrading": false + }, + "power": "mains", + "signal": 100, + "uptime": 2867400, + "operation": "DEPLOYED", + "ipAddress": "000.168.0.00", + "macAddress": "00:0A:11:BB:22:CC", + "capabilities": [ + "INFORMATION", + "RENAME", + "REBOOT_HUB", + "MIMIC_MODE", + "ActionsDaylight", + "ActionsMultiOutput", + "SLT5_INSTALL", + "DATA_SHARING", + "NOTIFICATIONS", + "CHANGE_WIFI", + "ACTIONS_2", + "INSTALL_PHILIPS_HUE", + "PRODUCT_GROUP", + "SECURITY_LITE", + "HOMEKIT", + "CAMERA_SIREN" + ], + "pmz": "OK", + "connection": "wifi", + "mimicMode": { + "consumers": [ + "light-0000-0000-0000-000000000001", + "light-0000-0000-0000-000000000003", + "light-0000-0000-0000-000000000006", + "light-0000-0000-0000-000000000005", + "light-0000-0000-0000-000000000004" + ], "enabled": false, - "start": 1494063000000, - "end": 1494167400000 + "start": "19:30", + "end": "02:00" }, - "capabilities": ["BOOST", "HOLIDAY_MODE"], - "previous": { - "mode": "OFF" + "assistedLiving": { + "noMorningActivity": false, + "noRecentActivity": false, + "nightTime": false, + "learning": true }, - "pmz": "OK" - }, - "state": { - "name": "Hotwater", - "mode": "SCHEDULE", - "schedule": { - "monday": [ - { - "value": { - "status": "ON" - }, - "start": 405 - }, - { - "value": { - "status": "OFF" - }, - "start": 435 - }, - { - "value": { - "status": "OFF" - }, - "start": 720 - }, - { - "value": { - "status": "OFF" - }, - "start": 840 - }, - { - "value": { - "status": "OFF" - }, - "start": 960 - }, - { - "value": { - "status": "OFF" - }, - "start": 1290 - } - ], - "tuesday": [ - { - "value": { - "status": "ON" - }, - "start": 495 - }, - { - "value": { - "status": "OFF" - }, - "start": 525 - }, - { - "value": { - "status": "OFF" - }, - "start": 720 - }, - { - "value": { - "status": "OFF" - }, - "start": 840 - }, - { - "value": { - "status": "OFF" - }, - "start": 960 - }, - { - "value": { - "status": "OFF" - }, - "start": 1290 - } - ], - "wednesday": [ - { - "value": { - "status": "ON" - }, - "start": 405 - }, - { - "value": { - "status": "OFF" - }, - "start": 435 - }, - { - "value": { - "status": "OFF" - }, - "start": 720 - }, - { - "value": { - "status": "OFF" - }, - "start": 840 - }, - { - "value": { - "status": "OFF" - }, - "start": 960 - }, - { - "value": { - "status": "OFF" - }, - "start": 1290 - } - ], - "thursday": [ - { - "value": { - "status": "ON" - }, - "start": 405 - }, - { - "value": { - "status": "OFF" - }, - "start": 435 - }, - { - "value": { - "status": "OFF" - }, - "start": 720 - }, - { - "value": { - "status": "OFF" - }, - "start": 840 - }, - { - "value": { - "status": "OFF" - }, - "start": 960 - }, - { - "value": { - "status": "OFF" - }, - "start": 1290 - } - ], - "friday": [ - { - "value": { - "status": "ON" - }, - "start": 405 - }, - { - "value": { - "status": "OFF" - }, - "start": 435 - }, - { - "value": { - "status": "OFF" - }, - "start": 720 - }, - { - "value": { - "status": "OFF" - }, - "start": 840 - }, - { - "value": { - "status": "OFF" - }, - "start": 960 - }, - { - "value": { - "status": "OFF" - }, - "start": 1290 - } - ], - "saturday": [ - { - "value": { - "status": "ON" - }, - "start": 405 - }, - { - "value": { - "status": "OFF" - }, - "start": 435 - }, - { - "value": { - "status": "OFF" - }, - "start": 720 - }, - { - "value": { - "status": "OFF" - }, - "start": 840 - }, - { - "value": { - "status": "OFF" - }, - "start": 960 - }, - { - "value": { - "status": "OFF" - }, - "start": 1290 - } - ], - "sunday": [ - { - "value": { - "status": "ON" - }, - "start": 495 - }, - { - "value": { - "status": "OFF" - }, - "start": 525 - }, - { - "value": { - "status": "OFF" - }, - "start": 720 - }, - { - "value": { - "status": "OFF" - }, - "start": 840 - }, - { - "value": { - "status": "OFF" - }, - "start": 960 - }, - { - "value": { - "status": "OFF" - }, - "start": 1290 - } - ] - }, - "boost": null, - "status": "OFF" - } - } - ], - "devices": [ - { - "id": "parent-0000-0000-0000-000000000001", - "type": "hub", - "sortOrder": 0, - "created": 1466594464511, - "lastSeen": 1550320637378, - "parent": "parent-0000-0000-0000-000000000001", - "props": { - "online": true, - "model": "SENSE", - "version": "1.0.0-5999-16.0", - "manufacturer": "AlertMe", - "hardwareIdentifier": "HIR-771", - "power": "mains", - "signal": 100, - "uptime": 1738705, - "operation": "DEPLOYED", - "ipAddress": "192.168.1.168", - "macAddress": "00:1C:2B:1C:2E:68", - "capabilities": [ - "INFORMATION", - "RENAME", - "REBOOT_HUB", - "MIMIC_MODE", - "ActionsDaylight", - "ActionsMultiOutput", - "SLT5_INSTALL", - "DATA_SHARING", - "NOTIFICATIONS", - "CHANGE_WIFI", - "ACTIONS_2", - "INSTALL_PHILIPS_HUE", - "PRODUCT_GROUP" - ], - "pmz": "OK", - "connection": "wifi", - "daylight": { - "status": "LIGHT", - "nextDark": 1550337661000, - "nextLight": 1550388301000, - "nextSunrise": 1550388300000, - "nextSunset": 1550337660000 - }, - "mimicMode": { - "consumers": [ - "light-0000-0000-0000-000000000003", - "light-0000-0000-0000-000000000002", - "light-0000-0000-0000-000000000004" - ], - "enabled": false, - "start": "21:00", - "end": "23:00" - }, - "assistedLiving": { - "noMorningActivity": false, - "nightTime": false, - "learning": true - } + "homeKit": { + "status": "ENABLED", + "paired": false, + "setupCode": "000-00-000", + "setupPayload": "X-HM://0000000000000" + } }, "state": { "name": "Hub", + "homeKitEnabled": true, + "homeKitPaired": false, "discovery": false } }, { - "id": "parent-0000-0000-0000-000000000002", - "type": "boilermodule", - "sortOrder": 0, - "created": 1498773552677, - "lastSeen": 1524848995008, - "parent": "parent-0000-0000-0000-000000000001", - "props": { - "online": true, - "model": "SLR1", - "version": "09134640", - "manufacturer": "Computime", - "power": "mains", - "signal": 100, - "zone": "parent-0000-0000-0000-000000000002", - "tui": "thermostat-0000-0000-0000-000000000001", - "modelVariant": "SLR1", - "capabilities": ["INFORMATION"] - }, - "state": { - "name": "Thermostat", - "zoneName": "Thermostat" - } - }, - { - "id": "thermostat-0000-0000-0000-000000000001", + "id": "thermostatui-0000-0000-0000-000000000001", "type": "thermostatui", - "sortOrder": 0, + "sortOrder": 1, "created": 1498773546051, - "lastSeen": 1549431062371, - "parent": "parent-0000-0000-0000-000000000001", + "lastSeen": 1746353808969, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1774895434871, "model": "SLT3", "version": "03130406", "manufacturer": "Computime", + "upgrade": { + "available": false, + "version": "03130406", + "upgrading": false, + "status": "COMPLETE" + }, "power": "battery", "signal": 100, - "battery": 40, + "battery": 80, "capabilities": [ "INFORMATION", "RENAME", @@ -3473,78 +3164,91 @@ "GEOLOCATION", "HOLIDAY_MODE", "SLT3B_REPLACEMENT", + "SLT_REPLACEMENT", "OPTIMUM_START" ], "modelVariant": "SLT3", - "zone": "parent-0000-0000-0000-000000000002" + "zone": "boilermodule-0000-0000-0000-000000000001" }, "state": { - "name": "UK Thermostat", - "zoneName": "UK Thermostat" + "name": "Heating Zone 1", + "zoneName": "Heating Zone 1" } }, { - "id": "thermostat-0000-0000-0000-000000000002", - "type": "nathermostat", + "id": "boilermodule-0000-0000-0000-000000000001", + "type": "boilermodule", "sortOrder": 1, - "created": 1568080039004, - "lastSeen": 1568085013210, - "parent": "parent-0000-0000-0000-000000000001", + "created": 1498773552677, + "lastSeen": 1761218641848, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, - "presenceLastChanged": 1568156820531, - "model": "SLT4", - "version": "08000300", + "presenceLastChanged": 1774895255878, + "model": "SLR1", + "version": "10094640", "manufacturer": "Computime", "upgrade": { "available": false, - "version": "08000300", - "upgrading": false + "upgrading": false, + "status": "COMPLETE" }, "power": "mains", - "signal": 100, - "capabilities": ["INFORMATION", "RENAME", "GEOLOCATION"] + "signal": 96, + "zone": "boilermodule-0000-0000-0000-000000000001", + "tui": "thermostatui-0000-0000-0000-000000000001", + "modelVariant": "SLR1", + "capabilities": [ + "INFORMATION" + ], + "reset": false, + "initialisedStatus": "INITIALISED" }, "state": { - "name": "US Thermostat" - }, - "error": "" + "name": "Heating Zone 1", + "zoneName": "Heating Zone 1" + } }, { "id": "trv-0000-0000-0000-000000000001", "type": "trv", "sortOrder": 1, - "created": 1570646884115, - "parent": "parent-0000-0000-0000-000000000001", + "created": 1714990847683, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, - "presenceLastChanged": 1591459318822, + "presenceLastChanged": 1774895303812, "model": "TRV001", - "version": "00000113", + "version": "0000023A", "manufacturer": "Danfoss", "upgrade": { "available": false, - "version": "00000113", + "progress": 0, "upgrading": false, "status": "COMPLETE" }, "power": "battery", "signal": 100, - "battery": 60, - "eui64": "14B457FFFEBEF30D", + "battery": 40, + "eui64": "0000000000000000", "calibration": { - "start": "2019-10-16T09: 56: 37.685+0000", - "end": "2019-10-16T10: 31: 35.292+0000", - "failureReason": "FAILED" + "start": "2024-05-06T10:23:55.661+00:00", + "end": "2024-05-06T15:24:55.629+00:00", + "failureReason": "CANCELED", + "errorStates": [] }, - "capabilities": ["INFORMATION", "RENAME", "DELETE", "TRV_CALIBRATION"] + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "TRV_CALIBRATION" + ] }, "state": { - "name": "TRV 1", - "control": "trv-0000-0000-0000-000000000001", - "zone": "parent-0000-0000-0000-000000000003", - "zoneName": "Thermostat 2", - "childLock": false, + "name": "TRV Zone 1", + "control": "trvcontrol-0000-0000-0000-000000000001", + "zoneName": "TRV Zone 1", + "childLock": true, "mountingMode": "VERTICAL", "mountingModeActive": false, "viewingAngle": "ANGLE_180", @@ -3552,862 +3256,572 @@ } }, { - "id": "camera-0000-0000-0000-000000000001", - "type": "hivecamera", - "sortOrder": 0, - "created": 1543677886208, - "lastSeen": 1550320722000, + "id": "plug-0000-0000-0000-000000000001", + "type": "activeplug", + "sortOrder": 5, + "created": 1619025208161, + "lastSeen": 1745520318574, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, - "model": "HCI001", - "version": "V0_0_00_093_svn825", - "manufacturer": "Hive", - "hardwareIdentifier": "51385FC1F5024E748A446456449E9601", + "presenceLastChanged": 1774895317911, + "model": "SLP2b", + "version": "01075700", + "manufacturer": "Computime", + "upgrade": { + "available": false, + "version": "01075700", + "upgrading": false + }, "power": "mains", - "signal": 37, - "battery": 100, - "ipAddress": "000.168.0.00", - "macAddress": "00:0A:11:BB:22:CC", - "temperature": "40", - "hardwareVersion": "H4", + "signal": 100, + "deviceClass": "UNKNOWN", "capabilities": [ - "CAMERA_VIDEO", - "CAMERA_DETECTION", - "CAMERA_DEVICE", - "NOTIFICATIONS", "INFORMATION", - "CHANGE_WIFI", "RENAME", - "DELETE" + "DELETE", + "DEVICE_CLASS", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE" ] }, "state": { - "name": "Camera 1", - "resolution": "1080P", - "motionDetection": "ALL", - "audioDetection": "ALL", - "ledDot": false, - "ledRing": true, - "soundAlert": true, - "nightVision": "AUTO", - "invertImage": false, - "cameraAudio": true, - "motionSensitivity": "LOW", - "audioSensitivity": "LOW", - "timeZone": "Europe/London", - "notificationScheduleEnabled": false, - "notificationsMode": "DISABLED", - "frameRate": "30", - "cameraZoom": "1X", - "storage": "CLOUD", - "activityZone": "ALL", - "scheduleEnabled": false, - "mode": "ARMED", - "systemNotificationSettings": { - "connectionStatus": true, - "batteryLevel": true, - "overheating": true - }, - "schedule": { - "monday": [ - { - "start": 390, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 510, - "value": { - "mode": "ARMED" - } - }, - { - "start": 960, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 1290, - "value": { - "mode": "ARMED" - } - } - ], - "tuesday": [ - { - "start": 390, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 510, - "value": { - "mode": "ARMED" - } - }, - { - "start": 960, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 1290, - "value": { - "mode": "ARMED" - } - } - ], - "wednesday": [ - { - "start": 390, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 510, - "value": { - "mode": "ARMED" - } - }, - { - "start": 960, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 1290, - "value": { - "mode": "ARMED" - } - } - ], - "thursday": [ - { - "start": 390, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 510, - "value": { - "mode": "ARMED" - } - }, - { - "start": 960, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 1290, - "value": { - "mode": "ARMED" - } - } - ], - "friday": [ - { - "start": 390, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 510, - "value": { - "mode": "ARMED" - } - }, - { - "start": 960, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 1290, - "value": { - "mode": "ARMED" - } - } - ], - "saturday": [ - { - "start": 390, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 510, - "value": { - "mode": "ARMED" - } - }, - { - "start": 960, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 1290, - "value": { - "mode": "ARMED" - } - } - ], - "sunday": [ - { - "start": 390, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 510, - "value": { - "mode": "ARMED" - } - }, - { - "start": 960, - "value": { - "mode": "PRIVACY" - } - }, - { - "start": 1290, - "value": { - "mode": "ARMED" - } - } - ] - }, - "notificationSchedule": { - "monday": [ - { - "start": 390, - "value": { - "state": "DISABLE" - } - }, - { - "start": 510, - "value": { - "state": "ENABLE" - } - }, - { - "start": 960, - "value": { - "state": "DISABLE" - } - }, - { - "start": 1290, - "value": { - "state": "ENABLE" - } - } - ], - "tuesday": [ - { - "start": 390, - "value": { - "state": "DISABLE" - } - }, - { - "start": 510, - "value": { - "state": "ENABLE" - } - }, - { - "start": 960, - "value": { - "state": "DISABLE" - } - }, - { - "start": 1290, - "value": { - "state": "ENABLE" - } - } - ], - "wednesday": [ - { - "start": 390, - "value": { - "state": "DISABLE" - } - }, - { - "start": 510, - "value": { - "state": "ENABLE" - } - }, - { - "start": 960, - "value": { - "state": "DISABLE" - } - }, - { - "start": 1290, - "value": { - "state": "ENABLE" - } - } - ], - "thursday": [ - { - "start": 390, - "value": { - "state": "DISABLE" - } - }, - { - "start": 510, - "value": { - "state": "ENABLE" - } - }, - { - "start": 960, - "value": { - "state": "DISABLE" - } - }, - { - "start": 1290, - "value": { - "state": "ENABLE" - } - } - ], - "friday": [ - { - "start": 390, - "value": { - "state": "DISABLE" - } - }, - { - "start": 510, - "value": { - "state": "ENABLE" - } - }, - { - "start": 960, - "value": { - "state": "DISABLE" - } - }, - { - "start": 1290, - "value": { - "state": "ENABLE" - } - } - ], - "saturday": [ - { - "start": 390, - "value": { - "state": "DISABLE" - } - }, - { - "start": 510, - "value": { - "state": "ENABLE" - } - }, - { - "start": 960, - "value": { - "state": "DISABLE" - } - }, - { - "start": 1290, - "value": { - "state": "ENABLE" - } - } - ], - "sunday": [ - { - "start": 390, - "value": { - "state": "DISABLE" - } - }, - { - "start": 510, - "value": { - "state": "ENABLE" - } - }, - { - "start": 960, - "value": { - "state": "DISABLE" - } - }, - { - "start": 1290, - "value": { - "state": "ENABLE" - } - } - ] - } - } - }, - { - "id": "plug-0000-0000-0000-000000000001", - "type": "activeplug", - "sortOrder": 0, - "created": 1495824791604, - "lastSeen": 1549431134911, - "parent": "parent-0000-0000-0000-000000000001", - "props": { - "online": true, - "model": "SLP2b", - "version": "01045700", - "manufacturer": "Computime", - "power": "mains", - "signal": 99, - "capabilities": ["INFORMATION", "RENAME", "DELETE"] - }, - "state": { - "name": "Plug 1" + "name": "Plug 1", + "deviceClass": "UNKNOWN" } }, { "id": "plug-0000-0000-0000-000000000002", "type": "activeplug", - "sortOrder": 0, - "created": 1543675662673, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 5, + "created": 1543675662699, + "lastSeen": 1734281036179, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1774895289031, "model": "SLP2b", - "version": "01035700", + "version": "01075700", "manufacturer": "Computime", + "upgrade": { + "available": false, + "version": "01075700", + "upgrading": false, + "status": "COMPLETE" + }, "power": "mains", "signal": 100, - "capabilities": ["INFORMATION", "RENAME", "DELETE"] + "deviceClass": "UNKNOWN", + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "DEVICE_CLASS", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE" + ] }, "state": { - "name": "Plug 2" + "name": "Plug 2", + "deviceClass": "UNKNOWN" } }, { "id": "plug-0000-0000-0000-000000000003", "type": "activeplug", - "sortOrder": 0, + "sortOrder": 5, "created": 1512759057107, - "lastSeen": 1549431076054, - "parent": "parent-0000-0000-0000-000000000001", + "lastSeen": 1770200352575, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1774895287386, "model": "SLP2b", - "version": "01035700", + "version": "01075700", "manufacturer": "Computime", + "upgrade": { + "available": false, + "version": "01075700", + "upgrading": false, + "status": "COMPLETE" + }, "power": "mains", "signal": 100, - "capabilities": ["INFORMATION", "RENAME", "DELETE"] + "deviceClass": "UNKNOWN", + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "DEVICE_CLASS", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE" + ] }, "state": { - "name": "Plug 3" + "name": "Plug 3", + "deviceClass": "UNKNOWN" } }, { - "id": "light-0000-0000-0000-000000000003", + "id": "light-0000-0000-0000-000000000001", "type": "warmwhitelight", - "sortOrder": 0, - "created": 1495822458543, - "lastSeen": 1547585974529, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 7, + "created": 1470158558350, + "lastSeen": 1774937545913, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1775155587293, "model": "FWBulb01", - "version": "11480002", + "version": "11500002", "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11500002", + "upgrading": false, + "status": "COMPLETE" + }, "power": "mains", - "signal": 99, + "signal": 100, "capabilities": [ "INFORMATION", "RENAME", "DELETE", "GROUPABLE", "MIMIC_MODE", - "IDENTIFY_DEVICE" - ] + "IDENTIFY_DEVICE", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "isHue": false }, "state": { - "name": "Light 3" + "name": "Light 1" } }, { - "id": "light-0000-0000-0000-000000000004", + "id": "light-0000-0000-0000-000000000002", "type": "warmwhitelight", - "sortOrder": 0, - "created": 1495819606370, - "lastSeen": 1549742852622, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 7, + "created": 1767908246495, + "lastSeen": 1777663934452, + "parent": "hub-0000-0000-0000-000000000001", "props": { - "online": true, + "online": false, + "presenceLastChanged": 1777664294498, "model": "FWBulb01", - "version": "11480002", + "version": "11500002", "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11500002", + "upgrading": false + }, "power": "mains", - "signal": 99, + "signal": 100, "capabilities": [ "INFORMATION", "RENAME", "DELETE", "GROUPABLE", "MIMIC_MODE", - "IDENTIFY_DEVICE" - ] + "IDENTIFY_DEVICE", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "isHue": false }, "state": { - "name": "Light 4" + "name": "Light 2" } }, { - "id": "light-0000-0000-0000-000000000002", + "id": "light-0000-0000-0000-000000000003", "type": "warmwhitelight", - "sortOrder": 0, - "created": 1470158558350, - "lastSeen": 1543686750227, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 7, + "created": 1636833865349, + "lastSeen": 1771712343506, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1774895290942, "model": "FWBulb01", - "version": "11480002", + "version": "11500002", "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11500002", + "upgrading": false + }, "power": "mains", - "signal": 99, + "signal": 100, "capabilities": [ "INFORMATION", "RENAME", "DELETE", "GROUPABLE", "MIMIC_MODE", - "IDENTIFY_DEVICE" - ] + "IDENTIFY_DEVICE", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "isHue": false }, "state": { - "name": "Light 2" + "name": "Light 3" } }, { - "id": "light-0000-0000-0000-000000000001", + "id": "light-0000-0000-0000-000000000004", "type": "tuneablelight", - "sortOrder": 0, - "created": 1543676094101, - "lastSeen": 1550215521717, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 8, + "created": 1714853620928, + "lastSeen": 1776191743321, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1776321032975, "model": "TWBulb01UK", - "version": "11300002", + "version": "11320002", "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11320002", + "upgrading": false + }, "power": "mains", - "signal": 96, + "signal": 90, "capabilities": [ "INFORMATION", "RENAME", "DELETE", "GROUPABLE", "MIMIC_MODE", - "IDENTIFY_DEVICE" - ] + "IDENTIFY_DEVICE", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "isHue": false }, "state": { - "name": "Light 1" + "name": "Light 4" } }, { "id": "light-0000-0000-0000-000000000005", "type": "tuneablelight", - "sortOrder": 0, - "created": 1543676824201, - "lastSeen": 1548315854100, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 8, + "created": 1591207586685, + "lastSeen": 1775761526088, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1775763889464, "model": "TWBulb01UK", - "version": "11300002", + "version": "11320002", "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11320002", + "upgrading": false, + "status": "COMPLETE" + }, "power": "mains", - "signal": 96, + "signal": 100, "capabilities": [ "INFORMATION", "RENAME", "DELETE", "GROUPABLE", "MIMIC_MODE", - "IDENTIFY_DEVICE" - ] + "IDENTIFY_DEVICE", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "isHue": false }, "state": { - "name": "Light 5" + "name": "Light 5", + "securityZone": "Security Zone 1" } }, { "id": "light-0000-0000-0000-000000000006", "type": "colourtuneablelight", - "sortOrder": 0, - "created": 1543676824201, - "lastSeen": 1548315854100, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 9, + "created": 1658855487200, + "lastSeen": 1775327751548, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, - "model": "RGBBulb01UK", - "version": "11200002", + "presenceLastChanged": 1775328532795, + "model": "RGBBulb02UK", + "version": "11110002", "manufacturer": "Aurora", + "upgrade": { + "available": false, + "version": "11110002", + "progress": 0, + "upgrading": false, + "status": "COMPLETE" + }, "power": "mains", - "signal": 96, + "signal": 98, "capabilities": [ "INFORMATION", "RENAME", "DELETE", "GROUPABLE", "MIMIC_MODE", - "IDENTIFY_DEVICE" - ] + "IDENTIFY_DEVICE", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_ACTION" + ], + "isHue": false }, "state": { "name": "Light 6" } }, { - "id": "motion-sensor-0000-0000-000000000001", + "id": "sensor-0000-0000-0000-000000000004", "type": "motionsensor", - "sortOrder": 0, + "sortOrder": 11, "created": 1495823441915, - "lastSeen": 1546318436728, - "parent": "parent-0000-0000-0000-000000000001", + "lastSeen": 1776942606733, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1777057366558, "model": "MOT003", "version": "05042603", "manufacturer": "HiveHome.com", + "upgrade": { + "available": false, + "version": "05042603", + "upgrading": false, + "status": "COMPLETE" + }, "power": "battery", "signal": 99, "battery": 80, - "capabilities": ["INFORMATION", "RENAME", "DELETE"] + "deviceClass": "UNKNOWN", + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "DEVICE_CLASS", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_TRIGGER" + ] }, "state": { - "name": "Motion Sensor 1" + "name": "Motion Sensor 1", + "deviceClass": "UNKNOWN" } }, { - "id": "contact-sensor-0000-0000-000000000001", + "id": "sensor-0000-0000-0000-000000000001", "type": "contactsensor", - "sortOrder": 0, + "sortOrder": 12, "created": 1498308140974, - "lastSeen": 1549430905506, - "parent": "parent-0000-0000-0000-000000000001", + "lastSeen": 1693654330379, + "parent": "hub-0000-0000-0000-000000000001", "props": { - "online": true, + "online": false, + "presenceLastChanged": 1693655830425, "model": "DWS003", "version": "05042603", "manufacturer": "HiveHome.com", + "upgrade": { + "available": false, + "version": "05042603", + "upgrading": false, + "status": "COMPLETE" + }, "power": "battery", - "signal": 100, - "battery": 100, - "capabilities": ["INFORMATION", "RENAME", "DELETE"] + "signal": 71, + "battery": 80, + "deviceClass": "OTHER", + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "DEVICE_CLASS", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_TRIGGER" + ] }, "state": { - "name": "Contact Sensor 1" + "name": "Contact Sensor 1", + "deviceClass": "OTHER", + "securityZone": "Security Zone 2", + "securityPlacement": "Door" } }, { - "id": "contact-sensor-0000-0000-000000000002", + "id": "sensor-0000-0000-0000-000000000002", "type": "contactsensor", - "sortOrder": 0, - "created": 1543762412780, - "lastSeen": 1550177054944, - "parent": "parent-0000-0000-0000-000000000001", + "sortOrder": 12, + "created": 1693654666634, + "lastSeen": 1713255795610, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": true, + "presenceLastChanged": 1774895438577, "model": "DWS003", "version": "05042603", "manufacturer": "HiveHome.com", + "upgrade": { + "available": false, + "version": "05042603", + "upgrading": false + }, "power": "battery", "signal": 100, - "battery": 100, - "capabilities": ["INFORMATION", "RENAME", "DELETE"] + "battery": 40, + "deviceClass": "OTHER", + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "DEVICE_CLASS", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_TRIGGER" + ] }, "state": { - "name": "Contact Sensor 2" + "name": "Contact Sensor 2", + "deviceClass": "OTHER", + "securityZone": "Security Zone 2", + "securityPlacement": "Door" } }, { - "id": "contact-sensor-0000-0000-000000000003", + "id": "sensor-0000-0000-0000-000000000003", "type": "contactsensor", - "sortOrder": 0, + "sortOrder": 12, "created": 1500998859813, - "lastSeen": 1536931887105, - "parent": "parent-0000-0000-0000-000000000001", + "lastSeen": 1769953564769, + "parent": "hub-0000-0000-0000-000000000001", "props": { "online": false, + "presenceLastChanged": 1769955064827, "model": "DWS003", - "version": "04002603", + "version": "05042603", "manufacturer": "HiveHome.com", + "upgrade": { + "available": false, + "version": "05042603", + "upgrading": false, + "status": "COMPLETE" + }, "power": "battery", - "signal": 100, + "signal": 99, "battery": 60, - "capabilities": ["INFORMATION", "RENAME", "DELETE"] + "deviceClass": "OTHER", + "capabilities": [ + "INFORMATION", + "RENAME", + "DELETE", + "DEVICE_CLASS", + "SECURITY_DEVICE", + "SECURITY_ZONE", + "SECURITY_TOGGLE", + "SECURITY_TRIGGER" + ] + }, + "state": { + "name": "Contact Sensor 3", + "deviceClass": "OTHER", + "securityZone": "Security Zone 3", + "securityPlacement": "Door" + } + }, + { + "id": "keypad-0000-0000-0000-000000000001", + "type": "keypad", + "sortOrder": 15, + "created": 1657912191259, + "lastSeen": 1668111204596, + "parent": "hub-0000-0000-0000-000000000001", + "props": { + "online": false, + "presenceLastChanged": 1668111864603, + "model": "KEYPAD001", + "version": "01176550", + "manufacturer": "LDS", + "upgrade": { + "available": false, + "upgrading": false + }, + "power": "battery", + "signal": 100, + "battery": 0, + "state": "DISARM", + "capabilities": [ + "DELETE", + "INFORMATION", + "RENAME", + "SECURITY_DEVICE", + "SECURITY_TOGGLE" + ] }, "state": { - "name": "Contact Sensor 3" + "name": "Heating Zone 1" } } ], + "holidayMode": { + "active": false, + "enabled": false, + "start": 1621203120000, + "end": 1801091820000, + "temperature": 7, + "status": "OK" + }, + "security": { + "hasSecurityDevice": false + }, "actions": [ { - "id": "action1-0000-0000-0000-000000000001", + "id": "action-0000-0000-0000-000000000001", "created": 1498848808604, - "name": "Action 1", + "name": "Contact Sensor 1 Opened", "enabled": true, "entitlements": [], "entitled": true, "template": "contact-sensor-canvas.1", "events": [ { - "id": "contact-sensor-0000-0000-000000000001", + "id": "sensor-0000-0000-0000-000000000001", "group": "when", "type": "contact-sensor", "settings": { "event": "OPEN" } }, - { - "group": "while", - "type": "schedule", - "settings": { - "schedule": { - "monday": [ - { - "start": 0, - "value": { - "state": "ARM" - } - }, - { - "start": 1425, - "value": { - "state": "ARM" - } - } - ], - "tuesday": [ - { - "start": 0, - "value": { - "state": "ARM" - } - }, - { - "start": 1425, - "value": { - "state": "ARM" - } - } - ], - "wednesday": [ - { - "start": 0, - "value": { - "state": "ARM" - } - }, - { - "start": 1425, - "value": { - "state": "ARM" - } - } - ], - "thursday": [ - { - "start": 0, - "value": { - "state": "ARM" - } - }, - { - "start": 1425, - "value": { - "state": "ARM" - } - } - ], - "friday": [ - { - "start": 0, - "value": { - "state": "ARM" - } - }, - { - "start": 1425, - "value": { - "state": "ARM" - } - } - ], - "saturday": [ - { - "start": 0, - "value": { - "state": "ARM" - } - }, - { - "start": 1425, - "value": { - "state": "ARM" - } - } - ], - "sunday": [ - { - "start": 0, - "value": { - "state": "ARM" - } - }, - { - "start": 1425, - "value": { - "state": "ARM" - } - } - ] - } - } - }, { "group": "then", "type": "notification", @@ -4420,16 +3834,16 @@ ] }, { - "id": "action2-0000-0000-0000-000000000002", + "id": "action-0000-0000-0000-000000000002", "created": 1534796888391, - "name": "Action 2", - "enabled": false, + "name": "Motion Night Light", + "enabled": true, "entitlements": [], "entitled": true, "template": "motion-sensor-light-dark-light-on-duration.1", "events": [ { - "id": "motion-sensor-0000-0000-000000000001", + "id": "sensor-0000-0000-0000-000000000004", "group": "when", "type": "motion-sensor" }, @@ -4441,30 +3855,20 @@ "fromOffset": 0, "untilOffset": 0 } - }, - { - "id": "light-0000-0000-0000-000000000001", - "group": "then", - "duration": 180, - "type": "light", - "settings": { - "status": "ON", - "brightness": 5 - } } ] }, { - "id": "action3-0000-0000-0000-000000000003", - "created": 1543763163317, - "name": "Action 3", + "id": "action-0000-0000-0000-000000000003", + "created": 1543781304708, + "name": "Contact Sensor 3 Opened", "enabled": true, "entitlements": [], "entitled": true, "template": "contact-sensor-canvas.1", "events": [ { - "id": "contact-sensor-0000-0000-000000000002", + "id": "sensor-0000-0000-0000-000000000003", "group": "when", "type": "contact-sensor", "settings": { @@ -4472,59 +3876,29 @@ } }, { - "id": "light-0000-0000-0000-000000000002", - "group": "then", - "type": "light", - "settings": { - "status": "ON", - "brightness": 100 - } - } - ] - }, - { - "id": "action4-0000-0000-0000-000000000004", - "created": 1543763220373, - "name": "Action 4", - "enabled": true, - "entitlements": [], - "entitled": true, - "template": "contact-sensor-canvas.1", - "events": [ - { - "id": "contact-sensor-0000-0000-000000000002", - "group": "when", - "type": "contact-sensor", - "settings": { - "event": "CLOSED" - } - }, - { - "id": "light-0000-0000-0000-000000000002", "group": "then", - "type": "light", + "type": "notification", "settings": { - "status": "OFF" + "email": false, + "push": true, + "sms": false } } ] }, { - "id": "action5-0000-0000-0000-000000000005", - "created": 1543781304658, - "name": "Action 5", - "enabled": true, + "id": "action-0000-0000-0000-000000000004", + "created": 1712594463517, + "name": "Get a notification when there\u2019s motion", + "enabled": false, "entitlements": [], "entitled": true, - "template": "contact-sensor-canvas.1", + "template": "motion-sensor-notification.3", "events": [ { - "id": "contact-sensor-0000-0000-000000000002", + "id": "sensor-0000-0000-0000-000000000004", "group": "when", - "type": "contact-sensor", - "settings": { - "event": "OPEN" - } + "type": "motion-sensor" }, { "group": "then", @@ -4537,6 +3911,154 @@ } ] } - ] + ], + "homes": { + "homes": [ + { + "id": "home-0000-0000-0000-000000000001", + "name": "Home", + "address": "Address", + "location": { + "country": "GB", + "state": null, + "timeZone": "Europe/London", + "latitude": 98.82, + "longitude": -9.02, + "addressFirstLine": "Address", + "addressSecondLine": null, + "city": "City", + "postcode": "AAA BBB" + }, + "primary": true, + "userType": "OWNER", + "homeUsers": { + "OWNER": [ + "user-0000-0000-0000-000000000001" + ] + }, + "owner": true, + "users": [], + "owners": [ + "user-0000-0000-0000-000000000001" + ], + "homeTypes": [ + "HIVE" + ], + "invitations": [ + { + "invitationId": "invitation-0000-0000-0000-000000000001", + "recipient": "Recipient1", + "accepted": false, + "declined": false, + "expired": false, + "revoked": true, + "homeId": "home-0000-0000-0000-000000000001", + "userType": "SUPERUSER", + "invitedBy": "user:user-0000-0000-0000-000000000001", + "invitationType": null, + "expiresOn": 1611003564073, + "inviterFirstName": null, + "homeOwnerFirstName": null + }, + { + "invitationId": "invitation-0000-0000-0000-000000000002", + "recipient": "Recipient2", + "accepted": true, + "declined": false, + "expired": false, + "revoked": false, + "homeId": "home-0000-0000-0000-000000000001", + "userType": "SECONDARY_USER", + "invitedBy": "user:user-0000-0000-0000-000000000001", + "invitationType": null, + "expiresOn": 1607622777368, + "inviterFirstName": null, + "homeOwnerFirstName": null + } + ], + "homePicture": null, + "shareOption": "FLEXIBLE", + "pets": null, + "notificationChannels": [ + "PUSH" + ], + "pinId": "pin-0000-0000-0000-000000000001", + "userNicknames": {}, + "proResponseProviders": [], + "homePhase": "Live", + "localisation": { + "currency": { + "code": "GBP", + "symbol": "\u00a3", + "subunits": "pence", + "subunitSymbol": "p" + }, + "locale": "en_GB", + "tariff": { + "code": "GBPp", + "unit": "p/kWh", + "electricity": { + "minRate": 1, + "maxRate": 100, + "minAnnumkWh": 1000, + "maxAnnumkWh": 15000, + "minStandingCharge": 0, + "maxStandingCharge": 99 + }, + "gas": { + "minRate": 1, + "maxRate": 50, + "minAnnumkWh": 4000, + "maxAnnumkWh": 60000, + "minStandingCharge": 9, + "maxStandingCharge": 99 + } + }, + "customerSupport": { + "ev": { + "supportNumber": "03332021054" + } + } + } + } + ], + "entitlements": { + "homeTypeEntitlements": { + "HIVE": { + "homes": 2, + "usersPerType": { + "SECONDARY_USER": 2147483647, + "OUTER_CIRCLE_USER": 0, + "SUPERUSER": 2147483647, + "EXTERNAL_USER": 0 + }, + "usersPerHome": 2147483647 + }, + "ASSISTED_LIVING": { + "homes": 2147483647, + "usersPerType": { + "SECONDARY_USER": 2147483647, + "OUTER_CIRCLE_USER": 5, + "SUPERUSER": 5, + "EXTERNAL_USER": 3 + }, + "usersPerHome": 2147483647 + }, + "SECURITY": { + "homes": 0, + "usersPerType": { + "SECONDARY_USER": 0, + "OUTER_CIRCLE_USER": 0, + "SUPERUSER": 0, + "EXTERNAL_USER": 0 + }, + "usersPerHome": 0 + } + }, + "proResponseEntitlements": {}, + "propertyEntitlements": 2, + "userEntitlements": 2147483647 + } + } } -} +} \ No newline at end of file diff --git a/src/device_attributes.py b/src/device_attributes.py index d075e37..a659279 100644 --- a/src/device_attributes.py +++ b/src/device_attributes.py @@ -1,6 +1,5 @@ """Hive Device Attribute Module.""" -# pylint: skip-file import logging from .helper.const import HIVETOHA @@ -20,7 +19,7 @@ def __init__(self, session: object = None): self.session = session self.type = "Attribute" - async def stateAttributes(self, n_id: str, _type: str): + async def state_attributes(self, n_id: str, _type: str): """Get HA State Attributes. Args: @@ -33,16 +32,16 @@ async def stateAttributes(self, n_id: str, _type: str): attr = {} if n_id in self.session.data.products or n_id in self.session.data.devices: - attr.update({"available": (await self.onlineOffline(n_id))}) + attr.update({"available": (await self.online_offline(n_id))}) if n_id in self.session.config.battery: - battery = await self.getBattery(n_id) + battery = await self.get_battery(n_id) if battery is not None: attr.update({"battery": str(battery) + "%"}) if n_id in self.session.config.mode: - attr.update({"mode": (await self.getMode(n_id))}) + attr.update({"mode": (await self.get_mode(n_id))}) return attr - async def onlineOffline(self, n_id: str): + async def online_offline(self, n_id: str): """Check if device is online. Args: @@ -61,7 +60,7 @@ async def onlineOffline(self, n_id: str): return state - async def getMode(self, n_id: str): + async def get_mode(self, n_id: str): """Get sensor mode. Args: @@ -82,7 +81,7 @@ async def getMode(self, n_id: str): return final - async def getBattery(self, n_id: str): + async def get_battery(self, n_id: str): """Get device battery level. Args: @@ -98,7 +97,7 @@ async def getBattery(self, n_id: str): data = self.session.data.devices[n_id] state = data["props"]["battery"] final = state - await self.session.helper.errorCheck(n_id, self.type, state) + await self.session.helper.error_check(n_id, self.type, state) except KeyError as e: _LOGGER.error(e) diff --git a/src/heating.py b/src/heating.py index 822ae49..9b71c9a 100644 --- a/src/heating.py +++ b/src/heating.py @@ -1,9 +1,10 @@ """Hive Heating Module.""" -# pylint: skip-file import logging +from datetime import datetime +from typing import Any -from .helper.const import HIVETOHA +from .helper.const import HIVETOHA, HTTP_OK _LOGGER = logging.getLogger(__name__) @@ -15,9 +16,10 @@ class HiveHeating: object: heating """ - heatingType = "Heating" + session: Any + heating_type = "Heating" - async def getMinTemperature(self, device: dict): + async def get_min_temperature(self, device: dict): """Get heating minimum target temperature. Args: @@ -26,11 +28,11 @@ async def getMinTemperature(self, device: dict): Returns: int: Minimum temperature """ - if device["hiveType"] == "nathermostat": - return self.session.data.products[device["hiveID"]]["props"]["minHeat"] + if device.hive_type == "nathermostat": + return self.session.data.products[device.hive_id]["props"]["minHeat"] return 5 - async def getMaxTemperature(self, device: dict): + async def get_max_temperature(self, device: dict): """Get heating maximum target temperature. Args: @@ -39,11 +41,11 @@ async def getMaxTemperature(self, device: dict): Returns: int: Maximum temperature """ - if device["hiveType"] == "nathermostat": - return self.session.data.products[device["hiveID"]]["props"]["maxHeat"] + if device.hive_type == "nathermostat": + return self.session.data.products[device.hive_id]["props"]["maxHeat"] return 32 - async def getCurrentTemperature(self, device: dict): + async def get_current_temperature(self, device: dict): """Get heating current temperature. Args: @@ -52,48 +54,50 @@ async def getCurrentTemperature(self, device: dict): Returns: float: current temperature """ - from datetime import datetime - state = None final = None - device_name = device.get("haName", device.get("hiveID", "Unknown")) + device_name = device.ha_name try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["props"]["temperature"] try: state = float(state) except (ValueError, TypeError): _LOGGER.warning( - "getCurrentTemperature - Non-numeric temperature value '%s' for %s.", + "get_current_temperature - Non-numeric temperature value '%s' for %s.", state, device_name, ) return None - if device["hiveID"] in self.session.data.minMax: - if self.session.data.minMax[device["hiveID"]]["TodayDate"] == str( + if device.hive_id in self.session.data.minMax: + if self.session.data.minMax[device.hive_id]["TodayDate"] == str( datetime.date(datetime.now()) ): - if state < self.session.data.minMax[device["hiveID"]]["TodayMin"]: - self.session.data.minMax[device["hiveID"]]["TodayMin"] = state + self.session.data.minMax[device.hive_id]["TodayMin"] = min( + self.session.data.minMax[device.hive_id]["TodayMin"], state + ) - if state > self.session.data.minMax[device["hiveID"]]["TodayMax"]: - self.session.data.minMax[device["hiveID"]]["TodayMax"] = state + self.session.data.minMax[device.hive_id]["TodayMax"] = max( + self.session.data.minMax[device.hive_id]["TodayMax"], state + ) else: data = { "TodayMin": state, "TodayMax": state, "TodayDate": str(datetime.date(datetime.now())), } - self.session.data.minMax[device["hiveID"]].update(data) + self.session.data.minMax[device.hive_id].update(data) - if state < self.session.data.minMax[device["hiveID"]]["RestartMin"]: - self.session.data.minMax[device["hiveID"]]["RestartMin"] = state + self.session.data.minMax[device.hive_id]["RestartMin"] = min( + self.session.data.minMax[device.hive_id]["RestartMin"], state + ) - if state > self.session.data.minMax[device["hiveID"]]["RestartMax"]: - self.session.data.minMax[device["hiveID"]]["RestartMax"] = state + self.session.data.minMax[device.hive_id]["RestartMax"] = max( + self.session.data.minMax[device.hive_id]["RestartMax"], state + ) else: data = { "TodayMin": state, @@ -102,19 +106,19 @@ async def getCurrentTemperature(self, device: dict): "RestartMin": state, "RestartMax": state, } - self.session.data.minMax[device["hiveID"]] = data + self.session.data.minMax[device.hive_id] = data final = round(state, 1) except KeyError as e: _LOGGER.error( - "getCurrentTemperature - KeyError getting temperature for %s: %s", + "get_current_temperature - KeyError getting temperature for %s: %s", device_name, str(e), ) return final - async def getTargetTemperature(self, device: dict): + async def get_target_temperature(self, device: dict): """Get heating target temperature. Args: @@ -124,10 +128,10 @@ async def getTargetTemperature(self, device: dict): float: Target temperature or None if invalid """ state = None - device_name = device.get("haName", device.get("hiveID", "Unknown")) + device_name = device.ha_name try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["state"].get("target", None) if state is None: state = data["state"].get("heat", None) @@ -137,21 +141,22 @@ async def getTargetTemperature(self, device: dict): state = float(state) except (ValueError, TypeError): _LOGGER.warning( - "getTargetTemperature - Non-numeric target temperature value '%s' for %s.", + "get_target_temperature - Non-numeric target temperature" + " value '%s' for %s.", state, device_name, ) return None except (KeyError, TypeError) as e: _LOGGER.error( - "getTargetTemperature - Error getting target temperature for %s: %s", + "get_target_temperature - Error getting target temperature for %s: %s", device_name, str(e), ) return state - async def getMode(self, device: dict): + async def get_mode(self, device: dict): """Get heating current mode. Args: @@ -164,17 +169,17 @@ async def getMode(self, device: dict): final = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["state"]["mode"] if state == "BOOST": state = data["props"]["previous"]["mode"] - final = HIVETOHA[self.heatingType].get(state, state) + final = HIVETOHA[self.heating_type].get(state, state) except KeyError as e: _LOGGER.error(e) return final - async def getState(self, device: dict): + async def get_state(self, device: dict): """Get heating current state. Args: @@ -187,20 +192,20 @@ async def getState(self, device: dict): final = None try: - current_temp = await self.getCurrentTemperature(device) - target_temp = await self.getTargetTemperature(device) + current_temp = await self.get_current_temperature(device) + target_temp = await self.get_target_temperature(device) if current_temp is not None and target_temp is not None: if current_temp < target_temp: state = "ON" else: state = "OFF" - final = HIVETOHA[self.heatingType].get(state, state) + final = HIVETOHA[self.heating_type].get(state, state) except (KeyError, TypeError) as e: _LOGGER.error(e) return final - async def getCurrentOperation(self, device: dict): + async def get_current_operation(self, device: dict): """Get heating current operation. Args: @@ -212,14 +217,14 @@ async def getCurrentOperation(self, device: dict): state = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["props"]["working"] except KeyError as e: _LOGGER.error(e) return state - async def getBoostStatus(self, device: dict): + async def get_boost_status(self, device: dict): """Get heating boost current status. Args: @@ -231,14 +236,14 @@ async def getBoostStatus(self, device: dict): state = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = HIVETOHA["Boost"].get(data["state"].get("boost", False), "ON") except KeyError as e: _LOGGER.error(e) return state - async def getBoostTime(self, device: dict): + async def get_boost_time(self, device: dict): """Get heating boost time remaining. Args: @@ -247,11 +252,11 @@ async def getBoostTime(self, device: dict): Returns: str: Boost time. """ - if await self.getBoostStatus(device) == "ON": + if await self.get_boost_status(device) == "ON": state = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["state"]["boost"] except KeyError as e: _LOGGER.error(e) @@ -259,7 +264,7 @@ async def getBoostTime(self, device: dict): return state return None - async def getHeatOnDemand(self, device): + async def get_heat_on_demand(self, device): """Get heat on demand status. Args: @@ -271,7 +276,7 @@ async def getHeatOnDemand(self, device): state = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["props"]["autoBoost"]["active"] except KeyError as e: _LOGGER.error(e) @@ -279,7 +284,7 @@ async def getHeatOnDemand(self, device): return state @staticmethod - async def getOperationModes(): + async def get_operation_modes(): """Get heating list of possible modes. Returns: @@ -287,7 +292,7 @@ async def getOperationModes(): """ return ["SCHEDULE", "MANUAL", "OFF"] - async def setTargetTemperature(self, device: dict, new_temp: str): + async def set_target_temperature(self, device: dict, new_temp: str): """Set heating target temperature. Args: @@ -297,51 +302,52 @@ async def setTargetTemperature(self, device: dict, new_temp: str): Returns: boolean: True/False if successful """ - device_name = device.get("haName", device.get("hiveID", "Unknown")) + device_name = device.ha_name _LOGGER.info( - "setTargetTemperature - Setting target temperature to %s°C for %s", + "set_target_temperature - Setting target temperature to %s°C for %s", new_temp, device_name, ) - await self.session.hiveRefreshTokens() + await self.session.hive_refresh_tokens() final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): _LOGGER.debug( - "setTargetTemperature - Device %s is online, proceeding with temperature change", + "set_target_temperature - Device %s is online, proceeding with temperature change", device_name, ) - data = self.session.data.products[device["hiveID"]] - resp = await self.session.api.setState( - data["type"], device["hiveID"], target=new_temp + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( + data["type"], device.hive_id, target=new_temp ) - if resp["original"] == 200: + if resp["original"] == HTTP_OK: _LOGGER.debug( - "setTargetTemperature - Temperature set successfully for %s, refreshing device data", + "set_target_temperature - Temperature set successfully" + " for %s, refreshing device data", device_name, ) - await self.session.getDevices(device["hiveID"]) + await self.session.get_devices(device.hive_id) final = True else: _LOGGER.error( - "setTargetTemperature - Failed to set temperature for %s, response: %s", + "set_target_temperature - Failed to set temperature for %s, response: %s", device_name, resp["original"], ) else: _LOGGER.warning( - "setTargetTemperature - Device %s not found or offline, cannot set temperature", + "set_target_temperature - Device %s not found or offline, cannot set temperature", device_name, ) return final - async def setMode(self, device: dict, new_mode: str): + async def set_mode(self, device: dict, new_mode: str): """Set heating mode. Args: @@ -351,48 +357,49 @@ async def setMode(self, device: dict, new_mode: str): Returns: boolean: True/False if successful """ - device_name = device.get("haName", device.get("hiveID", "Unknown")) + device_name = device.ha_name _LOGGER.info( - "setMode - Setting heating mode to %s for %s", new_mode, device_name + "set_mode - Setting heating mode to %s for %s", new_mode, device_name ) - await self.session.hiveRefreshTokens() + await self.session.hive_refresh_tokens() final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): _LOGGER.debug( - "setMode - Device %s is online, proceeding with mode change", + "set_mode - Device %s is online, proceeding with mode change", device_name, ) - data = self.session.data.products[device["hiveID"]] - resp = await self.session.api.setState( - data["type"], device["hiveID"], mode=new_mode + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( + data["type"], device.hive_id, mode=new_mode ) - if resp["original"] == 200: + if resp["original"] == HTTP_OK: _LOGGER.debug( - "setMode - Mode set successfully for %s, refreshing device data", + "set_mode - Mode set successfully for %s, refreshing device data", device_name, ) - await self.session.getDevices(device["hiveID"]) + await self.session.get_devices(device.hive_id) final = True else: _LOGGER.error( - "setMode - Failed to set mode for %s, response: %s", + "set_mode - Failed to set mode for %s, response: %s", device_name, resp["original"], ) else: _LOGGER.warning( - "setMode - Device %s not found or offline, cannot set mode", device_name + "set_mode - Device %s not found or offline, cannot set mode", + device_name, ) return final - async def setBoostOn(self, device: dict, mins: str, temp: float): + async def set_boost_on(self, device: dict, mins: str, temp: float): """Turn heating boost on. Args: @@ -403,38 +410,38 @@ async def setBoostOn(self, device: dict, mins: str, temp: float): Returns: boolean: True/False if successful """ - if int(mins) > 0 and int(temp) >= await self.getMinTemperature(device): - if int(temp) <= await self.getMaxTemperature(device): - await self.session.hiveRefreshTokens() + if int(mins) > 0 and int(temp) >= await self.get_min_temperature(device): + if int(temp) <= await self.get_max_temperature(device): + await self.session.hive_refresh_tokens() final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): _LOGGER.debug( - "setBoostOn - Setting heating boost ON for %s: %s mins at %s degrees.", - device["haName"], + "set_boost_on - Setting heating boost ON for %s: %s mins at %s degrees.", + device.ha_name, mins, temp, ) - data = self.session.data.products[device["hiveID"]] - resp = await self.session.api.setState( + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( data["type"], - device["hiveID"], + device.hive_id, mode="BOOST", boost=mins, target=temp, ) - if resp["original"] == 200: - await self.session.getDevices(device["hiveID"]) + if resp["original"] == HTTP_OK: + await self.session.get_devices(device.hive_id) final = True return final return None - async def setBoostOff(self, device: dict): + async def set_boost_off(self, device: dict): """Turn heating boost off. Args: @@ -446,36 +453,36 @@ async def setBoostOff(self, device: dict): final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): _LOGGER.debug( - "setBoostOff - Setting heating boost OFF for %s.", device["haName"] + "set_boost_off - Setting heating boost OFF for %s.", device.ha_name ) - await self.session.hiveRefreshTokens() - data = self.session.data.products[device["hiveID"]] - await self.session.getDevices(device["hiveID"]) - if await self.getBoostStatus(device) == "ON": + await self.session.hive_refresh_tokens() + data = self.session.data.products[device.hive_id] + await self.session.get_devices(device.hive_id) + if await self.get_boost_status(device) == "ON": prev_mode = data["props"]["previous"]["mode"] - if prev_mode == "MANUAL" or prev_mode == "OFF": + if prev_mode in ("MANUAL", "OFF"): pre_temp = data["props"]["previous"].get("target", 7) - resp = await self.session.api.setState( + resp = await self.session.api.set_state( data["type"], - device["hiveID"], + device.hive_id, mode=prev_mode, target=pre_temp, ) else: - resp = await self.session.api.setState( - data["type"], device["hiveID"], mode=prev_mode + resp = await self.session.api.set_state( + data["type"], device.hive_id, mode=prev_mode ) - if resp["original"] == 200: - await self.session.getDevices(device["hiveID"]) + if resp["original"] == HTTP_OK: + await self.session.get_devices(device.hive_id) final = True return final - async def setHeatOnDemand(self, device: dict, state: str): + async def set_heat_on_demand(self, device: dict, state: str): """Enable or disable Heat on Demand for a Thermostat. Args: @@ -488,22 +495,22 @@ async def setHeatOnDemand(self, device: dict, state: str): final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): _LOGGER.debug( - "setHeatOnDemand - Setting heat on demand to %s for %s.", + "set_heat_on_demand - Setting heat on demand to %s for %s.", state, - device["haName"], + device.ha_name, ) - data = self.session.data.products[device["hiveID"]] - await self.session.hiveRefreshTokens() - resp = await self.session.api.setState( - data["type"], device["hiveID"], autoBoost=state + data = self.session.data.products[device.hive_id] + await self.session.hive_refresh_tokens() + resp = await self.session.api.set_state( + data["type"], device.hive_id, autoBoost=state ) - if resp["original"] == 200: - await self.session.getDevices(device["hiveID"]) + if resp["original"] == HTTP_OK: + await self.session.get_devices(device.hive_id) final = True return final @@ -524,7 +531,7 @@ def __init__(self, session: object = None): """ self.session = session - async def getClimate(self, device: dict): + async def get_climate(self, device: dict): """Get heating data. Args: @@ -533,74 +540,59 @@ async def getClimate(self, device: dict): Returns: dict: Updated device. """ - if self.session.shouldUseCachedData(): - cached = self.session.getCachedDevice(device) + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) if cached is not None: _LOGGER.debug( - "getClimate - Returning cached state for climate %s (slow/busy poll).", - device["haName"], + "get_climate - Returning cached state for climate %s (slow/busy poll).", + device.ha_name, ) return cached - device["deviceData"].update( - {"online": await self.session.attr.onlineOffline(device["device_id"])} - ) - - if device["deviceData"]["online"]: - dev_data = {} - self.session.helper.deviceRecovered(device["device_id"]) - _LOGGER.debug( - "getClimate - Updating climate data for %s.", device["haName"] - ) - data = self.session.data.devices[device["device_id"]] - dev_data = { - "hiveID": device["hiveID"], - "hiveName": device["hiveName"], - "hiveType": device["hiveType"], - "haName": device["haName"], - "haType": device["haType"], - "device_id": device["device_id"], - "device_name": device["device_name"], - "temperatureunit": device["temperatureunit"], - "min_temp": await self.getMinTemperature(device), - "max_temp": await self.getMaxTemperature(device), - "status": { - "current_temperature": await self.getCurrentTemperature(device), - "target_temperature": await self.getTargetTemperature(device), - "action": await self.getCurrentOperation(device), - "mode": await self.getMode(device), - "boost": await self.getBoostStatus(device), - }, - "deviceData": data.get("props", None), - "parentDevice": data.get("parent", None), - "custom": device.get("custom", None), - "attributes": await self.session.attr.stateAttributes( - device["device_id"], device["hiveType"] - ), + online = await self.session.attr.online_offline(device.device_id) + if not isinstance(device.device_data, dict): + device.device_data = {} + device.device_data["online"] = online + + if device.device_data["online"]: + self.session.helper.device_recovered(device.device_id) + _LOGGER.debug("get_climate - Updating climate data for %s.", device.ha_name) + data = self.session.data.devices[device.device_id] + device.min_temp = await self.get_min_temperature(device) + device.max_temp = await self.get_max_temperature(device) + device.status = { + "current_temperature": await self.get_current_temperature(device), + "target_temperature": await self.get_target_temperature(device), + "action": await self.get_current_operation(device), + "mode": await self.get_mode(device), + "boost": await self.get_boost_status(device), } - _LOGGER.debug( - "getHeating - Heating device data for %s: %s", - device["haName"], - dev_data["status"], + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + device.attributes = await self.session.attr.state_attributes( + device.device_id, device.hive_type ) - return self.session.setCachedDevice(device, dev_data) - else: - await self.session.helper.errorCheck( - device["device_id"], "ERROR", device["deviceData"]["online"] - ) - device.setdefault( - "status", - { - "current_temperature": None, - "target_temperature": None, - "action": None, - "mode": None, - "boost": None, - "state": None, - }, + _LOGGER.debug( + "get_climate - Heating device data for %s: %s", + device.ha_name, + device.status, ) - return device - - async def getScheduleNowNextLater(self, device: dict): + return self.session.set_cached_device(device) + await self.session.helper.error_check( + device.device_id, "ERROR", device.device_data["online"] + ) + device.status = device.status or { + "current_temperature": None, + "target_temperature": None, + "action": None, + "mode": None, + "boost": None, + "state": None, + } + return device + + async def get_schedule_now_next_later(self, device: dict): """Hive get heating schedule now, next and later. Args: @@ -609,20 +601,20 @@ async def getScheduleNowNextLater(self, device: dict): Returns: dict: Schedule now, next and later """ - online = await self.session.attr.onlineOffline(device["device_id"]) - current_mode = await self.getMode(device) + online = await self.session.attr.online_offline(device.device_id) + current_mode = await self.get_mode(device) state = None try: if online and current_mode == "SCHEDULE": - data = self.session.data.products[device["hiveID"]] - state = self.session.helper.getScheduleNNL(data["state"]["schedule"]) + data = self.session.data.products[device.hive_id] + state = self.session.helper.get_schedule_nnl(data["state"]["schedule"]) except KeyError as e: _LOGGER.error(e) return state - async def minmaxTemperature(self, device: dict): + async def minmax_temperature(self, device: dict): """Min/Max Temp. Args: @@ -635,9 +627,29 @@ async def minmaxTemperature(self, device: dict): final = None try: - state = self.session.data.minMax[device["hiveID"]] + state = self.session.data.minMax[device.hive_id] final = state except KeyError as e: _LOGGER.error(e) return final + + async def setMode(self, device: dict, new_mode: str): # pylint: disable=invalid-name + """Backwards-compatible alias for set_mode.""" + return await self.set_mode(device, new_mode) + + async def setTargetTemperature(self, device: dict, new_temp: str): # pylint: disable=invalid-name + """Backwards-compatible alias for set_target_temperature.""" + return await self.set_target_temperature(device, new_temp) + + async def setBoostOn(self, device: dict, mins: str, temp: float): # pylint: disable=invalid-name + """Backwards-compatible alias for set_boost_on.""" + return await self.set_boost_on(device, mins, temp) + + async def setBoostOff(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for set_boost_off.""" + return await self.set_boost_off(device) + + async def getClimate(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for get_climate.""" + return await self.get_climate(device) diff --git a/src/helper/const.py b/src/helper/const.py index 3fa5588..55513e6 100644 --- a/src/helper/const.py +++ b/src/helper/const.py @@ -1,6 +1,7 @@ """Constants for Pyhiveapi.""" -# pylint: skip-file +from .hivedataclasses import EntityConfig + SYNC_PACKAGE_NAME = "pyhiveapi" SYNC_PACKAGE_DIR = "/pyhiveapi/" ASYNC_PACKAGE_NAME = "apyhiveapi" @@ -26,7 +27,6 @@ HIVETOHA = { - "Alarm": {"home": "armed_home", "away": "armed_away", "asleep": "armed_night"}, "Attribute": {True: "Online", False: "Offline"}, "Boost": {None: "OFF", False: "OFF"}, "Heating": {False: "OFF", "ENABLED": True, "DISABLED": False}, @@ -57,115 +57,288 @@ "Switch": ["activeplug"], } sensor_commands = { - "SMOKE_CO": "self.session.hub.getSmokeStatus(device)", - "DOG_BARK": "self.session.hub.getDogBarkStatus(device)", - "GLASS_BREAK": "self.session.hub.getGlassBreakStatus(device)", - "Camera_Temp": "self.session.camera.getCameraTemperature(device)", - "Current_Temperature": "self.session.heating.getCurrentTemperature(device)", - "Heating_Current_Temperature": "self.session.heating.getCurrentTemperature(device)", - "Heating_Target_Temperature": "self.session.heating.getTargetTemperature(device)", - "Heating_State": "self.session.heating.getState(device)", - "Heating_Mode": "self.session.heating.getMode(device)", - "Heating_Boost": "self.session.heating.getBoostStatus(device)", - "Hotwater_State": "self.session.hotwater.getState(device)", - "Hotwater_Mode": "self.session.hotwater.getMode(device)", - "Hotwater_Boost": "self.session.hotwater.getBoost(device)", - "Battery": 'self.session.attr.getBattery(device["device_id"])', - "Mode": 'self.session.attr.getMode(device["hiveID"])', - "Availability": "self.online(device)", - "Connectivity": "self.online(device)", - "Power": "self.session.switch.getPowerUsage(device)", + "SMOKE_CO": lambda s, d: s.session.hub.get_smoke_status(d), + "DOG_BARK": lambda s, d: s.session.hub.get_dog_bark_status(d), + "GLASS_BREAK": lambda s, d: s.session.hub.get_glass_break_status(d), + "Current_Temperature": lambda s, d: s.session.heating.get_current_temperature(d), + "Heating_Current_Temperature": lambda s, d: ( + s.session.heating.get_current_temperature(d) + ), + "Heating_Target_Temperature": lambda s, d: s.session.heating.get_target_temperature( + d + ), + "Heating_State": lambda s, d: s.session.heating.get_state(d), + "Heating_Mode": lambda s, d: s.session.heating.get_mode(d), + "Heating_Boost": lambda s, d: s.session.heating.get_boost_status(d), + "Hotwater_State": lambda s, d: s.session.hotwater.get_state(d), + "Hotwater_Mode": lambda s, d: s.session.hotwater.get_mode(d), + "Hotwater_Boost": lambda s, d: s.session.hotwater.get_boost(d), + "Battery": lambda s, d: s.session.attr.get_battery(d.device_id), + "Mode": lambda s, d: s.session.attr.get_mode(d.hive_id), + "Availability": lambda s, d: s.online(d), + "Connectivity": lambda s, d: s.online(d), + "Power": lambda s, d: s.session.switch.get_power_usage(d), } PRODUCTS = { "sense": [ - 'addList("binary_sensor", p, haName="Glass Detection", hiveType="GLASS_BREAK")', - 'addList("binary_sensor", p, haName="Smoke Detection", hiveType="SMOKE_CO")', - 'addList("binary_sensor", p, haName="Dog Bark Detection", hiveType="DOG_BARK")', + EntityConfig( + entity_type="binary_sensor", + ha_name="Glass Detection", + hive_type="GLASS_BREAK", + ), + EntityConfig( + entity_type="binary_sensor", ha_name="Smoke Detection", hive_type="SMOKE_CO" + ), + EntityConfig( + entity_type="binary_sensor", + ha_name="Dog Bark Detection", + hive_type="DOG_BARK", + ), ], "heating": [ - 'addList("climate", p, temperatureunit=self.data["user"]["temperatureUnit"])', - 'addList("switch", p, haName=" Heat on Demand", hiveType="Heating_Heat_On_Demand", category="config")', - 'addList("sensor", p, haName=" Current Temperature", hiveType="Heating_Current_Temperature", category="diagnostic")', - 'addList("sensor", p, haName=" Target Temperature", hiveType="Heating_Target_Temperature", category="diagnostic")', - 'addList("sensor", p, haName=" State", hiveType="Heating_State", category="diagnostic")', - 'addList("sensor", p, haName=" Mode", hiveType="Heating_Mode", category="diagnostic")', - 'addList("sensor", p, haName=" Boost", hiveType="Heating_Boost", category="diagnostic")', + EntityConfig(entity_type="climate"), + EntityConfig( + entity_type="switch", + ha_name=" Heat on Demand", + hive_type="Heating_Heat_On_Demand", + category="config", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Current Temperature", + hive_type="Heating_Current_Temperature", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Target Temperature", + hive_type="Heating_Target_Temperature", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" State", + hive_type="Heating_State", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Mode", + hive_type="Heating_Mode", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Boost", + hive_type="Heating_Boost", + category="diagnostic", + ), ], "trvcontrol": [ - 'addList("climate", p, temperatureunit=self.data["user"]["temperatureUnit"])', - 'addList("sensor", p, haName=" Current Temperature", hiveType="Heating_Current_Temperature", category="diagnostic")', - 'addList("sensor", p, haName=" Target Temperature", hiveType="Heating_Target_Temperature", category="diagnostic")', - 'addList("sensor", p, haName=" State", hiveType="Heating_State", category="diagnostic")', - 'addList("sensor", p, haName=" Mode", hiveType="Heating_Mode", category="diagnostic")', - 'addList("sensor", p, haName=" Boost", hiveType="Heating_Boost", category="diagnostic")', + EntityConfig(entity_type="climate"), + EntityConfig( + entity_type="sensor", + ha_name=" Current Temperature", + hive_type="Heating_Current_Temperature", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Target Temperature", + hive_type="Heating_Target_Temperature", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" State", + hive_type="Heating_State", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Mode", + hive_type="Heating_Mode", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Boost", + hive_type="Heating_Boost", + category="diagnostic", + ), ], "hotwater": [ - 'addList("water_heater", p,)', - 'addList("sensor", p, haName="Hotwater State", hiveType="Hotwater_State", category="diagnostic")', - 'addList("sensor", p, haName="Hotwater Mode", hiveType="Hotwater_Mode", category="diagnostic")', - 'addList("sensor", p, haName="Hotwater Boost", hiveType="Hotwater_Boost", category="diagnostic")', + EntityConfig(entity_type="water_heater"), + EntityConfig( + entity_type="sensor", + ha_name="Hotwater State", + hive_type="Hotwater_State", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name="Hotwater Mode", + hive_type="Hotwater_Mode", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name="Hotwater Boost", + hive_type="Hotwater_Boost", + category="diagnostic", + ), ], "activeplug": [ - 'addList("switch", p)', - 'addList("sensor", p, haName=" Mode", hiveType="Mode", category="diagnostic")', - 'addList("sensor", p, haName=" Availability", hiveType="Availability", category="diagnostic")', - 'addList("sensor", p, haName=" Power", hiveType="Power", category="diagnostic")', + EntityConfig(entity_type="switch"), + EntityConfig( + entity_type="sensor", + ha_name=" Mode", + hive_type="Mode", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Availability", + hive_type="Availability", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Power", + hive_type="Power", + category="diagnostic", + ), ], "warmwhitelight": [ - 'addList("light", p)', - 'addList("sensor", p, haName=" Mode", hiveType="Mode", category="diagnostic")', - 'addList("sensor", p, haName=" Availability", hiveType="Availability", category="diagnostic")', + EntityConfig(entity_type="light"), + EntityConfig( + entity_type="sensor", + ha_name=" Mode", + hive_type="Mode", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Availability", + hive_type="Availability", + category="diagnostic", + ), ], "tuneablelight": [ - 'addList("light", p)', - 'addList("sensor", p, haName=" Mode", hiveType="Mode", category="diagnostic")', - 'addList("sensor", p, haName=" Availability", hiveType="Availability", category="diagnostic")', + EntityConfig(entity_type="light"), + EntityConfig( + entity_type="sensor", + ha_name=" Mode", + hive_type="Mode", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Availability", + hive_type="Availability", + category="diagnostic", + ), ], "colourtuneablelight": [ - 'addList("light", p)', - 'addList("sensor", p, haName=" Mode", hiveType="Mode", category="diagnostic")', - 'addList("sensor", p, haName=" Availability", hiveType="Availability", category="diagnostic")', - ], - # "hivecamera": [ - # 'addList("camera", p)', - # 'addList("sensor", p, haName=" Mode", hiveType="Mode", category="diagnostic")', - # 'addList("sensor", p, haName=" Availability", hiveType="Availability", category="diagnostic")', - # 'addList("sensor", p, haName=" Temperature", hiveType="Camera_Temp", category="diagnostic")', - # ], + EntityConfig(entity_type="light"), + EntityConfig( + entity_type="sensor", + ha_name=" Mode", + hive_type="Mode", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Availability", + hive_type="Availability", + category="diagnostic", + ), + ], "motionsensor": [ - 'addList("binary_sensor", p)', - 'addList("sensor", p, haName=" Current Temperature", hiveType="Current_Temperature", category="diagnostic")', + EntityConfig(entity_type="binary_sensor"), + EntityConfig( + entity_type="sensor", + ha_name=" Current Temperature", + hive_type="Current_Temperature", + category="diagnostic", + ), + ], + "contactsensor": [ + EntityConfig(entity_type="binary_sensor"), ], - "contactsensor": ['addList("binary_sensor", p)'], } DEVICES = { "contactsensor": [ - 'addList("sensor", d, haName=" Battery Level", hiveType="Battery", category="diagnostic")', - 'addList("sensor", d, haName=" Availability", hiveType="Availability", category="diagnostic")', + EntityConfig( + entity_type="sensor", + ha_name=" Battery Level", + hive_type="Battery", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Availability", + hive_type="Availability", + category="diagnostic", + ), ], "hub": [ - 'addList("binary_sensor", d, haName="Hive Hub Status", hiveType="Connectivity", category="diagnostic")', + EntityConfig( + entity_type="binary_sensor", + ha_name="Hive Hub Status", + hive_type="Connectivity", + category="diagnostic", + ), ], "motionsensor": [ - 'addList("sensor", d, haName=" Battery Level", hiveType="Battery", category="diagnostic")', - 'addList("sensor", d, haName=" Availability", hiveType="Availability", category="diagnostic")', + EntityConfig( + entity_type="sensor", + ha_name=" Battery Level", + hive_type="Battery", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Availability", + hive_type="Availability", + category="diagnostic", + ), ], "sense": [ - 'addList("binary_sensor", d, haName="Hive Hub Status", hiveType="Connectivity")', + EntityConfig( + entity_type="binary_sensor", + ha_name="Hive Hub Status", + hive_type="Connectivity", + ), ], - "siren": ['addList("alarm_control_panel", d)'], "thermostatui": [ - 'addList("sensor", d, haName=" Battery Level", hiveType="Battery", category="diagnostic")', - 'addList("sensor", d, haName=" Availability", hiveType="Availability", category="diagnostic")', + EntityConfig( + entity_type="sensor", + ha_name=" Battery Level", + hive_type="Battery", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Availability", + hive_type="Availability", + category="diagnostic", + ), ], "trv": [ - 'addList("sensor", d, haName=" Battery Level", hiveType="Battery", category="diagnostic")', - 'addList("sensor", d, haName=" Availability", hiveType="Availability", category="diagnostic")', + EntityConfig( + entity_type="sensor", + ha_name=" Battery Level", + hive_type="Battery", + category="diagnostic", + ), + EntityConfig( + entity_type="sensor", + ha_name=" Availability", + hive_type="Availability", + category="diagnostic", + ), ], } - -ACTIONS = ( - 'addList("switch", a, hiveName=a["name"], haName=a["name"], hiveType="action")' -) diff --git a/src/helper/debugger.py b/src/helper/debugger.py index f3a3102..48331b1 100644 --- a/src/helper/debugger.py +++ b/src/helper/debugger.py @@ -1,7 +1,7 @@ """Debugger file.""" -# pylint: skip-file import logging +import sys class DebugContext: @@ -12,30 +12,31 @@ def __init__(self, name, enabled): self.name = name self.enabled = enabled self.logging = logging.getLogger(__name__) - self.debugOutFolder = "" - self.debugOutFile = "" - self.debugEnabled = False - self.debugList = [] + self.debug_out_folder = "" + self.debug_out_file = "" + self.debug_enabled = False + self.debug_list = [] def __enter__(self): """Set trace calls on entering debugger.""" print("Entering Debug Decorated func") - # Set the trace function to the trace_calls function - # So all events are now traced - self.traceCalls + sys.settrace(self.trace_calls) + return self - def traceCalls(self, frame, event, arg): + def __exit__(self, exc_type, exc_val, exc_tb): + """Remove trace on exiting debugger.""" + sys.settrace(None) + return False + + def trace_calls(self, frame, event, _arg): """Trace calls be made.""" - # We want to only trace our call to the decorated function if event != "call": - return - elif frame.f_code.co_name != self.name: - return - # return the trace function to use when you go into that - # function call - return self.traceLines + return None + if frame.f_code.co_name != self.name: + return None + return self.trace_lines - def traceLines(self, frame, event, arg): + def trace_lines(self, frame, event, _arg): """Print out lines for function.""" # If you want to print local variables each line # keep the check for the event 'line' diff --git a/src/helper/hive_helper.py b/src/helper/hive_helper.py index 8fe8b6e..8ddb8db 100644 --- a/src/helper/hive_helper.py +++ b/src/helper/hive_helper.py @@ -1,10 +1,10 @@ """Helper class for pyhiveapi.""" -# pylint: skip-file import copy import datetime import logging import operator +import time from typing import Any from .const import HIVE_TYPES @@ -12,6 +12,26 @@ _LOGGER = logging.getLogger(__name__) +def epoch_time(date_time: Any, pattern: str, action: str) -> Any: + """Convert between a datetime string and a Unix epoch integer. + + Args: + date_time: Epoch integer or date/time string to convert. + pattern: ``strptime``/``strftime`` format string used for the conversion. + action: ``"to_epoch"`` converts a datetime string → int; + ``"from_epoch"`` converts an int → formatted datetime string. + + Returns: + Converted value, or ``None`` if *action* is unrecognised. + """ + if action == "to_epoch": + pattern = "%d.%m.%Y %H:%M:%S" + return int(time.mktime(time.strptime(str(date_time), pattern))) + if action == "from_epoch": + return datetime.datetime.fromtimestamp(int(date_time)).strftime(pattern) + return None + + class HiveHelper: """Hive helper class.""" @@ -23,7 +43,7 @@ def __init__(self, session: object = None): """ self.session = session - async def getDeviceName(self, n_id: str): + async def get_device_name(self, n_id: str): """Resolve a id into a name. Args: @@ -47,7 +67,7 @@ async def getDeviceName(self, n_id: str): if not product_name and not device_name: _LOGGER.warning( - "getDeviceName - No product or device name found for ID: %s", n_id + "get_device_name - No product or device name found for ID: %s", n_id ) if product_name: @@ -61,34 +81,34 @@ async def getDeviceName(self, n_id: str): return final_name - def deviceRecovered(self, n_id: str): + def device_recovered(self, n_id: str): """Register that a device has recovered from being offline. Args: n_id (str): ID of the device. """ - # name = HiveHelper.getDeviceName(n_id) - if n_id in self.session.config.errorList: - self.session.config.errorList.pop(n_id) + # name = HiveHelper.get_device_name(n_id) + if n_id in self.session.config.error_list: + self.session.config.error_list.pop(n_id) - async def errorCheck(self, n_id, n_type, error_type, **kwargs): + async def error_check(self, n_id, _n_type, error_type, **_kwargs): """Error has occurred.""" message = None - name = await self.getDeviceName(n_id) + name = await self.get_device_name(n_id) device_name = name if isinstance(name, str) else n_id if error_type is False: message = "Device offline could not update entity - " + str(device_name) - if n_id not in self.session.config.errorList: + if n_id not in self.session.config.error_list: _LOGGER.warning(message) - self.session.config.errorList.update({n_id: datetime.datetime.now()}) + self.session.config.error_list.update({n_id: datetime.datetime.now()}) elif error_type == "Failed": message = "ERROR - No data found for device - " + str(device_name) - if n_id not in self.session.config.errorList: + if n_id not in self.session.config.error_list: _LOGGER.error(message) - self.session.config.errorList.update({n_id: datetime.datetime.now()}) + self.session.config.error_list.update({n_id: datetime.datetime.now()}) - def getDeviceFromID(self, n_id: str): + def get_device_from_id(self, n_id: str): """Get product/device data from ID. Args: @@ -97,18 +117,33 @@ def getDeviceFromID(self, n_id: str): Returns: dict: Device data. """ - if hasattr(self.session, "entityCache"): - for cached_id, cached in self.session.entityCache.items(): - if cached.get("hiveID") == n_id or cached.get("device_id") == n_id: + if hasattr(self.session, "entity_cache"): + for cached_id, cached in self.session.entity_cache.items(): + hive_id = ( + cached.get("hive_id") + if isinstance(cached, dict) + else getattr(cached, "hive_id", None) + ) + device_id = ( + cached.get("device_id") + if isinstance(cached, dict) + else getattr(cached, "device_id", None) + ) + if n_id in (hive_id, device_id): + ha_name = ( + cached.get("haName", cached_id) + if isinstance(cached, dict) + else getattr(cached, "ha_name", cached_id) + ) _LOGGER.debug( - "getDeviceFromID - Found cached device for ID %s: %s", + "get_device_from_id - Found cached device for ID %s: %s", n_id, - cached.get("haName", cached_id), + ha_name, ) return cached return False - def getDeviceData(self, product: dict): + def get_device_data(self, product: dict): """Get device from product data. Args: @@ -119,42 +154,43 @@ def getDeviceData(self, product: dict): """ product_id = product.get("id", "Unknown") device = product - type = product["type"] - if type in ("heating", "hotwater"): - for aDevice in self.session.data.devices: - if self.session.data.devices[aDevice]["type"] in HIVE_TYPES["Thermo"]: + product_type = product["type"] + if product_type in ("heating", "hotwater"): + for a_device in self.session.data.devices: + if self.session.data.devices[a_device]["type"] in HIVE_TYPES["Thermo"]: try: if ( product["props"]["zone"] - == self.session.data.devices[aDevice]["props"]["zone"] + == self.session.data.devices[a_device]["props"]["zone"] ): - device = self.session.data.devices[aDevice] + device = self.session.data.devices[a_device] except KeyError as e: _LOGGER.warning( - "getDeviceData - KeyError accessing zone data for device %s: %s", - aDevice, + "get_device_data - KeyError accessing zone data for device %s: %s", + a_device, str(e), ) - pass - elif type == "trvcontrol": + elif product_type == "trvcontrol": trv_present = len(product["props"]["trvs"]) > 0 if trv_present: device = self.session.data.devices[product["props"]["trvs"][0]] else: _LOGGER.error( - "getDeviceData - No TRVs found for product %s", product_id + "get_device_data - No TRVs found for product %s", product_id ) raise KeyError - elif type == "warmwhitelight" and product["props"]["model"] == "SIREN001": + elif ( + product_type == "warmwhitelight" and product["props"]["model"] == "SIREN001" + ): device = self.session.data.devices[product["parent"]] - elif type == "sense": + elif product_type == "sense": device = self.session.data.devices[product["parent"]] else: device = self.session.data.devices[product["id"]] return device - def convertMinutesToTime(self, minutes_to_convert: str): + def convert_minutes_to_time(self, minutes_to_convert: str): """Convert minutes string to datetime. Args: @@ -170,7 +206,7 @@ def convertMinutesToTime(self, minutes_to_convert: str): converted_time_string = converted_time.strftime("%H:%M") return converted_time_string - def getScheduleNNL(self, hive_api_schedule: list): + def get_schedule_nnl(self, hive_api_schedule: list): # pylint: disable=too-many-locals """Get the schedule now, next and later of a given nodes schedule. Args: @@ -180,7 +216,8 @@ def getScheduleNNL(self, hive_api_schedule: list): dict: Now, Next and later values. """ _LOGGER.debug( - "getScheduleNNL - Parsing schedule NNL for %d days", len(hive_api_schedule) + "get_schedule_nnl - Parsing schedule NNL for %d days", + len(hive_api_schedule), ) schedule_now_and_next = {} date_time_now = datetime.datetime.now() @@ -197,28 +234,26 @@ def getScheduleNNL(self, hive_api_schedule: list): ) days_rolling_list = list(days_t[date_time_now_day_int:] + days_t)[:7] - _LOGGER.debug("getScheduleNNL - Days rolling list: %s", days_rolling_list) + _LOGGER.debug("get_schedule_nnl - Days rolling list: %s", days_rolling_list) full_schedule_list = [] - for day_index in range(0, len(days_rolling_list)): - current_day_schedule = hive_api_schedule[days_rolling_list[day_index]] + for day_index, day_name in enumerate(days_rolling_list): + current_day_schedule = hive_api_schedule[day_name] current_day_schedule_sorted = sorted( current_day_schedule, key=operator.itemgetter("start"), reverse=False, ) _LOGGER.debug( - "getScheduleNNL - Processing day %s with %d schedule slots", - days_rolling_list[day_index], + "get_schedule_nnl - Processing day %s with %d schedule slots", + day_name, len(current_day_schedule_sorted), ) - for current_slot in range(0, len(current_day_schedule_sorted)): - current_slot_custom = current_day_schedule_sorted[current_slot] - + for current_slot_custom in current_day_schedule_sorted: slot_date = datetime.datetime.now() + datetime.timedelta(days=day_index) - slot_time = self.convertMinutesToTime(current_slot_custom["start"]) + slot_time = self.convert_minutes_to_time(current_slot_custom["start"]) slot_time_date_s = slot_date.strftime("%d-%m-%Y") + " " + slot_time slot_time_date_dt = datetime.datetime.strptime( slot_time_date_s, "%d-%m-%Y %H:%M" @@ -235,7 +270,7 @@ def getScheduleNNL(self, hive_api_schedule: list): reverse=False, ) - if len(fsl_sorted) >= 3: + if len(fsl_sorted) >= 3: # noqa: PLR2004 schedule_now = fsl_sorted[-1] schedule_next = fsl_sorted[0] schedule_later = fsl_sorted[1] @@ -253,20 +288,21 @@ def getScheduleNNL(self, hive_api_schedule: list): schedule_now_and_next["later"] = schedule_later _LOGGER.debug( - "getScheduleNNL - Schedule NNL parsed successfully - now: %s, next: %s, later: %s", + "get_schedule_nnl - Schedule NNL parsed successfully" + " - now: %s, next: %s, later: %s", schedule_now.get("Start_DateTime"), schedule_next.get("Start_DateTime"), schedule_later.get("Start_DateTime"), ) else: _LOGGER.warning( - "getScheduleNNL - Insufficient schedule data (%d slots) for NNL calculation", + "get_schedule_nnl - Insufficient schedule data (%d slots) for NNL calculation", len(fsl_sorted), ) return schedule_now_and_next - def getHeatOnDemandDevice(self, device: dict): + def get_heat_on_demand_device(self, device: dict): """Use TRV device to get the linked thermostat device. Args: @@ -279,20 +315,19 @@ def getHeatOnDemandDevice(self, device: dict): thermostat = self.session.data.products.get(trv["state"]["zone"]) return thermostat - def _sanitize_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + def sanitize_payload(self, payload: dict[str, Any]) -> dict[str, Any]: """Return a copy of payload with sensitive values masked for logs.""" def _mask(value: Any) -> Any: if isinstance(value, str): - if len(value) <= 8: + if len(value) <= 8: # noqa: PLR2004 return "***" return f"{value[:4]}...{value[-4:]}" - elif isinstance(value, dict): + if isinstance(value, dict): return {k: _mask(v) for k, v in value.items()} - elif isinstance(value, list): + if isinstance(value, list): return [_mask(item) for item in value] - else: - return value + return value def _walk(node: Any) -> Any: if isinstance(node, dict): diff --git a/src/helper/hivedataclasses.py b/src/helper/hivedataclasses.py index 184b3cb..19cf3c4 100644 --- a/src/helper/hivedataclasses.py +++ b/src/helper/hivedataclasses.py @@ -1,22 +1,113 @@ -"""Device data class.""" +"""Device and session data classes.""" -# pylint: skip-file +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Literal -from dataclasses import dataclass +_SCAN_INTERVAL = timedelta(seconds=120) + +_SENTINEL = object() + +_DEVICE_KEY_MAP = { + "hiveID": "hive_id", + "hiveName": "hive_name", + "hiveType": "hive_type", + "haName": "ha_name", + "haType": "ha_type", + "deviceData": "device_data", + "parentDevice": "parent_device", + "temperatureunit": "temperature_unit", +} @dataclass class Device: - """Class for keeping track of an device.""" - - hiveID: str - hiveName: str - hiveType: str - haType: str - deviceData: dict - status: dict - data: dict - parentDevice: str - isGroup: bool + """Class for keeping track of a device.""" + + hive_id: str + hive_name: str + hive_type: str + ha_type: str device_id: str device_name: str + device_data: dict + parent_device: str | None = None + is_group: bool = False + ha_name: str = "" + category: str | None = None + temperature_unit: str | None = None + status: dict | None = None + data: dict | None = None + attributes: dict | None = None + min_temp: float | None = None + max_temp: float | None = None + + def _resolve(self, key: str) -> str: + """Translate a legacy camelCase key to the current snake_case attribute name.""" + return _DEVICE_KEY_MAP.get(key, key) + + def __getitem__(self, key: str): + """Support dict-style read access, resolving legacy camelCase keys.""" + try: + return getattr(self, self._resolve(key)) + except AttributeError: + raise KeyError(key) from None + + def __setitem__(self, key: str, value) -> None: + """Support dict-style write access, resolving legacy camelCase keys.""" + setattr(self, self._resolve(key), value) + + def __contains__(self, key: str) -> bool: + """Return True if the key resolves to a non-None attribute.""" + val = getattr(self, self._resolve(key), _SENTINEL) + return val is not _SENTINEL and val is not None + + def get(self, key: str, default=None): + """Return the value for key, or default if missing or None.""" + try: + val = self[key] + return val if val is not None else default + except KeyError: + return default + + +@dataclass +class EntityConfig: + """Configuration for creating a device entity.""" + + entity_type: Literal[ + "sensor", + "binary_sensor", + "climate", + "light", + "switch", + "water_heater", + ] + ha_name: str = "" + hive_type: str = "" + category: str | None = None + temperature_unit: str | None = None + + +@dataclass +class SessionTokens: + """Typed container for session authentication tokens.""" + + token_data: dict = field(default_factory=dict) + token_created: datetime = field(default_factory=lambda: datetime.min) + token_expiry: timedelta = field(default_factory=lambda: timedelta(seconds=3600)) + + +@dataclass +class SessionConfig: + """Typed container for session configuration state.""" + + battery: list = field(default_factory=list) + error_list: dict = field(default_factory=dict) + file: bool = False + home_id: str | None = None + last_update: datetime = field(default_factory=datetime.now) + mode: list = field(default_factory=list) + scan_interval: timedelta = field(default_factory=lambda: _SCAN_INTERVAL) + user_id: str | None = None + username: str | None = None diff --git a/src/hive.py b/src/hive.py index 30cdbeb..90aa0e0 100644 --- a/src/hive.py +++ b/src/hive.py @@ -1,17 +1,14 @@ """Start Hive Session.""" -# pylint: skip-file +import asyncio import logging import sys import traceback from os.path import expanduser -from typing import Optional from aiohttp import ClientSession from .action import HiveAction -from .alarm import Alarm -from .camera import Camera from .heating import Climate from .hotwater import WaterHeater from .hub import HiveHub @@ -26,7 +23,7 @@ home = expanduser("~") -def exception_handler(exctype, value, tb): +def exception_handler(_exctype, _value, tb): """Custom exception handler. Args: @@ -35,13 +32,14 @@ def exception_handler(exctype, value, tb): tb ([type]): [description] """ last = len(traceback.extract_tb(tb)) - 1 + tb_entry = traceback.extract_tb(tb)[last] _LOGGER.error( - f"-> \n" - f"Error in {traceback.extract_tb(tb)[last].filename}\n" - f"when running {traceback.extract_tb(tb)[last].name} function\n" - f"on line {traceback.extract_tb(tb)[last].lineno} - " - f"{traceback.extract_tb(tb)[last].line} \n" - f"with vars {traceback.extract_tb(tb)[last].locals}" + "-> \nError in %s\nwhen running %s function\non line %s - %s \nwith vars %s", + tb_entry.filename, + tb_entry.name, + tb_entry.lineno, + tb_entry.line, + tb_entry.locals, ) traceback.print_exc(tb) @@ -72,14 +70,17 @@ def trace_debug(frame, event, arg): caller_filename = caller.f_code.co_filename.rsplit("/", 1) _LOGGER.debug( - f"Call to {func_name} on line {func_line_no} " - f"of {func_filename[1]} from line {caller_line_no} " - f"of {caller_filename[1]}" + "Call to %s on line %s of %s from line %s of %s", + func_name, + func_line_no, + func_filename[1], + caller_line_no, + caller_filename[1], ) elif event == "return": - _LOGGER.debug(f"returning {arg}") + _LOGGER.debug("returning %s", arg) - return trace_debug + return trace_debug class Hive(HiveSession): @@ -91,22 +92,21 @@ class Hive(HiveSession): def __init__( self, - websession: Optional[ClientSession] = None, + websession: ClientSession | None = None, username: str = None, password: str = None, ): """Generate a Hive session. Args: - websession (Optional[ClientSession], optional): This is a websession that can be used for the api. Defaults to None. + websession (Optional[ClientSession], optional): Websession for API calls. + Defaults to None. username (str, optional): This is the Hive username used for login. Defaults to None. password (str, optional): This is the Hive password used for login. Defaults to None. """ super().__init__(username, password, websession) self.session = self self.action = HiveAction(self.session) - self.alarm = Alarm(self.session) - self.camera = Camera(self.session) self.heating = Climate(self.session) self.hotwater = WaterHeater(self.session) self.hub = HiveHub(self.session) @@ -117,7 +117,7 @@ def __init__( if debug: sys.settrace(trace_debug) - def setDebugging(self, debugger: list): + def set_debugging(self, debugger: list): """Set function to debug. Args: @@ -126,8 +126,24 @@ def setDebugging(self, debugger: list): Returns: object: Returns traceback object. """ - global debug + global debug # pylint: disable=global-statement # noqa: PLW0603 debug = debugger if debug: return sys.settrace(trace_debug) return sys.settrace(None) + + async def force_update(self) -> bool: + """Immediately poll the Hive API, bypassing the 2-minute interval. + + For power users only. If a poll is already in progress, skips and + returns False. Otherwise polls and returns True on success. + """ + if self.update_lock.locked(): + _LOGGER.debug("force_update called while poll in progress — skipping.") + return False + async with self.update_lock: + self._update_task = asyncio.current_task() + try: + return await self._poll_devices() + finally: + self._update_task = None diff --git a/src/hotwater.py b/src/hotwater.py index 3c4e884..9fc639e 100644 --- a/src/hotwater.py +++ b/src/hotwater.py @@ -1,309 +1,319 @@ -"""Hive Hotwater Module.""" - -# pylint: skip-file -import logging - -from .helper.const import HIVETOHA - -_LOGGER = logging.getLogger(__name__) - - -class HiveHotwater: - """Hive Hotwater Code. - - Returns: - object: Hotwater Object. - """ - - hotwaterType = "Hotwater" - - async def getMode(self, device: dict): - """Get hotwater current mode. - - Args: - device (dict): Device to get the mode for. - - Returns: - str: Return mode. - """ - state = None - final = None - - try: - data = self.session.data.products[device["hiveID"]] - state = data["state"]["mode"] - if state == "BOOST": - state = data["props"]["previous"]["mode"] - final = HIVETOHA[self.hotwaterType].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final - - @staticmethod - async def getOperationModes(): - """Get heating list of possible modes. - - Returns: - list: Return list of operation modes. - """ - return ["SCHEDULE", "ON", "OFF"] - - async def getBoost(self, device: dict): - """Get hot water current boost status. - - Args: - device (dict): Device to get boost status for - - Returns: - str: Return boost status. - """ - state = None - final = None - - try: - data = self.session.data.products[device["hiveID"]] - state = data["state"]["boost"] - final = HIVETOHA["Boost"].get(state, "ON") - except KeyError as e: - _LOGGER.error(e) - - return final - - async def getBoostTime(self, device: dict): - """Get hotwater boost time remaining. - - Args: - device (dict): Device to get boost time for. - - Returns: - str: Return time remaining on the boost. - """ - state = None - if await self.getBoost(device) == "ON": - try: - data = self.session.data.products[device["hiveID"]] - state = data["state"]["boost"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def getState(self, device: dict): - """Get hot water current state. - - Args: - device (dict): Device to get the state for. - - Returns: - str: return state of device. - """ - state = None - final = None - - try: - data = self.session.data.products[device["hiveID"]] - state = data["state"]["status"] - mode_current = await self.getMode(device) - if mode_current == "SCHEDULE": - if await self.getBoost(device) == "ON": - state = "ON" - else: - snan = self.session.helper.getScheduleNNL(data["state"]["schedule"]) - state = snan["now"]["value"]["status"] - - final = HIVETOHA[self.hotwaterType].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final - - async def setMode(self, device: dict, new_mode: str): - """Set hot water mode. - - Args: - device (dict): device to update mode. - new_mode (str): Mode to set the device to. - - Returns: - boolean: return True/False if boost was successful. - """ - final = False - - if device["hiveID"] in self.session.data.products: - _LOGGER.debug( - "setMode - Setting hot water mode to %s for %s.", - new_mode, - device["haName"], - ) - await self.session.hiveRefreshTokens() - data = self.session.data.products[device["hiveID"]] - resp = await self.session.api.setState( - data["type"], device["hiveID"], mode=new_mode - ) - if resp["original"] == 200: - final = True - await self.session.getDevices(device["hiveID"]) - - return final - - async def setBoostOn(self, device: dict, mins: int): - """Turn hot water boost on. - - Args: - device (dict): Deice to boost. - mins (int): Number of minutes to boost it for. - - Returns: - boolean: return True/False if boost was successful. - """ - final = False - - if ( - int(mins) > 0 - and device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] - ): - _LOGGER.debug( - "setBoostOn - Setting hot water boost ON for %s: %s mins.", - device["haName"], - mins, - ) - await self.session.hiveRefreshTokens() - data = self.session.data.products[device["hiveID"]] - resp = await self.session.api.setState( - data["type"], device["hiveID"], mode="BOOST", boost=mins - ) - if resp["original"] == 200: - final = True - await self.session.getDevices(device["hiveID"]) - - return final - - async def setBoostOff(self, device: dict): - """Turn hot water boost off. - - Args: - device (dict): device to set boost off - - Returns: - boolean: return True/False if boost was successful. - """ - final = False - - if ( - device["hiveID"] in self.session.data.products - and await self.getBoost(device) == "ON" - and device["deviceData"]["online"] - ): - _LOGGER.debug( - "setBoostOff - Setting hot water boost OFF for %s.", device["haName"] - ) - await self.session.hiveRefreshTokens() - data = self.session.data.products[device["hiveID"]] - prev_mode = data["props"]["previous"]["mode"] - resp = await self.session.api.setState( - data["type"], device["hiveID"], mode=prev_mode - ) - if resp["original"] == 200: - await self.session.getDevices(device["hiveID"]) - final = True - - return final - - -class WaterHeater(HiveHotwater): - """Water heater class. - - Args: - Hotwater (object): Hotwater class. - """ - - def __init__(self, session: object = None): - """Initialise water heater. - - Args: - session (object, optional): Session to interact with account. Defaults to None. - """ - self.session = session - - async def getWaterHeater(self, device: dict): - """Update water heater device. - - Args: - device (dict): device to update. - - Returns: - dict: Updated device. - """ - if self.session.shouldUseCachedData(): - cached = self.session.getCachedDevice(device) - if cached is not None: - _LOGGER.debug( - "getWaterHeater - Returning cached state for water heater %s (slow/busy poll).", - device["haName"], - ) - return cached - device["deviceData"].update( - {"online": await self.session.attr.onlineOffline(device["device_id"])} - ) - - if device["deviceData"]["online"]: - - dev_data = {} - self.session.helper.deviceRecovered(device["device_id"]) - _LOGGER.debug( - "getWaterHeater - Updating hot water data for %s.", device["haName"] - ) - data = self.session.data.devices[device["device_id"]] - dev_data = { - "hiveID": device["hiveID"], - "hiveName": device["hiveName"], - "hiveType": device["hiveType"], - "haName": device["haName"], - "haType": device["haType"], - "device_id": device["device_id"], - "device_name": device["device_name"], - "status": {"current_operation": await self.getMode(device)}, - "deviceData": data.get("props", None), - "parentDevice": data.get("parent", None), - "custom": device.get("custom", None), - "attributes": await self.session.attr.stateAttributes( - device["device_id"], device["hiveType"] - ), - } - - _LOGGER.debug( - "getWaterHeater - Water heater device data for %s: %s", - device["haName"], - dev_data["status"], - ) - - return self.session.setCachedDevice(device, dev_data) - else: - await self.session.helper.errorCheck( - device["device_id"], "ERROR", device["deviceData"]["online"] - ) - device.setdefault("status", {"current_operation": None}) - return device - - async def getScheduleNowNextLater(self, device: dict): - """Hive get hotwater schedule now, next and later. - - Args: - device (dict): device to get schedule for. - - Returns: - dict: return now, next and later schedule. - """ - state = None - - try: - mode_current = await self.getMode(device) - if mode_current == "SCHEDULE": - data = self.session.data.products[device["hiveID"]] - state = self.session.helper.getScheduleNNL(data["state"]["schedule"]) - except KeyError as e: - _LOGGER.error(e) - - return state +"""Hive Hotwater Module.""" + +import logging +from typing import Any + +from .helper.const import HIVETOHA, HTTP_OK + +_LOGGER = logging.getLogger(__name__) + + +class HiveHotwater: + """Hive Hotwater Code. + + Returns: + object: Hotwater Object. + """ + + session: Any + hotwater_type = "Hotwater" + + async def get_mode(self, device: dict): + """Get hotwater current mode. + + Args: + device (dict): Device to get the mode for. + + Returns: + str: Return mode. + """ + state = None + final = None + + try: + data = self.session.data.products[device.hive_id] + state = data["state"]["mode"] + if state == "BOOST": + state = data["props"]["previous"]["mode"] + final = HIVETOHA[self.hotwater_type].get(state, state) + except KeyError as e: + _LOGGER.error(e) + + return final + + @staticmethod + async def get_operation_modes(): + """Get heating list of possible modes. + + Returns: + list: Return list of operation modes. + """ + return ["SCHEDULE", "ON", "OFF"] + + async def get_boost(self, device: dict): + """Get hot water current boost status. + + Args: + device (dict): Device to get boost status for + + Returns: + str: Return boost status. + """ + state = None + final = None + + try: + data = self.session.data.products[device.hive_id] + state = data["state"]["boost"] + final = HIVETOHA["Boost"].get(state, "ON") + except KeyError as e: + _LOGGER.error(e) + + return final + + async def get_boost_time(self, device: dict): + """Get hotwater boost time remaining. + + Args: + device (dict): Device to get boost time for. + + Returns: + str: Return time remaining on the boost. + """ + state = None + if await self.get_boost(device) == "ON": + try: + data = self.session.data.products[device.hive_id] + state = data["state"]["boost"] + except KeyError as e: + _LOGGER.error(e) + + return state + + async def get_state(self, device: dict): + """Get hot water current state. + + Args: + device (dict): Device to get the state for. + + Returns: + str: return state of device. + """ + state = None + final = None + + try: + data = self.session.data.products[device.hive_id] + state = data["state"]["status"] + mode_current = await self.get_mode(device) + if mode_current == "SCHEDULE": + if await self.get_boost(device) == "ON": + state = "ON" + else: + snan = self.session.helper.get_schedule_nnl( + data["state"]["schedule"] + ) + state = snan["now"]["value"]["status"] + + final = HIVETOHA[self.hotwater_type].get(state, state) + except KeyError as e: + _LOGGER.error(e) + + return final + + async def set_mode(self, device: dict, new_mode: str): + """Set hot water mode. + + Args: + device (dict): device to update mode. + new_mode (str): Mode to set the device to. + + Returns: + boolean: return True/False if boost was successful. + """ + final = False + + if device.hive_id in self.session.data.products: + _LOGGER.debug( + "set_mode - Setting hot water mode to %s for %s.", + new_mode, + device.ha_name, + ) + await self.session.hive_refresh_tokens() + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( + data["type"], device.hive_id, mode=new_mode + ) + if resp["original"] == HTTP_OK: + final = True + await self.session.get_devices(device.hive_id) + + return final + + async def set_boost_on(self, device: dict, mins: int): + """Turn hot water boost on. + + Args: + device (dict): Deice to boost. + mins (int): Number of minutes to boost it for. + + Returns: + boolean: return True/False if boost was successful. + """ + final = False + + if ( + int(mins) > 0 + and device.hive_id in self.session.data.products + and device.device_data["online"] + ): + _LOGGER.debug( + "set_boost_on - Setting hot water boost ON for %s: %s mins.", + device.ha_name, + mins, + ) + await self.session.hive_refresh_tokens() + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( + data["type"], device.hive_id, mode="BOOST", boost=mins + ) + if resp["original"] == HTTP_OK: + final = True + await self.session.get_devices(device.hive_id) + + return final + + async def set_boost_off(self, device: dict): + """Turn hot water boost off. + + Args: + device (dict): device to set boost off + + Returns: + boolean: return True/False if boost was successful. + """ + final = False + + if ( + device.hive_id in self.session.data.products + and await self.get_boost(device) == "ON" + and device.device_data["online"] + ): + _LOGGER.debug( + "set_boost_off - Setting hot water boost OFF for %s.", device.ha_name + ) + await self.session.hive_refresh_tokens() + data = self.session.data.products[device.hive_id] + prev_mode = data["props"]["previous"]["mode"] + resp = await self.session.api.set_state( + data["type"], device.hive_id, mode=prev_mode + ) + if resp["original"] == HTTP_OK: + await self.session.get_devices(device.hive_id) + final = True + + return final + + +class WaterHeater(HiveHotwater): + """Water heater class. + + Args: + Hotwater (object): Hotwater class. + """ + + def __init__(self, session: object = None): + """Initialise water heater. + + Args: + session (object, optional): Session to interact with account. Defaults to None. + """ + self.session = session + + async def get_water_heater(self, device: dict): + """Update water heater device. + + Args: + device (dict): device to update. + + Returns: + dict: Updated device. + """ + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) + if cached is not None: + _LOGGER.debug( + "get_water_heater - Returning cached state for" + " water heater %s (slow/busy poll).", + device.ha_name, + ) + return cached + online = await self.session.attr.online_offline(device.device_id) + if not isinstance(device.device_data, dict): + device.device_data = {} + device.device_data["online"] = online + + if device.device_data["online"]: + self.session.helper.device_recovered(device.device_id) + _LOGGER.debug( + "get_water_heater - Updating hot water data for %s.", device.ha_name + ) + data = self.session.data.devices[device.device_id] + device.status = {"current_operation": await self.get_mode(device)} + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + device.attributes = await self.session.attr.state_attributes( + device.device_id, device.hive_type + ) + + _LOGGER.debug( + "get_water_heater - Water heater device data for %s: %s", + device.ha_name, + device.status, + ) + + return self.session.set_cached_device(device) + await self.session.helper.error_check( + device.device_id, "ERROR", device.device_data["online"] + ) + device.status = device.status or {"current_operation": None} + return device + + async def get_schedule_now_next_later(self, device: dict): + """Hive get hotwater schedule now, next and later. + + Args: + device (dict): device to get schedule for. + + Returns: + dict: return now, next and later schedule. + """ + state = None + + try: + mode_current = await self.get_mode(device) + if mode_current == "SCHEDULE": + data = self.session.data.products[device.hive_id] + state = self.session.helper.get_schedule_nnl(data["state"]["schedule"]) + except KeyError as e: + _LOGGER.error(e) + + return state + + async def setMode(self, device: dict, new_mode: str): # pylint: disable=invalid-name + """Backwards-compatible alias for set_mode.""" + return await self.set_mode(device, new_mode) + + async def setBoostOn(self, device: dict, mins: int): # pylint: disable=invalid-name + """Backwards-compatible alias for set_boost_on.""" + return await self.set_boost_on(device, mins) + + async def setBoostOff(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for set_boost_off.""" + return await self.set_boost_off(device) + + async def getWaterHeater(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for get_water_heater.""" + return await self.get_water_heater(device) diff --git a/src/hub.py b/src/hub.py index 6af5feb..fca8b8f 100644 --- a/src/hub.py +++ b/src/hub.py @@ -1,6 +1,5 @@ """Hive Hub Module.""" -# pylint: skip-file import logging from .helper.const import HIVETOHA @@ -15,8 +14,8 @@ class HiveHub: object: Returns a hub object. """ - hubType = "Hub" - logType = "Sensor" + hub_type = "Hub" + log_type = "Sensor" def __init__(self, session: object = None): """Initialise hub. @@ -26,7 +25,7 @@ def __init__(self, session: object = None): """ self.session = session - async def getSmokeStatus(self, device: dict): + async def get_smoke_status(self, device: dict): """Get the hub smoke status. Args: @@ -39,15 +38,15 @@ async def getSmokeStatus(self, device: dict): final = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["props"]["sensors"]["SMOKE_CO"]["active"] - final = HIVETOHA[self.hubType]["Smoke"].get(state, state) + final = HIVETOHA[self.hub_type]["Smoke"].get(state, state) except KeyError as e: _LOGGER.error(e) return final - async def getDogBarkStatus(self, device: dict): + async def get_dog_bark_status(self, device: dict): """Get dog bark status. Args: @@ -60,15 +59,15 @@ async def getDogBarkStatus(self, device: dict): final = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["props"]["sensors"]["DOG_BARK"]["active"] - final = HIVETOHA[self.hubType]["Dog"].get(state, state) + final = HIVETOHA[self.hub_type]["Dog"].get(state, state) except KeyError as e: _LOGGER.error(e) return final - async def getGlassBreakStatus(self, device: dict): + async def get_glass_break_status(self, device: dict): """Get the glass detected status from the Hive hub. Args: @@ -81,9 +80,9 @@ async def getGlassBreakStatus(self, device: dict): final = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["props"]["sensors"]["GLASS_BREAK"]["active"] - final = HIVETOHA[self.hubType]["Glass"].get(state, state) + final = HIVETOHA[self.hub_type]["Glass"].get(state, state) except KeyError as e: _LOGGER.error(e) diff --git a/src/light.py b/src/light.py index ac31824..6f15f0b 100644 --- a/src/light.py +++ b/src/light.py @@ -1,10 +1,10 @@ """Hive Light Module.""" -# pylint: skip-file import colorsys import logging +from typing import Any -from .helper.const import HIVETOHA +from .helper.const import HIVETOHA, HTTP_OK _LOGGER = logging.getLogger(__name__) @@ -16,9 +16,10 @@ class HiveLight: object: Hivelight """ - lightType = "Light" + session: Any + light_type = "Light" - async def getState(self, device: dict): + async def get_state(self, device: dict): """Get light current state. Args: @@ -29,12 +30,12 @@ async def getState(self, device: dict): """ state = None final = None - device_name = device.get("haName", device.get("hiveID", "Unknown")) + device_name = device.ha_name try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["state"]["status"] - final = HIVETOHA[self.lightType].get(state, state) + final = HIVETOHA[self.light_type].get(state, state) except KeyError as e: _LOGGER.error( "KeyError getting light state for %s: %s", device_name, str(e) @@ -42,7 +43,7 @@ async def getState(self, device: dict): return final - async def getBrightness(self, device: dict): + async def get_brightness(self, device: dict): """Get light current brightness. Args: @@ -53,10 +54,10 @@ async def getBrightness(self, device: dict): """ state = None final = None - device_name = device.get("haName", device.get("hiveID", "Unknown")) + device_name = device.ha_name try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["state"]["brightness"] final = (state / 100) * 255 except KeyError as e: @@ -66,7 +67,7 @@ async def getBrightness(self, device: dict): return final - async def getMinColorTemp(self, device: dict): + async def get_min_color_temp(self, device: dict): """Get light minimum color temperature. Args: @@ -79,7 +80,7 @@ async def getMinColorTemp(self, device: dict): final = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["props"]["colourTemperature"]["max"] final = round((1 / state) * 1000000) except KeyError as e: @@ -87,7 +88,7 @@ async def getMinColorTemp(self, device: dict): return final - async def getMaxColorTemp(self, device: dict): + async def get_max_color_temp(self, device: dict): """Get light maximum color temperature. Args: @@ -100,7 +101,7 @@ async def getMaxColorTemp(self, device: dict): final = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["props"]["colourTemperature"]["min"] final = round((1 / state) * 1000000) except KeyError as e: @@ -108,7 +109,7 @@ async def getMaxColorTemp(self, device: dict): return final - async def getColorTemp(self, device: dict): + async def get_color_temp(self, device: dict): """Get light current color temperature. Args: @@ -121,7 +122,7 @@ async def getColorTemp(self, device: dict): final = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["state"]["colourTemperature"] final = round((1 / state) * 1000000) except KeyError as e: @@ -129,7 +130,7 @@ async def getColorTemp(self, device: dict): return final - async def getColor(self, device: dict): + async def get_color(self, device: dict): """Get light current colour. Args: @@ -142,7 +143,7 @@ async def getColor(self, device: dict): final = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = [ (data["state"]["hue"]) / 360, (data["state"]["saturation"]) / 100, @@ -156,7 +157,7 @@ async def getColor(self, device: dict): return final - async def getColorMode(self, device: dict): + async def get_color_mode(self, device: dict): """Get Colour Mode. Args: @@ -168,14 +169,14 @@ async def getColorMode(self, device: dict): state = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["state"]["colourMode"] except KeyError as e: _LOGGER.error(e) return state - async def setStatusOff(self, device: dict): + async def set_status_off(self, device: dict): """Set light to turn off. Args: @@ -184,31 +185,31 @@ async def setStatusOff(self, device: dict): Returns: boolean: True/False if successful """ - device_name = device.get("haName", device.get("hiveID", "Unknown")) + device_name = device.ha_name _LOGGER.info("Turning off light %s", device_name) - await self.session.hiveRefreshTokens() + await self.session.hive_refresh_tokens() final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): _LOGGER.debug( - "setStatusOff - Device %s is online, proceeding with turn off", + "set_status_off - Device %s is online, proceeding with turn off", device_name, ) - data = self.session.data.products[device["hiveID"]] - resp = await self.session.api.setState( - data["type"], device["hiveID"], status="OFF" + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( + data["type"], device.hive_id, status="OFF" ) - if resp["original"] == 200: + if resp["original"] == HTTP_OK: _LOGGER.debug( - "setStatusOff - Light turned off successfully for %s, refreshing device data", + "set_status_off - Light turned off successfully for %s, refreshing device data", device_name, ) - await self.session.getDevices(device["hiveID"]) + await self.session.get_devices(device.hive_id) final = True else: _LOGGER.error( @@ -223,7 +224,7 @@ async def setStatusOff(self, device: dict): return final - async def setStatusOn(self, device: dict): + async def set_status_on(self, device: dict): """Set light to turn on. Args: @@ -232,31 +233,31 @@ async def setStatusOn(self, device: dict): Returns: boolean: True/False if successful """ - device_name = device.get("haName", device.get("hiveID", "Unknown")) + device_name = device.ha_name _LOGGER.info("Turning on light %s", device_name) - await self.session.hiveRefreshTokens() + await self.session.hive_refresh_tokens() final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): _LOGGER.debug( - "setStatusOn - Device %s is online, proceeding with turn on", + "set_status_on - Device %s is online, proceeding with turn on", device_name, ) - data = self.session.data.products[device["hiveID"]] - resp = await self.session.api.setState( - data["type"], device["hiveID"], status="ON" + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( + data["type"], device.hive_id, status="ON" ) - if resp["original"] == 200: + if resp["original"] == HTTP_OK: _LOGGER.debug( - "setStatusOn - Light turned on successfully for %s, refreshing device data", + "set_status_on - Light turned on successfully for %s, refreshing device data", device_name, ) - await self.session.getDevices(device["hiveID"]) + await self.session.get_devices(device.hive_id) final = True else: _LOGGER.error( @@ -271,7 +272,7 @@ async def setStatusOn(self, device: dict): return final - async def setBrightness(self, device: dict, n_brightness: int): + async def set_brightness(self, device: dict, n_brightness: int): """Set brightness of the light. Args: @@ -281,32 +282,32 @@ async def setBrightness(self, device: dict, n_brightness: int): Returns: boolean: True/False if successful """ - device_name = device.get("haName", device.get("hiveID", "Unknown")) + device_name = device.ha_name _LOGGER.info("Setting brightness to %s for light %s", n_brightness, device_name) - await self.session.hiveRefreshTokens() + await self.session.hive_refresh_tokens() final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): _LOGGER.debug( - "setBrightness - Device %s is online, proceeding with brightness change", + "set_brightness - Device %s is online, proceeding with brightness change", device_name, ) - data = self.session.data.products[device["hiveID"]] - resp = await self.session.api.setState( - data["type"], device["hiveID"], status="ON", brightness=n_brightness + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( + data["type"], device.hive_id, status="ON", brightness=n_brightness ) - if resp["original"] == 200: + if resp["original"] == HTTP_OK: final = True - await self.session.getDevices(device["hiveID"]) + await self.session.get_devices(device.hive_id) return final - async def setColorTemp(self, device: dict, color_temp: int): + async def set_color_temp(self, device: dict, color_temp: int): """Set light to turn on. Args: @@ -319,38 +320,38 @@ async def setColorTemp(self, device: dict, color_temp: int): final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): _LOGGER.debug( - "setColorTemp - Setting colour temperature to %s for %s.", + "set_color_temp - Setting colour temperature to %s for %s.", color_temp, - device["haName"], + device.ha_name, ) - await self.session.hiveRefreshTokens() - data = self.session.data.products[device["hiveID"]] + await self.session.hive_refresh_tokens() + data = self.session.data.products[device.hive_id] if data["type"] == "tuneablelight": - resp = await self.session.api.setState( + resp = await self.session.api.set_state( data["type"], - device["hiveID"], + device.hive_id, colourTemperature=color_temp, ) else: - resp = await self.session.api.setState( + resp = await self.session.api.set_state( data["type"], - device["hiveID"], + device.hive_id, colourMode="WHITE", colourTemperature=color_temp, ) - if resp["original"] == 200: + if resp["original"] == HTTP_OK: final = True - await self.session.getDevices(device["hiveID"]) + await self.session.get_devices(device.hive_id) return final - async def setColor(self, device: dict, new_color: list): + async def set_color(self, device: dict, new_color: list): """Set light to turn on. Args: @@ -363,26 +364,26 @@ async def setColor(self, device: dict, new_color: list): final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): _LOGGER.debug( - "setColor - Setting colour to %s for %s.", new_color, device["haName"] + "set_color - Setting colour to %s for %s.", new_color, device.ha_name ) - await self.session.hiveRefreshTokens() - data = self.session.data.products[device["hiveID"]] + await self.session.hive_refresh_tokens() + data = self.session.data.products[device.hive_id] - resp = await self.session.api.setState( + resp = await self.session.api.set_state( data["type"], - device["hiveID"], + device.hive_id, colourMode="COLOUR", hue=str(new_color[0]), saturation=str(new_color[1]), value=str(new_color[2]), ) - if resp["original"] == 200: + if resp["original"] == HTTP_OK: final = True - await self.session.getDevices(device["hiveID"]) + await self.session.get_devices(device.hive_id) return final @@ -402,7 +403,7 @@ def __init__(self, session: object = None): """ self.session = session - async def getLight(self, device: dict): + async def get_light(self, device: dict): """Get light data. Args: @@ -411,83 +412,63 @@ async def getLight(self, device: dict): Returns: dict: Updated device. """ - if self.session.shouldUseCachedData(): - cached = self.session.getCachedDevice(device) + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) if cached is not None: _LOGGER.debug( - "getLight - Returning cached state for light %s (slow/busy poll).", - device["haName"], + "get_light - Returning cached state for light %s (slow/busy poll).", + device.ha_name, ) return cached - device["deviceData"].update( - {"online": await self.session.attr.onlineOffline(device["device_id"])} - ) - dev_data = {} - - if device["deviceData"]["online"]: - self.session.helper.deviceRecovered(device["device_id"]) - _LOGGER.debug("getLight - Updating light data for %s.", device["haName"]) - data = self.session.data.devices[device["device_id"]] - dev_data = { - "hiveID": device["hiveID"], - "hiveName": device["hiveName"], - "hiveType": device["hiveType"], - "haName": device["haName"], - "haType": device["haType"], - "device_id": device["device_id"], - "device_name": device["device_name"], - "status": { - "state": await self.getState(device), - "brightness": await self.getBrightness(device), - }, - "deviceData": data.get("props", None), - "parentDevice": data.get("parent", None), - "custom": device.get("custom", None), - "attributes": await self.session.attr.stateAttributes( - device["device_id"], device["hiveType"] - ), + online = await self.session.attr.online_offline(device.device_id) + if not isinstance(device.device_data, dict): + device.device_data = {} + device.device_data["online"] = online + + if device.device_data["online"]: + self.session.helper.device_recovered(device.device_id) + _LOGGER.debug("get_light - Updating light data for %s.", device.ha_name) + data = self.session.data.devices[device.device_id] + device.status = { + "state": await self.get_state(device), + "brightness": await self.get_brightness(device), } + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + device.attributes = await self.session.attr.state_attributes( + device.device_id, device.hive_type + ) - if device["hiveType"] in ("tuneablelight", "colourtuneablelight"): - dev_data.update( - { - "min_mireds": await self.getMinColorTemp(device), - "max_mireds": await self.getMaxColorTemp(device), - } - ) - dev_data["status"].update( - {"color_temp": await self.getColorTemp(device)} - ) - if device["hiveType"] == "colourtuneablelight": - mode = await self.getColorMode(device) + if device.hive_type in ("tuneablelight", "colourtuneablelight"): + device.status["color_temp"] = await self.get_color_temp(device) + if device.hive_type == "colourtuneablelight": + mode = await self.get_color_mode(device) + device.status["mode"] = mode if mode == "COLOUR": - dev_data["status"].update( - { - "hs_color": await self.getColor(device), - "mode": await self.getColorMode(device), - } - ) - else: - dev_data["status"].update( - { - "mode": await self.getColorMode(device), - } - ) - _LOGGER.debug( - "getLight - Light device data for %s: %s", - device["haName"], - dev_data["status"], - ) + device.status["hs_color"] = await self.get_color(device) - return self.session.setCachedDevice(device, dev_data) - else: - await self.session.helper.errorCheck( - device["device_id"], "ERROR", device["deviceData"]["online"] + _LOGGER.debug( + "get_light - Light device data for %s: %s", + device.ha_name, + device.status, ) - device.setdefault("status", {"state": None}) - return device - async def turnOn(self, device: dict, brightness: int, color_temp: int, color: list): + return self.session.set_cached_device(device) + await self.session.helper.error_check( + device.device_id, "ERROR", device.device_data["online"] + ) + device.status = device.status or {"state": None} + return device + + async def turn_on( + self, + device: dict, + brightness: int | None, + color_temp: int | None, + color: list | None, + ): """Set light to turn on. Args: @@ -500,15 +481,15 @@ async def turnOn(self, device: dict, brightness: int, color_temp: int, color: li boolean: True/False if successful. """ if brightness is not None: - return await self.setBrightness(device, brightness) + return await self.set_brightness(device, brightness) if color_temp is not None: - return await self.setColorTemp(device, color_temp) + return await self.set_color_temp(device, color_temp) if color is not None: - return await self.setColor(device, color) + return await self.set_color(device, color) - return await self.setStatusOn(device) + return await self.set_status_on(device) - async def turnOff(self, device: dict): + async def turn_off(self, device: dict): """Set light to turn off. Args: @@ -517,4 +498,16 @@ async def turnOff(self, device: dict): Returns: boolean: True/False if successful. """ - return await self.setStatusOff(device) + return await self.set_status_off(device) + + async def turnOn(self, device: dict, brightness: int, color_temp: int, color: list): # pylint: disable=invalid-name + """Backwards-compatible alias for turn_on.""" + return await self.turn_on(device, brightness, color_temp, color) + + async def turnOff(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for turn_off.""" + return await self.turn_off(device) + + async def getLight(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for get_light.""" + return await self.get_light(device) diff --git a/src/plug.py b/src/plug.py index c95590c..15bf313 100644 --- a/src/plug.py +++ b/src/plug.py @@ -1,9 +1,9 @@ """Hive Switch Module.""" -# pylint: skip-file import logging +from typing import Any -from .helper.const import HIVETOHA +from .helper.const import HIVETOHA, HTTP_OK _LOGGER = logging.getLogger(__name__) @@ -15,9 +15,10 @@ class HiveSmartPlug: object: Returns Plug object """ - plugType = "Switch" + session: Any + plug_type = "Switch" - async def getState(self, device: dict): + async def get_state(self, device: dict): """Get smart plug state. Args: @@ -29,7 +30,7 @@ async def getState(self, device: dict): state = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["state"]["status"] state = HIVETOHA["Switch"].get(state, state) except KeyError as e: @@ -37,7 +38,7 @@ async def getState(self, device: dict): return state - async def getPowerUsage(self, device: dict): + async def get_power_usage(self, device: dict): """Get smart plug current power usage. Args: @@ -49,14 +50,14 @@ async def getPowerUsage(self, device: dict): state = None try: - data = self.session.data.products[device["hiveID"]] + data = self.session.data.products[device.hive_id] state = data["props"]["powerConsumption"] except KeyError as e: _LOGGER.error(e) return state - async def setStatusOn(self, device: dict): + async def set_status_on(self, device: dict): """Set smart plug to turn on. Args: @@ -68,22 +69,22 @@ async def setStatusOn(self, device: dict): final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): - _LOGGER.debug("setStatusOn - Turning plug ON for %s.", device["haName"]) - await self.session.hiveRefreshTokens() - data = self.session.data.products[device["hiveID"]] - resp = await self.session.api.setState( + _LOGGER.debug("set_status_on - Turning plug ON for %s.", device.ha_name) + await self.session.hive_refresh_tokens() + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( data["type"], data["id"], status="ON" ) - if resp["original"] == 200: + if resp["original"] == HTTP_OK: final = True - await self.session.getDevices(device["hiveID"]) + await self.session.get_devices(device.hive_id) return final - async def setStatusOff(self, device: dict): + async def set_status_off(self, device: dict): """Set smart plug to turn off. Args: @@ -95,18 +96,18 @@ async def setStatusOff(self, device: dict): final = False if ( - device["hiveID"] in self.session.data.products - and device["deviceData"]["online"] + device.hive_id in self.session.data.products + and device.device_data["online"] ): - _LOGGER.debug("setStatusOff - Turning plug OFF for %s.", device["haName"]) - await self.session.hiveRefreshTokens() - data = self.session.data.products[device["hiveID"]] - resp = await self.session.api.setState( + _LOGGER.debug("set_status_off - Turning plug OFF for %s.", device.ha_name) + await self.session.hive_refresh_tokens() + data = self.session.data.products[device.hive_id] + resp = await self.session.api.set_state( data["type"], data["id"], status="OFF" ) - if resp["original"] == 200: + if resp["original"] == HTTP_OK: final = True - await self.session.getDevices(device["hiveID"]) + await self.session.get_devices(device.hive_id) return final @@ -126,7 +127,7 @@ def __init__(self, session: object): """ self.session = session - async def getSwitch(self, device: dict): + async def get_switch(self, device: dict): """Home assistant wrapper to get switch device. Args: @@ -135,68 +136,50 @@ async def getSwitch(self, device: dict): Returns: dict: Return device after update is complete. """ - if self.session.shouldUseCachedData(): - cached = self.session.getCachedDevice(device) + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) if cached is not None: _LOGGER.debug( - "getSwitch - Returning cached state for switch %s (slow/busy poll).", - device["haName"], + "get_switch - Returning cached state for switch %s (slow/busy poll).", + device.ha_name, ) return cached - device["deviceData"].update( - {"online": await self.session.attr.onlineOffline(device["device_id"])} - ) - dev_data = {} - - if device["deviceData"]["online"]: - self.session.helper.deviceRecovered(device["device_id"]) - _LOGGER.debug("getSwitch - Updating switch data for %s.", device["haName"]) - data = self.session.data.devices[device["device_id"]] - dev_data = { - "hiveID": device["hiveID"], - "hiveName": device["hiveName"], - "hiveType": device["hiveType"], - "haName": device["haName"], - "haType": device["haType"], - "device_id": device["device_id"], - "device_name": device["device_name"], - "status": { - "state": await self.getSwitchState(device), - }, - "deviceData": data.get("props", None), - "parentDevice": data.get("parent", None), - "custom": device.get("custom", None), - "attributes": {}, - } - - if device["hiveType"] == "activeplug": - dev_data.update( - { - "status": { - "state": dev_data["status"]["state"], - "power_usage": await self.getPowerUsage(device), - }, - "attributes": await self.session.attr.stateAttributes( - device["device_id"], device["hiveType"] - ), - } + online = await self.session.attr.online_offline(device.device_id) + if not isinstance(device.device_data, dict): + device.device_data = {} + device.device_data["online"] = online + + if device.device_data["online"]: + self.session.helper.device_recovered(device.device_id) + _LOGGER.debug("get_switch - Updating switch data for %s.", device.ha_name) + data = self.session.data.devices[device.device_id] + device.status = {"state": await self.get_switch_state(device)} + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + device.attributes = {} + + if device.hive_type == "activeplug": + device.status["power_usage"] = await self.get_power_usage(device) + device.attributes = await self.session.attr.state_attributes( + device.device_id, device.hive_type ) _LOGGER.debug( - "getSwitch - Switch device data for %s: %s", - device["haName"], - dev_data["status"], + "get_switch - Switch device data for %s: %s", + device.ha_name, + device.status, ) - return self.session.setCachedDevice(device, dev_data) - else: - await self.session.helper.errorCheck( - device["device_id"], "ERROR", device["deviceData"]["online"] - ) - device.setdefault("status", {"state": None}) - return device + return self.session.set_cached_device(device) + await self.session.helper.error_check( + device.device_id, "ERROR", device.device_data["online"] + ) + device.status = device.status or {"state": None} + return device - async def getSwitchState(self, device: dict): + async def get_switch_state(self, device: dict): """Home Assistant wrapper to get updated switch state. Args: @@ -205,12 +188,11 @@ async def getSwitchState(self, device: dict): Returns: boolean: Return True or False for the state. """ - if device["hiveType"] == "Heating_Heat_On_Demand": - return await self.session.heating.getHeatOnDemand(device) - else: - return await self.getState(device) + if device.hive_type == "Heating_Heat_On_Demand": + return await self.session.heating.get_heat_on_demand(device) + return await self.get_state(device) - async def turnOn(self, device: dict): + async def turn_on(self, device: dict): """Home Assisatnt wrapper for turning switch on. Args: @@ -219,12 +201,11 @@ async def turnOn(self, device: dict): Returns: function: Calls relevant function. """ - if device["hiveType"] == "Heating_Heat_On_Demand": - return await self.session.heating.setHeatOnDemand(device, "ENABLED") - else: - return await self.setStatusOn(device) + if device.hive_type == "Heating_Heat_On_Demand": + return await self.session.heating.set_heat_on_demand(device, "ENABLED") + return await self.set_status_on(device) - async def turnOff(self, device: dict): + async def turn_off(self, device: dict): """Home Assisatnt wrapper for turning switch off. Args: @@ -233,7 +214,18 @@ async def turnOff(self, device: dict): Returns: function: Calls relevant function. """ - if device["hiveType"] == "Heating_Heat_On_Demand": - return await self.session.heating.setHeatOnDemand(device, "DISABLED") - else: - return await self.setStatusOff(device) + if device.hive_type == "Heating_Heat_On_Demand": + return await self.session.heating.set_heat_on_demand(device, "DISABLED") + return await self.set_status_off(device) + + async def turnOn(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for turn_on.""" + return await self.turn_on(device) + + async def turnOff(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for turn_off.""" + return await self.turn_off(device) + + async def getSwitch(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for get_switch.""" + return await self.get_switch(device) diff --git a/src/sensor.py b/src/sensor.py index cc0546a..0ef830d 100644 --- a/src/sensor.py +++ b/src/sensor.py @@ -1,169 +1,158 @@ -"""Hive Sensor Module.""" - -# pylint: skip-file -import logging - -from .helper.const import HIVE_TYPES, HIVETOHA, sensor_commands - -_LOGGER = logging.getLogger(__name__) - - -class HiveSensor: - """Hive Sensor Code.""" - - sensorType = "Sensor" - - async def getState(self, device: dict): - """Get sensor state. - - Args: - device (dict): Device to get state off. - - Returns: - str: State of device. - """ - state = None - final = None - - try: - data = self.session.data.products[device["hiveID"]] - if data["type"] == "contactsensor": - state = data["props"]["status"] - final = HIVETOHA[self.sensorType].get(state, state) - elif data["type"] == "motionsensor": - final = data["props"]["motion"]["status"] - except KeyError as e: - _LOGGER.error(e) - - return final - - async def online(self, device: dict): - """Get the online status of the Hive hub. - - Args: - device (dict): Device to get the state of. - - Returns: - boolean: True/False if the device is online. - """ - state = None - final = None - - try: - data = self.session.data.devices[device["device_id"]] - state = data["props"]["online"] - final = HIVETOHA[self.sensorType].get(state, state) - except KeyError as e: - _LOGGER.error(e) - - return final - - -class Sensor(HiveSensor): - """Home Assisatnt sensor code. - - Args: - HiveSensor (object): Hive sensor code. - """ - - def __init__(self, session: object = None): - """Initialise sensor. - - Args: - session (object, optional): session to interact with Hive account. Defaults to None. - """ - self.session = session - - async def getSensor(self, device: dict): - """Gets updated sensor data. - - Args: - device (dict): Device to update. - - Returns: - dict: Updated device. - """ - if self.session.shouldUseCachedData(): - cached = self.session.getCachedDevice(device) - if cached is not None: - _LOGGER.debug( - "Returning cached state for sensor %s (slow/busy poll).", - device["haName"], - ) - return cached - device["deviceData"].update( - {"online": await self.session.attr.onlineOffline(device["device_id"])} - ) - data = {} - - if device["deviceData"]["online"] or device["hiveType"] in ( - "Availability", - "Connectivity", - ): - if device["hiveType"] not in ("Availability", "Connectivity"): - self.session.helper.deviceRecovered(device["device_id"]) - - _LOGGER.debug( - "getSensor - Updating sensor data for %s (%s).", - device["haName"], - device["hiveType"], - ) - dev_data = {} - dev_data = { - "hiveID": device["hiveID"], - "hiveName": device["hiveName"], - "hiveType": device["hiveType"], - "haName": device["haName"], - "haType": device["haType"], - "device_id": device.get("device_id", None), - "device_name": device.get("device_name", None), - "deviceData": {}, - "custom": device.get("custom", None), - } - - if device["device_id"] in self.session.data.devices: - data = self.session.data.devices.get(device["device_id"], {}) - elif device["hiveID"] in self.session.data.products: - data = self.session.data.products.get(device["hiveID"], {}) - - if ( - dev_data["hiveType"] in sensor_commands - or dev_data.get("custom", None) in sensor_commands - ): - code = sensor_commands.get( - dev_data["hiveType"], - sensor_commands.get(dev_data["custom"]), - ) - dev_data.update( - { - "status": {"state": await eval(code)}, - "deviceData": data.get("props", None), - "parentDevice": data.get("parent", None), - } - ) - elif device["hiveType"] in HIVE_TYPES["Sensor"]: - data = self.session.data.devices.get(device["hiveID"], {}) - dev_data.update( - { - "status": {"state": await self.getState(device)}, - "deviceData": data.get("props", None), - "parentDevice": data.get("parent", None), - "attributes": await self.session.attr.stateAttributes( - device["device_id"], device["hiveType"] - ), - } - ) - - _LOGGER.debug( - "getSensor - Sensor device data for %s: %s", - device["haName"], - dev_data["status"], - ) - - return self.session.setCachedDevice(device, dev_data) - else: - await self.session.helper.errorCheck( - device["device_id"], "ERROR", device["deviceData"]["online"] - ) - device.setdefault("status", {"state": None}) - return device +"""Hive Sensor Module.""" + +import logging +from typing import Any + +from .helper.const import HIVE_TYPES, HIVETOHA, sensor_commands + +_LOGGER = logging.getLogger(__name__) + + +class HiveSensor: + """Hive Sensor Code.""" + + session: Any + sensor_type = "Sensor" + + async def get_state(self, device: dict): + """Get sensor state. + + Args: + device (dict): Device to get state off. + + Returns: + str: State of device. + """ + state = None + final = None + + try: + data = self.session.data.products[device.hive_id] + if data["type"] == "contactsensor": + state = data["props"]["status"] + final = HIVETOHA[self.sensor_type].get(state, state) + elif data["type"] == "motionsensor": + final = data["props"]["motion"]["status"] + except KeyError as e: + _LOGGER.error(e) + + return final + + async def online(self, device: dict): + """Get the online status of the Hive hub. + + Args: + device (dict): Device to get the state of. + + Returns: + boolean: True/False if the device is online. + """ + state = None + final = None + + try: + data = self.session.data.devices[device.device_id] + state = data["props"]["online"] + final = HIVETOHA[self.sensor_type].get(state, state) + except KeyError as e: + _LOGGER.error(e) + + return final + + +class Sensor(HiveSensor): + """Home Assisatnt sensor code. + + Args: + HiveSensor (object): Hive sensor code. + """ + + def __init__(self, session: object = None): + """Initialise sensor. + + Args: + session (object, optional): session to interact with Hive account. Defaults to None. + """ + self.session = session + + async def get_sensor(self, device: dict): + """Gets updated sensor data. + + Args: + device (dict): Device to update. + + Returns: + dict: Updated device. + """ + if self.session.should_use_cached_data(): + cached = self.session.get_cached_device(device) + if cached is not None: + _LOGGER.debug( + "Returning cached state for sensor %s (slow/busy poll).", + device.ha_name, + ) + return cached + online = await self.session.attr.online_offline(device.device_id) + if not isinstance(device.device_data, dict): + device.device_data = {} + device.device_data["online"] = online + data = {} + + if device.device_data["online"] or device.hive_type in ( + "Availability", + "Connectivity", + ): + if device.hive_type not in ("Availability", "Connectivity"): + self.session.helper.device_recovered(device.device_id) + + _LOGGER.debug( + "get_sensor - Updating sensor data for %s (%s).", + device.ha_name, + device.hive_type, + ) + + if device.device_id in self.session.data.devices: + data = self.session.data.devices.get(device.device_id, {}) + elif device.hive_id in self.session.data.products: + data = self.session.data.products.get(device.hive_id, {}) + + if ( + device.hive_type in sensor_commands + or getattr(device, "custom", None) in sensor_commands + ): + code = sensor_commands.get( + device.hive_type, + sensor_commands.get(getattr(device, "custom", None)), + ) + device.status = {"state": await code(self, device)} + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + elif device.hive_type in HIVE_TYPES["Sensor"]: + data = self.session.data.devices.get(device.hive_id, {}) + device.status = {"state": await self.get_state(device)} + props = data.get("props") or {} + props["online"] = online + device.device_data = props + device.parent_device = data.get("parent", None) + device.attributes = await self.session.attr.state_attributes( + device.device_id, device.hive_type + ) + + _LOGGER.debug( + "get_sensor - Sensor device data for %s: %s", + device.ha_name, + device.status, + ) + + return self.session.set_cached_device(device) + await self.session.helper.error_check( + device.device_id, "ERROR", device.device_data["online"] + ) + device.status = device.status or {"state": None} + return device + + async def getSensor(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for get_sensor.""" + return await self.get_sensor(device) diff --git a/src/session.py b/src/session.py index a61a8fd..e51c625 100644 --- a/src/session.py +++ b/src/session.py @@ -1,20 +1,20 @@ """Hive Session Module.""" -# pylint: skip-file +from __future__ import annotations + import asyncio -import copy import json import logging -import operator -import os import time from datetime import datetime, timedelta +from pathlib import Path +from aiohttp import ClientSession from aiohttp.web import HTTPException from apyhiveapi import API, Auth from .device_attributes import HiveAttributes -from .helper.const import ACTIONS, DEVICES, HIVE_TYPES, PRODUCTS +from .helper.const import DEVICES, HIVE_TYPES, PRODUCTS from .helper.hive_exceptions import ( HiveApiError, HiveAuthError, @@ -26,11 +26,13 @@ HiveReauthRequired, HiveRefreshTokenExpired, HiveUnknownConfiguration, - NoApiToken, ) from .helper.hive_helper import HiveHelper +from .helper.hivedataclasses import Device, SessionConfig, SessionTokens from .helper.map import Map +_DATA_DIR = Path(__file__).parent / "data" + _LOGGER = logging.getLogger(__name__) @@ -47,14 +49,14 @@ class HiveSession: object: Session object. """ - sessionType = "Session" + session_type = "Session" def __init__( self, - username: str = None, - password: str = None, - websession: object = None, - ): + username: str | None = None, + password: str | None = None, + websession: ClientSession | None = None, + ) -> None: """Initialise the base variable values. Args: @@ -66,33 +68,13 @@ def __init__( username=username, password=password, ) - self.api = API(hiveSession=self, websession=websession) + self.api = API(hive_session=self, websession=websession) self.helper = HiveHelper(self) self.attr = HiveAttributes(self) - self.updateLock = asyncio.Lock() - self._refreshLock = asyncio.Lock() - self.tokens = Map( - { - "tokenData": {}, - "tokenCreated": datetime.min, - "tokenExpiry": timedelta(seconds=3600), - } - ) - self.config = Map( - { - "alarm": False, - "battery": [], - "camera": False, - "errorList": {}, - "file": False, - "homeID": None, - "lastUpdate": datetime.now(), - "mode": [], - "scanInterval": timedelta(seconds=120), - "userID": None, - "username": username, - } - ) + self.update_lock = asyncio.Lock() + self._refresh_lock = asyncio.Lock() + self.tokens = SessionTokens() + self.config = SessionConfig(username=username) self.data = Map( { "products": {}, @@ -100,142 +82,174 @@ def __init__( "actions": {}, "user": {}, "minMax": {}, - "alarm": {}, - "camera": {}, } ) - self.entityCache = {} - self.deviceList = {} + self.entity_cache = {} + self.device_list = {} self.hub_id = None - self._lastPollSlow = False - self._slowPollThreshold = 3 - self._refreshThreshold = 0.90 - self._updateTask = None + self._last_poll_slow = False + self._slow_poll_threshold = 3 + self._refresh_threshold = 0.90 + self._update_task = None @staticmethod - def _entityCacheKey(device: dict): + def _entity_cache_key(device) -> str: """Build a stable cache key for an entity instance.""" return "|".join( [ - str(device.get("haType", "")), - str(device.get("hiveID", "")), - str(device.get("hiveType", "")), + str(getattr(device, "ha_type", "")), + str(getattr(device, "hive_id", "")), + str(getattr(device, "hive_type", "")), ] ) - def getCachedDevice(self, device: dict): + def get_cached_device(self, device): """Get cached state for a specific entity.""" - cache_key = self._entityCacheKey(device) - return self.entityCache.get(cache_key) + cache_key = self._entity_cache_key(device) + return self.entity_cache.get(cache_key) - def setCachedDevice(self, device: dict, dev_data: dict): - """Store cached state for a specific entity.""" - self.entityCache[self._entityCacheKey(device)] = dev_data - return dev_data + def set_cached_device(self, device): + """Store device state in cache and return it.""" + self.entity_cache[self._entity_cache_key(device)] = device + return device - def shouldUseCachedData(self): + def should_use_cached_data(self): """Determine whether callers should use cached entity state. Returns: bool: True when the last poll was slow or another task is currently polling. """ - if self._lastPollSlow: + if self._last_poll_slow: return True - if self.updateLock.locked(): + if self.update_lock.locked(): current_task = asyncio.current_task() - return self._updateTask is None or current_task is not self._updateTask + return self._update_task is None or current_task is not self._update_task return False - def openFile(self, file: str): - """Open a file. + async def _poll_devices(self) -> bool: + """Fetch latest device state from the Hive API.""" + return await self.get_devices("No_ID") + + async def _retry_with_backoff( + self, + coro_factory, + *, + delays: tuple = (0, 5, 10), + reraise_as=None, + pass_through: tuple = (), + ): + """Retry an async operation with sequential delays. Args: - file (str): File location + coro_factory: Zero-argument callable returning a coroutine to attempt. + delays: Seconds to wait before each attempt; the first (0) is immediate. + reraise_as: Exception *type* to raise once all attempts are exhausted. + Defaults to the type of the last caught exception. + pass_through: Exception types that bypass retrying and propagate + immediately to the caller. Returns: - dict: Data from the chosen file. + The result of the first successful ``coro_factory()`` call. + + Raises: + reraise_as (or type of last error): When all retry attempts fail. """ - path = os.path.dirname(os.path.realpath(__file__)) + "/data/" + file - path = path.replace("/pyhiveapi/", "/apyhiveapi/") - with open(path) as j: - data = json.loads(j.read()) + last_err = None + for delay in delays: + if delay: + await asyncio.sleep(delay) + try: + return await coro_factory() + except pass_through: + raise + except Exception as err: # pylint: disable=broad-except + last_err = err + raise (reraise_as or type(last_err)) from last_err - return data + def open_file(self, file: str) -> dict: + """Open a JSON fixture file from the package data directory. - def addList(self, entityType: str, data: dict, **kwargs: dict): - """Add entity to the list. + Args: + file (str): Filename relative to the ``data/`` directory (e.g. ``"data.json"``). + + Returns: + dict: Parsed JSON content of the file. + """ + return json.loads((_DATA_DIR / file).read_text(encoding="utf-8")) + + def add_list(self, entity_type: str, data: dict, **kwargs) -> Device: + """Add entity to the device list. Args: - type (str): Type of entity - data (dict): Information to create entity. + entity_type (str): HA entity type (e.g. "climate", "sensor"). + data (dict): Raw product or device data from the Hive API. Returns: - dict: Entity. + Device: Created device entity, or None on error. """ try: - device = self.helper.getDeviceData(data) - device_name = ( - device["state"]["name"] - if device["state"]["name"] != "Receiver" - else "Heating" - ) - formatted_data = {} - - formatted_data = { - "hiveID": data.get("id", ""), - "hiveName": device_name, - "hiveType": data.get("type", ""), - "haType": entityType, - "deviceData": device.get("props", data.get("props", {})), - "parentDevice": self.hub_id, - "isGroup": data.get("isGroup", False), - "device_id": device["id"], - "device_name": device_name, - } - - if kwargs.get("haName", "FALSE")[0] == " ": - kwargs["haName"] = device_name + kwargs["haName"] + hive_type = kwargs.get("hive_type", data.get("type", "")) + if hive_type == "action": + device_name = kwargs.get("ha_name", data.get("name", "Action")) + device_obj = Device( + hive_id=data.get("id", ""), + hive_name=device_name, + hive_type="action", + ha_type=entity_type, + device_id=data.get("id", ""), + device_name=device_name, + device_data={}, + parent_device=self.hub_id, + ha_name=device_name, + ) else: - formatted_data["haName"] = device_name + device_data = self.helper.get_device_data(data) + device_name = ( + device_data["state"]["name"] + if device_data["state"]["name"] != "Receiver" + else "Heating" + ) - formatted_data.update(kwargs) + ha_name = kwargs.get("ha_name", "") + if ha_name.startswith(" "): + ha_name = device_name + ha_name + elif not ha_name: + ha_name = device_name + + device_obj = Device( + hive_id=data.get("id", ""), + hive_name=device_name, + hive_type=hive_type, + ha_type=entity_type, + device_id=device_data["id"], + device_name=device_name, + device_data=device_data.get("props", data.get("props", {})), + parent_device=self.hub_id, + is_group=data.get("isGroup", False), + ha_name=ha_name, + category=kwargs.get("category"), + temperature_unit=kwargs.get("temperature_unit"), + ) - if data.get("type", "") == "hub": - self.deviceList["parent"].append(formatted_data) - self.deviceList[entityType].append(formatted_data) - else: - self.deviceList[entityType].append(formatted_data) + if data.get("type", "") == "hub": + self.device_list["parent"].append(device_obj) - return formatted_data + self.device_list[entity_type].append(device_obj) + return device_obj except KeyError as error: _LOGGER.error(error) return None - async def updateInterval(self, new_interval: timedelta): - """Update the scan interval. + def _configure_file_mode(self, username: str | None = None) -> None: + """Set file mode when the magic testing username is detected. Args: - new_interval (int): New interval for polling. + username: If ``"use@file.com"``, switches the session to file-based mode. """ - if isinstance(new_interval, int): - new_interval = timedelta(seconds=new_interval) - - interval = new_interval - if interval < timedelta(seconds=15): - interval = timedelta(seconds=15) - self.config.scanInterval = interval - - async def useFile(self, username: str = None): - """Update to check if file is being used. - - Args: - username (str, optional): Looks for use@file.com. Defaults to None. - """ - using_file = True if username == "use@file.com" else False - if using_file: + if username == "use@file.com": self.config.file = True - async def updateTokens(self, tokens: dict, update_expiry_time: bool = True): + async def update_tokens(self, tokens: dict, update_expiry_time: bool = True): """Update session tokens. Args: @@ -247,46 +261,43 @@ async def updateTokens(self, tokens: dict, update_expiry_time: bool = True): """ data = {} _LOGGER.debug( - "updateTokens - Input tokens: %s", self.helper._sanitize_payload(tokens) + "update_tokens - Input tokens: %s", self.helper.sanitize_payload(tokens) ) if "AuthenticationResult" in tokens: data = tokens.get("AuthenticationResult") - self.tokens.tokenData.update({"token": data["IdToken"]}) + self.tokens.token_data.update({"token": data["IdToken"]}) if "RefreshToken" in data: - self.tokens.tokenData.update({"refreshToken": data["RefreshToken"]}) - self.tokens.tokenData.update({"accessToken": data["AccessToken"]}) + self.tokens.token_data.update({"refreshToken": data["RefreshToken"]}) + self.tokens.token_data.update({"accessToken": data["AccessToken"]}) if update_expiry_time: - self.tokens.tokenCreated = datetime.now() + self.tokens.token_created = datetime.now() elif "token" in tokens: data = tokens - self.tokens.tokenData.update({"token": data["token"]}) - self.tokens.tokenData.update({"refreshToken": data["refreshToken"]}) - self.tokens.tokenData.update({"accessToken": data["accessToken"]}) + self.tokens.token_data.update({"token": data["token"]}) + self.tokens.token_data.update({"refreshToken": data["refreshToken"]}) + self.tokens.token_data.update({"accessToken": data["accessToken"]}) if "ExpiresIn" in data: - self.tokens.tokenExpiry = timedelta(seconds=data["ExpiresIn"]) + self.tokens.token_expiry = timedelta(seconds=data["ExpiresIn"]) _LOGGER.debug( - "updateTokens — Final session tokens: IdToken: len=%d tail=…%s | " + "update_tokens — Final session tokens: IdToken: len=%d tail=…%s | " "AccessToken: len=%d tail=…%s | " "RefreshToken: %s | " - "ExpiresIn: %s | tokenCreated: %s | tokenExpiry: %s", - len(self.tokens.tokenData.get("token", "")), - self.tokens.tokenData.get("token", "")[-4:], - len(self.tokens.tokenData.get("accessToken", "")), - self.tokens.tokenData.get("accessToken", "")[-4:], + "ExpiresIn: %s | token_created: %s | token_expiry: %s", + len(self.tokens.token_data.get("token", "")), + self.tokens.token_data.get("token", "")[-4:], + len(self.tokens.token_data.get("accessToken", "")), + self.tokens.token_data.get("accessToken", "")[-4:], ( - "present (len=%d tail=…%s)" - % ( - len(self.tokens.tokenData.get("refreshToken", "")), - self.tokens.tokenData.get("refreshToken", "")[-4:], - ) - if self.tokens.tokenData.get("refreshToken") + f"present (len={len(self.tokens.token_data.get('refreshToken', ''))}" + f" tail=…{self.tokens.token_data.get('refreshToken', '')[-4:]})" + if self.tokens.token_data.get("refreshToken") else "not present" ), data.get("ExpiresIn", "N/A"), - self.tokens.tokenCreated, - self.tokens.tokenExpiry, + self.tokens.token_created, + self.tokens.token_expiry, ) return self.tokens @@ -330,7 +341,7 @@ async def login(self): _LOGGER.debug( "login - Login successful — AuthenticationResult keys: %s", auth_keys ) - await self.updateTokens(result) + await self.update_tokens(result) return result # Rule 2 & 3: Check for device login or SMS challenges and route @@ -340,16 +351,15 @@ async def login(self): if challenge_name == self.auth.DEVICE_VERIFIER_CHALLENGE: # Rule 4: Device login flow - check if device is registered _LOGGER.debug("login - Routing to device login flow") - return await self._handleDeviceLoginChallenge(result) - elif challenge_name == self.auth.SMS_MFA_CHALLENGE: + return await self._handle_device_login_challenge(result) + if challenge_name == self.auth.SMS_MFA_CHALLENGE: # Rule 5: SMS flow - will need device registration after 2FA _LOGGER.debug("login - Routing to SMS 2FA flow (requires user input)") return result - else: - _LOGGER.error("login - Unsupported challenge: %s", challenge_name) - raise HiveUnknownConfiguration + _LOGGER.error("login - Unsupported challenge: %s", challenge_name) + raise HiveUnknownConfiguration - async def _handleDeviceLoginChallenge(self, login_result): + async def _handle_device_login_challenge(self, _login_result): """Handle device login challenge. Args: @@ -362,25 +372,27 @@ async def _handleDeviceLoginChallenge(self, login_result): HiveReauthRequired: If device login encounters SMS_MFA (device not remembered). HiveInvalidDeviceAuthentication: If device is not registered. """ - _LOGGER.debug("_handleDeviceLoginChallenge - Processing device login") + _LOGGER.debug("_handle_device_login_challenge - Processing device login") # Check if device is registered before attempting device login is_registered = await self.auth.is_device_registered() if not is_registered: _LOGGER.warning( - "_handleDeviceLoginChallenge - Device not registered, " + "_handle_device_login_challenge - Device not registered, " "cannot complete device login. User must complete SMS 2FA." ) raise HiveInvalidDeviceAuthentication # Device is registered, proceed with device login - _LOGGER.debug("_handleDeviceLoginChallenge - Device is registered, proceeding") + _LOGGER.debug( + "_handle_device_login_challenge - Device is registered, proceeding" + ) result = await self.auth.device_login() # Check if device login returned SMS_MFA challenge (device not remembered by Cognito) if result and result.get("ChallengeName") == self.auth.SMS_MFA_CHALLENGE: _LOGGER.error( - "_handleDeviceLoginChallenge - Device login failed: SMS MFA challenge detected. " + "_handle_device_login_challenge - Device login failed: SMS MFA challenge detected. " "Device is not remembered by Cognito. User must reauthenticate." ) raise HiveReauthRequired @@ -388,10 +400,11 @@ async def _handleDeviceLoginChallenge(self, login_result): if result and "AuthenticationResult" in result: auth_keys = list(result["AuthenticationResult"].keys()) _LOGGER.debug( - "_handleDeviceLoginChallenge - Device login successful — AuthenticationResult keys: %s", + "_handle_device_login_challenge - Device login successful" + " — AuthenticationResult keys: %s", auth_keys, ) - await self.updateTokens(result) + await self.update_tokens(result) return result @@ -428,61 +441,51 @@ async def sms2fa(self, code, session): "sms_2fa - 2FA login successful — AuthenticationResult keys: %s", auth_keys, ) - await self.updateTokens(result) + await self.update_tokens(result) return result - async def _retryLogin(self): + async def _retry_login(self): """Attempt login with retries and backoff. This is called when token refresh fails. It attempts to login again, which may succeed via device login or may require user interaction (SMS 2FA). Raises: - HiveReauthRequired: User interaction required (SMS 2FA challenge). - HiveInvalidDeviceAuthentication: Device credentials are invalid. + HiveReauthRequired: User interaction required (SMS 2FA challenge), + credentials invalid, or all retries exhausted. HiveApiError: API error or no internet connection. """ - last_err = None - for delay_s in (0, 5, 10): - try: - if delay_s: - _LOGGER.debug( - "_retryLogin - Retrying login in %s seconds.", delay_s - ) - await asyncio.sleep(delay_s) - result = await self.login() - - # Check if login returned SMS_MFA challenge (requires user interaction) - if ( - result - and result.get("ChallengeName") == self.auth.SMS_MFA_CHALLENGE - ): - _LOGGER.error( - "_retryLogin - Login requires SMS 2FA. User must reauthenticate." - ) - raise HiveReauthRequired - last_err = None - break - except (HiveInvalidUsername, HiveInvalidPassword): + async def _attempt(): + result = await self.login() + if result and result.get("ChallengeName") == self.auth.SMS_MFA_CHALLENGE: _LOGGER.error( - "_retryLogin - Login failed with invalid credentials, reauthentication required." + "_retry_login - Login requires SMS 2FA. User must reauthenticate." ) raise HiveReauthRequired - except HiveReauthRequired: - # Propagate reauthentication requirement immediately - raise - except HiveApiError as err: - _LOGGER.error("_retryLogin - Login attempt failed: %s", err) - last_err = err - if last_err is not None: - _LOGGER.error("_retryLogin - All login retries exhausted.") - raise HiveReauthRequired from last_err + return result - await self.hiveRefreshTokens(force_refresh=True) + try: + await self._retry_with_backoff( + _attempt, + reraise_as=HiveReauthRequired, + pass_through=( + HiveReauthRequired, + HiveInvalidUsername, + HiveInvalidPassword, + ), + ) + except (HiveInvalidUsername, HiveInvalidPassword) as exc: + _LOGGER.error( + "_retry_login - Login failed with invalid credentials," + " reauthentication required." + ) + raise HiveReauthRequired from exc - async def hiveRefreshTokens(self, force_refresh: bool = False): + await self.hive_refresh_tokens(force_refresh=True) + + async def hive_refresh_tokens(self, force_refresh: bool = False): """Refresh Hive tokens. Args: @@ -493,54 +496,54 @@ async def hiveRefreshTokens(self, force_refresh: bool = False): """ result = None - if self.config.file: - return None - else: - expiry_time = self.tokens.tokenCreated + ( - self.tokens.tokenExpiry * self._refreshThreshold + if not self.config.file: + expiry_time = self.tokens.token_created + ( + self.tokens.token_expiry * self._refresh_threshold ) # Refresh at 90% of token lifetime to prevent expiration during API calls _LOGGER.debug( - "hiveRefreshTokens - Session token expiry time ( Current: %s | Expiry: %s)", + "hive_refresh_tokens - Session token expiry time ( Current: %s | Expiry: %s)", datetime.now(), expiry_time, ) if datetime.now() >= expiry_time or force_refresh: - async with self._refreshLock: + async with self._refresh_lock: # Re-check after acquiring lock — another caller may have already refreshed - expiry_time = self.tokens.tokenCreated + ( - self.tokens.tokenExpiry * self._refreshThreshold + expiry_time = self.tokens.token_created + ( + self.tokens.token_expiry * self._refresh_threshold ) if datetime.now() < expiry_time and not force_refresh: return result - actual_expiry = self.tokens.tokenCreated + self.tokens.tokenExpiry + actual_expiry = self.tokens.token_created + self.tokens.token_expiry _LOGGER.debug( - "hiveRefreshTokens - Session Token created: %s | Actual expiry: %s | " + "hive_refresh_tokens - Session Token created: %s | Actual expiry: %s | " "Early refresh (×%s): %s | Now: %s | Force refresh: %s", - self.tokens.tokenCreated, + self.tokens.token_created, actual_expiry, - self._refreshThreshold, + self._refresh_threshold, expiry_time, datetime.now(), force_refresh, ) try: result = await self.auth.refresh_token( - self.tokens.tokenData["refreshToken"] + self.tokens.token_data["refreshToken"] ) if result and "AuthenticationResult" in result: auth_keys = list(result["AuthenticationResult"].keys()) _LOGGER.debug( - "hiveRefreshTokens - Token refresh — AuthenticationResult keys: %s", + "hive_refresh_tokens - Token refresh" + " — AuthenticationResult keys: %s", auth_keys, ) - await self.updateTokens(result) + await self.update_tokens(result) new_expiry = ( - self.tokens.tokenCreated + self.tokens.tokenExpiry + self.tokens.token_created + self.tokens.token_expiry ) _LOGGER.debug( - "hiveRefreshTokens - Session Token refresh successful. New expiry: %s", + "hive_refresh_tokens - Session Token refresh" + " successful. New expiry: %s", new_expiry, ) except (HiveRefreshTokenExpired, HiveFailedToRefreshTokens) as exc: @@ -549,7 +552,7 @@ async def hiveRefreshTokens(self, force_refresh: bool = False): type(exc).__name__, ) if not force_refresh: - await self._retryLogin() + await self._retry_login() else: _LOGGER.error( "Token refresh failed during retry attempt, giving up." @@ -561,7 +564,7 @@ async def hiveRefreshTokens(self, force_refresh: bool = False): return result - async def updateData(self, device: dict): + async def update_data(self, _device: dict): """Get latest data for Hive nodes - rate limiting. Args: @@ -571,109 +574,38 @@ async def updateData(self, device: dict): boolean: True/False if update was successful """ updated = False - ep = self.config.lastUpdate + self.config.scanInterval + ep = self.config.last_update + self.config.scan_interval if datetime.now() >= ep: current_task = asyncio.current_task() - if self.updateLock.locked() and ( - self._updateTask is None or current_task is not self._updateTask + if self.update_lock.locked() and ( + self._update_task is None or current_task is not self._update_task ): - _LOGGER.debug("updateData - Update poll already in progress") + _LOGGER.debug("update_data - Update poll already in progress") return updated - async with self.updateLock: + async with self.update_lock: # Re-check after acquiring lock — another caller may have already updated - ep = self.config.lastUpdate + self.config.scanInterval + ep = self.config.last_update + self.config.scan_interval if datetime.now() < ep: return updated - self._updateTask = current_task + self._update_task = current_task try: - _LOGGER.debug("updateData - Polling Hive API for device updates.") - updated = await self.getDevices(device["hiveID"]) - if updated and len(self.deviceList["camera"]) > 0: - for camera in self.data.camera: - camera_device = self.data.devices.get(camera) - if camera_device is not None: - await self.getCamera(camera_device) + _LOGGER.debug("Polling Hive API for device updates.") + updated = await self._poll_devices() if updated: _LOGGER.debug( - "updateData - Device update completed successfully." + "update_data - Device update completed successfully." ) else: _LOGGER.debug( - "updateData - Device update failed, will retry after scan interval." + "update_data - Device update failed, will retry after scan interval." ) finally: - if self._updateTask is current_task: - self._updateTask = None + if self._update_task is current_task: + self._update_task = None return updated - async def getAlarm(self): - """Get alarm data. - - Raises: - HTTPException: HTTP error has occurred updating the devices. - HiveApiError: An API error code has been returned. - """ - if self.config.file: - api_resp_d = self.openFile("alarm.json") - elif self.tokens is not None: - api_resp_d = await self.api.getAlarm() - if operator.contains(str(api_resp_d["original"]), "20") is False: - raise HTTPException - elif api_resp_d["parsed"] is None: - raise HiveApiError - - self.data.alarm = api_resp_d["parsed"] - - async def getCamera(self, device): - """Get camera data. - - Raises: - HTTPException: HTTP error has occurred updating the devices. - HiveApiError: An API error code has been returned. - """ - cameraImage = None - cameraRecording = None - hasCameraImage = False - hasCameraRecording = False - - if self.config.file: - cameraImage = self.openFile("camera.json") - cameraRecording = self.openFile("camera.json") - elif self.tokens is not None: - cameraImage = await self.api.getCameraImage(device) - hasCameraRecording = bool( - cameraImage["parsed"]["events"][0]["hasRecording"] - ) - if hasCameraRecording: - cameraRecording = await self.api.getCameraRecording( - device, cameraImage["parsed"]["events"][0]["eventId"] - ) - - if operator.contains(str(cameraImage["original"]), "20") is False: - raise HTTPException - elif cameraImage["parsed"] is None: - raise HiveApiError - else: - raise NoApiToken - - hasCameraImage = bool(cameraImage["parsed"]["events"][0]) - - self.data.camera[device["id"]] = {} - self.data.camera[device["id"]]["cameraImage"] = None - self.data.camera[device["id"]]["cameraRecording"] = None - - if cameraImage is not None and hasCameraImage: - self.data.camera[device["id"]] = {} - self.data.camera[device["id"]]["cameraImage"] = cameraImage["parsed"][ - "events" - ][0] - if cameraRecording is not None and hasCameraRecording: - self.data.camera[device["id"]]["cameraRecording"] = cameraRecording[ - "parsed" - ] - - async def getDevices(self, n_id: str): + async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too-many-statements # noqa: PLR0912, PLR0915 """Get latest data for Hive nodes. Args: @@ -691,104 +623,84 @@ async def getDevices(self, n_id: str): try: if self.config.file: - _LOGGER.debug("getDevices - Loading device data from file.") - api_resp_d = self.openFile("data.json") + _LOGGER.debug("get_devices - Loading device data from file.") + api_resp_d = self.open_file("data.json") elif self.tokens is not None: - _LOGGER.debug("getDevices - Refreshing tokens before fetching devices.") - await self.hiveRefreshTokens() - _LOGGER.debug("getDevices - Fetching all devices from Hive API.") + _LOGGER.debug( + "get_devices - Refreshing tokens before fetching devices." + ) + await self.hive_refresh_tokens() + _LOGGER.debug("get_devices - Fetching all devices from Hive API.") api_call_start = time.monotonic() try: - api_resp_d = await self.api.getAll() + api_resp_d = await self.api.get_all() except HiveAuthError: _LOGGER.warning( "Auth error (401/403) after token refresh, " "falling back to full device re-login." ) - await self._retryLogin() - last_auth_err = None - for api_retry_delay in (0, 5, 10): - try: - if api_retry_delay: - _LOGGER.debug( - "getDevices - Retrying API call in %ss after device re-login.", - api_retry_delay, - ) - await asyncio.sleep(api_retry_delay) - api_resp_d = await self.api.getAll() - last_auth_err = None - break - except HiveAuthError as retry_err: - _LOGGER.warning( - "API call still rejected after device re-login (attempt delay=%ss).", - api_retry_delay, - ) - last_auth_err = retry_err - if last_auth_err is not None: - raise HiveReauthRequired from last_auth_err + await self._retry_login() + api_resp_d = await self._retry_with_backoff( + self.api.get_all, + reraise_as=HiveReauthRequired, + ) api_call_duration = time.monotonic() - api_call_start - if api_call_duration > self._slowPollThreshold: + if api_call_duration > self._slow_poll_threshold: _LOGGER.debug( - "getDevices - Hive API response took %.1fs — marking poll as slow.", + "get_devices - Hive API response took %.1fs — marking poll as slow.", api_call_duration, ) - self._lastPollSlow = True + self._last_poll_slow = True else: - self._lastPollSlow = False - if operator.contains(str(api_resp_d["original"]), "20") is False: + self._last_poll_slow = False + if not str(api_resp_d["original"]).startswith("2"): raise HTTPException - elif api_resp_d["parsed"] is None: + if api_resp_d["parsed"] is None: raise HiveApiError api_resp_p = api_resp_d["parsed"] - tmpProducts = {} - tmpDevices = {} - tmpActions = {} - - for hiveType in api_resp_p: - if hiveType == "user": - self.data.user = api_resp_p[hiveType] - self.config.userID = api_resp_p[hiveType]["id"] - if hiveType == "products": - for aProduct in api_resp_p[hiveType]: - tmpProducts.update({aProduct["id"]: aProduct}) - if hiveType == "devices": - for aDevice in api_resp_p[hiveType]: - tmpDevices.update({aDevice["id"]: aDevice}) - if aDevice["type"] == "siren": - self.config.alarm = True - # if aDevice["type"] == "hivecamera": - # await self.getCamera(aDevice) - if hiveType == "actions": - for aAction in api_resp_p[hiveType]: - tmpActions.update({aAction["id"]: aAction}) - if hiveType == "homes": - self.config.homeID = api_resp_p[hiveType]["homes"][0]["id"] + tmp_products = {} + tmp_devices = {} + tmp_actions = {} + + for hive_type_key in api_resp_p: + if hive_type_key == "user": + self.data.user = api_resp_p[hive_type_key] + self.config.user_id = api_resp_p[hive_type_key]["id"] + if hive_type_key == "products": + for a_product in api_resp_p[hive_type_key]: + tmp_products.update({a_product["id"]: a_product}) + if hive_type_key == "devices": + for a_device in api_resp_p[hive_type_key]: + tmp_devices.update({a_device["id"]: a_device}) + if hive_type_key == "actions": + for a_action in api_resp_p[hive_type_key]: + tmp_actions.update({a_action["id"]: a_action}) + if hive_type_key == "homes": + self.config.home_id = api_resp_p[hive_type_key]["homes"][0]["id"] _LOGGER.debug( - "getDevices - API returned %d products, %d devices, %d actions.", - len(tmpProducts), - len(tmpDevices), - len(tmpActions), + "get_devices - API returned %d products, %d devices, %d actions.", + len(tmp_products), + len(tmp_devices), + len(tmp_actions), ) - if len(tmpProducts) > 0: - self.data.products = copy.deepcopy(tmpProducts) - if len(tmpDevices) > 0: - self.data.devices = copy.deepcopy(tmpDevices) - self.data.actions = copy.deepcopy(tmpActions) - if self.config.alarm: - await self.getAlarm() - self.config.lastUpdate = datetime.now() + if tmp_products: + self.data.products = tmp_products + if tmp_devices: + self.data.devices = tmp_devices + self.data.actions = tmp_actions + self.config.last_update = datetime.now() get_nodes_successful = True except HiveReauthRequired: _LOGGER.error("Reauthentication required, propagating to caller.") - self.config.lastUpdate = datetime.now() + self.config.last_update = datetime.now() raise except asyncio.TimeoutError: _LOGGER.warning("Hive API request timed out — keeping cached device data.") - self._lastPollSlow = True - self.config.lastUpdate = ( - datetime.now() - self.config.scanInterval + timedelta(seconds=30) + self._last_poll_slow = True + self.config.last_update = ( + datetime.now() - self.config.scan_interval + timedelta(seconds=30) ) get_nodes_successful = False except ( @@ -799,14 +711,14 @@ async def getDevices(self, n_id: str): HTTPException, ) as err: _LOGGER.error("Failed to fetch devices: %s", err) - self.config.lastUpdate = ( - datetime.now() - self.config.scanInterval + timedelta(seconds=30) + self.config.last_update = ( + datetime.now() - self.config.scan_interval + timedelta(seconds=30) ) get_nodes_successful = False return get_nodes_successful - async def startSession(self, config: dict = None): + async def start_session(self, config: dict = None): """Setup the Hive platform. Args: @@ -821,19 +733,16 @@ async def startSession(self, config: dict = None): """ if config is None: config = {} - _LOGGER.debug("startSession - Starting Hive session.") + _LOGGER.debug("start_session - Starting Hive session.") _LOGGER.debug( - "startSession - Config: %s", self.helper._sanitize_payload(config) - ) - await self.useFile(config.get("username", self.config.username)) - await self.updateInterval( - config.get("options", {}).get("scan_interval", self.config.scanInterval) + "start_session - Config: %s", self.helper.sanitize_payload(config) ) + self._configure_file_mode(config.get("username", self.config.username)) if config != {}: if "tokens" in config and not self.config.file: - _LOGGER.debug("startSession - Updating tokens from config") - await self.updateTokens(config["tokens"], False) + _LOGGER.debug("start_session - Updating tokens from config") + await self.update_tokens(config["tokens"], False) if "username" in config and not self.config.file: self.auth.username = config["username"] @@ -849,142 +758,155 @@ async def startSession(self, config: dict = None): if not self.config.file and "tokens" not in config: raise HiveUnknownConfiguration - try: - await self.getDevices("No_ID") - except HTTPException: - raise + await self.get_devices("No_ID") - if self.data.devices == {} or self.data.products == {}: + if not self.data.devices or not self.data.products: _LOGGER.error( "No devices or products returned from Hive API, reauthentication required." ) raise HiveReauthRequired - return await self.createDevices() + return await self.create_devices() - async def createDevices(self): + async def create_devices( # noqa: PLR0912, PLR0915 + self, + ): # pylint: disable=too-many-locals,too-many-statements """Create list of devices. Returns: list: List of devices """ - _LOGGER.info("createDevices - Starting device discovery process") - - self.deviceList["parent"] = [] - self.deviceList["alarm_control_panel"] = [] - self.deviceList["binary_sensor"] = [] - self.deviceList["camera"] = [] - self.deviceList["climate"] = [] - self.deviceList["light"] = [] - self.deviceList["sensor"] = [] - self.deviceList["switch"] = [] - self.deviceList["water_heater"] = [] + _LOGGER.info("create_devices - Starting device discovery process") + + self.device_list["parent"] = [] + self.device_list["binary_sensor"] = [] + self.device_list["climate"] = [] + self.device_list["light"] = [] + self.device_list["sensor"] = [] + self.device_list["switch"] = [] + self.device_list["water_heater"] = [] hive_type = HIVE_TYPES["Thermo"] + HIVE_TYPES["Sensor"] # Find hub device first - for aDevice in self.data["devices"]: - if self.data["devices"][aDevice]["type"] == "hub": - self.hub_id = aDevice + for a_device in self.data["devices"]: + if self.data["devices"][a_device]["type"] == "hub": + self.hub_id = a_device hub_name = ( - self.data["devices"][aDevice].get("state", {}).get("name", aDevice) + self.data["devices"][a_device] + .get("state", {}) + .get("name", a_device) ) _LOGGER.debug( - "createDevices - Found hub device: %s (ID: %s)", hub_name, aDevice + "create_devices - Found hub device: %s (ID: %s)", hub_name, a_device ) break else: - _LOGGER.warning("createDevices - No hub device found in device list") + _LOGGER.warning("create_devices - No hub device found in device list") # Process devices device_count = 0 - for aDevice in self.data["devices"]: - d = self.data.devices[aDevice] - device_name = d.get("state", {}).get("name", aDevice) + for a_device in self.data["devices"]: + d = self.data.devices[a_device] + device_name = d.get("state", {}).get("name", a_device) device_type = d.get("type", "Unknown") _LOGGER.debug( - "createDevices - Processing device: %s (%s - %s)", + "create_devices - Processing device: %s (%s - %s)", device_name, - aDevice, + a_device, device_type, ) - device_list = DEVICES.get(self.data.devices[aDevice]["type"], []) - for code in device_list: + for entity_config in DEVICES.get(device_type, []): + kwargs = {} + if entity_config.ha_name: + kwargs["ha_name"] = entity_config.ha_name + if entity_config.hive_type: + kwargs["hive_type"] = entity_config.hive_type + if entity_config.category: + kwargs["category"] = entity_config.category try: - eval("self." + code) - except Exception as e: + self.add_list(entity_config.entity_type, d, **kwargs) + except (KeyError, TypeError, AttributeError) as e: _LOGGER.error( - "Failed to execute device code '%s' for %s: %s", - code, + "Failed to create device entity for %s: %s", device_name, str(e), ) - if self.data["devices"][aDevice]["type"] in hive_type: + if device_type in hive_type: self.config.battery.append(d["id"]) _LOGGER.debug( - "createDevices - Added device %s to battery monitoring list", + "create_devices - Added device %s to battery monitoring list", device_name, ) device_count += 1 # Process actions - if "action" in HIVE_TYPES["Switch"]: - _LOGGER.debug( - "createDevices - Processing %d actions", len(self.data["actions"]) - ) - for action in self.data["actions"]: - a = self.data["actions"][action] # noqa: F841 - try: - eval("self." + ACTIONS) - except Exception as e: - _LOGGER.error( - "Failed to execute action code for action %s: %s", - action, - str(e), - ) + _LOGGER.debug( + "create_devices - Processing %d actions", len(self.data["actions"]) + ) + for action_id in self.data["actions"]: + action = self.data["actions"][action_id] + try: + self.add_list( + "switch", action, ha_name=action["name"], hive_type="action" + ) + except (KeyError, TypeError, AttributeError) as e: + _LOGGER.error( + "Failed to create action entity for %s: %s", + action_id, + str(e), + ) # Process products hive_type = HIVE_TYPES["Heating"] + HIVE_TYPES["Switch"] + HIVE_TYPES["Light"] product_count = 0 - for aProduct in self.data.products: - p = self.data.products[aProduct] + for a_product, p in self.data.products.items(): if "error" in p: _LOGGER.warning( - "Skipping product %s due to error: %s", aProduct, p["error"] + "Skipping product %s due to error: %s", a_product, p["error"] ) continue - product_name = p.get("state", {}).get("name", aProduct) + product_name = p.get("state", {}).get("name", a_product) product_type = p.get("type", "Unknown") _LOGGER.debug( - "createDevices - Processing product: %s (%s - %s)", + "create_devices - Processing product: %s (%s - %s)", product_name, - aProduct, + a_product, product_type, ) # Only consider single items or heating groups - if ( - p.get("isGroup", False) - and self.data.products[aProduct]["type"] not in HIVE_TYPES["Heating"] - ): + if p.get("isGroup", False) and p["type"] not in HIVE_TYPES["Heating"]: _LOGGER.debug( - "createDevices - Skipping group product currently not supported %s (type: %s)", + "create_devices - Skipping group product currently not supported %s (type: %s)", product_name, product_type, ) continue - product_list = PRODUCTS.get(product_type, []) - for code in product_list: + for entity_config in PRODUCTS.get(product_type, []): + kwargs = {} + if entity_config.ha_name: + kwargs["ha_name"] = entity_config.ha_name + if entity_config.hive_type: + kwargs["hive_type"] = entity_config.hive_type + if entity_config.category: + kwargs["category"] = entity_config.category + if entity_config.entity_type == "climate": + kwargs["temperature_unit"] = self.data["user"].get( + "temperatureUnit" + ) + elif entity_config.temperature_unit is not None: + kwargs["temperature_unit"] = entity_config.temperature_unit try: - eval("self." + code) + self.add_list(entity_config.entity_type, p, **kwargs) except (NameError, AttributeError) as e: _LOGGER.warning( - "createDevices - Device %s cannot be setup - %s", + "create_devices - Device %s cannot be setup - %s", product_name, e, ) @@ -992,43 +914,41 @@ async def createDevices(self): if product_type in hive_type: self.config.mode.append(p["id"]) _LOGGER.debug( - "createDevices - Added product %s to mode list", product_name + "create_devices - Added product %s to mode list", product_name ) product_count += 1 _LOGGER.info( "Device discovery completed: %d devices, %d products processed. " - "Found: %d parent, %d binary_sensor, %d climate, %d light, %d sensor, %d switch, %d water_heater", + "Found: %d parent, %d binary_sensor, %d climate," + " %d light, %d sensor, %d switch, %d water_heater", device_count, product_count, - len(self.deviceList.get("parent", [])), - len(self.deviceList.get("binary_sensor", [])), - len(self.deviceList.get("climate", [])), - len(self.deviceList.get("light", [])), - len(self.deviceList.get("sensor", [])), - len(self.deviceList.get("switch", [])), - len(self.deviceList.get("water_heater", [])), + len(self.device_list.get("parent", [])), + len(self.device_list.get("binary_sensor", [])), + len(self.device_list.get("climate", [])), + len(self.device_list.get("light", [])), + len(self.device_list.get("sensor", [])), + len(self.device_list.get("switch", [])), + len(self.device_list.get("water_heater", [])), ) - return self.deviceList + return self.device_list - @staticmethod - def epochTime(date_time: any, pattern: str, action: str): - """date/time conversion to epoch. + @property + def deviceList(self): # pylint: disable=invalid-name + """Backwards-compatible alias for device_list.""" + return self.device_list - Args: - date_time (any): epoch time or date and time to use. - pattern (str): Pattern for converting to epoch. - action (str): Convert from/to. + async def startSession(self, config: dict = None): # pylint: disable=invalid-name + """Backwards-compatible alias for start_session.""" + return await self.start_session(config) - Returns: - any: Converted time. - """ - if action == "to_epoch": - pattern = "%d.%m.%Y %H:%M:%S" - epochtime = int(time.mktime(time.strptime(str(date_time), pattern))) - return epochtime - elif action == "from_epoch": - date = datetime.fromtimestamp(int(date_time)).strftime(pattern) - return date + async def updateData(self, device: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for update_data.""" + return await self.update_data(device) + + async def updateInterval(self, new_interval: int): # pylint: disable=invalid-name,unused-argument + """Backwards-compatible alias for Home Assistant Scan Interval.""" + return True diff --git a/tests/test_hub.py b/tests/test_hub.py index 6c83435..6424d0a 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -1,8 +1,37 @@ -"""Test hub framework.""" +"""Tests for session polling behaviour.""" + +# pylint: disable=protected-access +from unittest.mock import AsyncMock + +import pytest +from apyhiveapi import Hive def test_hub_smoke(): - """Test for hub smoke.""" - result = None + """Placeholder smoke test.""" + assert True + + +@pytest.mark.asyncio +async def test_force_update_polls_when_idle(): + """force_update() calls _poll_devices and returns its result when no poll is running.""" + hive = Hive(username="test@example.com", password="pass") + hive._poll_devices = AsyncMock(return_value=True) + + result = await hive.force_update() + + assert result is True + hive._poll_devices.assert_called_once() + + +@pytest.mark.asyncio +async def test_force_update_skips_when_locked(): + """force_update() returns False without polling when the update lock is already held.""" + hive = Hive(username="test@example.com", password="pass") + hive._poll_devices = AsyncMock(return_value=True) + + async with hive.update_lock: + result = await hive.force_update() - assert result + assert result is False + hive._poll_devices.assert_not_called()