diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..d2eda15d --- /dev/null +++ b/.coveragerc @@ -0,0 +1,55 @@ +[run] +# Coverage configuration for Govee integration + +source = custom_components.govee +omit = + */tests/* + */test_*.py + */__pycache__/* + */venv/* + */.venv/* + */dist/* + */build/* + +[report] +# Report configuration +precision = 2 +show_missing = True +skip_covered = False +sort = Cover + +# Fail if coverage is below threshold +fail_under = 95 + +exclude_lines = + # Standard pragma + pragma: no cover + + # Debug-only code + def __repr__ + + # Type checking blocks + if TYPE_CHECKING: + if typing.TYPE_CHECKING: + + # Abstract methods + raise NotImplementedError + + # Defensive programming + raise AssertionError + + # Non-runnable code + if __name__ == .__main__.: + if 0: + if False: + + # Protocols and abstract base classes + @(abc\.)?abstractmethod + class .*\bProtocol\): + @(typing\.)?overload + +[html] +directory = htmlcov + +[xml] +output = coverage.xml diff --git a/.devcontainer/README.md b/.devcontainer/README.md deleted file mode 100644 index 60458f61..00000000 --- a/.devcontainer/README.md +++ /dev/null @@ -1,43 +0,0 @@ -## Developing with Visual Studio Code + devcontainer - -The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. - -In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. - -**Prerequisites** - -- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -- Docker - - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) - - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. -- [Visual Studio code](https://code.visualstudio.com/) -- [Remote - Containers (VSC Extension)][extension-link] - -[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) - -[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers - -**Getting started:** - -1. Fork the repository. -2. Clone the repository to your computer. -3. Open the repository using Visual Studio code. - -When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. - -_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ - -### Tasks - -The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. - -When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. - -The available tasks are: - -Task | Description --- | -- -Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. -Run Home Assistant configuration against /config | Check the configuration. -Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. -Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml deleted file mode 100644 index fec452b4..00000000 --- a/.devcontainer/configuration.yaml +++ /dev/null @@ -1,57 +0,0 @@ -http: - server_port: 9123 - -# default_config: -# no default config, be more specific and select from https://www.home-assistant.io/integrations/default_config/ -automation: -# cloud: -config: -counter: -dhcp: -frontend: -history: -image: -input_boolean: -input_datetime: -input_number: -input_select: -input_text: -logbook: -map: -media_source: -mobile_app: -person: -scene: -script: -ssdp: -sun: -system_health: -tag: -timer: -updater: -zeroconf: -zone: -group: - -# enable remote python debugger -debugpy: - -# debug logging for govee integration -logger: - default: info - logs: - homeassistant.components.govee: debug - custom_components.govee: debug - govee_api_laggat: debug - custom_components.hacs: warning - homeassistant.components.discovery: debug - homeassistant.components.zeroconf: debug - homeassistant.components.ssdp: debug - homeassistant.components.dhcp: debug - - -device_tracker: - - platform: bluetooth_le_tracker - # avoid some log spam - interval_seconds: 60 - track_new_devices: true diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 66724444..00000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,97 +0,0 @@ -// See https://aka.ms/vscode-remote/devcontainer.json for format details. -{ - // for development container on remote pi we need to clone source into the remote container, instead of bind: - // "workspaceFolder": "/workspaces/hacs-govee", - // "workspaceMount": "source=/usr/share/hassio/share/dev/hacs-govee,target=/workspaces/hacs-govee,type=bind,consistency=delegated", - // "remoteEnv": { - // "WS_PATH": "..", - // }, - // "containerEnv": { - // "WS_PATH": "/usr/share/hassio/share/dev/hacs-govee", - // }, - //TODO: finish your own container for faster dev setup - //"image": "laggat/hacs-base-container", - //cannot use arm64 Image from here, as my raspberry is ARMv7 (32bit) - // "image": "ghcr.io/ludeeus/debian/base:latest", - //"image": "ludeeus/container:python-base-debian", - "image": "ghcr.io/laggat/ha-devcontainer:main", - //"dockerFile": "Dockerfile", - "name": "Govee integration development", - "appPort": [ - "9123:8123" - ], - "postCreateCommand": "/bin/chmod +x /workspaces/hacs-govee/.devcontainer/*.sh && /workspaces/hacs-govee/.devcontainer/postCreateContainer.sh", - "extensions": [ - "cschleiden.vscode-github-actions", - "github.vscode-pull-request-github", - "hbenl.vscode-test-explorer", - "knisterpeter.vscode-github", - "littlefoxteam.vscode-python-test-adapter", - "mhutchie.git-graph", - "ms-python.python", - "ms-python.vscode-pylance", - "ryanluker.vscode-coverage-gutters", - "tht13.python" - ], - "settings": { - //linux line breaks - "files.eol": "\n", - //prefer ZSH shell - "terminal.integrated.profiles.linux": { - "zsh": { - "path": "/usr/bin/zsh" - } - }, - "terminal.integrated.defaultProfile.linux": "zsh", - "editor.tabSize": 4, - "python.pythonPath": "/usr/bin/python3", - "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true, - // to develop on your raspberry pi 4 (for BLE): - //"docker.host": "ssh://root@192.168.144.5" - // also set docker.host for this workspace in vscode - // create keys with "ssh-keygen -t rsa -b 4096" - // also run this on your pi - // copy your local C:\Users\root\.ssh\id_rsa.pub content to /root/.ssh/authorized_keys - // then configure remote-ssh to use key auth: "Remote-SSH: Open Configuration file": - // Host 192.168.144.5 - // HostName 192.168.144.5 - // User root - // ForwardAgent yes - // IdentityFile C:\Users\root\.ssh\id_rsa - // then in "Remote Containers: Re-build and re-open in container" - // if this doesn't work or takes forever: stop frigate, or any cpu hungry process on your pi! Also check if your local "Docker Desktop" is working (if not: reinstall)! - }, - "runArgs": [ - "--name", - "devcontainer_govee", - // "--network", - // "host", - "--privileged", - "-v", - "/etc/machine-id:/etc/machine-id:ro", - "-v", - "/run/dbus:/run/dbus:ro", - "-v", - "/dev:/dev:ro", - "-v", - "/run/udev:/run/udev:ro" - ], - //Environment Variables to set in the dev container - "containerEnv": {}, - //security options - "securityOpt": [ - "seccomp:unconfined", - "apparmor:unconfined" - ], - //user defaults to vscode, let's use root for easier handling for now - "remoteUser": "root", - "containerUser": "root", - "context": ".." -} \ No newline at end of file diff --git a/.devcontainer/postCreateContainer.sh b/.devcontainer/postCreateContainer.sh deleted file mode 100644 index 6225490a..00000000 --- a/.devcontainer/postCreateContainer.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -/bin/echo ------------ postCreateCommand apt-get ------------ -#TODO: remove unused -apt-get update -apt-get -y install bash bluetooth bluez bluez-tools build-essential ca-certificates cargo cython gcc git iputils-ping libatomic1 libavcodec-dev libc-dev libffi-dev libjpeg-dev libpcap-dev libssl-dev make nano openssh-client procps python3 python3-dev python3-pip python3-setuptools rfkill unzip wget wget zlib1g-dev - -#activate all custom_components in /workspaces/... -/usr/local/bin/dev component activate --all - -# install dependencies - - - -#start home assistant in background -/usr/local/bin/dev ha start & - -#/bin/echo ------------ postCreateCommand finished ------------ diff --git a/.github/workflows/tox.yaml b/.github/workflows/tox.yaml index 7fccda17..f29fd070 100644 --- a/.github/workflows/tox.yaml +++ b/.github/workflows/tox.yaml @@ -1,25 +1,39 @@ -name: Test wtih tox +name: Tests on: - - push - - pull_request + push: + branches: [master, develop] + pull_request: + branches: [master] jobs: - build: + test: runs-on: ubuntu-latest strategy: matrix: # NOTE: keep this in sync with envlist in tox.ini - python-version: [3.12, 3.13] + python-version: ["3.12", "3.13"] + steps: - - uses: actions/checkout@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox tox-gh-actions - - name: Test with tox - run: tox + - uses: actions/checkout@v4 + + - 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 + pip install tox tox-gh-actions + + - name: Run tox + run: tox + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.github/workflows/type-check.yaml b/.github/workflows/type-check.yaml new file mode 100644 index 00000000..eb6726b5 --- /dev/null +++ b/.github/workflows/type-check.yaml @@ -0,0 +1,28 @@ +name: Type Checking + +on: + push: + branches: [master, develop] + pull_request: + branches: [master] + +jobs: + mypy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install mypy + pip install -r requirements_test.txt + + - name: Run mypy + run: mypy custom_components/govee diff --git a/.gitignore b/.gitignore index 1e9bf031..a167d585 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,15 @@ cython_debug/ custom_components/hacs # remove the govee library subtree from the integration -.git-subtree/ \ No newline at end of file +.git-subtree/ + +# Local test scripts +test_api_validation.py + +# Log files and captures +logs/ +*.pcap + +# Local/temporary documentation (keep govee-protocol-reference.md) +docs/epic-*.md +docs/legacy-*.md \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..b0d63d4a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + language_version: python3.12 + args: [--line-length=119] + + - repo: https://github.com/PyCQA/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + args: [--max-line-length=119] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: [--strict] + files: ^custom_components/govee/ diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 61e0d251..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,35 +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": "Launch Home Assistant UI in Chrome", - "request": "launch", - "type": "pwa-chrome", - "url": "http://192.168.144.5:9123", - "webRoot": "${workspaceFolder}" - }, - { - "name": "Python: Remote Attach", - "type": "python", - "request": "attach", - "connect": { - "host": "localhost", - "port": 5678 - }, - "pathMappings": [ - { - "localRoot": "${workspaceFolder}", - "remoteRoot": "/workspaces/hacs-govee" - }, - { - "localRoot": "/custom_components/govee", - "remoteRoot": "/workspaces/hacs-govee" - }, - ], - "justMyCode": false - } - ] -} \ No newline at end of file diff --git a/.vscode/settings.json.DISABLED b/.vscode/settings.json.DISABLED deleted file mode 100644 index b82b1afb..00000000 --- a/.vscode/settings.json.DISABLED +++ /dev/null @@ -1,21 +0,0 @@ -{ - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "files.associations": { - "*.yaml": "home-assistant" - }, - "files.watcherExclude": { - "**/.git/objects/**": true, - "**/.git/subtree-cache/**": true, - "**/.tox/**": true - }, - "jupyter.debugJustMyCode": false, - "python.testing.unittestEnabled": false, - "python.testing.nosetestsEnabled": false, - "python.testing.pytestEnabled": true, - "python.testing.pytestArgs": [ - "tests" - ], - "docker.host": "ssh://root@192.168.144.5", - "docker.imageBuildContextPath": "/usr/share/hassio/share/dev/hacs-govee", -} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 7092f3a8..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Run Home Assistant on port 9123", - "type": "shell", - "command": "container start", - "problemMatcher": [] - }, - { - "label": "Run Home Assistant configuration against /config", - "type": "shell", - "command": "container check", - "problemMatcher": [] - }, - { - "label": "Upgrade Home Assistant to latest dev", - "type": "shell", - "command": "container install; /workspaces/hacs-govee/.devcontainer/postHassUpdated.sh", - "problemMatcher": [] - }, - { - "label": "Install a specific version of Home Assistant", - "type": "shell", - "command": "container set-version; /workspaces/hacs-govee/.devcontainer/postHassUpdated.sh", - "problemMatcher": [] - }//, - // { - // "label": "Run HASS", - // "type": "shell", - // "command": "hass -c /config", - // // "group": { - // // "kind": "test", - // // "isDefault": true - // // }, - // // "presentation": { - // // "reveal": "always", - // // "panel": "new" - // // }, - // "problemMatcher": [] - // } - ] -} \ No newline at end of file diff --git a/.whitesource b/.whitesource deleted file mode 100644 index 9c7ae90b..00000000 --- a/.whitesource +++ /dev/null @@ -1,14 +0,0 @@ -{ - "scanSettings": { - "baseBranches": [] - }, - "checkRunSettings": { - "vulnerableCheckRunConclusionLevel": "failure", - "displayMode": "diff", - "useMendCheckNames": true - }, - "issueSettings": { - "minSeverityLevel": "LOW", - "issueType": "DEPENDENCY" - } -} \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..79d1547c --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,253 @@ +# Govee Integration Architecture + +This document provides a comprehensive overview of the Govee Home Assistant integration architecture. + +--- + +## Overview + +The Govee integration is a **hub-type** Home Assistant integration that connects to the Govee Cloud API v2.0 to control lights, LED strips, and smart plugs. It follows Clean Architecture principles with: + +- **Config Flow**: UI-based configuration with reauth and reconfigure support +- **DataUpdateCoordinator**: Centralized state management and polling +- **Platform Entities**: Light, Scene, Switch, Sensor, Button platforms +- **Command Pattern**: Immutable command objects for device control +- **Protocol Interfaces**: Clean separation between layers +- **Repairs Framework**: Actionable notifications for common issues + +**Integration Type**: `hub` (cloud service managing multiple devices) +**IoT Class**: `cloud_push` (MQTT real-time updates with polling fallback) +**API Version**: Govee API v2.0 + +--- + +## Directory Structure + +``` +custom_components/govee/ +├── __init__.py # Integration entry point +├── config_flow.py # Config flow (user, account, reauth, reconfigure) +├── coordinator.py # DataUpdateCoordinator with MQTT integration +├── entity.py # Base entity class (GoveeEntity) +├── light.py # Light platform +├── scene.py # Scene platform +├── switch.py # Switch platform (plugs, night light) +├── sensor.py # Sensor platform (rate limit, MQTT status) +├── button.py # Button platform (refresh scenes) +├── services.py # Custom services +├── repairs.py # Repairs framework integration +├── diagnostics.py # Diagnostics for troubleshooting +├── const.py # Constants +├── manifest.json # Integration metadata +├── strings.json # UI strings +├── services.yaml # Service definitions +├── quality_scale.yaml # Quality scale tracking +├── translations/ +│ └── en.json # English translations +├── models/ # Domain models (frozen dataclasses) +│ ├── __init__.py +│ ├── device.py # GoveeDevice, GoveeCapability +│ ├── state.py # GoveeDeviceState, RGBColor +│ └── commands.py # Command pattern implementations +├── platforms/ +│ ├── __init__.py +│ └── segment.py # Segment light entities (RGBIC) +├── protocols/ # Protocol interfaces +│ ├── __init__.py +│ ├── api.py # IApiClient, IAuthProvider +│ └── state.py # IStateProvider, IStateObserver +└── api/ # API layer + ├── __init__.py + ├── client.py # GoveeApiClient (REST) + ├── auth.py # GoveeAuthClient (account login) + ├── mqtt.py # GoveeAwsIotClient (real-time MQTT) + └── exceptions.py # Exception hierarchy +``` + +--- + +## Component Responsibilities + +### Entry Point (`__init__.py`) + +- `async_setup_entry()`: Initialize integration +- `async_unload_entry()`: Clean up on removal +- Creates API client and coordinator +- Forwards platform setup +- Registers update listener for options changes + +### Coordinator (`coordinator.py`) + +Central hub for device state management: + +- **Device Discovery**: Fetches devices from API on setup +- **Parallel State Fetching**: Queries all device states concurrently +- **MQTT Integration**: Real-time state updates via AWS IoT +- **Scene Caching**: Caches scenes to minimize API calls +- **Optimistic Updates**: Immediate UI feedback after commands +- **Observer Pattern**: Notifies entities of state changes +- **Repairs Integration**: Creates repair issues for errors + +### Config Flow (`config_flow.py`) + +UI-based configuration: + +1. **User Step**: Enter API key +2. **Account Step**: Optional email/password for MQTT +3. **Reauth Step**: Re-authenticate on 401 errors +4. **Reconfigure Step**: Update credentials without removing integration +5. **Options Flow**: Poll interval, enable groups/scenes/segments + +### Models (`models/`) + +Frozen dataclasses for immutability: + +- **GoveeDevice**: Device metadata and capabilities +- **GoveeDeviceState**: Current device state (mutable for updates) +- **RGBColor**: Immutable RGB color value +- **Commands**: PowerCommand, BrightnessCommand, ColorCommand, etc. + +### Protocols (`protocols/`) + +Clean Architecture interfaces: + +- **IApiClient**: Contract for API operations +- **IAuthProvider**: Contract for authentication +- **IStateProvider**: Contract for state access +- **IStateObserver**: Contract for state change notifications + +### API Layer (`api/`) + +- **GoveeApiClient**: REST API with aiohttp-retry for resilience +- **GoveeAuthClient**: Account login and IoT credential retrieval +- **GoveeAwsIotClient**: AWS IoT MQTT for real-time updates +- **Exceptions**: Hierarchical exception classes with translation support + +--- + +## Data Flow + +### State Update Flow + +``` +Poll Interval Timer + ↓ +coordinator._async_update_data() + ↓ +Parallel: fetch state for all devices + ↓ +Process results: + - Success → Update state + - Auth Error → Create repair issue, trigger reauth + - Rate Limit → Create repair issue, keep previous state + ↓ +coordinator.async_set_updated_data() + ↓ +Entities receive state update +``` + +### MQTT Real-time Flow + +``` +MQTT Message Received + ↓ +_on_mqtt_state_update() + ↓ +Update state from MQTT data + ↓ +coordinator.async_set_updated_data() + ↓ +Notify observers + ↓ +UI updated immediately +``` + +### Control Command Flow + +``` +User Action (turn on, set color, etc.) + ↓ +Entity method (async_turn_on, etc.) + ↓ +coordinator.async_control_device() + ↓ +Create Command object (immutable) + ↓ +API client sends command + ↓ +Apply optimistic state update + ↓ +UI updated immediately +``` + +--- + +## Platforms + +| Platform | Entity Types | Description | +|----------|--------------|-------------| +| `light` | GoveeLightEntity, GoveeSegmentLight | Main lights and RGBIC segments | +| `scene` | GoveeSceneEntity | Dynamic scenes from Govee cloud | +| `switch` | GoveePlugSwitchEntity, GoveeNightLightSwitchEntity | Smart plugs, night light toggle | +| `sensor` | Rate limit, MQTT status | Diagnostic sensors | +| `button` | Refresh scenes | Manual scene refresh | + +--- + +## Services + +| Service | Description | +|---------|-------------| +| `govee.refresh_scenes` | Refresh scene list from API | +| `govee.set_segment_color` | Set color for RGBIC segments | + +--- + +## Error Handling + +### Exception Hierarchy + +``` +GoveeApiError (base) +├── GoveeAuthError (401) → Triggers reauth, creates repair issue +├── GoveeRateLimitError (429) → Creates repair issue, keeps previous state +├── GoveeConnectionError → Logs warning, retries +└── GoveeDeviceNotFoundError (400) → Expected for groups, uses optimistic state +``` + +### Repairs Framework + +Actionable repair notifications: + +- **auth_failed**: Fixable, guides to reauth flow +- **rate_limited**: Warning with reset time estimate +- **mqtt_disconnected**: Warning about real-time updates + +--- + +## Configuration Options + +| Option | Default | Description | +|--------|---------|-------------| +| `poll_interval` | 60s | State refresh frequency | +| `enable_groups` | false | Include Govee app groups | +| `enable_scenes` | true | Create scene entities | +| `enable_segments` | true | Create segment entities for RGBIC | + +--- + +## Quality Scale + +The integration targets **Gold tier** compliance: + +- ✅ Config flow with test coverage +- ✅ Unique entity IDs +- ✅ Device info for all entities +- ✅ Diagnostics platform +- ✅ Reauthentication flow +- ✅ Reconfigure flow +- ✅ Repairs framework +- ✅ Entity translations +- ✅ Async dependencies + +See `quality_scale.yaml` for detailed compliance tracking. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9915fc54 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,276 @@ +# CLAUDE.md + +Instructions for Claude Code when working with this repository. + +## Project Overview + +**Govee Integration for Home Assistant** - A HACS custom component that controls Govee lights, LED strips, and smart devices via the Govee Cloud API v2.0. + +| Attribute | Value | +|-----------|-------| +| Type | Home Assistant Custom Component | +| Language | Python 3.12+ | +| Integration Type | Hub (cloud service) | +| IoT Class | cloud_push (MQTT + polling) | +| API Version | Govee API v2.0 | + +## Quick Commands + +```bash +# Run tests (recommended) +tox + +# Run pytest directly +pytest + +# Single test file +pytest tests/test_config_flow.py + +# Single test +pytest tests/test_models.py::TestRGBColor::test_valid_color + +# Format code +black . + +# Lint +flake8 . + +# Type check +mypy custom_components/govee +``` + +## Directory Structure + +``` +custom_components/govee/ +├── __init__.py # Entry point, async_setup_entry +├── config_flow.py # Config/options/reauth/reconfigure flows +├── coordinator.py # DataUpdateCoordinator with MQTT +├── entity.py # Base GoveeEntity class +├── light.py # Light platform +├── scene.py # Scene platform +├── switch.py # Switch platform (plugs, night light) +├── sensor.py # Diagnostic sensors +├── button.py # Refresh scenes button +├── services.py # Custom services +├── repairs.py # Repairs framework integration +├── diagnostics.py # Diagnostics for troubleshooting +├── const.py # Constants +├── models/ # Domain models (frozen dataclasses) +│ ├── device.py # GoveeDevice, GoveeCapability +│ ├── state.py # GoveeDeviceState, RGBColor +│ └── commands.py # Command pattern implementations +├── protocols/ # Protocol interfaces (Clean Architecture) +│ ├── api.py # IApiClient, IAuthProvider +│ └── state.py # IStateProvider, IStateObserver +└── api/ # API layer + ├── client.py # GoveeApiClient (REST) + ├── auth.py # GoveeAuthClient (account login) + ├── mqtt.py # GoveeAwsIotClient (AWS IoT MQTT) + └── exceptions.py # Exception hierarchy +``` + +## Architecture Patterns + +### Clean Architecture +- **Models**: Immutable frozen dataclasses, no I/O +- **Protocols**: Abstract interfaces (Python Protocols) +- **API Layer**: HTTP/MQTT clients, exception handling +- **Coordinator**: State management, orchestration +- **Entities**: Home Assistant platform integration + +### Command Pattern +Device control uses immutable command objects: +```python +PowerCommand(device_id="xxx", value=True) +BrightnessCommand(device_id="xxx", value=128) +ColorCommand(device_id="xxx", value=RGBColor(255, 0, 0)) +``` + +### Observer Pattern +Entities register as observers for state changes: +```python +coordinator.register_observer(device_id, entity) +``` + +## Key Components + +### GoveeDataUpdateCoordinator +Central hub managing: +- Device discovery and state polling +- MQTT real-time updates +- Scene caching +- Optimistic state updates +- Repairs integration + +### GoveeApiClient +REST client with: +- aiohttp-retry for resilience +- Rate limit tracking +- Parallel state fetching +- Command serialization + +### GoveeAwsIotClient +MQTT client for real-time updates: +- AWS IoT Core connection +- Certificate-based auth +- State push notifications + +## Testing + +| File | Tests | Focus | +|------|-------|-------| +| test_models.py | 50 | RGBColor, Device, State, Commands | +| test_config_flow.py | 41 | Config, options, reauth, reconfigure | +| test_coordinator.py | 32 | Observer pattern, commands, state | +| test_api_client.py | 28 | Exceptions, client, rate limits | +| **Total** | **151** | | + +## Code Style + +- **Formatting**: Black (line length 119) +- **Linting**: Flake8 +- **Types**: mypy strict mode +- **Docstrings**: Google style +- **Coverage**: 95% minimum + +## Common Tasks + +### Add a new platform +1. Create `platform.py` with entity class +2. Register in `__init__.py` PLATFORMS list +3. Add to coordinator device processing +4. Add tests + +### Add a new command +1. Add command class to `models/commands.py` +2. Implement in `api/client.py` +3. Add coordinator method +4. Add entity method +5. Add tests + +### Handle a new error type +1. Add exception to `api/exceptions.py` +2. Handle in coordinator +3. Consider repairs integration +4. Add tests + +## Important Notes + +- All I/O must be async +- Use `asyncio.gather()` for parallel operations +- Entities inherit from `GoveeEntity` base class +- Coordinator manages all state - entities are observers +- MQTT is optional - polling is the fallback +- Rate limits: 100/min, 10,000/day + +## Govee API v2.0 Patterns + +### Control Command Payload +Commands use a flat structure (NOT nested): +```json +{ + "requestId": "uuid", + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C", + "capability": { + "type": "devices.capabilities.on_off", + "instance": "powerSwitch", + "value": 1 + } + } +} +``` + +Reference: `docs/govee-protocol-reference.md` + +### Device ID Detection +- **Regular devices**: MAC address format `03:9C:DC:06:75:4B:10:7C` +- **Group devices**: Numeric-only IDs like `11825917` +- Detection: `device_id.isdigit()` returns True for groups + +### Segment Capability Parsing +RGBIC segment count is in `fields[].elementRange.max + 1`: +```python +# API returns elementRange with 0-based max index +# e.g., {"min": 0, "max": 6} = 7 segments (0-6) +segment_count = element_range["max"] + 1 +``` + +## API Limitations & State Handling + +### Scene State +- **Limitation**: API doesn't reliably return active scene +- **Solution**: Preserve scene via optimistic state +- **Clear when**: Device is turned off (scene is no longer active) +- **Implementation**: `coordinator.py` preserves `active_scene` on API poll if device is ON + +### Segment Colors +- **Limitation**: API returns empty strings for segment colors +- **Solution**: Segment entities use local optimistic state + `RestoreEntity` +- **Clear when**: Never (persists across restarts via HA state machine) +- **Implementation**: `platforms/segment.py` does NOT subscribe to coordinator updates + +### Pattern +For API values that aren't reliably returned: +1. Use optimistic state from commands +2. Use `RestoreEntity` to persist across HA restarts +3. Don't overwrite with API responses +4. Clear on appropriate events (e.g., power off for scenes) + +## Debug Logging Patterns + +Add debug logging when: +1. Processing capabilities during device discovery +2. Creating entities to show which ones are being set up +3. Control commands fail to show payload details +4. State updates from MQTT + +Example pattern: +```python +_LOGGER.debug( + "Device: %s (%s) type=%s is_group=%s", + device.name, device.device_id, device.device_type, device.is_group, +) +for cap in device.capabilities: + _LOGGER.debug(" Capability: type=%s instance=%s params=%s", + cap.type, cap.instance, cap.parameters) +``` + +## Options/Config Patterns + +### Options schema (config_flow.py) +Options are defined in `GoveeOptionsFlowHandler.async_step_init()`: +```python +vol.Optional(CONF_POLL_INTERVAL, default=...): vol.All(vol.Coerce(int), vol.Range(min=30, max=600)), +vol.Optional(CONF_ENABLE_GROUPS, default=...): bool, +vol.Optional(CONF_ENABLE_SCENES, default=...): bool, +vol.Optional(CONF_ENABLE_SEGMENTS, default=...): bool, +``` + +### Translations +Update both files when changing option labels: +- `strings.json` - Primary source +- `translations/en.json` - English translation + +## Release Process + +1. **Bump version** in `manifest.json` (CalVer: `YYYY.MM.patch`) +2. **Commit**: `git add -A && git commit -m "message"` +3. **Push**: `git push origin master` +4. **Wait for CI**: Check with `gh run list --limit 5` +5. **Create release**: `gh release create vYYYY.MM.patch --title "vYYYY.MM.patch" --notes "..."` + +## Directory Updates + +The project structure has evolved: +``` +custom_components/govee/ +├── select.py # Scene selector dropdowns (replaced scene.py) +├── platforms/ +│ └── segment.py # RGBIC segment light entities +``` + +- **select.py**: One dropdown per device for scene selection +- **segment.py**: Individual light entities for each RGBIC segment diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13a5fd57..562ca3c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,78 +1,185 @@ -# Contribution guidelines +# Contributing -Contributing to this project should be as easy and transparent as possible, whether it's: +Thank you for your interest in contributing to this project! -- Reporting a bug -- Discussing the current state of the code -- Submitting a fix -- Proposing new features +--- -## Github is used for everything +## Ways to Contribute -Github is used to host code, to track issues and feature requests, as well as accept pull requests. +- Report bugs via [GitHub Issues](../../issues) +- Submit feature requests +- Create pull requests +- Improve documentation -Pull requests are the best way to propose changes to the codebase. +--- -1. Fork the repo and create your branch from `master`. -2. If you've changed something, update the documentation. -3. Make sure your code lints (using black). -4. Test you contribution (using tox). -5. Issue that pull request! +## Development Setup -### TODO Document: Work on library govee-api-laggat +### Prerequisites -* this library is added as subtree in '.git-subtree/python-govee-api' +- Python 3.12 or 3.13 +- Home Assistant development environment (optional) +### Quick Start + +```bash +# Clone and install dependencies +git clone https://github.com/lasswellt/hacs-govee.git +cd hacs-govee +pip install -r requirements_test.txt + +# Run tests +tox + +# Format code +black . ``` -# how we added it: -git subtree add --prefix .git-subtree/python-govee-api https://github.com/LaggAt/python-govee-api master -# you may want to pull latest master: -git subtree pull --prefix=.git-subtree/python-govee-api/ https://github.com/LaggAt/python-govee-api master +### VS Code DevContainer + +A devcontainer is provided in `.devcontainer/` that sets up a complete Home Assistant development instance accessible at `localhost:9123`. + +--- + +## Pull Request Process -# if you made changes to the library don't forget to push the changes to bug/feature branches there before doing a pull request: -git subtree push --prefix=.git-subtree/python-govee-api/ https://github.com/LaggAt/python-govee-api feature/describe-your-feature +### 1. Fork and Branch +```bash +git checkout -b feature/your-feature-name ``` +### 2. Make Changes -## Any contributions you make will be under the MIT Software License +- Follow existing code patterns +- Add tests for new functionality +- Update documentation if needed -In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. +### 3. Verify -## Report bugs using Github's [issues](../../issues) +```bash +# Format code +black . -GitHub issues are used to track public bugs. -Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! +# Run linting +flake8 . -## Write bug reports with detail, background, and sample code +# Run type checking +mypy custom_components/govee -**Great Bug Reports** tend to have: +# Run tests +pytest +``` -- A quick summary and/or background -- Steps to reproduce - - Be specific! - - Give sample code if you can. -- What you expected would happen -- What actually happens -- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) +### 4. Commit + +Use conventional commit messages: -People *love* thorough bug reports. I'm not even kidding. +``` +feat(light): add color temperature support +fix(coordinator): resolve state sync issue +docs(readme): update installation guide +test(config): add reauth flow tests +``` -## Use a Consistent Coding Style +### 5. Submit PR -Use [black](https://github.com/ambv/black) to make sure the code follows the style. +- Provide clear description +- Reference any related issues +- Ensure CI passes -## Test your code modification +--- + +## Code Standards + +### Type Hints + +All functions must have type annotations: + +```python +async def async_turn_on( + self, + brightness: int | None = None, + rgb_color: tuple[int, int, int] | None = None, +) -> None: + """Turn on the light.""" + ... +``` + +### Async Architecture + +All I/O operations must be async: + +```python +# Correct - parallel execution +tasks = [self._fetch_state(d) for d in devices] +results = await asyncio.gather(*tasks) + +# Incorrect - sequential +for device in devices: + result = await self._fetch_state(device) +``` -This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). +### Docstrings + +Use Google-style docstrings: + +```python +def process_state(self, data: dict[str, Any]) -> GoveeDeviceState: + """Process raw API state data into domain model. + + Args: + data: Raw state dictionary from API response. + + Returns: + Processed device state object. + + Raises: + ValueError: If required fields are missing. + """ +``` + +--- + +## Testing Requirements + +| Requirement | Standard | +|-------------|----------| +| Coverage | 95%+ overall | +| Python versions | 3.12 and 3.13 | +| Async tests | Use `@pytest.mark.asyncio` | +| Mocking | Mock all external dependencies | + +See [TESTING.md](TESTING.md) for detailed testing guide. + +--- + +## Architecture Guidelines + +When making changes: + +1. **Models** (`models/`): Immutable dataclasses, no I/O +2. **Protocols** (`protocols/`): Interfaces only, no implementation +3. **API Layer** (`api/`): HTTP/MQTT clients, exception handling +4. **Coordinator**: State management, orchestration +5. **Entities**: Home Assistant platform integration + +See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed architecture. + +--- + +## Bug Reports + +Good bug reports include: + +- Summary and background +- Steps to reproduce +- Expected vs actual behavior +- Debug logs (enable with `custom_components.govee: debug`) +- Diagnostics download -It comes with development environment in a container, easy to launch -if you use Visual Studio Code. With this container you will have a stand alone -Home Assistant instance running and already configured with the included -[`.devcontainer/configuration.yaml`](https://github.com/oncleben31/ha-pool_pump/blob/master/.devcontainer/configuration.yaml) -file. +--- ## License -By contributing, you agree that your contributions will be licensed under its MIT License. +Contributions are licensed under the MIT License. diff --git a/LICENSE.txt b/LICENSE.txt index fa30cbcb..b858fa20 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,7 +1,22 @@ -Copyright 2021 Florian Lagg @LaggAt +MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Copyright (c) 2021 Florian Lagg (@LaggAt) +Copyright (c) 2025-2026 Tom Lasswell (@lasswellt) -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 71e4f7cb..00000000 --- a/Pipfile +++ /dev/null @@ -1,11 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] - -[dev-packages] - -[requires] -python_version = "3.9" diff --git a/README.md b/README.md index 04436f49..ac914e5f 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,95 @@ -# 'Govee' integration +

+ Govee Logo +

-The Govee integration allows you to control and monitor lights and switches using the Govee API. +

Govee Integration for Home Assistant

-## Discontinuation - who wants to continue? +

+ Your Govee lights + Home Assistant = RGB bliss with real-time control +

-I will probably only allow pull reqests which fix breaking changes. If you want to take over this repository feel free to contact me. Preferably I will hand over to some developers which have done pull requests in the past. Give me some time to answer - thanks. +

+ HACS Custom + GitHub Release + License +

-Greetings, Florian. +

+ + Open in HACS + +

--------------------------------------------------- +--- -## Installation +## What's This? -* The installation is done inside [HACS](https://hacs.xyz/) (Home Assistant Community Store). If you don't have HACS, you must install it before adding this integration. [Installation instructions here.](https://hacs.xyz/docs/setup/download) -* Once HACS is installed, navigate to the 'Integrations' tab in HACS and search for the 'Govee' integration there. Click "Download this repository in HACS". On the next screen, select "Download". Once fully downloaded, restart HomeAssistant. -* In the sidebar, click 'Configuration', then 'Devices & Services'. Click the + icon to add "Govee" to your Home Assistant installation. An API key -is required, you need to obtain it in the 'Govee Home' app on your mobile device. This can be done from the Account Page (Far right icon at the bottom) > Settings (top right icon) > About Us > Apply for API Key. The key will be sent to your account email. +Ever wanted your Govee lights to actually *talk* to Home Assistant? This integration gives you: -## Sponsor +- **Full light control** — brightness, RGB colors, color temp, the works +- **Scene magic** — your Govee scenes become HA scenes +- **RGBIC segment control** — paint each segment a different color +- **Real-time sync** — optional MQTT for instant updates (bye-bye polling lag) -A lot of effort is going into that integration. So if you can afford it and want to support us: +--- -Buy Me A Coffee +## Get Started -Thank you! +### 1. Grab Your API Key -## Is it stable? +In the **Govee Home** app: **Profile** → **Settings** → **About Us** → **Apply for API Key** -We think so. It is used often, and the support thread is active. +Check your email in ~5 minutes. -![usage statistics per version](https://raw.githubusercontent.com/LaggAt/actions/main/output/goveestats_installations.png) +### 2. Install via HACS -Usage Data is taken from Home Assistant analytics, and plotted over time by us. You need to enable analytics if you want to show here. +Click the button above, search "Govee", hit **Download**, restart Home Assistant. -## Is there an issue right now? +### 3. Add the Integration -This graph uses the same library to do simple checks. If you see round dots on the right of the graph (= today), probably there is an issue. +**Settings** → **Devices & Services** → **Add Integration** → **Govee** -![Govee API running?](https://raw.githubusercontent.com/LaggAt/actions/main/output/govee-api-up.png) +Enter your API key. Want instant updates? Add your Govee email/password for MQTT. -## Pulling or assuming state +--- -Some devices do not support pulling state. In this case we assume the state on your last input. -For others, we assume the state just after controlling the light, but will otherwise request it from the cloud API. +## What Works -## DISABLING state updates for specific attributes +| Device | Features | +|--------|----------| +| **LED Lights & Strips** | On/off, brightness, RGB, color temp | +| **RGBIC Strips** | All the above + per-segment colors | -You shouldn't use this feature in normal operation, but if something is broke e.g. on Govee API you could help yourself and others on the forum with a little tweak. +> **Note:** Cloud-enabled devices only. Bluetooth-only devices need a different integration. -Not all attribute updates can be disabled, but most can. Fiddling here could also lead to other misbehavours, but it could help with some issues. +--- -Let's talk about an example: +## Real-time Updates -### What if power_state isn't correctly returned from API? +Polling is *so* 2020. Add your Govee account credentials during setup for instant state sync via AWS IoT MQTT. -We can have state from two sources: 'API' and 'HISTORY'. History for example means, when we turn on a light we already guess the state will be on, so we set a history state of on before we get data from the API. In the default configuration you could also see this, as it shows two buttons until the final state from API arrives. +No credentials? Polling works fine (every 60 seconds by default). -![two-button-state](https://community-assets.home-assistant.io/original/3X/d/7/d7d2ee09520672e7671fdeed5bb461fcfaab8493.png) +--- -So let's say we have an issue, that the ON/OFF state from API is wrong, we always get OFF. (This happended, and this is why I developed that feature). If we disable the power state we get from API we could work around this, and thats exactly what we do: +## Troubleshooting -1. 'API' or 'History': state from which source do we want to disable? In our example the API state is wrong, as we could see in logs, so we choose 'API' -2. Look up the attribute you want to disable in GoveeDevice data class. Don't worry, you don't need to understand any of the code here. [Here is that data class (click)](https://github.com/LaggAt/python-govee-api/blob/master/govee_api_laggat/govee_dtos.py). In our Example we will find 'power_state' -3. Next, in Home Assistant we open Configuration - Integrations and click on the options on the Govee integration. Here is an example how this config option could look: +| Problem | Fix | +|---------|-----| +| Devices not showing | Make sure they're WiFi devices, not Bluetooth-only | +| Slow updates | Enable MQTT or reduce poll interval in options | +| Rate limit errors | Increase poll interval (Govee allows 100 req/min) | -![DISABLE state updates option](https://community-assets.home-assistant.io/original/3X/6/c/6cffe0de8b100ef4efc0e460482ff659b8f9444c.png) +Need debug logs? Add `custom_components.govee: debug` to your logger config. -4. With the information from 1. and 2. we could write a string disabling power_state from API. This will do the trick for our case: -``` -API:power_state -``` +--- -IF you want to disable a state from both sources, do that separately. You may have as many disables as you like. -``` -API:online;HISTORY:online -``` +## Contributing -[If you fix an issue like that, consider helping other users on the forum thread (click).](https://community.home-assistant.io/t/govee-integration/228516/438?u=laggat) +PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). -ALWAYS REMEMBER: this should always be a temporarly workaround, if you use it in daily business you should probably request a feature or bug fix :) +--- -To remind you disabling it asap, this wil log a warning on every update. +## License -## What is config/govee_learning.yaml - -Usually you don't have to do anything here - just in case something feels wrong read on: - -``` -40:83:FF:FF:FF:FF:FF:FF: - set_brightness_max: 100 - get_brightness_max: 100 - before_set_brightness_turn_on: false - config_offline_is_off: false -``` - -Different Govee devices use different settings. These will be learned by the used library, or can be configured by you. - -* set_brightness_max: is autolearned, defines the range to set the brightness to 0-100 or 0-254. -* get_brightness_max: is autolearned, defines the range how to interpet brightness state (also 0-100 or 0-254). -* before_set_brightness_turn_on: Configurable by you, default false. When true, if the device is off and you set the brightness > 0 the device is turned on first, then after a second the brightness is set. Some device don't turn on with the set brightness command. -* config_offline_is_off: Configurable by you, default false. This is useful if your device is e.g. powered by a TV's USB port, where when the TV is off, the LED is also off. Usually we stay in the state we know, this changes this to OFF state when the device disconnects. - -## Support - -Support thread is here: -There you'll also find links to code repositories and their issue trackers. - -For bug reports, include the debug log, which can be enabled in configuration YAML + restart: - -```YAML -logger: - default: warning - logs: - homeassistant.components.govee: debug - custom_components.govee: debug - govee_api_laggat: debug -``` - -Then in Settings - Logs click on “full logs” button and add them to the bug report after removing personal data. - -## Caveats - -You can set a poll interval, but don't set it too low as the API has a limit of 60 requests per minute, and each device needs one request per state pull and control action. -If you have more than one lamp use a higher interval. Govee wants to implement a single request for all devices in 2021. - -Once the integration is active, you will see all your registered devices, and may control on/off, brightness, color temperature and color. +MIT — see [LICENSE.txt](LICENSE.txt) diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..227c5865 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,348 @@ +# Testing Guide + +This document explains how to run tests, write new tests, and understand the testing infrastructure for the Govee integration. + +--- + +## Quick Start + +```bash +# Install test dependencies +pip install -r requirements_test.txt + +# Run all tests with tox (recommended - includes linting) +tox + +# Run tests with coverage +pytest --cov=custom_components.govee --cov-report=term-missing + +# Run specific test file +pytest tests/test_light.py + +# Run specific test +pytest tests/test_models.py::TestRGBColor::test_valid_color +``` + +--- + +## Test Infrastructure + +### Dependencies + +Required packages (from `requirements_test.txt`): + +| Package | Purpose | +|---------|---------| +| `pytest` | Test framework | +| `pytest-asyncio` | Async test support | +| `pytest-cov` | Coverage measurement | +| `pytest-homeassistant-custom-component` | HA test fixtures | +| `flake8` | Linting | +| `mypy` | Type checking | +| `black` | Code formatting | + +### Configuration + +**pytest.ini**: +```ini +[pytest] +asyncio_mode = auto +testpaths = tests +addopts = --cov=custom_components.govee --cov-fail-under=95 +``` + +**tox.ini**: +```ini +[tox] +envlist = py312,py313 + +[testenv] +deps = -r{toxinidir}/requirements_test.txt +commands = + flake8 . + mypy custom_components/govee + pytest --cov=custom_components.govee --cov-fail-under=95 +``` + +--- + +## Test Organization + +``` +tests/ +├── __init__.py # Package init +├── conftest.py # Shared fixtures +├── test_models.py # Domain models (RGBColor, Device, State, Commands) +├── test_api_client.py # API client and exceptions +├── test_coordinator.py # Coordinator logic and observer pattern +└── test_config_flow.py # Config flow, options, reauth, reconfigure, repairs +``` + +### Test Coverage by File + +| File | Tests | Focus | +|------|-------|-------| +| `test_models.py` | 50 | RGBColor, GoveeDevice, GoveeDeviceState, Commands | +| `test_config_flow.py` | 41 | Config flow, options, reauth, reconfigure, repairs | +| `test_coordinator.py` | 32 | Observer pattern, commands, state management | +| `test_api_client.py` | 28 | Exceptions, client creation, rate limits | +| **Total** | **151** | | + +--- + +## Running Tests + +### Full Test Suite + +```bash +# Using tox (runs linting + type checking + tests) +tox + +# Using pytest directly +pytest + +# Verbose output +pytest -v + +# Show print statements +pytest -vv -s +``` + +### Specific Tests + +```bash +# Single file +pytest tests/test_coordinator.py + +# Single class +pytest tests/test_models.py::TestGoveeDevice + +# Single test +pytest tests/test_config_flow.py::TestReconfigureFlow::test_reconfigure_success + +# Pattern matching +pytest -k "test_turn_on" + +# Only failed tests from last run +pytest --lf +``` + +### Coverage + +```bash +# Terminal report with missing lines +pytest --cov=custom_components.govee --cov-report=term-missing + +# HTML report +pytest --cov=custom_components.govee --cov-report=html +open htmlcov/index.html + +# Fail if below threshold +pytest --cov=custom_components.govee --cov-fail-under=95 +``` + +### Linting and Type Checking + +```bash +# Linting +flake8 . + +# Type checking +mypy custom_components/govee + +# Code formatting +black --check . +``` + +--- + +## Writing Tests + +### Test Structure + +Use class-based organization with descriptive names: + +```python +import pytest +from custom_components.govee.models import RGBColor + +class TestRGBColor: + """Tests for RGBColor dataclass.""" + + def test_valid_color(self): + """Test creating valid RGB color.""" + color = RGBColor(255, 128, 0) + assert color.red == 255 + assert color.green == 128 + assert color.blue == 0 + + def test_invalid_color_raises(self): + """Test invalid color values raise ValueError.""" + with pytest.raises(ValueError): + RGBColor(256, 0, 0) +``` + +### Async Tests + +Use `@pytest.mark.asyncio` for async tests: + +```python +@pytest.mark.asyncio +async def test_async_operation(mock_api_client): + """Test async API call.""" + mock_api_client.get_devices = AsyncMock(return_value=[]) + + result = await mock_api_client.get_devices() + + assert result == [] +``` + +### Using Fixtures + +Fixtures are defined in `conftest.py`: + +```python +@pytest.fixture +def mock_device_light(): + """Factory fixture for light devices.""" + def _create(device_id="test_id", device_name="Test Light"): + return GoveeDevice( + device_id=device_id, + device_name=device_name, + model="H6XXX", + # ... other properties + ) + return _create + +# Usage +def test_device(mock_device_light): + device = mock_device_light(device_id="custom_id") + assert device.device_id == "custom_id" +``` + +### Mocking API Calls + +```python +from unittest.mock import AsyncMock + +@pytest.mark.asyncio +async def test_api_error(mock_api_client): + """Test API error handling.""" + mock_api_client.get_device_state = AsyncMock( + side_effect=GoveeAuthError("Invalid key") + ) + + with pytest.raises(GoveeAuthError): + await mock_api_client.get_device_state("device_1", "H6XXX") +``` + +--- + +## Coverage Requirements + +| Component | Minimum | +|-----------|---------| +| Overall | 95% | +| Critical (coordinator, API) | 100% | +| Per-file | 90% | + +### Excluded from Coverage + +```python +# Lines excluded via pragma +if TYPE_CHECKING: # pragma: no cover + from homeassistant.core import HomeAssistant + +def __repr__(self) -> str: # pragma: no cover + return f"Device({self.device_id})" +``` + +--- + +## CI/CD + +### GitHub Actions + +Tests run automatically on: +- Push to `master` or `develop` +- Pull requests + +Workflow (`.github/workflows/tox.yaml`): +```yaml +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - run: pip install tox + - run: tox +``` + +### Pre-commit Hooks + +```bash +pip install pre-commit +pre-commit install +``` + +Runs before each commit: +- Black (formatting) +- Flake8 (linting) +- Mypy (type checking) + +--- + +## Troubleshooting + +### Common Issues + +| Problem | Solution | +|---------|----------| +| `ModuleNotFoundError: homeassistant` | Run `pip install -r requirements_test.txt` | +| `await outside async function` | Add `@pytest.mark.asyncio` decorator | +| Mock not returning value | Use `AsyncMock` for async methods | +| Tests pass locally, fail in CI | Check Python version compatibility | + +### Debug Commands + +```bash +pytest -s # Show print statements +pytest -x # Stop on first failure +pytest -l # Show locals on failure +pytest --pdb # Drop into debugger +pytest -vv # Extra verbosity +``` + +--- + +## Best Practices + +### Do + +- Use descriptive test names: `test_turn_on_with_brightness_and_color` +- Test one behavior per test +- Use fixtures for common setup +- Mock all external dependencies +- Test both success and error paths +- Keep tests fast + +### Don't + +- Make real API calls +- Share state between tests +- Skip tests for "simple" code +- Depend on test execution order +- Test Home Assistant internals + +--- + +## Resources + +- [Pytest Documentation](https://docs.pytest.org/) +- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/) +- [Home Assistant Testing](https://developers.home-assistant.io/docs/development_testing) +- [Python unittest.mock](https://docs.python.org/3/library/unittest.mock.html) diff --git a/core b/core deleted file mode 100644 index b75c5fdf..00000000 Binary files a/core and /dev/null differ diff --git a/custom_components/govee/__init__.py b/custom_components/govee/__init__.py index 1bc17a81..9703e73d 100644 --- a/custom_components/govee/__init__.py +++ b/custom_components/govee/__init__.py @@ -1,112 +1,208 @@ -"""The Govee integration.""" -import asyncio -import logging +"""Govee integration for Home Assistant. + +Controls Govee lights, LED strips, and smart devices via the Govee Cloud API. +Supports real-time state updates via AWS IoT MQTT. +""" -from govee_api_laggat import Govee -import voluptuous as vol +from __future__ import annotations + +import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady - -from .const import DOMAIN -from .learning_storage import GoveeLearningStorage +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er + +from .api import GoveeApiClient, GoveeAuthError, GoveeIotCredentials +from .api.auth import GoveeAuthClient +from .const import ( + CONF_API_KEY, + CONF_EMAIL, + CONF_ENABLE_GROUPS, + CONF_PASSWORD, + CONF_POLL_INTERVAL, + DEFAULT_ENABLE_GROUPS, + DEFAULT_POLL_INTERVAL, + DOMAIN, +) +from .coordinator import GoveeCoordinator +from .services import async_setup_services, async_unload_services _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) -# supported platforms -PLATFORMS = ["light"] - - -def setup(hass, config): - """This setup does nothing, we use the async setup.""" - hass.states.set("govee.state", "setup called") - return True +# Platforms to set up +PLATFORMS: list[Platform] = [ + Platform.LIGHT, + Platform.SELECT, # Scene dropdowns + Platform.SWITCH, + Platform.SENSOR, + Platform.BUTTON, +] + +# Type alias for runtime data +type GoveeConfigEntry = ConfigEntry[GoveeCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: GoveeConfigEntry) -> bool: + """Set up Govee from a config entry. + + Args: + hass: Home Assistant instance. + entry: Config entry being set up. + + Returns: + True if setup was successful. + + Raises: + ConfigEntryAuthFailed: Invalid API key. + ConfigEntryNotReady: Temporary setup failure. + """ + api_key = entry.data[CONF_API_KEY] + + # Create API client + api_client = GoveeApiClient(api_key) + + # Optionally get IoT credentials for MQTT + iot_credentials: GoveeIotCredentials | None = None + email = entry.data.get(CONF_EMAIL) + password = entry.data.get(CONF_PASSWORD) + + if email and password: + try: + async with GoveeAuthClient() as auth_client: + iot_credentials = await auth_client.login(email, password) + _LOGGER.info("MQTT credentials obtained for real-time updates") + except GoveeAuthError as err: + _LOGGER.warning("Failed to get MQTT credentials: %s", err) + # Continue without MQTT - not a fatal error + except Exception as err: + _LOGGER.warning("MQTT setup failed: %s", err) + + # Get options + options = entry.options + poll_interval = options.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL) + enable_groups = options.get(CONF_ENABLE_GROUPS, DEFAULT_ENABLE_GROUPS) + + # Create coordinator + coordinator = GoveeCoordinator( + hass=hass, + config_entry=entry, + api_client=api_client, + iot_credentials=iot_credentials, + poll_interval=poll_interval, + enable_groups=enable_groups, + ) + # Set up coordinator (discover devices, start MQTT) + try: + await coordinator.async_setup() + except ConfigEntryAuthFailed: + await api_client.close() + raise + except Exception as err: + await api_client.close() + raise ConfigEntryNotReady(f"Failed to set up Govee: {err}") from err -async def async_setup(hass: HomeAssistant, config: dict): - """Set up the Govee component.""" - hass.states.async_set("govee.state", "async_setup called") - hass.data[DOMAIN] = {} - return True + # Initial refresh + await coordinator.async_config_entry_first_refresh() + # Clean up orphaned entities (e.g., groups that are now disabled) + await _async_cleanup_orphaned_entities(hass, entry, coordinator) -def is_online(online: bool): - """Log online/offline change.""" - msg = "API is offline." - if online: - msg = "API is back online." - _LOGGER.warning(msg) + # Store coordinator in entry + entry.runtime_data = coordinator + # Set up platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): - """Set up Govee from a config entry.""" + # Set up services (only once) + if not hass.data.get(DOMAIN): + hass.data[DOMAIN] = {} + await async_setup_services(hass) - # get vars from ConfigFlow/OptionsFlow - config = entry.data - options = entry.options - api_key = options.get(CONF_API_KEY, config.get(CONF_API_KEY, "")) + # Store coordinator in hass.data for services access + hass.data[DOMAIN][entry.entry_id] = coordinator - # Setup connection with devices/cloud - hub = await Govee.create( - api_key, learning_storage=GoveeLearningStorage(hass.config.config_dir) - ) - # keep reference for disposing - hass.data[DOMAIN] = {} - hass.data[DOMAIN]["hub"] = hub + # Register update listener for options changes + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - # inform when api is offline/online - hub.events.online += is_online + return True - # Verify that passed in configuration works - _, err = await hub.get_devices() - if err: - _LOGGER.warning("Could not connect to Govee API: %s", err) - await hub.rate_limit_delay() - await async_unload_entry(hass, entry) - raise PlatformNotReady() - for component in PLATFORMS: - await hass.config_entries.async_forward_entry_setups(entry, [component]) +async def async_unload_entry(hass: HomeAssistant, entry: GoveeConfigEntry) -> bool: + """Unload a config entry. - return True + Args: + hass: Home Assistant instance. + entry: Config entry being unloaded. + Returns: + True if unload was successful. + """ + # Unload platforms + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): - """Unload a config entry.""" + if unload_ok: + # Shutdown coordinator + coordinator = entry.runtime_data + await coordinator.async_shutdown() - unload_ok = all( - await asyncio.gather( - *[ - _unload_component_entry(hass, entry, component) - for component in PLATFORMS - ] - ) - ) + # Remove from hass.data + hass.data[DOMAIN].pop(entry.entry_id, None) - if unload_ok: - hub = hass.data[DOMAIN].pop("hub") - await hub.close() + # Unload services if no more entries + if not hass.data[DOMAIN]: + await async_unload_services(hass) + hass.data.pop(DOMAIN, None) return unload_ok -def _unload_component_entry( - hass: HomeAssistant, entry: ConfigEntry, component: str -) -> bool: - """Unload an entry for a specific component.""" - success = False - try: - success = hass.config_entries.async_forward_entry_unload(entry, component) - except ValueError: - # probably ValueError: Config entry was never loaded! - return success - except Exception as ex: - _LOGGER.warning( - "Continuing on exception when unloading %s component's entry: %s", - component, - ex, - ) - return success +async def _async_cleanup_orphaned_entities( + hass: HomeAssistant, + entry: ConfigEntry, + coordinator: GoveeCoordinator, +) -> None: + """Remove entity registry entries for devices no longer discovered. + + This handles cleanup when group devices are disabled or devices are removed. + """ + entity_registry = er.async_get(hass) + + # Get all entity entries for this config entry + entries_to_remove = [] + for entity_entry in er.async_entries_for_config_entry(entity_registry, entry.entry_id): + # Extract device_id from unique_id (format: "device_id" or "device_id_segment_X") + unique_id = entity_entry.unique_id + if unique_id: + # Handle segment entities (e.g., "AA:BB:CC:DD_segment_0") + device_id = unique_id.split("_segment_")[0] if "_segment_" in unique_id else unique_id + + # Check if this device is still discovered + if device_id not in coordinator.devices: + entries_to_remove.append(entity_entry) + _LOGGER.debug( + "Marking orphaned entity for removal: %s (device %s not discovered)", + entity_entry.entity_id, + device_id, + ) + + # Remove orphaned entries + for entity_entry in entries_to_remove: + _LOGGER.info("Removing orphaned entity: %s", entity_entry.entity_id) + entity_registry.async_remove(entity_entry.entity_id) + + if entries_to_remove: + _LOGGER.info("Cleaned up %d orphaned entities", len(entries_to_remove)) + + +async def _async_update_listener( + hass: HomeAssistant, + entry: GoveeConfigEntry, +) -> None: + """Handle options update. + + Reloads the integration when options change. + """ + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/govee/api/__init__.py b/custom_components/govee/api/__init__.py new file mode 100644 index 00000000..8bdd27df --- /dev/null +++ b/custom_components/govee/api/__init__.py @@ -0,0 +1,32 @@ +"""API layer for Govee integration. + +Contains REST client, MQTT client, and authentication. +""" + +from .auth import GoveeAuthClient, GoveeIotCredentials, validate_govee_credentials +from .client import GoveeApiClient +from .exceptions import ( + GoveeApiError, + GoveeAuthError, + GoveeConnectionError, + GoveeDeviceNotFoundError, + GoveeRateLimitError, +) +from .mqtt import GoveeAwsIotClient + +__all__ = [ + # Client + "GoveeApiClient", + # Auth + "GoveeAuthClient", + "GoveeIotCredentials", + "validate_govee_credentials", + # MQTT + "GoveeAwsIotClient", + # Exceptions + "GoveeApiError", + "GoveeAuthError", + "GoveeConnectionError", + "GoveeDeviceNotFoundError", + "GoveeRateLimitError", +] diff --git a/custom_components/govee/api/auth.py b/custom_components/govee/api/auth.py new file mode 100644 index 00000000..418ec7b8 --- /dev/null +++ b/custom_components/govee/api/auth.py @@ -0,0 +1,341 @@ +"""Govee authentication API for AWS IoT MQTT credentials. + +Authenticates with Govee's account API to obtain certificates for AWS IoT MQTT +which provides real-time device state updates. + +Reference: homebridge-govee, govee2mqtt implementations +""" + +from __future__ import annotations + +import base64 +import logging +import uuid +from dataclasses import dataclass +from typing import Any + +import aiohttp +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + pkcs12, +) + +from .exceptions import GoveeApiError, GoveeAuthError + +_LOGGER = logging.getLogger(__name__) + +# Govee Account API endpoints +GOVEE_LOGIN_URL = "https://app2.govee.com/account/rest/account/v1/login" +GOVEE_IOT_KEY_URL = "https://app2.govee.com/app/v1/account/iot/key" +GOVEE_CLIENT_TYPE = "1" # Android client type + + +def _extract_p12_credentials( + p12_base64: str, password: str | None = None +) -> tuple[str, str]: + """Extract certificate and private key from P12/PFX container. + + Govee API returns AWS IoT credentials as a PKCS#12 (P12/PFX) container + in base64 encoding. This function extracts the certificate and private + key and converts them to PEM format for use with SSL/TLS. + + Args: + p12_base64: Base64-encoded P12/PFX container from Govee API. + password: Optional password for the P12 container. + + Returns: + Tuple of (certificate_pem, private_key_pem). + + Raises: + GoveeApiError: If P12 extraction fails. + """ + if not p12_base64: + raise GoveeApiError("Empty P12 data received from Govee API") + + try: + # Clean base64 string: strip whitespace, newlines + cleaned = p12_base64.strip().replace("\n", "").replace("\r", "").replace(" ", "") + + # Handle URL-safe base64 (convert - to + and _ to /) + cleaned = cleaned.replace("-", "+").replace("_", "/") + + # Fix base64 padding if needed + padding_needed = len(cleaned) % 4 + if padding_needed: + cleaned += "=" * (4 - padding_needed) + + # Decode base64 to get raw P12 bytes + try: + p12_data = base64.b64decode(cleaned) + except Exception as b64_err: + raise GoveeApiError(f"Base64 decode failed: {b64_err}") from b64_err + + # Parse PKCS#12 container with optional password + pwd_bytes = password.encode("utf-8") if password else None + try: + private_key, certificate, _ = pkcs12.load_key_and_certificates( + p12_data, pwd_bytes + ) + except Exception as p12_err: + raise GoveeApiError(f"P12 container parse failed: {p12_err}") from p12_err + + if private_key is None: + raise GoveeApiError("No private key found in P12 container") + if certificate is None: + raise GoveeApiError("No certificate found in P12 container") + + # Convert private key to PEM format (PKCS8) + key_pem = private_key.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.PKCS8, + encryption_algorithm=NoEncryption(), + ).decode("utf-8") + + # Convert certificate to PEM format + cert_pem = certificate.public_bytes(Encoding.PEM).decode("utf-8") + + _LOGGER.debug("Successfully extracted certificate and key from P12 container") + return cert_pem, key_pem + + except GoveeApiError: + raise + except Exception as err: + raise GoveeApiError(f"Failed to parse P12 certificate: {err}") from err + + +@dataclass +class GoveeIotCredentials: + """Credentials for AWS IoT MQTT connection.""" + + token: str + refresh_token: str + account_topic: str + iot_cert: str + iot_key: str + iot_ca: str | None + client_id: str + endpoint: str + + @property + def is_valid(self) -> bool: + """Check if credentials appear valid.""" + return bool(self.token and self.iot_cert and self.iot_key and self.account_topic) + + +class GoveeAuthClient: + """Client for Govee account authentication. + + Handles login to obtain AWS IoT MQTT certificates for real-time state updates. + + Note: Login is rate-limited to 30 attempts per 24 hours by Govee. + Credentials should be cached and reused. + """ + + def __init__( + self, + session: aiohttp.ClientSession | None = None, + ) -> None: + """Initialize the auth client. + + Args: + session: Optional shared aiohttp session. + """ + self._session = session + self._owns_session = session is None + + async def __aenter__(self) -> GoveeAuthClient: + """Async context manager entry.""" + if self._session is None: + self._session = aiohttp.ClientSession() + return self + + async def __aexit__(self, *args: Any) -> None: + """Async context manager exit.""" + await self.close() + + async def close(self) -> None: + """Close the session if we own it.""" + if self._owns_session and self._session: + await self._session.close() + self._session = None + + async def get_iot_key(self, token: str) -> dict[str, Any]: + """Fetch IoT credentials from Govee API. + + Args: + token: Authentication token from login response. + + Returns: + Dict with keys: p12, p12_pass, endpoint, etc. + + Raises: + GoveeApiError: If the request fails. + """ + if self._session is None: + self._session = aiohttp.ClientSession() + self._owns_session = True + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + try: + async with self._session.get( + GOVEE_IOT_KEY_URL, + headers=headers, + ) as response: + data = await response.json() + + if response.status != 200: + message = data.get("message", f"HTTP {response.status}") + raise GoveeApiError(f"Failed to get IoT key: {message}", code=response.status) + + # IoT key response wraps data in a "data" field + return data.get("data", {}) if isinstance(data, dict) else {} + + except aiohttp.ClientError as err: + raise GoveeApiError(f"Connection error getting IoT key: {err}") from err + + async def login( + self, + email: str, + password: str, + client_id: str | None = None, + ) -> GoveeIotCredentials: + """Login to Govee account to obtain AWS IoT credentials. + + Args: + email: Govee account email. + password: Govee account password. + client_id: Optional client ID (32-char UUID). Generated if not provided. + + Returns: + GoveeIotCredentials with AWS IoT connection details. + + Raises: + GoveeAuthError: Invalid credentials or login failed. + GoveeApiError: API communication error. + """ + if self._session is None: + self._session = aiohttp.ClientSession() + self._owns_session = True + + if client_id is None: + client_id = uuid.uuid4().hex + + payload = { + "email": email, + "password": password, + "client": client_id, + "clientType": GOVEE_CLIENT_TYPE, + } + + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + + try: + async with self._session.post( + GOVEE_LOGIN_URL, + json=payload, + headers=headers, + ) as response: + data = await response.json() + + if response.status == 401: + raise GoveeAuthError("Invalid email or password") + + if response.status != 200: + message = data.get("message", f"HTTP {response.status}") + raise GoveeApiError(f"Login failed: {message}", code=response.status) + + # Check response status code within JSON + status = data.get("status") + if status != 200: + message = data.get("message", "Login failed") + if status == 401 or "password" in message.lower(): + raise GoveeAuthError(message) + raise GoveeApiError(f"Login failed: {message}", code=status) + + client_data = data.get("client", {}) + + # Get token from login response + token = client_data.get("token", "") + if not token: + raise GoveeApiError("No token in login response") + + # Fetch IoT credentials from separate endpoint + iot_data = await self.get_iot_key(token) + + # Extract AWS IoT credentials (PEM or P12 format) + iot_endpoint = iot_data.get( + "endpoint", "aqm3wd1qlc3dy-ats.iot.us-east-1.amazonaws.com" + ) + + # Check for direct PEM format first + cert_pem = iot_data.get("certificatePem", "") + key_pem = iot_data.get("privateKey", "") + + if not (cert_pem and key_pem): + # Fall back to P12 container format + p12_base64 = iot_data.get("p12", "") + p12_password = iot_data.get("p12Pass") or iot_data.get("p12_pass", "") + + if not p12_base64: + raise GoveeApiError("No certificate data in IoT key response") + + cert_pem, key_pem = _extract_p12_credentials(p12_base64, p12_password) + + # Build MQTT client ID: AP/{accountId}/{uuid} + account_id = str(client_data.get("accountId", "")) + mqtt_client_id = f"AP/{account_id}/{client_id}" if account_id else client_id + + credentials = GoveeIotCredentials( + token=token, + refresh_token=client_data.get("refreshToken", ""), + account_topic=client_data.get("topic", ""), + iot_cert=cert_pem, + iot_key=key_pem, + iot_ca=client_data.get("caCertificate"), + client_id=mqtt_client_id, + endpoint=iot_endpoint, + ) + + if not credentials.is_valid: + raise GoveeApiError("Missing IoT credentials in response") + + _LOGGER.info("Successfully authenticated with Govee") + return credentials + + except aiohttp.ClientError as err: + raise GoveeApiError(f"Connection error during login: {err}") from err + + +async def validate_govee_credentials( + email: str, + password: str, + session: aiohttp.ClientSession | None = None, +) -> GoveeIotCredentials: + """Validate Govee account credentials and return IoT credentials. + + Convenience function for config flow validation. + + Args: + email: Govee account email. + password: Govee account password. + session: Optional aiohttp session. + + Returns: + GoveeIotCredentials if valid. + + Raises: + GoveeAuthError: Invalid credentials. + GoveeApiError: API communication error. + """ + async with GoveeAuthClient(session=session) as client: + return await client.login(email, password) diff --git a/custom_components/govee/api/client.py b/custom_components/govee/api/client.py new file mode 100644 index 00000000..78bdf69b --- /dev/null +++ b/custom_components/govee/api/client.py @@ -0,0 +1,407 @@ +"""Govee REST API client with automatic retry support. + +Uses aiohttp-retry for exponential backoff on transient failures. +Implements IApiClient protocol. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import aiohttp +from aiohttp_retry import ExponentialRetry, RetryClient + +from ..models.device import GoveeDevice +from ..models.state import GoveeDeviceState +from .exceptions import ( + GoveeApiError, + GoveeAuthError, + GoveeConnectionError, + GoveeDeviceNotFoundError, + GoveeRateLimitError, +) + +if TYPE_CHECKING: + from ..models.commands import DeviceCommand + +_LOGGER = logging.getLogger(__name__) + +# Govee API v2.0 endpoints +API_BASE = "https://openapi.api.govee.com/router/api/v1" +ENDPOINT_DEVICES = f"{API_BASE}/user/devices" +ENDPOINT_STATE = f"{API_BASE}/device/state" +ENDPOINT_CONTROL = f"{API_BASE}/device/control" +ENDPOINT_SCENES = f"{API_BASE}/device/scenes" + +# Retry configuration +RETRY_ATTEMPTS = 3 +RETRY_START_TIMEOUT = 1.0 # Initial retry delay in seconds +RETRY_MAX_TIMEOUT = 30.0 # Maximum retry delay +RETRY_FACTOR = 2.0 # Exponential factor + +# Non-retryable status codes +NO_RETRY_STATUSES = {400, 401, 403, 404} + + +class GoveeApiClient: + """Async HTTP client for Govee Cloud API v2.0. + + Features: + - Automatic retry with exponential backoff (via aiohttp-retry) + - Rate limit tracking from response headers + - Proper exception mapping + - Device and state parsing + + Usage: + async with GoveeApiClient(api_key) as client: + devices = await client.get_devices() + """ + + def __init__( + self, + api_key: str, + session: aiohttp.ClientSession | None = None, + ) -> None: + """Initialize the API client. + + Args: + api_key: Govee API key from developer portal. + session: Optional shared aiohttp session. + """ + self._api_key = api_key + self._session = session + self._owns_session = session is None + self._retry_client: RetryClient | None = None + + # Rate limit tracking (updated from response headers) + self.rate_limit_remaining: int = 100 + self.rate_limit_total: int = 100 + self.rate_limit_reset: int = 0 + + async def __aenter__(self) -> GoveeApiClient: + """Async context manager entry.""" + await self._ensure_client() + return self + + async def __aexit__(self, *args: Any) -> None: + """Async context manager exit.""" + await self.close() + + async def _ensure_client(self) -> RetryClient: + """Ensure retry client is initialized.""" + if self._retry_client is None: + if self._session is None: + self._session = aiohttp.ClientSession() + self._owns_session = True + + retry_options = ExponentialRetry( + attempts=RETRY_ATTEMPTS, + start_timeout=RETRY_START_TIMEOUT, + max_timeout=RETRY_MAX_TIMEOUT, + factor=RETRY_FACTOR, + statuses=set(), # Retry all except NO_RETRY_STATUSES + exceptions={aiohttp.ClientError, TimeoutError}, + ) + + self._retry_client = RetryClient( + client_session=self._session, + retry_options=retry_options, + ) + + return self._retry_client + + async def close(self) -> None: + """Close the client and release resources.""" + if self._retry_client is not None: + await self._retry_client.close() + self._retry_client = None + + if self._owns_session and self._session is not None: + await self._session.close() + self._session = None + + def _get_headers(self) -> dict[str, str]: + """Get request headers with API key.""" + return { + "Govee-API-Key": self._api_key, + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _update_rate_limits(self, headers: Any) -> None: + """Update rate limit tracking from response headers.""" + if "X-RateLimit-Remaining" in headers: + try: + self.rate_limit_remaining = int(headers["X-RateLimit-Remaining"]) + except (ValueError, TypeError): + pass + + if "X-RateLimit-Limit" in headers: + try: + self.rate_limit_total = int(headers["X-RateLimit-Limit"]) + except (ValueError, TypeError): + pass + + if "X-RateLimit-Reset" in headers: + try: + self.rate_limit_reset = int(headers["X-RateLimit-Reset"]) + except (ValueError, TypeError): + pass + + async def _handle_response( + self, + response: aiohttp.ClientResponse, + ) -> dict[str, Any]: + """Handle API response and raise appropriate exceptions. + + Args: + response: aiohttp response object. + + Returns: + Parsed JSON response data. + + Raises: + GoveeAuthError: 401 Unauthorized. + GoveeRateLimitError: 429 Too Many Requests. + GoveeDeviceNotFoundError: 400 for missing device. + GoveeApiError: Other API errors. + """ + self._update_rate_limits(response.headers) + + try: + data: dict[str, Any] = await response.json() + except aiohttp.ContentTypeError: + text = await response.text() + raise GoveeApiError(f"Invalid JSON response: {text[:200]}") + + # Check HTTP status + if response.status == 401: + raise GoveeAuthError("Invalid API key") + + if response.status == 429: + retry_after = response.headers.get("Retry-After") + raise GoveeRateLimitError( + "Rate limit exceeded", + retry_after=float(retry_after) if retry_after else None, + ) + + if response.status == 400: + message = data.get("message", "Bad request") + # Check for "devices not exist" error (expected for groups) + if "not exist" in message.lower(): + raise GoveeDeviceNotFoundError(message) + raise GoveeApiError(message, code=400) + + if response.status >= 400: + message = data.get("message", f"HTTP {response.status}") + raise GoveeApiError(message, code=response.status) + + # Check response code within JSON + code = data.get("code") + if code is not None and code != 200: + message = data.get("message", f"API error code {code}") + if code == 401: + raise GoveeAuthError(message) + raise GoveeApiError(message, code=code) + + return data + + async def get_devices(self) -> list[GoveeDevice]: + """Fetch all devices from Govee API. + + Returns: + List of GoveeDevice instances with capabilities. + + Raises: + GoveeAuthError: Invalid API key. + GoveeConnectionError: Network error. + GoveeApiError: Other API errors. + """ + client = await self._ensure_client() + + try: + async with client.get( + ENDPOINT_DEVICES, + headers=self._get_headers(), + ) as response: + data = await self._handle_response(response) + + devices = [] + for device_data in data.get("data", []): + try: + device = GoveeDevice.from_api_response(device_data) + devices.append(device) + except Exception as err: + _LOGGER.warning( + "Failed to parse device %s: %s", + device_data.get("device", "unknown"), + err, + ) + + _LOGGER.debug("Fetched %d devices from Govee API", len(devices)) + return devices + + except aiohttp.ClientError as err: + raise GoveeConnectionError(f"Connection error: {err}") from err + + async def get_device_state( + self, + device_id: str, + sku: str, + ) -> GoveeDeviceState: + """Fetch current state for a device. + + Args: + device_id: Device identifier (MAC address format). + sku: Device SKU/model number. + + Returns: + GoveeDeviceState with current values. + + Raises: + GoveeDeviceNotFoundError: Device not found (expected for groups). + GoveeApiError: Other API errors. + """ + client = await self._ensure_client() + + payload = { + "requestId": "uuid", + "payload": { + "sku": sku, + "device": device_id, + }, + } + + try: + async with client.post( + ENDPOINT_STATE, + headers=self._get_headers(), + json=payload, + ) as response: + data = await self._handle_response(response) + + state = GoveeDeviceState.create_empty(device_id) + payload_data = data.get("payload", {}) + state.update_from_api(payload_data) + + return state + + except aiohttp.ClientError as err: + raise GoveeConnectionError(f"Connection error: {err}") from err + + async def control_device( + self, + device_id: str, + sku: str, + command: DeviceCommand, + ) -> bool: + """Send control command to device. + + Args: + device_id: Device identifier. + sku: Device SKU. + command: Command to execute. + + Returns: + True if command was accepted by API. + + Raises: + GoveeApiError: If command fails. + """ + client = await self._ensure_client() + + # Build request payload + cmd_payload = command.to_api_payload() + payload = { + "requestId": "uuid", + "payload": { + "sku": sku, + "device": device_id, + "capability": cmd_payload, + }, + } + + try: + async with client.post( + ENDPOINT_CONTROL, + headers=self._get_headers(), + json=payload, + ) as response: + await self._handle_response(response) + return True + + except aiohttp.ClientError as err: + raise GoveeConnectionError(f"Connection error: {err}") from err + + async def get_dynamic_scenes( + self, + device_id: str, + sku: str, + ) -> list[dict[str, Any]]: + """Fetch available scenes for a device. + + Args: + device_id: Device identifier. + sku: Device SKU. + + Returns: + List of scene definitions with id, name, etc. + """ + client = await self._ensure_client() + + payload = { + "requestId": "uuid", + "payload": { + "sku": sku, + "device": device_id, + }, + } + + try: + async with client.post( + ENDPOINT_SCENES, + headers=self._get_headers(), + json=payload, + ) as response: + data = await self._handle_response(response) + + scenes = [] + capabilities = data.get("payload", {}).get("capabilities", []) + for cap in capabilities: + if cap.get("type") == "devices.capabilities.dynamic_scene": + params = cap.get("parameters", {}) + options = params.get("options", []) + scenes.extend(options) + + _LOGGER.debug( + "Fetched %d scenes for device %s", + len(scenes), + device_id, + ) + return scenes + + except GoveeDeviceNotFoundError: + _LOGGER.debug("No scenes available for device %s", device_id) + return [] + except aiohttp.ClientError as err: + raise GoveeConnectionError(f"Connection error: {err}") from err + + +async def validate_api_key(api_key: str) -> bool: + """Validate a Govee API key by making a test request. + + Args: + api_key: API key to validate. + + Returns: + True if valid. + + Raises: + GoveeAuthError: Invalid API key. + GoveeApiError: Other errors. + """ + async with GoveeApiClient(api_key) as client: + # get_devices will raise GoveeAuthError if key is invalid + await client.get_devices() + return True diff --git a/custom_components/govee/api/exceptions.py b/custom_components/govee/api/exceptions.py new file mode 100644 index 00000000..16f21d9d --- /dev/null +++ b/custom_components/govee/api/exceptions.py @@ -0,0 +1,49 @@ +"""API layer exceptions. + +Lightweight exceptions without Home Assistant dependencies. +The coordinator layer wraps these in translatable HA exceptions. +""" + +from __future__ import annotations + + +class GoveeApiError(Exception): + """Base exception for Govee API errors.""" + + def __init__(self, message: str, code: int | None = None) -> None: + super().__init__(message) + self.code = code + + +class GoveeAuthError(GoveeApiError): + """Authentication failed - invalid API key or credentials.""" + + def __init__(self, message: str = "Invalid API key") -> None: + super().__init__(message, code=401) + + +class GoveeRateLimitError(GoveeApiError): + """Rate limit exceeded - too many requests.""" + + def __init__( + self, + message: str = "Rate limit exceeded", + retry_after: float | None = None, + ) -> None: + super().__init__(message, code=429) + self.retry_after = retry_after + + +class GoveeConnectionError(GoveeApiError): + """Network or connection error.""" + + def __init__(self, message: str = "Failed to connect to Govee API") -> None: + super().__init__(message) + + +class GoveeDeviceNotFoundError(GoveeApiError): + """Device not found (expected for group devices).""" + + def __init__(self, device_id: str) -> None: + super().__init__(f"Device not found: {device_id}", code=400) + self.device_id = device_id diff --git a/custom_components/govee/api/mqtt.py b/custom_components/govee/api/mqtt.py new file mode 100644 index 00000000..c78dc0d0 --- /dev/null +++ b/custom_components/govee/api/mqtt.py @@ -0,0 +1,348 @@ +"""AWS IoT MQTT client for Govee real-time device state updates. + +Connects to Govee's AWS IoT endpoint to receive push notifications of device +state changes (power, brightness, color). This provides instant state updates +without polling, eliminating the "flipflop" bug from optimistic updates. + +PCAP validated endpoint: aqm3wd1qlc3dy-ats.iot.us-east-1.amazonaws.com:8883 + +Key differences from official Govee MQTT (mqtt.openapi.govee.com): +- AWS IoT provides full state updates (power, brightness, color, temp) +- Official MQTT only provides EVENT capabilities (sensors, alerts) +- AWS IoT requires certificate auth (from login API), not API key +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import ssl +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable + +# Import aiomqtt at module level to avoid blocking in event loop +try: + import aiomqtt + + AIOMQTT_AVAILABLE = True +except ImportError: + AIOMQTT_AVAILABLE = False + +if TYPE_CHECKING: + from .auth import GoveeIotCredentials + +_LOGGER = logging.getLogger(__name__) + +# AWS IoT connection settings +AWS_IOT_PORT = 8883 +AWS_IOT_KEEPALIVE = 120 +RECONNECT_BASE = 5 +RECONNECT_MAX = 300 +CONNECTION_TIMEOUT = 60 + +# Amazon Root CA 1 - Required for AWS IoT server certificate verification +# Source: https://www.amazontrust.com/repository/AmazonRootCA1.pem +AMAZON_ROOT_CA1 = """-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF +ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 +b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL +MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv +b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj +ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM +9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw +IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 +VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L +93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm +jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC +AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA +A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI +U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs +N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv +o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU +5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy +rqXRfboQnoZsG4q5WTP468SQvvG5 +-----END CERTIFICATE-----""" + + +# Type for state update callback +StateUpdateCallback = Callable[[str, dict[str, Any]], None] + + +class GoveeAwsIotClient: + """AWS IoT MQTT client for real-time Govee device state updates. + + Receives push notifications for device state changes including: + - Power state (onOff) + - Brightness + - Color (RGB) + - Color temperature + + Uses certificate-based authentication obtained from Govee login API. + + Usage: + client = GoveeAwsIotClient(credentials, on_state_update) + await client.async_start() + # ... receives updates via callback ... + await client.async_stop() + """ + + def __init__( + self, + credentials: GoveeIotCredentials, + on_state_update: StateUpdateCallback, + ) -> None: + """Initialize the AWS IoT MQTT client. + + Args: + credentials: IoT credentials from Govee login API. + on_state_update: Callback(device_id, state_dict) for state changes. + """ + self._credentials = credentials + self._on_state_update = on_state_update + self._running = False + self._connected = False + self._task: asyncio.Task[None] | None = None + self._temp_dir: tempfile.TemporaryDirectory[str] | None = None + self._max_backoff_count = 0 + + @property + def connected(self) -> bool: + """Return True if connected to AWS IoT.""" + return self._connected + + @property + def available(self) -> bool: + """Return True if MQTT library is available.""" + return AIOMQTT_AVAILABLE + + async def async_start(self) -> None: + """Start the AWS IoT MQTT connection loop. + + Spawns a background task that maintains the connection with + automatic reconnection on failure. + """ + if not AIOMQTT_AVAILABLE: + _LOGGER.warning( + "aiomqtt library not available - AWS IoT MQTT disabled. " + "Install with: pip install aiomqtt" + ) + return + + if self._running: + return + + self._running = True + self._task = asyncio.create_task(self._connection_loop()) + _LOGGER.debug("AWS IoT MQTT client started") + + async def async_stop(self) -> None: + """Stop the AWS IoT MQTT connection. + + Cancels the connection loop and cleans up temporary certificate files. + Cleanup is run in executor to avoid blocking the event loop. + """ + _LOGGER.debug("Stopping AWS IoT MQTT client") + self._running = False + + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + # Clean up temp certificate files in executor to avoid blocking + if self._temp_dir: + temp_dir = self._temp_dir + self._temp_dir = None + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, temp_dir.cleanup) + except Exception: + pass + + self._connected = False + _LOGGER.info("AWS IoT MQTT client stopped") + + def _create_ssl_context_sync(self) -> ssl.SSLContext: + """Create SSL context with certificate files (synchronous). + + Configures mutual TLS authentication for AWS IoT: + - Loads Amazon Root CA for server verification + - Loads client certificate and key for client authentication + - Enforces TLS 1.2+ as required by AWS IoT + + This method is blocking and should be run in an executor. + """ + # Clean up any existing temp directory first + if self._temp_dir: + try: + self._temp_dir.cleanup() + except Exception: + pass + self._temp_dir = None + + temp_dir = None + try: + # Create temp directory for certificate files + temp_dir = tempfile.TemporaryDirectory() + temp_path = Path(temp_dir.name) + + cert_path = temp_path / "cert.pem" + key_path = temp_path / "key.pem" + + # Write certificate files with restricted permissions + cert_path.write_text(self._credentials.iot_cert) + cert_path.chmod(0o600) + key_path.write_text(self._credentials.iot_key) + key_path.chmod(0o600) + + # Create SSL context for mutual TLS with AWS IoT + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 + ssl_context.verify_mode = ssl.CERT_REQUIRED + ssl_context.check_hostname = True + + # Load Amazon Root CA for server certificate verification + ssl_context.load_verify_locations(cadata=AMAZON_ROOT_CA1) + + # Load client certificate and private key for mutual TLS + ssl_context.load_cert_chain(str(cert_path), str(key_path)) + + _LOGGER.debug("SSL context created for AWS IoT MQTT") + + # Store reference after successful creation + self._temp_dir = temp_dir + return ssl_context + + except Exception: + # Clean up temp directory on failure + if temp_dir: + try: + temp_dir.cleanup() + except Exception: + pass + raise + + async def _create_ssl_context(self) -> ssl.SSLContext: + """Create SSL context with certificate files (async wrapper). + + Runs blocking SSL context creation in an executor. + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._create_ssl_context_sync) + + async def _connection_loop(self) -> None: + """Maintain AWS IoT MQTT connection with exponential backoff.""" + reconnect_interval = RECONNECT_BASE + + while self._running: + try: + ssl_context = await self._create_ssl_context() + + _LOGGER.debug( + "Connecting to AWS IoT: %s:%d", + self._credentials.endpoint, + AWS_IOT_PORT, + ) + + async with aiomqtt.Client( + hostname=self._credentials.endpoint, + port=AWS_IOT_PORT, + identifier=self._credentials.client_id, + tls_context=ssl_context, + keepalive=AWS_IOT_KEEPALIVE, + timeout=CONNECTION_TIMEOUT, + ) as client: + self._connected = True + self._max_backoff_count = 0 + reconnect_interval = RECONNECT_BASE + + _LOGGER.info( + "Connected to AWS IoT MQTT at %s", + self._credentials.endpoint, + ) + + # Subscribe to account topic for all device updates + topic = self._credentials.account_topic + await client.subscribe(topic) + _LOGGER.debug("Subscribed to topic: %s", topic[:30] + "...") + + async for message in client.messages: + if not self._running: + break # type: ignore[unreachable] + await self._handle_message(message) + + except asyncio.CancelledError: + _LOGGER.debug("AWS IoT connection loop cancelled") + raise + + except Exception as err: + self._connected = False + + if self._running: + _LOGGER.warning( + "AWS IoT connection error (%s): %s. Reconnecting in %ds", + type(err).__name__, + err, + reconnect_interval, + ) + + await asyncio.sleep(reconnect_interval) + reconnect_interval = min(reconnect_interval * 2, RECONNECT_MAX) + + if reconnect_interval >= RECONNECT_MAX: + self._max_backoff_count += 1 + if self._max_backoff_count >= 3: + _LOGGER.error( + "AWS IoT connection failed %d times at max backoff", + self._max_backoff_count, + ) + + self._connected = False + + async def _handle_message(self, message: Any) -> None: + """Handle incoming AWS IoT MQTT message. + + Message format from PCAP analysis: + { + "device": "XX:XX:XX:XX:XX:XX:XX:XX", + "sku": "H6072", + "state": { + "onOff": 1, + "brightness": 50, + "color": {"r": 255, "g": 0, "b": 0}, + "colorTemInKelvin": 0 + } + } + """ + try: + raw_payload = message.payload + payload_str = raw_payload.decode() if isinstance(raw_payload, bytes) else str(raw_payload) + + data = json.loads(payload_str) + + device_id = data.get("device") + state = data.get("state", {}) + + if not device_id: + _LOGGER.debug("AWS IoT message missing device ID") + return + + _LOGGER.debug( + "MQTT state update for %s: power=%s, brightness=%s", + device_id, + state.get("onOff"), + state.get("brightness"), + ) + + # Invoke callback with device ID and state dict + self._on_state_update(device_id, state) + + except json.JSONDecodeError as err: + _LOGGER.warning("Failed to parse AWS IoT message: %s", err) + except Exception as err: + _LOGGER.error("Error handling AWS IoT message: %s", err) diff --git a/custom_components/govee/button.py b/custom_components/govee/button.py new file mode 100644 index 00000000..5db0f9bf --- /dev/null +++ b/custom_components/govee/button.py @@ -0,0 +1,76 @@ +"""Button platform for Govee integration. + +Provides button entities for: +- Refresh scenes (per device) +- Identify device (flash lights if supported) +""" + +from __future__ import annotations + +import logging + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import GoveeCoordinator +from .entity import GoveeEntity +from .models import GoveeDevice + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Govee buttons from a config entry.""" + coordinator: GoveeCoordinator = entry.runtime_data + + entities: list[ButtonEntity] = [] + + for device in coordinator.devices.values(): + # Add refresh scenes button for devices with scenes + if device.supports_scenes: + entities.append(GoveeRefreshScenesButton(coordinator, device)) + + async_add_entities(entities) + _LOGGER.debug("Set up %d Govee button entities", len(entities)) + + +class GoveeRefreshScenesButton(GoveeEntity, ButtonEntity): + """Button to refresh scenes for a device. + + Useful when new scenes are created in the Govee app. + """ + + _attr_device_class = ButtonDeviceClass.UPDATE + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "refresh_scenes" + _attr_icon = "mdi:refresh" + + def __init__( + self, + coordinator: GoveeCoordinator, + device: GoveeDevice, + ) -> None: + """Initialize the refresh scenes button.""" + super().__init__(coordinator, device) + + self._attr_unique_id = f"{device.device_id}_refresh_scenes" + self._attr_name = "Refresh Scenes" + + async def async_press(self) -> None: + """Handle the button press - refresh scenes.""" + _LOGGER.debug("Refreshing scenes for %s", self._device.name) + + # Force refresh scenes from API + await self.coordinator.async_get_scenes( + self._device_id, + refresh=True, + ) + + _LOGGER.info("Scenes refreshed for %s", self._device.name) diff --git a/custom_components/govee/config_flow.py b/custom_components/govee/config_flow.py index a0ebc987..5ec237a9 100644 --- a/custom_components/govee/config_flow.py +++ b/custom_components/govee/config_flow.py @@ -1,209 +1,347 @@ -"""Config flow for Govee integration.""" +"""Config flow for Govee integration. -import logging +Fresh version 1 - no migration complexity. +Supports API key authentication with optional account login for MQTT. +""" -from govee_api_laggat import Govee, GoveeNoLearningStorage, GoveeError +from __future__ import annotations + +import logging +from typing import Any -from homeassistant import config_entries, core, exceptions -import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_API_KEY, CONF_DELAY -from homeassistant.core import callback import voluptuous as vol +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.core import callback +from .api import ( + GoveeApiError, + GoveeAuthError, + GoveeIotCredentials, + validate_govee_credentials, +) +from .api.client import validate_api_key from .const import ( - CONF_DISABLE_ATTRIBUTE_UPDATES, - CONF_OFFLINE_IS_OFF, - CONF_USE_ASSUMED_STATE, + CONF_API_KEY, + CONF_EMAIL, + CONF_ENABLE_GROUPS, + CONF_ENABLE_SCENES, + CONF_ENABLE_SEGMENTS, + CONF_PASSWORD, + CONF_POLL_INTERVAL, + CONFIG_VERSION, + DEFAULT_ENABLE_GROUPS, + DEFAULT_ENABLE_SCENES, + DEFAULT_ENABLE_SEGMENTS, + DEFAULT_POLL_INTERVAL, DOMAIN, ) _LOGGER = logging.getLogger(__name__) -async def validate_api_key(hass: core.HomeAssistant, user_input): - """Validate the user input allows us to connect. +class GoveeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Govee. - Return info that you want to store in the config entry. + Steps: + 1. User enters API key (required) + 2. Optionally enter email/password for MQTT real-time updates + 3. Create config entry """ - api_key = user_input[CONF_API_KEY] - async with Govee(api_key, learning_storage=GoveeNoLearningStorage()) as hub: - _, error = await hub.get_devices() - if error: - raise CannotConnect(error) - # Return info that you want to store in the config entry. - return user_input + VERSION = CONFIG_VERSION + def __init__(self) -> None: + """Initialize the config flow.""" + self._api_key: str | None = None + self._email: str | None = None + self._password: str | None = None + self._iot_credentials: GoveeIotCredentials | None = None + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return GoveeOptionsFlow(config_entry) -async def validate_disabled_attribute_updates(hass: core.HomeAssistant, user_input): - """Validate format of the ignore_device_attributes parameter string + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the initial step - API key entry.""" + errors: dict[str, str] = {} - Return info that you want to store in the config entry. - """ - disable_str = user_input[CONF_DISABLE_ATTRIBUTE_UPDATES] - if disable_str: - # we have something to check, connect without API key - async with Govee("", learning_storage=GoveeNoLearningStorage()) as hub: - # this will throw an GoveeError if something fails - hub.ignore_device_attributes(disable_str) + if user_input is not None: + api_key = user_input[CONF_API_KEY] - # Return info that you want to store in the config entry. - return user_input + try: + await validate_api_key(api_key) + self._api_key = api_key + + # Proceed to optional account step for MQTT + return await self.async_step_account() + + except GoveeAuthError: + errors["base"] = "invalid_auth" + except GoveeApiError as err: + _LOGGER.error("API validation failed: %s", err) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during API validation") + errors["base"] = "unknown" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + description_placeholders={ + "api_url": "https://developer.govee.com/", + }, + ) -@config_entries.HANDLERS.register(DOMAIN) -class GoveeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Govee.""" + async def async_step_account( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle optional account credentials for MQTT. - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + Users can skip this step if they don't want real-time updates. + """ + errors: dict[str, str] = {} - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - errors = {} if user_input is not None: + # Check if user wants to skip MQTT + if not user_input.get(CONF_EMAIL): + # Skip MQTT, create entry with API key only + return self._create_entry() + + email = user_input[CONF_EMAIL] + password = user_input[CONF_PASSWORD] + try: - user_input = await validate_api_key(self.hass, user_input) - - except CannotConnect as conn_ex: - _LOGGER.exception("Cannot connect: %s", conn_ex) - errors[CONF_API_KEY] = "cannot_connect" - except GoveeError as govee_ex: - _LOGGER.exception("Govee library error: %s", govee_ex) - errors["base"] = "govee_ex" - except Exception as ex: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception: %s", ex) + self._iot_credentials = await validate_govee_credentials(email, password) + self._email = email + self._password = password + + return self._create_entry() + + except GoveeAuthError: + errors["base"] = "invalid_auth" + except GoveeApiError as err: + _LOGGER.error("Account validation failed: %s", err) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during account validation") errors["base"] = "unknown" - if not errors: - return self.async_create_entry(title=DOMAIN, data=user_input) - return self.async_show_form( - step_id="user", + step_id="account", data_schema=vol.Schema( { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_DELAY, default=10): cv.positive_int, + vol.Optional(CONF_EMAIL): str, + vol.Optional(CONF_PASSWORD): str, } ), errors=errors, + description_placeholders={ + "note": "Optional: Enter Govee account for real-time MQTT updates", + }, ) - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get the options flow.""" - return GoveeOptionsFlowHandler(config_entry) + def _create_entry(self) -> ConfigFlowResult: + """Create the config entry.""" + data: dict[str, Any] = { + CONF_API_KEY: self._api_key, + } + + # Add account credentials if provided + if self._email and self._password: + data[CONF_EMAIL] = self._email + data[CONF_PASSWORD] = self._password + + return self.async_create_entry( + title="Govee", + data=data, + options={ + CONF_POLL_INTERVAL: DEFAULT_POLL_INTERVAL, + CONF_ENABLE_GROUPS: DEFAULT_ENABLE_GROUPS, + CONF_ENABLE_SCENES: DEFAULT_ENABLE_SCENES, + CONF_ENABLE_SEGMENTS: DEFAULT_ENABLE_SEGMENTS, + }, + ) + async def async_step_reauth( + self, + entry_data: dict[str, Any], + ) -> ConfigFlowResult: + """Handle re-authentication request.""" + return await self.async_step_reauth_confirm() -class GoveeOptionsFlowHandler(config_entries.OptionsFlow): - """Handle options.""" + async def async_step_reauth_confirm( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle re-authentication confirmation.""" + errors: dict[str, str] = {} - VERSION = 1 + if user_input is not None: + api_key = user_input[CONF_API_KEY] - def __init__(self, config_entry): - """Initialize options flow.""" - self.options = dict(config_entry.options) + try: + await validate_api_key(api_key) - async def async_step_init(self, user_input=None): - """Manage the options.""" - return await self.async_step_user() + # Update existing entry + entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + if entry: + self.hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_API_KEY: api_key}, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + + except GoveeAuthError: + errors["base"] = "invalid_auth" + except GoveeApiError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during reauth") + errors["base"] = "unknown" - async def async_step_user(self, user_input=None): - """Manage the options.""" - # get the current value for API key for comparison and default value - old_api_key = self.config_entry.options.get( - CONF_API_KEY, self.config_entry.data.get(CONF_API_KEY, "") + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, ) - errors = {} + async def async_step_reconfigure( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration. + + Allows users to update API key and account credentials without + removing and re-adding the integration. + """ + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input is not None: - # check if API Key changed and is valid - try: - api_key = user_input[CONF_API_KEY] - if old_api_key != api_key: - user_input = await validate_api_key(self.hass, user_input) - - except CannotConnect as conn_ex: - _LOGGER.exception("Cannot connect: %s", conn_ex) - errors[CONF_API_KEY] = "cannot_connect" - except GoveeError as govee_ex: - _LOGGER.exception("Govee library error: %s", govee_ex) - errors["base"] = "govee_ex" - except Exception as ex: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception: %s", ex) - errors["base"] = "unknown" + api_key = user_input[CONF_API_KEY] - # check validate_disabled_attribute_updates try: - user_input = await validate_disabled_attribute_updates( - self.hass, user_input - ) + await validate_api_key(api_key) - # apply settings to the running instance - if DOMAIN in self.hass.data and "hub" in self.hass.data[DOMAIN]: - hub = self.hass.data[DOMAIN]["hub"] - if hub: - disable_str = user_input[CONF_DISABLE_ATTRIBUTE_UPDATES] - hub.ignore_device_attributes(disable_str) - except GoveeError as govee_ex: - _LOGGER.exception( - "Wrong input format for validate_disabled_attribute_updates: %s", - govee_ex, - ) - errors[ - CONF_DISABLE_ATTRIBUTE_UPDATES - ] = "disabled_attribute_updates_wrong" - - if not errors: - # update options flow values - self.options.update(user_input) - return await self._update_options() - # for later - extend with options you don't want in config but option flow - # return await self.async_step_options_2() - - options_schema = vol.Schema( - { - # to config flow - vol.Required( - CONF_API_KEY, - default=old_api_key, - ): cv.string, - vol.Optional( - CONF_DELAY, - default=self.config_entry.options.get( - CONF_DELAY, self.config_entry.data.get(CONF_DELAY, 10) - ), - ): cv.positive_int, - # to options flow - vol.Required( - CONF_USE_ASSUMED_STATE, - default=self.config_entry.options.get(CONF_USE_ASSUMED_STATE, True), - ): cv.boolean, - vol.Required( - CONF_OFFLINE_IS_OFF, - default=self.config_entry.options.get(CONF_OFFLINE_IS_OFF, False), - ): cv.boolean, - # TODO: validator doesn't work, change to list? - vol.Optional( - CONF_DISABLE_ATTRIBUTE_UPDATES, - default=self.config_entry.options.get( - CONF_DISABLE_ATTRIBUTE_UPDATES, "" - ), - ): cv.string, - }, - ) + # Build updated data + new_data: dict[str, Any] = { + **reconfigure_entry.data, + CONF_API_KEY: api_key, + } + + # Handle optional account credentials + email = user_input.get(CONF_EMAIL, "").strip() + password = user_input.get(CONF_PASSWORD, "").strip() + + if email and password: + # Validate account credentials if provided + try: + await validate_govee_credentials(email, password) + new_data[CONF_EMAIL] = email + new_data[CONF_PASSWORD] = password + except GoveeAuthError: + errors["base"] = "invalid_account" + # Continue to show form with error + except GoveeApiError: + errors["base"] = "cannot_connect" + elif not email and not password: + # Remove account credentials if both are empty + new_data.pop(CONF_EMAIL, None) + new_data.pop(CONF_PASSWORD, None) + + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=new_data, + ) + + except GoveeAuthError: + errors["base"] = "invalid_auth" + except GoveeApiError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during reconfigure") + errors["base"] = "unknown" + + # Pre-fill current values (except sensitive data) + current_email = reconfigure_entry.data.get(CONF_EMAIL, "") return self.async_show_form( - step_id="user", - data_schema=options_schema, + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_EMAIL, default=current_email): str, + vol.Optional(CONF_PASSWORD): str, + } + ), errors=errors, + description_placeholders={ + "current_email": current_email or "not configured", + }, ) - async def _update_options(self): - """Update config entry options.""" - return self.async_create_entry(title=DOMAIN, data=self.options) +class GoveeOptionsFlow(OptionsFlow): + """Handle options for Govee integration.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = self._config_entry.options -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_POLL_INTERVAL, + default=options.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL), + ): vol.All(vol.Coerce(int), vol.Range(min=30, max=300)), + vol.Optional( + CONF_ENABLE_GROUPS, + default=options.get(CONF_ENABLE_GROUPS, DEFAULT_ENABLE_GROUPS), + ): bool, + vol.Optional( + CONF_ENABLE_SCENES, + default=options.get(CONF_ENABLE_SCENES, DEFAULT_ENABLE_SCENES), + ): bool, + vol.Optional( + CONF_ENABLE_SEGMENTS, + default=options.get(CONF_ENABLE_SEGMENTS, DEFAULT_ENABLE_SEGMENTS), + ): bool, + } + ), + ) diff --git a/custom_components/govee/const.py b/custom_components/govee/const.py index 9b90feb2..72a23d2c 100644 --- a/custom_components/govee/const.py +++ b/custom_components/govee/const.py @@ -1,10 +1,28 @@ -"""Constants for the Govee LED strips integration.""" +"""Constants for Govee integration.""" -DOMAIN = "govee" +from typing import Final -CONF_DISABLE_ATTRIBUTE_UPDATES = "disable_attribute_updates" -CONF_OFFLINE_IS_OFF = "offline_is_off" -CONF_USE_ASSUMED_STATE = "use_assumed_state" +DOMAIN: Final = "govee" -COLOR_TEMP_KELVIN_MIN = 2000 -COLOR_TEMP_KELVIN_MAX = 9000 +# Config entry keys +CONF_API_KEY: Final = "api_key" +CONF_EMAIL: Final = "email" +CONF_PASSWORD: Final = "password" + +# Options keys +CONF_POLL_INTERVAL: Final = "poll_interval" +CONF_ENABLE_GROUPS: Final = "enable_groups" +CONF_ENABLE_SCENES: Final = "enable_scenes" +CONF_ENABLE_SEGMENTS: Final = "enable_segments" + +# Defaults +DEFAULT_POLL_INTERVAL: Final = 60 # seconds +DEFAULT_ENABLE_GROUPS: Final = False +DEFAULT_ENABLE_SCENES: Final = True +DEFAULT_ENABLE_SEGMENTS: Final = True + +# Platforms to set up +PLATFORMS: Final = ["light", "scene"] + +# Config entry version (fresh start) +CONFIG_VERSION: Final = 1 diff --git a/custom_components/govee/coordinator.py b/custom_components/govee/coordinator.py new file mode 100644 index 00000000..d7a9e63c --- /dev/null +++ b/custom_components/govee/coordinator.py @@ -0,0 +1,474 @@ +"""DataUpdateCoordinator for Govee integration. + +Manages device discovery, state polling, and MQTT integration. +Implements IStateProvider protocol for clean architecture. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import timedelta +from typing import TYPE_CHECKING, Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import ( + GoveeApiClient, + GoveeApiError, + GoveeAuthError, + GoveeAwsIotClient, + GoveeDeviceNotFoundError, + GoveeIotCredentials, + GoveeRateLimitError, +) +from .const import DOMAIN +from .models import GoveeDevice, GoveeDeviceState +from .protocols import IStateObserver +from .repairs import ( + async_create_auth_issue, + async_create_mqtt_issue, + async_create_rate_limit_issue, + async_delete_auth_issue, + async_delete_mqtt_issue, + async_delete_rate_limit_issue, +) + +if TYPE_CHECKING: + from .models.commands import DeviceCommand + +_LOGGER = logging.getLogger(__name__) + +# State fetch timeout per device +STATE_FETCH_TIMEOUT = 30 + + +class GoveeCoordinator(DataUpdateCoordinator[dict[str, GoveeDeviceState]]): + """Coordinator for Govee device state management. + + Features: + - Parallel state fetching for all devices + - MQTT integration for real-time updates + - Scene caching + - Optimistic state updates + - Group device handling + + Implements IStateProvider protocol for entities. + """ + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api_client: GoveeApiClient, + iot_credentials: GoveeIotCredentials | None, + poll_interval: int, + enable_groups: bool = False, + ) -> None: + """Initialize the coordinator. + + Args: + hass: Home Assistant instance. + config_entry: Config entry for this integration. + api_client: Govee REST API client. + iot_credentials: Optional IoT credentials for MQTT. + poll_interval: Polling interval in seconds. + enable_groups: Whether to include group devices. + """ + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=poll_interval), + ) + + self._config_entry = config_entry + self._api_client = api_client + self._iot_credentials = iot_credentials + self._enable_groups = enable_groups + + # Device registry + self._devices: dict[str, GoveeDevice] = {} + + # State cache + self._states: dict[str, GoveeDeviceState] = {} + + # Scene cache {device_id: [scenes]} + self._scene_cache: dict[str, list[dict[str, Any]]] = {} + + # Observers for state changes + self._observers: list[IStateObserver] = [] + + # MQTT client for real-time updates + self._mqtt_client: GoveeAwsIotClient | None = None + + # Track rate limit state to avoid spamming repair issues + self._rate_limited: bool = False + + @property + def devices(self) -> dict[str, GoveeDevice]: + """Get all discovered devices.""" + return self._devices + + @property + def states(self) -> dict[str, GoveeDeviceState]: + """Get current states for all devices.""" + return self._states + + def get_device(self, device_id: str) -> GoveeDevice | None: + """Get device by ID.""" + return self._devices.get(device_id) + + def get_state(self, device_id: str) -> GoveeDeviceState | None: + """Get current state for a device.""" + return self._states.get(device_id) + + def register_observer(self, observer: IStateObserver) -> None: + """Register a state change observer.""" + if observer not in self._observers: + self._observers.append(observer) + + def unregister_observer(self, observer: IStateObserver) -> None: + """Unregister a state change observer.""" + if observer in self._observers: + self._observers.remove(observer) + + def _notify_observers(self, device_id: str, state: GoveeDeviceState) -> None: + """Notify all observers of state change.""" + for observer in self._observers: + try: + observer.on_state_changed(device_id, state) + except Exception as err: + _LOGGER.warning("Observer notification failed: %s", err) + + async def async_setup(self) -> None: + """Set up the coordinator - discover devices and start MQTT. + + Should be called once during integration setup. + """ + # Discover devices + await self._discover_devices() + + # Start MQTT client if credentials available + if self._iot_credentials: + await self._start_mqtt() + + async def _discover_devices(self) -> None: + """Discover all devices from Govee API.""" + try: + devices = await self._api_client.get_devices() + + for device in devices: + _LOGGER.debug( + "Device: %s (%s) type=%s is_group=%s", + device.name, + device.device_id, + device.device_type, + device.is_group, + ) + # Log capabilities for debugging segment issues + for cap in device.capabilities: + _LOGGER.debug( + " Capability: type=%s instance=%s params=%s", + cap.type, + cap.instance, + cap.parameters, + ) + + # Filter group devices unless enabled + if device.is_group and not self._enable_groups: + _LOGGER.debug("Skipping group device: %s", device.name) + continue + + self._devices[device.device_id] = device + # Create empty state for each device + self._states[device.device_id] = GoveeDeviceState.create_empty( + device.device_id + ) + + _LOGGER.info( + "Discovered %d Govee devices (enable_groups=%s)", + len(self._devices), + self._enable_groups, + ) + + # Clear any auth issues on success + await async_delete_auth_issue(self.hass, self._config_entry) + + except GoveeAuthError as err: + # Create repair issue for auth failure + await async_create_auth_issue(self.hass, self._config_entry) + raise ConfigEntryAuthFailed("Invalid API key") from err + except GoveeApiError as err: + raise UpdateFailed(f"Failed to discover devices: {err}") from err + + async def _start_mqtt(self) -> None: + """Start MQTT client for real-time updates.""" + if not self._iot_credentials: + return + + self._mqtt_client = GoveeAwsIotClient( + credentials=self._iot_credentials, + on_state_update=self._on_mqtt_state_update, + ) + + if self._mqtt_client.available: + try: + await self._mqtt_client.async_start() + _LOGGER.info("MQTT client started for real-time updates") + # Clear any MQTT issues on success + await async_delete_mqtt_issue(self.hass, self._config_entry) + except Exception as err: + _LOGGER.warning("MQTT client failed to start: %s", err) + await async_create_mqtt_issue( + self.hass, + self._config_entry, + str(err), + ) + else: + _LOGGER.warning("MQTT library not available") + + def _on_mqtt_state_update(self, device_id: str, state_data: dict[str, Any]) -> None: + """Handle state update from MQTT. + + This is called from the MQTT client when a state message is received. + Updates internal state and notifies observers. + """ + if device_id not in self._devices: + _LOGGER.debug("MQTT update for unknown device: %s", device_id) + return + + state = self._states.get(device_id) + if state is None: + state = GoveeDeviceState.create_empty(device_id) + self._states[device_id] = state + + # Update state from MQTT data + state.update_from_mqtt(state_data) + + # Update coordinator data and notify HA + self.async_set_updated_data(self._states) + + # Notify observers + self._notify_observers(device_id, state) + + _LOGGER.debug( + "MQTT state applied for %s: power=%s", + device_id, + state.power_state, + ) + + async def _async_update_data(self) -> dict[str, GoveeDeviceState]: + """Fetch state for all devices (parallel). + + Called by DataUpdateCoordinator on poll interval. + """ + if not self._devices: + return self._states + + # Create tasks for parallel fetching + tasks = [ + self._fetch_device_state(device_id, device) + for device_id, device in self._devices.items() + ] + + # Wait for all with timeout + try: + async with asyncio.timeout(STATE_FETCH_TIMEOUT): + results = await asyncio.gather(*tasks, return_exceptions=True) + except TimeoutError: + _LOGGER.warning("State fetch timed out after %ds", STATE_FETCH_TIMEOUT) + return self._states + + # Process results + successful_updates = 0 + for device_id, result in zip(self._devices.keys(), results): + if isinstance(result, GoveeDeviceState): + self._states[device_id] = result + successful_updates += 1 + elif isinstance(result, GoveeAuthError): + await async_create_auth_issue(self.hass, self._config_entry) + raise ConfigEntryAuthFailed("Invalid API key") from result + elif isinstance(result, Exception): + _LOGGER.debug( + "Failed to fetch state for %s: %s", + device_id, + result, + ) + # Keep previous state on error + + # Clear rate limit issue if we got successful updates + if successful_updates > 0 and self._rate_limited: + self._rate_limited = False + await async_delete_rate_limit_issue(self.hass, self._config_entry) + + return self._states + + async def _fetch_device_state( + self, + device_id: str, + device: GoveeDevice, + ) -> GoveeDeviceState | Exception: + """Fetch state for a single device. + + Args: + device_id: Device identifier. + device: Device instance. + + Returns: + GoveeDeviceState or Exception on error. + """ + try: + state = await self._api_client.get_device_state(device_id, device.sku) + + # Preserve optimistic state for scenes (API doesn't return active scene) + # But clear scene if device was turned off + existing_state = self._states.get(device_id) + if existing_state and existing_state.active_scene: + if state.power_state: + # Device is still on, preserve the scene + state.active_scene = existing_state.active_scene + else: + # Device is off, clear the scene + _LOGGER.debug("Clearing scene for %s (device turned off)", device_id) + + return state + + except GoveeDeviceNotFoundError: + # Expected for group devices - use existing/optimistic state + _LOGGER.debug("State query failed for group device %s [expected]", device_id) + existing = self._states.get(device_id) + if existing: + existing.online = True # Group devices are always "available" + return existing + return GoveeDeviceState.create_empty(device_id) + + except GoveeRateLimitError as err: + _LOGGER.warning("Rate limit hit, keeping previous state") + # Create rate limit repair issue (only once) + if not self._rate_limited: + self._rate_limited = True + reset_time = "unknown" + if err.retry_after: + reset_time = f"{int(err.retry_after)} seconds" + self.hass.async_create_task( + async_create_rate_limit_issue( + self.hass, + self._config_entry, + reset_time, + ) + ) + existing = self._states.get(device_id) + return existing if existing else GoveeDeviceState.create_empty(device_id) + + except Exception as err: + return err + + async def async_control_device( + self, + device_id: str, + command: DeviceCommand, + ) -> bool: + """Send control command to device with optimistic update. + + Args: + device_id: Device identifier. + command: Command to execute. + + Returns: + True if command succeeded. + """ + device = self._devices.get(device_id) + if not device: + _LOGGER.error("Unknown device: %s", device_id) + return False + + try: + success = await self._api_client.control_device( + device_id, + device.sku, + command, + ) + + if success: + # Apply optimistic update + self._apply_optimistic_update(device_id, command) + self.async_set_updated_data(self._states) + + return success + + except GoveeAuthError as err: + raise ConfigEntryAuthFailed("Invalid API key") from err + except GoveeApiError as err: + _LOGGER.error("Control command failed: %s", err) + return False + + def _apply_optimistic_update( + self, + device_id: str, + command: DeviceCommand, + ) -> None: + """Apply optimistic state update based on command.""" + state = self._states.get(device_id) + if not state: + return + + # Import here to avoid circular dependency + from .models.commands import ( + BrightnessCommand, + ColorCommand, + ColorTempCommand, + PowerCommand, + SceneCommand, + ) + + if isinstance(command, PowerCommand): + state.apply_optimistic_power(command.power_on) + elif isinstance(command, BrightnessCommand): + state.apply_optimistic_brightness(command.brightness) + elif isinstance(command, ColorCommand): + state.apply_optimistic_color(command.color) + elif isinstance(command, ColorTempCommand): + state.apply_optimistic_color_temp(command.kelvin) + elif isinstance(command, SceneCommand): + state.apply_optimistic_scene(str(command.scene_id)) + + async def async_get_scenes( + self, + device_id: str, + refresh: bool = False, + ) -> list[dict[str, Any]]: + """Get available scenes for a device. + + Args: + device_id: Device identifier. + refresh: Force refresh from API. + + Returns: + List of scene definitions. + """ + if not refresh and device_id in self._scene_cache: + return self._scene_cache[device_id] + + device = self._devices.get(device_id) + if not device: + return [] + + try: + scenes = await self._api_client.get_dynamic_scenes(device_id, device.sku) + self._scene_cache[device_id] = scenes + return scenes + except GoveeApiError as err: + _LOGGER.warning("Failed to fetch scenes for %s: %s", device_id, err) + return self._scene_cache.get(device_id, []) + + async def async_shutdown(self) -> None: + """Shutdown coordinator and cleanup resources.""" + if self._mqtt_client: + await self._mqtt_client.async_stop() + self._mqtt_client = None + + await self._api_client.close() diff --git a/custom_components/govee/diagnostics.py b/custom_components/govee/diagnostics.py new file mode 100644 index 00000000..4917f231 --- /dev/null +++ b/custom_components/govee/diagnostics.py @@ -0,0 +1,95 @@ +"""Diagnostics support for Govee integration. + +Provides debug information for troubleshooting without exposing sensitive data. +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_API_KEY, CONF_EMAIL, CONF_PASSWORD +from .coordinator import GoveeCoordinator + +# Keys to redact from diagnostic output +TO_REDACT = { + CONF_API_KEY, + CONF_EMAIL, + CONF_PASSWORD, + "token", + "refresh_token", + "iot_cert", + "iot_key", + "iot_ca", + "client_id", + "account_topic", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: GoveeCoordinator = entry.runtime_data + + # Collect device information + devices_info = {} + for device_id, device in coordinator.devices.items(): + state = coordinator.get_state(device_id) + devices_info[device_id] = { + "sku": device.sku, + "name": device.name, + "device_type": device.device_type, + "is_group": device.is_group, + "capabilities": [ + { + "type": cap.type, + "instance": cap.instance, + } + for cap in device.capabilities + ], + "state": { + "online": state.online if state else None, + "power_state": state.power_state if state else None, + "brightness": state.brightness if state else None, + "color": state.color.as_tuple if state and state.color else None, + "color_temp_kelvin": state.color_temp_kelvin if state else None, + "source": state.source if state else None, + }, + } + + # Collect MQTT status + mqtt_info = None + if coordinator._mqtt_client: + mqtt_info = { + "available": coordinator._mqtt_client.available, + "connected": coordinator._mqtt_client.connected, + } + + # Collect API client info + api_info = { + "rate_limit_remaining": coordinator._api_client.rate_limit_remaining, + "rate_limit_total": coordinator._api_client.rate_limit_total, + "rate_limit_reset": coordinator._api_client.rate_limit_reset, + } + + # Build diagnostics data + diagnostics_data = { + "config_entry": { + "entry_id": entry.entry_id, + "version": entry.version, + "data": async_redact_data(dict(entry.data), TO_REDACT), + "options": dict(entry.options), + }, + "devices": devices_info, + "device_count": len(coordinator.devices), + "mqtt": mqtt_info, + "api": api_info, + "scene_cache_count": len(coordinator._scene_cache), + } + + return diagnostics_data diff --git a/custom_components/govee/entity.py b/custom_components/govee/entity.py new file mode 100644 index 00000000..47ea06b1 --- /dev/null +++ b/custom_components/govee/entity.py @@ -0,0 +1,122 @@ +"""Base entity class for Govee integration. + +Provides common functionality for all Govee entities: +- Device info +- Coordinator integration +- State updates +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +if TYPE_CHECKING: + from .coordinator import GoveeCoordinator + from .models import GoveeDevice, GoveeDeviceState + + +class GoveeEntity(CoordinatorEntity["GoveeCoordinator"]): + """Base class for Govee entities. + + Provides: + - Automatic coordinator integration + - Device info with rich metadata + - Availability tracking + - has_entity_name = True for Gold tier compliance + """ + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: GoveeCoordinator, + device: GoveeDevice, + ) -> None: + """Initialize the entity. + + Args: + coordinator: Govee data coordinator. + device: Device this entity represents. + """ + super().__init__(coordinator) + self._device = device + self._device_id = device.device_id + + # Set unique_id based on device + self._attr_unique_id = f"{device.device_id}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information for device registry.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device.device_id)}, + name=self._device.name, + manufacturer="Govee", + model=self._device.sku, + # Suggested area from device name (e.g., "Living Room Lamp" -> "Living Room") + suggested_area=self._infer_area_from_name(self._device.name), + ) + + @property + def available(self) -> bool: + """Return True if entity is available. + + Group devices are always considered available since we can't + query their state but can still control them. + """ + if self._device.is_group: + return True + + state = self.coordinator.get_state(self._device_id) + return state is not None and state.online + + @property + def device_state(self) -> GoveeDeviceState | None: + """Get current device state from coordinator.""" + return self.coordinator.get_state(self._device_id) + + @staticmethod + def _infer_area_from_name(name: str) -> str | None: + """Infer area from device name. + + Extracts common room names from device names like: + - "Living Room Lamp" -> "Living Room" + - "Bedroom LED Strip" -> "Bedroom" + - "Kitchen Lights" -> "Kitchen" + + Returns None if no area can be inferred. + """ + # Common area keywords (check in order) + areas = [ + "Living Room", + "Bedroom", + "Kitchen", + "Bathroom", + "Office", + "Dining Room", + "Garage", + "Basement", + "Attic", + "Hallway", + "Patio", + "Backyard", + "Front Yard", + "Game Room", + "Media Room", + "Nursery", + "Guest Room", + "Master Bedroom", + "Kids Room", + ] + + name_lower = name.lower() + for area in areas: + if area.lower() in name_lower: + return area + + return None diff --git a/custom_components/govee/govee b/custom_components/govee/govee deleted file mode 120000 index dc2b666d..00000000 --- a/custom_components/govee/govee +++ /dev/null @@ -1 +0,0 @@ -/workspaces/hacs-govee/custom_components/govee \ No newline at end of file diff --git a/custom_components/govee/learning_storage.py b/custom_components/govee/learning_storage.py deleted file mode 100644 index f33ce3aa..00000000 --- a/custom_components/govee/learning_storage.py +++ /dev/null @@ -1,66 +0,0 @@ -"""The Govee learned storage yaml file manager.""" - -from dataclasses import asdict -import logging - -import dacite -from govee_api_laggat import GoveeAbstractLearningStorage, GoveeLearnedInfo -import yaml - -from homeassistant.util.yaml import load_yaml, save_yaml - -_LOGGER = logging.getLogger(__name__) -LEARNING_STORAGE_YAML = "/govee_learning.yaml" - - -class GoveeLearningStorage(GoveeAbstractLearningStorage): - """The govee_api_laggat library uses this to store learned information about lights.""" - - def __init__(self, config_dir, *args, **kwargs): - """Get the config directory.""" - super().__init__(*args, **kwargs) - self._config_dir = config_dir - - async def read(self): - """Restore from yaml file.""" - learned_info = {} - try: - learned_dict = load_yaml(self._config_dir + LEARNING_STORAGE_YAML) - learned_info = { - device: dacite.from_dict( - data_class=GoveeLearnedInfo, data=learned_dict[device] - ) - for device in learned_dict - } - _LOGGER.info( - "Loaded learning information from %s.", - self._config_dir + LEARNING_STORAGE_YAML, - ) - except FileNotFoundError: - _LOGGER.warning( - "There is no %s file containing learned information about your devices. " - + "This is normal for first start of Govee integration.", - self._config_dir + LEARNING_STORAGE_YAML, - ) - except ( - dacite.DaciteError, - TypeError, - UnicodeDecodeError, - yaml.YAMLError, - ) as ex: - _LOGGER.warning( - "The %s file containing learned information about your devices is invalid: %s. " - + "Learning starts from scratch.", - self._config_dir + LEARNING_STORAGE_YAML, - ex, - ) - return learned_info - - async def write(self, learned_info): - """Save to yaml file.""" - leaned_dict = {device: asdict(learned_info[device]) for device in learned_info} - save_yaml(self._config_dir + LEARNING_STORAGE_YAML, leaned_dict) - _LOGGER.info( - "Stored learning information to %s.", - self._config_dir + LEARNING_STORAGE_YAML, - ) diff --git a/custom_components/govee/light.py b/custom_components/govee/light.py index 95231ec4..2c87201b 100644 --- a/custom_components/govee/light.py +++ b/custom_components/govee/light.py @@ -1,325 +1,273 @@ -"""Govee platform.""" +"""Light platform for Govee integration. -from datetime import timedelta, datetime -import logging +Provides light entities with support for: +- On/Off control +- Brightness control +- RGB color +- Color temperature +""" -from propcache import cached_property +from __future__ import annotations -from govee_api_laggat import Govee, GoveeDevice, GoveeError -from govee_api_laggat.govee_dtos import GoveeSource +import logging +from typing import Any -from homeassistant.components.light import ( +from homeassistant.components.light import ( # type: ignore[attr-defined] ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, - ATTR_HS_COLOR, + ATTR_RGB_COLOR, ColorMode, LightEntity, + LightEntityFeature, ) -from homeassistant.const import CONF_DELAY -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import color - -from .const import ( - DOMAIN, - CONF_OFFLINE_IS_OFF, - CONF_USE_ASSUMED_STATE, - COLOR_TEMP_KELVIN_MIN, - COLOR_TEMP_KELVIN_MAX, +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_ENABLE_SEGMENTS, DEFAULT_ENABLE_SEGMENTS +from .coordinator import GoveeCoordinator +from .entity import GoveeEntity +from .models import ( + BrightnessCommand, + ColorCommand, + ColorTempCommand, + GoveeDevice, + PowerCommand, + RGBColor, ) - +from .platforms.segment import GoveeSegmentEntity _LOGGER = logging.getLogger(__name__) +# Home Assistant brightness range +HA_BRIGHTNESS_MAX = 255 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Govee lights from a config entry.""" + coordinator: GoveeCoordinator = entry.runtime_data + + entities: list[LightEntity] = [] + + # Check if segments are enabled + enable_segments = entry.options.get(CONF_ENABLE_SEGMENTS, DEFAULT_ENABLE_SEGMENTS) -async def async_setup_entry(hass, entry, async_add_entities): - """Set up the Govee Light platform.""" - _LOGGER.debug("Setting up Govee lights") - config = entry.data - options = entry.options - hub = hass.data[DOMAIN]["hub"] - - # refresh - update_interval = timedelta( - seconds=options.get(CONF_DELAY, config.get(CONF_DELAY, 10)) - ) - coordinator = GoveeDataUpdateCoordinator( - hass, _LOGGER, update_interval=update_interval, config_entry=entry - ) - # Fetch initial data so we have data when entities subscribe - hub.events.new_device += lambda device: add_entity( - async_add_entities, hub, entry, coordinator, device - ) - await coordinator.async_refresh() - - # Add devices - for device in hub.devices: - add_entity(async_add_entities, hub, entry, coordinator, device) - # async_add_entities( - # [ - # GoveeLightEntity(hub, entry.title, coordinator, device) - # for device in hub.devices - # ], - # update_before_add=False, - # ) - - -def add_entity(async_add_entities, hub, entry, coordinator, device): - async_add_entities( - [GoveeLightEntity(hub, entry.title, coordinator, device)], - update_before_add=False, - ) - - -class GoveeDataUpdateCoordinator(DataUpdateCoordinator): - """Device state update handler.""" - - def __init__(self, hass, logger, update_interval=None, *, config_entry): - """Initialize global data updater.""" - self._config_entry = config_entry - - super().__init__( - hass, - logger, - name=DOMAIN, - update_interval=update_interval, - update_method=self._async_update, + for device in coordinator.devices.values(): + # Only create light entities for devices with power control + if device.supports_power: + entities.append(GoveeLightEntity(coordinator, device)) + + # Create segment entities for RGBIC devices + _LOGGER.debug( + "Segment check for %s: enable_segments=%s, supports_segments=%s, segment_count=%d", + device.name, + enable_segments, + device.supports_segments, + device.segment_count, ) + if enable_segments and device.supports_segments and device.segment_count > 0: + _LOGGER.debug( + "Creating %d segment entities for %s", + device.segment_count, + device.name, + ) + for segment_index in range(device.segment_count): + entities.append( + GoveeSegmentEntity( + coordinator=coordinator, + device=device, + segment_index=segment_index, + ) + ) - @property - def use_assumed_state(self): - """Use assumed states.""" - return self._config_entry.options.get(CONF_USE_ASSUMED_STATE, True) + async_add_entities(entities) + _LOGGER.debug("Set up %d Govee light entities", len(entities)) - @property - def config_offline_is_off(self): - """Interpret offline led's as off (global config).""" - return self._config_entry.options.get(CONF_OFFLINE_IS_OFF, False) - - async def _async_update(self): - """Fetch data.""" - self.logger.debug("_async_update") - if "govee" not in self.hass.data: - raise UpdateFailed("Govee instance not available") - try: - hub = self.hass.data[DOMAIN]["hub"] - - if not hub.online: - # when offline, check connection, this will set hub.online - await hub.check_connection() - - if hub.online: - # set global options to library - if self.config_offline_is_off: - hub.config_offline_is_off = True - else: - hub.config_offline_is_off = None # allow override in learning info - - # govee will change this to a single request in 2021 - device_states = await hub.get_states() - for device in device_states: - if device.error: - self.logger.warning( - "update failed for %s: %s", device.device, device.error - ) - return device_states - except GoveeError as ex: - raise UpdateFailed(f"Exception on getting states: {ex}") from ex +class GoveeLightEntity(GoveeEntity, LightEntity, RestoreEntity): + """Govee light entity. + + Supports: + - On/Off + - Brightness (scaled to device range) + - RGB color + - Color temperature + - State restoration for group devices + """ -class GoveeLightEntity(LightEntity): - """Representation of a stateful light entity.""" + _attr_translation_key = "govee_light" def __init__( self, - hub: Govee, - title: str, - coordinator: GoveeDataUpdateCoordinator, + coordinator: GoveeCoordinator, device: GoveeDevice, - ): - """Init a Govee light strip.""" - self._hub = hub - self._title = title - self._coordinator = coordinator - self._device = device + ) -> None: + """Initialize the light entity.""" + super().__init__(coordinator, device) - @property - def entity_registry_enabled_default(self): - """Return if the entity should be enabled when first added to the entity registry.""" - return True + # Set name (uses has_entity_name = True) + self._attr_name = None # Use device name - async def async_added_to_hass(self): - """Connect to dispatcher listening for entity data notifications.""" - self._coordinator.async_add_listener(self.async_write_ha_state) + # Determine supported color modes + self._attr_supported_color_modes = self._determine_color_modes() + self._attr_color_mode = self._get_current_color_mode() - @property - def _state(self): - """Lights internal state.""" - return self._device # self._hub.state(self._device) - - @cached_property - def supported_color_modes(self) -> set[ColorMode]: - """Get supported color modes.""" - color_mode = set() - if self._device.support_color: - color_mode.add(ColorMode.HS) - if self._device.support_color_tem: - color_mode.add(ColorMode.COLOR_TEMP) - if not color_mode: - # brightness or on/off must be the only supported mode - if self._device.support_brightness: - color_mode.add(ColorMode.BRIGHTNESS) - else: - color_mode.add(ColorMode.ONOFF) - return color_mode - - async def async_turn_on(self, **kwargs): - """Turn device on.""" - _LOGGER.debug( - "async_turn_on for Govee light %s, kwargs: %s", self._device.device, kwargs - ) - err = None - - just_turn_on = True - if ATTR_HS_COLOR in kwargs: - hs_color = kwargs.pop(ATTR_HS_COLOR) - just_turn_on = False - col = color.color_hs_to_RGB(hs_color[0], hs_color[1]) - _, err = await self._hub.set_color(self._device, col) - if ATTR_BRIGHTNESS in kwargs: - brightness = kwargs.pop(ATTR_BRIGHTNESS) - just_turn_on = False - bright_set = brightness - 1 - _, err = await self._hub.set_brightness(self._device, bright_set) - if ATTR_COLOR_TEMP_KELVIN in kwargs: - color_temp = kwargs.pop(ATTR_COLOR_TEMP_KELVIN) - just_turn_on = False - if color_temp > COLOR_TEMP_KELVIN_MAX: - color_temp = COLOR_TEMP_KELVIN_MAX - elif color_temp < COLOR_TEMP_KELVIN_MIN: - color_temp = COLOR_TEMP_KELVIN_MIN - _, err = await self._hub.set_color_temp(self._device, color_temp) - - # if there is no known specific command - turn on - if just_turn_on: - _, err = await self._hub.turn_on(self._device) - # debug log unknown commands - if kwargs: - _LOGGER.debug( - "async_turn_on doesnt know how to handle kwargs: %s", repr(kwargs) - ) - # warn on any error - if err: - _LOGGER.warning( - "async_turn_on failed with '%s' for %s, kwargs: %s", - err, - self._device.device, - kwargs, - ) + # Get device brightness range + self._brightness_min, self._brightness_max = device.brightness_range - async def async_turn_off(self, **kwargs): - """Turn device off.""" - _LOGGER.debug("async_turn_off for Govee light %s", self._device.device) - await self._hub.turn_off(self._device) + # Add effect support if device has scenes + if device.supports_scenes: + self._attr_supported_features = LightEntityFeature.EFFECT - @property - def unique_id(self): - """Return the unique ID.""" - return f"govee_{self._title}_{self._device.device}" + def _determine_color_modes(self) -> set[ColorMode]: + """Determine supported color modes from device capabilities.""" + modes: set[ColorMode] = set() - @property - def device_id(self): - """Return the ID.""" - return self.unique_id + if self._device.supports_rgb: + modes.add(ColorMode.RGB) - @property - def name(self): - """Return the name.""" - return self._device.device_name + if self._device.supports_color_temp: + modes.add(ColorMode.COLOR_TEMP) - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self.device_id)}, - "name": self.name, - "manufacturer": "Govee", - "model": self._device.model, - } + if not modes and self._device.supports_brightness: + modes.add(ColorMode.BRIGHTNESS) - @property - def is_on(self): - """Return true if device is on.""" - return self._device.power_state + if not modes: + modes.add(ColorMode.ONOFF) - @property - def assumed_state(self): - """ - Return true if the state is assumed. - - This can be disabled in options. - """ - return ( - self._coordinator.use_assumed_state - and self._device.source == GoveeSource.HISTORY - ) + return modes - @property - def available(self): - """Return if light is available.""" - return self._device.online + def _get_current_color_mode(self) -> ColorMode: + """Get current color mode based on state.""" + state = self.device_state + modes = self._attr_supported_color_modes or set() - @property - def hs_color(self): - """Return the hs color value.""" - return color.color_RGB_to_hs( - self._device.color[0], - self._device.color[1], - self._device.color[2], - ) + if state and state.color_temp_kelvin is not None: + if ColorMode.COLOR_TEMP in modes: + return ColorMode.COLOR_TEMP + + if state and state.color is not None: + if ColorMode.RGB in modes: + return ColorMode.RGB + + if ColorMode.BRIGHTNESS in modes: + return ColorMode.BRIGHTNESS + + return ColorMode.ONOFF @property - def rgb_color(self): - """Return the rgb color value.""" - return [ - self._device.color[0], - self._device.color[1], - self._device.color[2], - ] + def is_on(self) -> bool | None: + """Return True if light is on.""" + state = self.device_state + return state.power_state if state else None @property - def brightness(self): - """Return the brightness value.""" - # govee is reporting 0 to 254 - home assistant uses 1 to 255 - return self._device.brightness + 1 + def brightness(self) -> int | None: + """Return brightness (0-255).""" + state = self.device_state + if state is None: + return None + + # Convert device brightness to HA scale + return self._device_to_ha_brightness(state.brightness) @property - def color_temp(self): - """Return the color_temp of the light.""" - return self._device.color_temp + def rgb_color(self) -> tuple[int, int, int] | None: + """Return RGB color as (r, g, b) tuple.""" + state = self.device_state + if state and state.color: + return state.color.as_tuple + return None @property - def min_color_temp_kelvin(self): - """Return the coldest color_temp that this light supports.""" - return COLOR_TEMP_KELVIN_MAX + def color_temp_kelvin(self) -> int | None: + """Return color temperature in Kelvin.""" + state = self.device_state + return state.color_temp_kelvin if state else None @property - def max_color_temp_kelvin(self): - """Return the warmest color_temp that this light supports.""" - return COLOR_TEMP_KELVIN_MIN + def min_color_temp_kelvin(self) -> int: + """Return minimum color temperature in Kelvin.""" + temp_range = self._device.color_temp_range + return temp_range.min_kelvin if temp_range else 2000 @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return { - # rate limiting information on Govee API - "rate_limit_total": self._hub.rate_limit_total, - "rate_limit_remaining": self._hub.rate_limit_remaining, - "rate_limit_reset_seconds": round(self._hub.rate_limit_reset_seconds, 2), - "rate_limit_reset": datetime.fromtimestamp( - self._hub.rate_limit_reset - ).isoformat(), - "rate_limit_on": self._hub.rate_limit_on, - # general information - "manufacturer": "Govee", - "model": self._device.model, - } + def max_color_temp_kelvin(self) -> int: + """Return maximum color temperature in Kelvin.""" + temp_range = self._device.color_temp_range + return temp_range.max_kelvin if temp_range else 9000 + + def _ha_to_device_brightness(self, ha_brightness: int) -> int: + """Convert HA brightness (0-255) to device range.""" + return int(ha_brightness / HA_BRIGHTNESS_MAX * self._brightness_max) + + def _device_to_ha_brightness(self, device_brightness: int) -> int: + """Convert device brightness to HA range (0-255).""" + return int(device_brightness / self._brightness_max * HA_BRIGHTNESS_MAX) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on with optional parameters.""" + # Handle brightness + if ATTR_BRIGHTNESS in kwargs: + ha_brightness = kwargs[ATTR_BRIGHTNESS] + device_brightness = self._ha_to_device_brightness(ha_brightness) + await self.coordinator.async_control_device( + self._device_id, + BrightnessCommand(brightness=device_brightness), + ) + + # Handle RGB color + if ATTR_RGB_COLOR in kwargs: + r, g, b = kwargs[ATTR_RGB_COLOR] + color = RGBColor(r=r, g=g, b=b) + await self.coordinator.async_control_device( + self._device_id, + ColorCommand(color=color), + ) + self._attr_color_mode = ColorMode.RGB + + # Handle color temperature + if ATTR_COLOR_TEMP_KELVIN in kwargs: + kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] + await self.coordinator.async_control_device( + self._device_id, + ColorTempCommand(kelvin=kelvin), + ) + self._attr_color_mode = ColorMode.COLOR_TEMP + + # Always send power on + await self.coordinator.async_control_device( + self._device_id, + PowerCommand(power_on=True), + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.coordinator.async_control_device( + self._device_id, + PowerCommand(power_on=False), + ) + + async def async_added_to_hass(self) -> None: + """Restore state for group devices.""" + await super().async_added_to_hass() + + if self._device.is_group: + last_state = await self.async_get_last_state() + if last_state: + # Restore power state + state = self.device_state + if state: + state.power_state = last_state.state == "on" + + # Restore brightness + if last_state.attributes.get("brightness"): + device_brightness = self._ha_to_device_brightness( + last_state.attributes["brightness"] + ) + state.brightness = device_brightness diff --git a/custom_components/govee/manifest.json b/custom_components/govee/manifest.json index dd3ee4f3..3fd69076 100644 --- a/custom_components/govee/manifest.json +++ b/custom_components/govee/manifest.json @@ -1,15 +1,20 @@ { "domain": "govee", "name": "Govee", - "codeowners": ["@LaggAt"], + "codeowners": ["@lasswellt"], "config_flow": true, "dependencies": [], - "documentation": "https://github.com/LaggAt/hacs-govee/blob/master/README.md", + "documentation": "https://github.com/lasswellt/hacs-govee/blob/master/README.md", "homekit": {}, - "iot_class": "cloud_polling", - "issue_tracker": "https://github.com/LaggAt/hacs-govee/issues", - "requirements": ["govee-api-laggat==0.2.2", "dacite==1.8.0"], + "integration_type": "hub", + "iot_class": "cloud_push", + "issue_tracker": "https://github.com/lasswellt/hacs-govee/issues", + "requirements": [ + "aiohttp-retry>=2.8.3", + "aiomqtt>=2.0.0", + "cryptography>=41.0.0" + ], "ssdp": [], - "version": "2025.1.1", + "version": "2026.01.38", "zeroconf": [] } diff --git a/custom_components/govee/models/__init__.py b/custom_components/govee/models/__init__.py new file mode 100644 index 00000000..3ce76ba4 --- /dev/null +++ b/custom_components/govee/models/__init__.py @@ -0,0 +1,45 @@ +"""Domain models for Govee integration. + +All models are frozen dataclasses for immutability. +""" + +from .commands import ( + BrightnessCommand, + ColorCommand, + ColorTempCommand, + DeviceCommand, + PowerCommand, + SceneCommand, + SegmentColorCommand, + ToggleCommand, + create_night_light_command, +) +from .device import ( + ColorTempRange, + GoveeCapability, + GoveeDevice, + SegmentCapability, +) +from .state import GoveeDeviceState, RGBColor, SegmentState + +__all__ = [ + # Device + "GoveeDevice", + "GoveeCapability", + "ColorTempRange", + "SegmentCapability", + # State + "GoveeDeviceState", + "RGBColor", + "SegmentState", + # Commands + "DeviceCommand", + "PowerCommand", + "BrightnessCommand", + "ColorCommand", + "ColorTempCommand", + "SceneCommand", + "SegmentColorCommand", + "ToggleCommand", + "create_night_light_command", +] diff --git a/custom_components/govee/models/commands.py b/custom_components/govee/models/commands.py new file mode 100644 index 00000000..646c6dbb --- /dev/null +++ b/custom_components/govee/models/commands.py @@ -0,0 +1,207 @@ +"""Command pattern models for device control. + +Each command encapsulates a single control action and knows how to +serialize itself for the Govee API. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + +from .device import ( + CAPABILITY_COLOR_SETTING, + CAPABILITY_DYNAMIC_SCENE, + CAPABILITY_ON_OFF, + CAPABILITY_RANGE, + CAPABILITY_SEGMENT_COLOR, + CAPABILITY_TOGGLE, + INSTANCE_BRIGHTNESS, + INSTANCE_COLOR_RGB, + INSTANCE_COLOR_TEMP, + INSTANCE_NIGHT_LIGHT, + INSTANCE_POWER, + INSTANCE_SCENE, + INSTANCE_SEGMENT_COLOR, +) +from .state import RGBColor + + +@dataclass(frozen=True) +class DeviceCommand(ABC): + """Base class for device commands. + + Commands are immutable value objects that know how to serialize + themselves for the Govee API. + """ + + @property + @abstractmethod + def capability_type(self) -> str: + """Get the capability type for this command.""" + ... + + @property + @abstractmethod + def instance(self) -> str: + """Get the instance for this command.""" + ... + + @abstractmethod + def get_value(self) -> Any: + """Get the value to send to the API.""" + ... + + def to_api_payload(self) -> dict[str, Any]: + """Convert to Govee API command payload. + + Returns: + Dict matching Govee API v2.0 /device/control format. + """ + return { + "type": self.capability_type, + "instance": self.instance, + "value": self.get_value(), + } + + +@dataclass(frozen=True) +class PowerCommand(DeviceCommand): + """Command to turn device on or off.""" + + power_on: bool + + @property + def capability_type(self) -> str: + return CAPABILITY_ON_OFF + + @property + def instance(self) -> str: + return INSTANCE_POWER + + def get_value(self) -> int: + return 1 if self.power_on else 0 + + +@dataclass(frozen=True) +class BrightnessCommand(DeviceCommand): + """Command to set device brightness.""" + + brightness: int # Device-scale value (typically 0-100 or 0-254) + + @property + def capability_type(self) -> str: + return CAPABILITY_RANGE + + @property + def instance(self) -> str: + return INSTANCE_BRIGHTNESS + + def get_value(self) -> int: + return self.brightness + + +@dataclass(frozen=True) +class ColorCommand(DeviceCommand): + """Command to set device RGB color.""" + + color: RGBColor + + @property + def capability_type(self) -> str: + return CAPABILITY_COLOR_SETTING + + @property + def instance(self) -> str: + return INSTANCE_COLOR_RGB + + def get_value(self) -> int: + """Return packed RGB integer.""" + return self.color.as_packed_int + + +@dataclass(frozen=True) +class ColorTempCommand(DeviceCommand): + """Command to set device color temperature.""" + + kelvin: int + + @property + def capability_type(self) -> str: + return CAPABILITY_COLOR_SETTING + + @property + def instance(self) -> str: + return INSTANCE_COLOR_TEMP + + def get_value(self) -> int: + return self.kelvin + + +@dataclass(frozen=True) +class SceneCommand(DeviceCommand): + """Command to activate a scene.""" + + scene_id: int + scene_name: str = "" + + @property + def capability_type(self) -> str: + return CAPABILITY_DYNAMIC_SCENE + + @property + def instance(self) -> str: + return INSTANCE_SCENE + + def get_value(self) -> dict[str, Any]: + return { + "id": self.scene_id, + "name": self.scene_name, + } + + +@dataclass(frozen=True) +class SegmentColorCommand(DeviceCommand): + """Command to set color for specific segments.""" + + segment_indices: tuple[int, ...] + color: RGBColor + + @property + def capability_type(self) -> str: + return CAPABILITY_SEGMENT_COLOR + + @property + def instance(self) -> str: + return INSTANCE_SEGMENT_COLOR + + def get_value(self) -> dict[str, Any]: + return { + "segment": list(self.segment_indices), + "rgb": self.color.as_packed_int, + } + + +@dataclass(frozen=True) +class ToggleCommand(DeviceCommand): + """Command to toggle a feature (night light, gradual on, etc).""" + + toggle_instance: str + enabled: bool + + @property + def capability_type(self) -> str: + return CAPABILITY_TOGGLE + + @property + def instance(self) -> str: + return self.toggle_instance + + def get_value(self) -> int: + return 1 if self.enabled else 0 + + +def create_night_light_command(enabled: bool) -> ToggleCommand: + """Create a command to toggle night light mode.""" + return ToggleCommand(toggle_instance=INSTANCE_NIGHT_LIGHT, enabled=enabled) diff --git a/custom_components/govee/models/device.py b/custom_components/govee/models/device.py new file mode 100644 index 00000000..31ba79c1 --- /dev/null +++ b/custom_components/govee/models/device.py @@ -0,0 +1,294 @@ +"""Device model representing a Govee device and its capabilities. + +Frozen dataclass for immutability - device properties don't change at runtime. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +# Capability type constants (from Govee API v2.0) +CAPABILITY_ON_OFF = "devices.capabilities.on_off" +CAPABILITY_RANGE = "devices.capabilities.range" +CAPABILITY_COLOR_SETTING = "devices.capabilities.color_setting" +CAPABILITY_SEGMENT_COLOR = "devices.capabilities.segment_color_setting" +CAPABILITY_DYNAMIC_SCENE = "devices.capabilities.dynamic_scene" +CAPABILITY_DIY_SCENE = "devices.capabilities.diy_scene" +CAPABILITY_MUSIC_MODE = "devices.capabilities.music_setting" +CAPABILITY_TOGGLE = "devices.capabilities.toggle" +CAPABILITY_WORK_MODE = "devices.capabilities.work_mode" +CAPABILITY_PROPERTY = "devices.capabilities.property" + +# Device type constants +DEVICE_TYPE_LIGHT = "devices.types.light" +DEVICE_TYPE_PLUG = "devices.types.socket" +DEVICE_TYPE_HEATER = "devices.types.heater" +DEVICE_TYPE_HUMIDIFIER = "devices.types.humidifier" + +# Instance constants +INSTANCE_POWER = "powerSwitch" +INSTANCE_BRIGHTNESS = "brightness" +INSTANCE_COLOR_RGB = "colorRgb" +INSTANCE_COLOR_TEMP = "colorTemperatureK" +INSTANCE_SEGMENT_COLOR = "segmentedColorRgb" +INSTANCE_SCENE = "lightScene" +INSTANCE_DIY = "diyScene" +INSTANCE_NIGHT_LIGHT = "nightlightToggle" +INSTANCE_GRADUAL_ON = "gradientToggle" +INSTANCE_TIMER = "timer" + + +@dataclass(frozen=True) +class ColorTempRange: + """Color temperature range in Kelvin.""" + + min_kelvin: int + max_kelvin: int + + @classmethod + def from_capability(cls, capability: dict[str, Any]) -> ColorTempRange | None: + """Parse from capability parameters.""" + params = capability.get("parameters", {}) + range_data = params.get("range", {}) + min_k = range_data.get("min") + max_k = range_data.get("max") + if min_k is not None and max_k is not None: + return cls(min_kelvin=int(min_k), max_kelvin=int(max_k)) + return None + + +@dataclass(frozen=True) +class SegmentCapability: + """Segment control capability for RGBIC devices.""" + + segment_count: int + + @classmethod + def from_capability(cls, capability: dict[str, Any]) -> SegmentCapability | None: + """Parse from capability parameters. + + The segment count can be found in different places: + 1. Direct 'segmentCount' parameter + 2. In fields[].elementRange.max + 1 (0-based index) + 3. In fields[].size.max (max array size) + """ + params = capability.get("parameters", {}) + + # Try direct segmentCount parameter + count = params.get("segmentCount", 0) + + if not count: + # Try to get from fields array structure + fields = params.get("fields", []) + for f in fields: + if f.get("fieldName") == "segment": + # Check elementRange (0-based max index) + element_range = f.get("elementRange", {}) + if "max" in element_range: + count = element_range["max"] + 1 # Convert to count + break + # Fallback to size.max + size = f.get("size", {}) + if "max" in size: + count = size["max"] + break + + return cls(segment_count=count) if count else None + + +@dataclass(frozen=True) +class GoveeCapability: + """Represents a device capability from Govee API.""" + + type: str + instance: str + parameters: dict[str, Any] = field(default_factory=dict) + + @property + def is_power(self) -> bool: + """Check if this is a power on/off capability.""" + return self.type == CAPABILITY_ON_OFF and self.instance == INSTANCE_POWER + + @property + def is_brightness(self) -> bool: + """Check if this is a brightness capability.""" + return self.type == CAPABILITY_RANGE and self.instance == INSTANCE_BRIGHTNESS + + @property + def is_color_rgb(self) -> bool: + """Check if this is an RGB color capability.""" + return self.type == CAPABILITY_COLOR_SETTING and self.instance == INSTANCE_COLOR_RGB + + @property + def is_color_temp(self) -> bool: + """Check if this is a color temperature capability.""" + return self.type == CAPABILITY_COLOR_SETTING and self.instance == INSTANCE_COLOR_TEMP + + @property + def is_segment_color(self) -> bool: + """Check if this is a segment color capability.""" + return self.type == CAPABILITY_SEGMENT_COLOR + + @property + def is_scene(self) -> bool: + """Check if this is a scene capability.""" + return self.type == CAPABILITY_DYNAMIC_SCENE + + @property + def is_toggle(self) -> bool: + """Check if this is a toggle capability.""" + return self.type == CAPABILITY_TOGGLE + + @property + def is_night_light(self) -> bool: + """Check if this is a night light toggle.""" + return self.type == CAPABILITY_TOGGLE and self.instance == INSTANCE_NIGHT_LIGHT + + @property + def brightness_range(self) -> tuple[int, int]: + """Get brightness min/max range. Default (0, 100).""" + if not self.is_brightness: + return (0, 100) + range_data = self.parameters.get("range", {}) + return ( + int(range_data.get("min", 0)), + int(range_data.get("max", 100)), + ) + + +@dataclass(frozen=True) +class GoveeDevice: + """Represents a Govee device with its static properties. + + Frozen for immutability - device capabilities don't change at runtime. + """ + + device_id: str + sku: str + name: str + device_type: str + capabilities: tuple[GoveeCapability, ...] = field(default_factory=tuple) + is_group: bool = False + + @property + def supports_power(self) -> bool: + """Check if device supports on/off control.""" + return any(cap.is_power for cap in self.capabilities) + + @property + def supports_brightness(self) -> bool: + """Check if device supports brightness control.""" + return any(cap.is_brightness for cap in self.capabilities) + + @property + def supports_rgb(self) -> bool: + """Check if device supports RGB color.""" + return any(cap.is_color_rgb for cap in self.capabilities) + + @property + def supports_color_temp(self) -> bool: + """Check if device supports color temperature.""" + return any(cap.is_color_temp for cap in self.capabilities) + + @property + def supports_segments(self) -> bool: + """Check if device supports segment control (RGBIC).""" + return any(cap.is_segment_color for cap in self.capabilities) + + @property + def supports_scenes(self) -> bool: + """Check if device supports dynamic scenes.""" + return any(cap.is_scene for cap in self.capabilities) + + @property + def supports_night_light(self) -> bool: + """Check if device supports night light toggle.""" + return any(cap.is_night_light for cap in self.capabilities) + + @property + def is_plug(self) -> bool: + """Check if device is a smart plug.""" + return self.device_type == DEVICE_TYPE_PLUG + + @property + def is_light_device(self) -> bool: + """Check if device is a light (not a plug or other appliance).""" + return self.device_type == DEVICE_TYPE_LIGHT or self.supports_rgb or self.supports_color_temp + + @property + def brightness_range(self) -> tuple[int, int]: + """Get brightness range from capability. Default (0, 100).""" + for cap in self.capabilities: + if cap.is_brightness: + return cap.brightness_range + return (0, 100) + + @property + def color_temp_range(self) -> ColorTempRange | None: + """Get color temperature range if supported.""" + for cap in self.capabilities: + if cap.is_color_temp: + return ColorTempRange.from_capability({"parameters": cap.parameters}) + return None + + @property + def segment_count(self) -> int: + """Get number of segments for RGBIC devices.""" + for cap in self.capabilities: + if cap.is_segment_color: + seg = SegmentCapability.from_capability({"parameters": cap.parameters}) + return seg.segment_count if seg else 0 + return 0 + + def get_capability(self, cap_type: str, instance: str) -> GoveeCapability | None: + """Get a specific capability by type and instance.""" + for cap in self.capabilities: + if cap.type == cap_type and cap.instance == instance: + return cap + return None + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> GoveeDevice: + """Create GoveeDevice from API response data. + + Args: + data: Device dict from /user/devices endpoint. + + Returns: + GoveeDevice instance. + """ + device_id = data.get("device", "") + sku = data.get("sku", "") + name = data.get("deviceName", sku) + device_type = data.get("type", "devices.types.light") + + # Check for group device types + # Groups can be identified by: + # 1. Explicit group device types + # 2. Numeric-only device IDs (no colons like MAC addresses) + is_group = device_type in ( + "devices.types.group", + "devices.types.same_mode_group", + "devices.types.scenic_group", + ) or (device_id.isdigit()) + + # Parse capabilities + raw_caps = data.get("capabilities", []) + capabilities = [] + for raw_cap in raw_caps: + cap = GoveeCapability( + type=raw_cap.get("type", ""), + instance=raw_cap.get("instance", ""), + parameters=raw_cap.get("parameters", {}), + ) + capabilities.append(cap) + + return cls( + device_id=device_id, + sku=sku, + name=name, + device_type=device_type, + capabilities=tuple(capabilities), + is_group=is_group, + ) diff --git a/custom_components/govee/models/state.py b/custom_components/govee/models/state.py new file mode 100644 index 00000000..d9aeff47 --- /dev/null +++ b/custom_components/govee/models/state.py @@ -0,0 +1,188 @@ +"""Device state models. + +Mutable state that changes with device updates from API or MQTT. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class RGBColor: + """Immutable RGB color representation.""" + + r: int + g: int + b: int + + def __post_init__(self) -> None: + """Validate color values are in range.""" + # Use object.__setattr__ because dataclass is frozen + object.__setattr__(self, "r", max(0, min(255, self.r))) + object.__setattr__(self, "g", max(0, min(255, self.g))) + object.__setattr__(self, "b", max(0, min(255, self.b))) + + @property + def as_tuple(self) -> tuple[int, int, int]: + """Return as (r, g, b) tuple.""" + return (self.r, self.g, self.b) + + @property + def as_packed_int(self) -> int: + """Return as packed integer for Govee API: (R << 16) + (G << 8) + B.""" + return (self.r << 16) + (self.g << 8) + self.b + + @classmethod + def from_packed_int(cls, value: int) -> RGBColor: + """Create from Govee API packed integer.""" + r = (value >> 16) & 0xFF + g = (value >> 8) & 0xFF + b = value & 0xFF + return cls(r=r, g=g, b=b) + + @classmethod + def from_dict(cls, data: dict[str, int]) -> RGBColor: + """Create from dict with r, g, b keys.""" + return cls( + r=data.get("r", 0), + g=data.get("g", 0), + b=data.get("b", 0), + ) + + +@dataclass(frozen=True) +class SegmentState: + """State of a single segment in RGBIC device.""" + + index: int + color: RGBColor + brightness: int = 100 + + @classmethod + def from_dict(cls, data: dict[str, Any], index: int) -> SegmentState: + """Create from segment dict.""" + color = RGBColor.from_dict(data.get("color", {})) + brightness = data.get("brightness", 100) + return cls(index=index, color=color, brightness=brightness) + + +@dataclass +class GoveeDeviceState: + """Mutable device state updated from API or MQTT. + + Unlike GoveeDevice (frozen), state changes frequently and needs + to be updated in-place for performance. + """ + + device_id: str + online: bool = True + power_state: bool = False + brightness: int = 100 + color: RGBColor | None = None + color_temp_kelvin: int | None = None + active_scene: str | None = None + segments: list[SegmentState] = field(default_factory=list) + + # Source tracking for state management + # "api" = from REST poll, "mqtt" = from push, "optimistic" = from command + source: str = "api" + + def update_from_api(self, data: dict[str, Any]) -> None: + """Update state from API response. + + Args: + data: Device state dict from /device/state endpoint. + """ + self.source = "api" + + # Parse capabilities array for state values + capabilities = data.get("capabilities", []) + for cap in capabilities: + cap_type = cap.get("type", "") + instance = cap.get("instance", "") + state = cap.get("state", {}) + value = state.get("value") + + if cap_type == "devices.capabilities.online": + self.online = bool(value) + + elif cap_type == "devices.capabilities.on_off": + if instance == "powerSwitch": + self.power_state = bool(value) + + elif cap_type == "devices.capabilities.range": + if instance == "brightness": + self.brightness = int(value) if value is not None else 100 + + elif cap_type == "devices.capabilities.color_setting": + if instance == "colorRgb": + if isinstance(value, int): + self.color = RGBColor.from_packed_int(value) + elif isinstance(value, dict): + self.color = RGBColor.from_dict(value) + elif instance == "colorTemperatureK": + self.color_temp_kelvin = int(value) if value is not None else None + + def update_from_mqtt(self, data: dict[str, Any]) -> None: + """Update state from MQTT push message. + + MQTT format differs from REST API - uses onOff/brightness/color keys. + + Args: + data: State dict from MQTT message. + """ + self.source = "mqtt" + + if "onOff" in data: + self.power_state = bool(data["onOff"]) + + if "brightness" in data: + self.brightness = int(data["brightness"]) + + if "color" in data: + color_data = data["color"] + if isinstance(color_data, dict): + self.color = RGBColor.from_dict(color_data) + elif isinstance(color_data, int): + self.color = RGBColor.from_packed_int(color_data) + + if "colorTemInKelvin" in data: + temp = data["colorTemInKelvin"] + self.color_temp_kelvin = int(temp) if temp else None + + def apply_optimistic_power(self, power_on: bool) -> None: + """Apply optimistic power state update.""" + self.power_state = power_on + self.source = "optimistic" + # Clear scene when turning off (scene is no longer active) + if not power_on: + self.active_scene = None + + def apply_optimistic_brightness(self, brightness: int) -> None: + """Apply optimistic brightness update.""" + self.brightness = brightness + self.source = "optimistic" + + def apply_optimistic_color(self, color: RGBColor) -> None: + """Apply optimistic color update.""" + self.color = color + self.color_temp_kelvin = None # RGB mode + self.source = "optimistic" + + def apply_optimistic_color_temp(self, kelvin: int) -> None: + """Apply optimistic color temperature update.""" + self.color_temp_kelvin = kelvin + self.color = None # Color temp mode + self.source = "optimistic" + + def apply_optimistic_scene(self, scene_id: str) -> None: + """Apply optimistic scene activation.""" + self.active_scene = scene_id + self.source = "optimistic" + + @classmethod + def create_empty(cls, device_id: str) -> GoveeDeviceState: + """Create empty state for a device.""" + return cls(device_id=device_id) diff --git a/custom_components/govee/platforms/__init__.py b/custom_components/govee/platforms/__init__.py new file mode 100644 index 00000000..5772f0c6 --- /dev/null +++ b/custom_components/govee/platforms/__init__.py @@ -0,0 +1 @@ +"""Platform implementations for Govee integration.""" diff --git a/custom_components/govee/platforms/segment.py b/custom_components/govee/platforms/segment.py new file mode 100644 index 00000000..e842b998 --- /dev/null +++ b/custom_components/govee/platforms/segment.py @@ -0,0 +1,202 @@ +"""Segment light entities for RGBIC devices. + +Each segment of an RGBIC LED strip is exposed as a separate light entity, +following the WLED pattern for segment control. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.light import ( # type: ignore[attr-defined] + ATTR_BRIGHTNESS, + ATTR_RGB_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo # type: ignore[attr-defined] +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from ..const import CONF_ENABLE_SEGMENTS, DEFAULT_ENABLE_SEGMENTS, DOMAIN +from ..coordinator import GoveeCoordinator +from ..models import GoveeDevice, RGBColor, SegmentColorCommand + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Govee segment lights from a config entry.""" + coordinator: GoveeCoordinator = entry.runtime_data + + # Check if segments are enabled + if not entry.options.get(CONF_ENABLE_SEGMENTS, DEFAULT_ENABLE_SEGMENTS): + _LOGGER.debug("Segment entities disabled") + return + + entities: list[LightEntity] = [] + + for device in coordinator.devices.values(): + if device.supports_segments and device.segment_count > 0: + # Create entity for each segment + for segment_index in range(device.segment_count): + entities.append( + GoveeSegmentEntity( + coordinator=coordinator, + device=device, + segment_index=segment_index, + ) + ) + + async_add_entities(entities) + _LOGGER.debug("Set up %d Govee segment entities", len(entities)) + + +class GoveeSegmentEntity(LightEntity, RestoreEntity): + """Govee segment light entity. + + Represents a single segment of an RGBIC LED strip. + + API Limitation: Govee API returns empty strings for segment colors. + We use purely optimistic/local state that persists via RestoreEntity. + This entity intentionally does NOT subscribe to coordinator updates + to prevent API responses from overwriting local state. + """ + + _attr_has_entity_name = True + _attr_translation_key = "govee_segment" + _attr_supported_color_modes = {ColorMode.RGB} + _attr_color_mode = ColorMode.RGB + + def __init__( + self, + coordinator: GoveeCoordinator, + device: GoveeDevice, + segment_index: int, + ) -> None: + """Initialize the segment entity. + + Args: + coordinator: Govee data coordinator. + device: Device this segment belongs to. + segment_index: Zero-based segment index. + """ + self._coordinator = coordinator + self._device = device + self._device_id = device.device_id + self._segment_index = segment_index + + # Unique ID combines device and segment + self._attr_unique_id = f"{device.device_id}_segment_{segment_index}" + + # Segment name with 1-based index for user display + self._attr_name = f"Segment {segment_index + 1}" + + # Translation placeholders + self._attr_translation_placeholders = { + "device_name": device.name, + "segment_index": str(segment_index + 1), + } + + # Optimistic state (API doesn't return per-segment state) + self._is_on = True + self._brightness = 255 + self._rgb_color: tuple[int, int, int] = (255, 255, 255) + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device.device_id)}, + name=self._device.name, + manufacturer="Govee", + model=self._device.sku, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + # Check parent device availability + state = self._coordinator.get_state(self._device_id) + if state is None: + return False + return state.online or self._device.is_group + + @property + def is_on(self) -> bool: + """Return True if segment is on.""" + return self._is_on + + @property + def brightness(self) -> int: + """Return brightness (0-255).""" + return self._brightness + + @property + def rgb_color(self) -> tuple[int, int, int]: + """Return RGB color.""" + return self._rgb_color + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the segment on with optional parameters.""" + # Update brightness if provided + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + + # Update color if provided + if ATTR_RGB_COLOR in kwargs: + self._rgb_color = kwargs[ATTR_RGB_COLOR] + + # Create segment color command + r, g, b = self._rgb_color + color = RGBColor(r=r, g=g, b=b) + + command = SegmentColorCommand( + segment_indices=(self._segment_index,), + color=color, + ) + + await self._coordinator.async_control_device( + self._device_id, + command, + ) + + self._is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the segment off (set to black).""" + # Set segment to black + command = SegmentColorCommand( + segment_indices=(self._segment_index,), + color=RGBColor(r=0, g=0, b=0), + ) + + await self._coordinator.async_control_device( + self._device_id, + command, + ) + + self._is_on = False + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Restore previous state.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if last_state: + self._is_on = last_state.state == "on" + + if last_state.attributes.get("brightness"): + self._brightness = last_state.attributes["brightness"] + + if last_state.attributes.get("rgb_color"): + self._rgb_color = tuple(last_state.attributes["rgb_color"]) diff --git a/custom_components/govee/protocols/__init__.py b/custom_components/govee/protocols/__init__.py new file mode 100644 index 00000000..40b30b5e --- /dev/null +++ b/custom_components/govee/protocols/__init__.py @@ -0,0 +1,14 @@ +"""Protocol interfaces for Govee integration. + +Defines contracts between layers following Hexagonal/Clean Architecture. +""" + +from .api import IApiClient, IAuthProvider +from .state import IStateProvider, IStateObserver + +__all__ = [ + "IApiClient", + "IAuthProvider", + "IStateProvider", + "IStateObserver", +] diff --git a/custom_components/govee/protocols/api.py b/custom_components/govee/protocols/api.py new file mode 100644 index 00000000..c5b354a7 --- /dev/null +++ b/custom_components/govee/protocols/api.py @@ -0,0 +1,146 @@ +"""API layer protocol interfaces. + +Defines contracts for API client and authentication provider implementations. +These protocols enable dependency injection and testing with mock implementations. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from ..models.commands import DeviceCommand + from ..models.device import GoveeDevice + from ..models.state import GoveeDeviceState + + +@runtime_checkable +class IApiClient(Protocol): + """Protocol for Govee API client operations. + + Defines the contract for REST API communication with Govee cloud. + Implementations must handle rate limiting, retries, and error mapping. + """ + + async def get_devices(self) -> list[GoveeDevice]: + """Fetch all devices from Govee API. + + Returns: + List of GoveeDevice instances with capabilities. + + Raises: + GoveeAuthError: Invalid API key. + GoveeRateLimitError: Rate limit exceeded. + GoveeConnectionError: Network/connection error. + """ + ... + + async def get_device_state( + self, + device_id: str, + sku: str, + ) -> GoveeDeviceState: + """Fetch current state for a device. + + Args: + device_id: Device identifier (MAC address format). + sku: Device SKU/model number. + + Returns: + GoveeDeviceState with current values. + + Raises: + GoveeApiError: If state query fails. + """ + ... + + async def control_device( + self, + device_id: str, + sku: str, + command: DeviceCommand, + ) -> bool: + """Send control command to device. + + Args: + device_id: Device identifier. + sku: Device SKU. + command: Command to execute. + + Returns: + True if command was accepted by API. + + Raises: + GoveeApiError: If command fails. + """ + ... + + async def get_dynamic_scenes( + self, + device_id: str, + sku: str, + ) -> list[dict[str, Any]]: + """Fetch available scenes for a device. + + Args: + device_id: Device identifier. + sku: Device SKU. + + Returns: + List of scene definitions with id, name, etc. + """ + ... + + async def close(self) -> None: + """Close the API client and release resources.""" + ... + + +@runtime_checkable +class IAuthProvider(Protocol): + """Protocol for authentication provider. + + Handles Govee account login and IoT credential retrieval. + Credentials are used for AWS IoT MQTT real-time updates. + """ + + async def login( + self, + email: str, + password: str, + ) -> dict[str, Any]: + """Authenticate with Govee account. + + Args: + email: Govee account email. + password: Govee account password. + + Returns: + Dict containing token, IoT credentials, etc. + + Raises: + GoveeAuthError: Invalid credentials. + GoveeApiError: API communication error. + """ + ... + + async def get_iot_credentials( + self, + token: str, + ) -> dict[str, Any]: + """Fetch IoT credentials for MQTT connection. + + Args: + token: Authentication token from login. + + Returns: + Dict with certificate, key, endpoint, etc. + + Raises: + GoveeApiError: If credential fetch fails. + """ + ... + + async def close(self) -> None: + """Close the auth provider and release resources.""" + ... diff --git a/custom_components/govee/protocols/state.py b/custom_components/govee/protocols/state.py new file mode 100644 index 00000000..a8075f2f --- /dev/null +++ b/custom_components/govee/protocols/state.py @@ -0,0 +1,92 @@ +"""State management protocol interfaces. + +Defines contracts for state providers and observers. +Enables separation between polling, MQTT push, and UI layers. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +if TYPE_CHECKING: + from ..models.device import GoveeDevice + from ..models.state import GoveeDeviceState + + +@runtime_checkable +class IStateObserver(Protocol): + """Protocol for state change observers. + + Implemented by entities and other components that need + to be notified when device state changes. + """ + + def on_state_changed( + self, + device_id: str, + state: GoveeDeviceState, + ) -> None: + """Called when device state changes. + + Args: + device_id: Device that changed. + state: New device state. + """ + ... + + +@runtime_checkable +class IStateProvider(Protocol): + """Protocol for state providers. + + Implemented by coordinator and MQTT client to provide + device state to the integration. + """ + + def get_device(self, device_id: str) -> GoveeDevice | None: + """Get device by ID. + + Args: + device_id: Device identifier. + + Returns: + GoveeDevice or None if not found. + """ + ... + + def get_state(self, device_id: str) -> GoveeDeviceState | None: + """Get current state for a device. + + Args: + device_id: Device identifier. + + Returns: + Current state or None if unavailable. + """ + ... + + @property + def devices(self) -> dict[str, GoveeDevice]: + """All known devices.""" + ... + + @property + def states(self) -> dict[str, GoveeDeviceState]: + """Current states for all devices.""" + ... + + def register_observer(self, observer: IStateObserver) -> None: + """Register a state change observer. + + Args: + observer: Observer to register. + """ + ... + + def unregister_observer(self, observer: IStateObserver) -> None: + """Unregister a state change observer. + + Args: + observer: Observer to unregister. + """ + ... diff --git a/custom_components/govee/quality_scale.yaml b/custom_components/govee/quality_scale.yaml new file mode 100644 index 00000000..b83a5d1a --- /dev/null +++ b/custom_components/govee/quality_scale.yaml @@ -0,0 +1,163 @@ +# Home Assistant Integration Quality Scale +# https://developers.home-assistant.io/docs/integration_quality_scale_index +# +# This file tracks compliance with Home Assistant's quality scale requirements. + +rules: + # Bronze tier requirements + action-setup: + status: done + comment: Services registered in async_setup_entry via async_setup_services + appropriate-polling: + status: done + comment: Uses DataUpdateCoordinator with configurable poll interval (default 60s) + brands: + status: exempt + comment: HACS integration, not core + common-modules: + status: done + comment: Uses homeassistant.helpers, entity platforms, coordinator pattern + config-flow: + status: done + comment: Full config flow with user, account, and options steps + config-flow-test-coverage: + status: done + comment: Config flow logic tested in test_config_flow.py + dependency-transparency: + status: done + comment: All requirements in manifest.json (aiohttp-retry, aiomqtt, cryptography) + docs-actions: + status: done + comment: Services documented in services.yaml with descriptions + docs-high-level-description: + status: done + comment: README.md provides integration overview + docs-installation-instructions: + status: done + comment: HACS installation documented in README.md + docs-removal-instructions: + status: done + comment: Standard HACS removal applies + entity-event-setup: + status: done + comment: Entities use coordinator pattern for state updates + entity-unique-id: + status: done + comment: All entities have unique IDs based on device_id + has-entity-name: + status: done + comment: All entities use has_entity_name = True + runtime-data: + status: done + comment: Uses entry.runtime_data for coordinator storage + test-before-configure: + status: done + comment: API key validated before config entry creation + test-before-setup: + status: done + comment: Coordinator validates API connection in async_config_entry_first_refresh + unique-config-entry: + status: done + comment: Config entries are unique per API key + + # Silver tier requirements + config-entry-unloading: + status: done + comment: Full unload support with platform unloading and client cleanup + log-when-unavailable: + status: done + comment: UpdateFailed exception logged when API fails + parallel-updates: + status: done + comment: DEFAULT_PARALLEL_UPDATES set per platform + reauthentication-flow: + status: done + comment: Reauth flow implemented for expired API keys + test-coverage: + status: done + comment: 143+ tests covering models, API, coordinator, config flow + + # Gold tier requirements + devices: + status: done + comment: DeviceInfo provided for all entities with manufacturer, model, identifiers + diagnostics: + status: done + comment: Diagnostics platform implemented with device and config redaction + discovery: + status: exempt + comment: Cloud API integration, no local discovery available + discovery-update-info: + status: exempt + comment: Cloud API integration, device info updated via API polling + docs-configuration-parameters: + status: done + comment: Options (poll interval, scenes, segments, groups) documented + docs-data-update: + status: done + comment: Polling and MQTT push documented in README + docs-examples: + status: todo + comment: Example automations could be added + docs-known-limitations: + status: done + comment: API rate limits and cloud dependency documented + docs-supported-devices: + status: done + comment: Device categories (lights, plugs, sensors) listed in README + docs-supported-functions: + status: done + comment: Supported features (on/off, brightness, color, scenes) documented + docs-troubleshooting: + status: done + comment: Troubleshooting section in README with common issues + docs-use-cases: + status: todo + comment: Use case examples could be expanded + dynamic-devices: + status: done + comment: Devices discovered and updated via API polling + entity-category: + status: done + comment: Diagnostic entities use EntityCategory.DIAGNOSTIC + entity-device-class: + status: done + comment: Appropriate device classes (outlet, sensor types) assigned + entity-disabled-by-default: + status: done + comment: Diagnostic sensors disabled by default + entity-translations: + status: done + comment: Full translations in en.json and strings.json + exception-translations: + status: done + comment: Config flow errors have translation keys + icon-translations: + status: exempt + comment: Uses default icons per device class + reconfigure-flow: + status: done + comment: Reconfigure flow allows updating API key and account credentials + repair-issues: + status: done + comment: Repairs framework integration for auth failures, rate limits, and MQTT issues + stale-devices: + status: todo + comment: Device removal detection not yet implemented + + # Platinum tier requirements + async-dependency: + status: done + comment: All dependencies (aiohttp, aiomqtt) are async-native + inject-websession: + status: done + comment: Uses async_get_clientsession from homeassistant.helpers.aiohttp_client + strict-typing: + status: done + comment: Type hints throughout codebase + +# Overall quality tier assessment +# Bronze: All requirements met +# Silver: All requirements met +# Gold: Most requirements met, some todo items +# Platinum: Most requirements met diff --git a/custom_components/govee/repairs.py b/custom_components/govee/repairs.py new file mode 100644 index 00000000..bbb426b4 --- /dev/null +++ b/custom_components/govee/repairs.py @@ -0,0 +1,196 @@ +"""Repairs framework integration for Govee. + +Provides actionable repair notifications for common issues: +- Expired or invalid API credentials +- Rate limit exceeded +- Offline devices +""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +# Issue IDs +ISSUE_AUTH_FAILED = "auth_failed" +ISSUE_RATE_LIMITED = "rate_limited" +ISSUE_MQTT_DISCONNECTED = "mqtt_disconnected" + + +async def async_create_auth_issue( + hass: HomeAssistant, + entry: ConfigEntry, +) -> None: + """Create a repair issue for authentication failure. + + This issue is fixable - user can re-authenticate via the repair flow. + """ + ir.async_create_issue( + hass, + DOMAIN, + f"{ISSUE_AUTH_FAILED}_{entry.entry_id}", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key=ISSUE_AUTH_FAILED, + translation_placeholders={ + "entry_title": entry.title, + }, + data={"entry_id": entry.entry_id}, + ) + _LOGGER.info("Created auth_failed repair issue for entry %s", entry.entry_id) + + +async def async_delete_auth_issue( + hass: HomeAssistant, + entry: ConfigEntry, +) -> None: + """Delete the auth failure issue when resolved.""" + ir.async_delete_issue( + hass, + DOMAIN, + f"{ISSUE_AUTH_FAILED}_{entry.entry_id}", + ) + _LOGGER.debug("Deleted auth_failed repair issue for entry %s", entry.entry_id) + + +async def async_create_rate_limit_issue( + hass: HomeAssistant, + entry: ConfigEntry, + reset_time: str, +) -> None: + """Create a repair issue for rate limiting. + + This issue is informational - not directly fixable, but provides guidance. + """ + ir.async_create_issue( + hass, + DOMAIN, + f"{ISSUE_RATE_LIMITED}_{entry.entry_id}", + is_fixable=False, + is_persistent=False, # Will auto-dismiss on next successful update + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_RATE_LIMITED, + translation_placeholders={ + "reset_time": reset_time, + "entry_title": entry.title, + }, + ) + _LOGGER.info("Created rate_limited repair issue for entry %s", entry.entry_id) + + +async def async_delete_rate_limit_issue( + hass: HomeAssistant, + entry: ConfigEntry, +) -> None: + """Delete the rate limit issue when resolved.""" + ir.async_delete_issue( + hass, + DOMAIN, + f"{ISSUE_RATE_LIMITED}_{entry.entry_id}", + ) + + +async def async_create_mqtt_issue( + hass: HomeAssistant, + entry: ConfigEntry, + reason: str, +) -> None: + """Create a repair issue for MQTT disconnection. + + This issue provides guidance on MQTT connectivity issues. + """ + ir.async_create_issue( + hass, + DOMAIN, + f"{ISSUE_MQTT_DISCONNECTED}_{entry.entry_id}", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_MQTT_DISCONNECTED, + translation_placeholders={ + "reason": reason, + "entry_title": entry.title, + }, + ) + _LOGGER.info("Created mqtt_disconnected repair issue for entry %s", entry.entry_id) + + +async def async_delete_mqtt_issue( + hass: HomeAssistant, + entry: ConfigEntry, +) -> None: + """Delete the MQTT issue when resolved.""" + ir.async_delete_issue( + hass, + DOMAIN, + f"{ISSUE_MQTT_DISCONNECTED}_{entry.entry_id}", + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, Any] | None, +) -> RepairsFlow: + """Create repair flow for fixable issues.""" + if issue_id.startswith(ISSUE_AUTH_FAILED): + return AuthRepairFlow() + + # Default to confirm-only flow for non-fixable issues + return ConfirmRepairFlow() + + +class AuthRepairFlow(RepairsFlow): + """Repair flow for authentication issues. + + Guides user through re-authentication process. + """ + + async def async_step_init( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle the initial step of the repair flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle confirmation and redirect to reauth flow.""" + if user_input is not None: + # Get the entry ID from the issue data + entry_id = str(self.data.get("entry_id", "")) if self.data else "" + if entry_id: + entry = self.hass.config_entries.async_get_entry(entry_id) + if entry: + # Trigger reauth flow + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth", "entry_id": entry_id}, + data=dict(entry.data), + ) + ) + return self.async_create_entry(data={}) + + entry_title = "Govee" + if self.data: + entry_title = str(self.data.get("entry_title", "Govee")) + + return self.async_show_form( + step_id="confirm", + description_placeholders={"entry_title": entry_title}, + ) diff --git a/custom_components/govee/select.py b/custom_components/govee/select.py new file mode 100644 index 00000000..a6fc6ad9 --- /dev/null +++ b/custom_components/govee/select.py @@ -0,0 +1,174 @@ +"""Select platform for Govee integration. + +Provides select entities for scene control - one dropdown per device. +This replaces individual scene entities with a more manageable interface. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo # type: ignore[attr-defined] +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_ENABLE_SCENES, DEFAULT_ENABLE_SCENES, DOMAIN +from .coordinator import GoveeCoordinator +from .models import GoveeDevice, SceneCommand + +_LOGGER = logging.getLogger(__name__) + +# Option for "no scene" / off state +SCENE_NONE = "None" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Govee scene selects from a config entry.""" + coordinator: GoveeCoordinator = entry.runtime_data + + # Check if scenes are enabled + if not entry.options.get(CONF_ENABLE_SCENES, DEFAULT_ENABLE_SCENES): + _LOGGER.debug("Scene select entities disabled") + return + + entities: list[SelectEntity] = [] + + for device in coordinator.devices.values(): + if device.supports_scenes: + # Fetch scenes for this device + scenes = await coordinator.async_get_scenes(device.device_id) + + if scenes: + entities.append( + GoveeSceneSelectEntity( + coordinator=coordinator, + device=device, + scenes=scenes, + ) + ) + + async_add_entities(entities) + _LOGGER.debug("Set up %d Govee scene select entities", len(entities)) + + +class GoveeSceneSelectEntity(CoordinatorEntity["GoveeCoordinator"], SelectEntity): + """Govee scene select entity. + + Provides a dropdown to select and activate scenes on a device. + Much more manageable than individual scene entities. + """ + + _attr_has_entity_name = True + _attr_translation_key = "govee_scene_select" + _attr_icon = "mdi:palette" + + def __init__( + self, + coordinator: GoveeCoordinator, + device: GoveeDevice, + scenes: list[dict[str, Any]], + ) -> None: + """Initialize the scene select entity. + + Args: + coordinator: Govee data coordinator. + device: Device this select belongs to. + scenes: List of scene data from API. + """ + super().__init__(coordinator) + + self._device = device + self._device_id = device.device_id + + # Build scene mapping: name -> (id, name) + self._scene_map: dict[str, tuple[int, str]] = {} + options = [SCENE_NONE] + + for scene_data in scenes: + scene_id = scene_data.get("value", {}).get("id", 0) + scene_name = scene_data.get("name", f"Scene {scene_id}") + + # Handle duplicate names by appending ID + unique_name = scene_name + counter = 1 + while unique_name in self._scene_map: + unique_name = f"{scene_name} ({counter})" + counter += 1 + + self._scene_map[unique_name] = (scene_id, scene_name) + options.append(unique_name) + + self._attr_options = options + self._attr_current_option = SCENE_NONE + + # Unique ID + self._attr_unique_id = f"{device.device_id}_scene_select" + + # Entity name + self._attr_name = "Scene" + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device.device_id)}, + name=self._device.name, + manufacturer="Govee", + model=self._device.sku, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + state = self.coordinator.get_state(self._device_id) + if state is None: + return False + return state.online or self._device.is_group + + async def async_select_option(self, option: str) -> None: + """Handle scene selection.""" + if option == SCENE_NONE: + # Just update state, don't send command + self._attr_current_option = SCENE_NONE + self.async_write_ha_state() + return + + scene_info = self._scene_map.get(option) + if not scene_info: + _LOGGER.warning("Unknown scene option: %s", option) + return + + scene_id, scene_name = scene_info + + command = SceneCommand( + scene_id=scene_id, + scene_name=scene_name, + ) + + success = await self.coordinator.async_control_device( + self._device_id, + command, + ) + + if success: + self._attr_current_option = option + self.async_write_ha_state() + _LOGGER.debug( + "Activated scene '%s' on %s", + scene_name, + self._device.name, + ) + else: + _LOGGER.warning( + "Failed to activate scene '%s' on %s", + scene_name, + self._device.name, + ) diff --git a/custom_components/govee/sensor.py b/custom_components/govee/sensor.py new file mode 100644 index 00000000..e0f82d20 --- /dev/null +++ b/custom_components/govee/sensor.py @@ -0,0 +1,139 @@ +"""Sensor platform for Govee integration. + +Provides sensor entities for: +- Rate limit remaining (diagnostic) +- MQTT connection status (diagnostic) +""" + +from __future__ import annotations + +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo # type: ignore[attr-defined] +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GoveeCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Govee sensors from a config entry.""" + coordinator: GoveeCoordinator = entry.runtime_data + + entities: list[SensorEntity] = [ + GoveeRateLimitSensor(coordinator, entry.entry_id), + ] + + # Add MQTT status sensor if MQTT is configured + if coordinator._mqtt_client is not None: + entities.append(GoveeMqttStatusSensor(coordinator, entry.entry_id)) + + async_add_entities(entities) + _LOGGER.debug("Set up %d Govee sensor entities", len(entities)) + + +class GoveeRateLimitSensor(CoordinatorEntity["GoveeCoordinator"], SensorEntity): + """Sensor showing API rate limit remaining. + + Helps users monitor their API usage and avoid hitting limits. + """ + + _attr_has_entity_name = True + _attr_translation_key = "rate_limit_remaining" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = "requests" + _attr_icon = "mdi:speedometer" + + def __init__( + self, + coordinator: GoveeCoordinator, + entry_id: str, + ) -> None: + """Initialize the rate limit sensor.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{entry_id}_rate_limit" + self._attr_name = "API Rate Limit Remaining" + + @property + def device_info(self) -> DeviceInfo: + """Return device info for the integration hub.""" + return DeviceInfo( + identifiers={(DOMAIN, "hub")}, + name="Govee Integration", + manufacturer="Govee", + model="Cloud API", + ) + + @property + def native_value(self) -> int: + """Return the current rate limit remaining.""" + return self.coordinator._api_client.rate_limit_remaining + + @property + def extra_state_attributes(self) -> dict[str, int]: + """Return additional rate limit info.""" + client = self.coordinator._api_client + return { + "total_limit": client.rate_limit_total, + "reset_time": client.rate_limit_reset, + } + + +class GoveeMqttStatusSensor(CoordinatorEntity["GoveeCoordinator"], SensorEntity): + """Sensor showing MQTT connection status. + + Indicates whether real-time push updates are working. + """ + + _attr_has_entity_name = True + _attr_translation_key = "mqtt_status" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = ["connected", "disconnected", "unavailable"] + _attr_icon = "mdi:cloud-sync" + + def __init__( + self, + coordinator: GoveeCoordinator, + entry_id: str, + ) -> None: + """Initialize the MQTT status sensor.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{entry_id}_mqtt_status" + self._attr_name = "MQTT Status" + + @property + def device_info(self) -> DeviceInfo: + """Return device info for the integration hub.""" + return DeviceInfo( + identifiers={(DOMAIN, "hub")}, + name="Govee Integration", + manufacturer="Govee", + model="Cloud API", + ) + + @property + def native_value(self) -> str: + """Return the current MQTT status.""" + mqtt_client = self.coordinator._mqtt_client + if mqtt_client is None: + return "unavailable" + return "connected" if mqtt_client.connected else "disconnected" diff --git a/custom_components/govee/services.py b/custom_components/govee/services.py new file mode 100644 index 00000000..5100d30e --- /dev/null +++ b/custom_components/govee/services.py @@ -0,0 +1,136 @@ +"""Custom services for Govee integration. + +Provides services for: +- Refresh all scenes +- Control segment colors +- Send raw commands (advanced) +""" + +from __future__ import annotations + +import logging + +import voluptuous as vol +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import GoveeCoordinator +from .models import RGBColor, SegmentColorCommand + +_LOGGER = logging.getLogger(__name__) + +# Service names +SERVICE_REFRESH_SCENES = "refresh_scenes" +SERVICE_SET_SEGMENT_COLOR = "set_segment_color" + +# Service schemas +SERVICE_REFRESH_SCENES_SCHEMA = vol.Schema( + { + vol.Optional("device_id"): cv.string, + } +) + +SERVICE_SET_SEGMENT_COLOR_SCHEMA = vol.Schema( + { + vol.Required("device_id"): cv.string, + vol.Required("segments"): vol.All(cv.ensure_list, [cv.positive_int]), + vol.Required("rgb_color"): vol.All( + vol.ExactSequence((cv.byte, cv.byte, cv.byte)), + vol.Coerce(tuple), + ), + } +) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up Govee services.""" + + async def async_refresh_scenes(call: ServiceCall) -> None: + """Refresh scenes for device(s).""" + device_id = call.data.get("device_id") + + # Get all coordinators + coordinators = _get_coordinators(hass) + + for coordinator in coordinators: + if device_id: + # Refresh specific device + if device_id in coordinator.devices: + await coordinator.async_get_scenes(device_id, refresh=True) + _LOGGER.info("Refreshed scenes for device %s", device_id) + else: + # Refresh all devices + for dev_id, device in coordinator.devices.items(): + if device.supports_scenes: + await coordinator.async_get_scenes(dev_id, refresh=True) + _LOGGER.info("Refreshed scenes for all devices") + + async def async_set_segment_color(call: ServiceCall) -> None: + """Set color for specific segments.""" + device_id = call.data["device_id"] + segments = call.data["segments"] + rgb = call.data["rgb_color"] + + coordinator = _get_coordinator_for_device(hass, device_id) + if not coordinator: + _LOGGER.error("Device %s not found", device_id) + return + + color = RGBColor(r=rgb[0], g=rgb[1], b=rgb[2]) + command = SegmentColorCommand( + segment_indices=tuple(segments), + color=color, + ) + + await coordinator.async_control_device(device_id, command) + _LOGGER.info( + "Set segments %s to color %s on device %s", + segments, + rgb, + device_id, + ) + + # Register services + hass.services.async_register( + DOMAIN, + SERVICE_REFRESH_SCENES, + async_refresh_scenes, + schema=SERVICE_REFRESH_SCENES_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_SEGMENT_COLOR, + async_set_segment_color, + schema=SERVICE_SET_SEGMENT_COLOR_SCHEMA, + ) + + _LOGGER.debug("Govee services registered") + + +async def async_unload_services(hass: HomeAssistant) -> None: + """Unload Govee services.""" + hass.services.async_remove(DOMAIN, SERVICE_REFRESH_SCENES) + hass.services.async_remove(DOMAIN, SERVICE_SET_SEGMENT_COLOR) + _LOGGER.debug("Govee services unloaded") + + +def _get_coordinators(hass: HomeAssistant) -> list[GoveeCoordinator]: + """Get all Govee coordinators.""" + coordinators = [] + for entry_id, entry_data in hass.data.get(DOMAIN, {}).items(): + if isinstance(entry_data, GoveeCoordinator): + coordinators.append(entry_data) + return coordinators + + +def _get_coordinator_for_device( + hass: HomeAssistant, + device_id: str, +) -> GoveeCoordinator | None: + """Get coordinator that manages a specific device.""" + for coordinator in _get_coordinators(hass): + if device_id in coordinator.devices: + return coordinator + return None diff --git a/custom_components/govee/services.yaml b/custom_components/govee/services.yaml new file mode 100644 index 00000000..96d797f9 --- /dev/null +++ b/custom_components/govee/services.yaml @@ -0,0 +1,37 @@ +refresh_scenes: + name: Refresh Scenes + description: Refresh available scenes from Govee API + fields: + device_id: + name: Device ID + description: Specific device to refresh (optional, refreshes all if omitted) + required: false + example: "AA:BB:CC:DD:EE:FF:00:11" + selector: + text: + +set_segment_color: + name: Set Segment Color + description: Set color for specific segments on RGBIC devices + fields: + device_id: + name: Device ID + description: Device ID of the RGBIC light + required: true + example: "AA:BB:CC:DD:EE:FF:00:11" + selector: + text: + segments: + name: Segments + description: List of segment indices (0-based) + required: true + example: "[0, 1, 2]" + selector: + object: + rgb_color: + name: RGB Color + description: Color as [R, G, B] values (0-255) + required: true + example: "[255, 0, 0]" + selector: + color_rgb: diff --git a/custom_components/govee/strings.json b/custom_components/govee/strings.json index 8838a75c..2ffd9778 100644 --- a/custom_components/govee/strings.json +++ b/custom_components/govee/strings.json @@ -1,42 +1,145 @@ { - "title": "Govee", "config": { - "abort": { - "already_configured": "Already configured. Only a single configuration possible." - }, - "error": { - "cannot_connect": "Cannot connect. Is the API-Key correct and the internet connection working?", - "unknown": "Unknown Error." - }, "step": { "user": { + "title": "Govee API Key", + "description": "Enter your Govee API key from {api_url}", + "data": { + "api_key": "API Key" + } + }, + "account": { + "title": "Govee Account (Optional)", + "description": "Enter Govee account credentials for real-time MQTT updates. Leave empty to skip.", + "data": { + "email": "Email", + "password": "Password" + } + }, + "reauth_confirm": { + "title": "Re-authenticate Govee", + "description": "Your API key is invalid. Please enter a new one.", + "data": { + "api_key": "API Key" + } + }, + "reconfigure": { + "title": "Reconfigure Govee", + "description": "Update your Govee credentials. Current email: {current_email}", "data": { "api_key": "API Key", - "delay": "Poll Interval" + "email": "Email (optional)", + "password": "Password (optional)" }, - "title": "", - "description": "Get your API Key from the Govee Home App. For Details see https://github.com/LaggAt/hacs-govee/blob/master/README.md" + "data_description": { + "api_key": "Your Govee API key", + "email": "Govee account email for real-time MQTT updates", + "password": "Leave empty to keep existing password, or enter new password" + } } + }, + "error": { + "invalid_auth": "Invalid API key or credentials", + "invalid_account": "Invalid Govee account credentials", + "cannot_connect": "Failed to connect to Govee API", + "unknown": "Unexpected error occurred" + }, + "abort": { + "reauth_successful": "Re-authentication was successful", + "reconfigure_successful": "Reconfiguration was successful" } }, "options": { - "error": { - "cannot_connect": "Cannot connect. Is the API-Key correct and the internet connection working?", - "unknown": "Unknown Error.", - "disabled_attribute_updates_wrong": "Wrong format, see README above." - }, "step": { - "user": { + "init": { + "title": "Govee Options", "data": { - "api_key": "API Key (requires restart)", - "delay": "Poll Interval (requires restart)", - "use_assumed_state": "Use 'assumed state' (two buttons). Default: True", - "offline_is_off": "When a led is offline, show it as off (default doesn't change state). Default: False", - "disable_attribute_updates": "DISABLE state updates. Space to disable. Read the README above!" + "poll_interval": "Polling interval (seconds)", + "enable_groups": "Enable group devices", + "enable_scenes": "Enable scene selector", + "enable_segments": "Enable segment entities" + } + } + } + }, + "entity": { + "light": { + "govee_light": { + "name": "{device_name}" + }, + "govee_segment": { + "name": "{device_name} Segment {segment_index}" + } + }, + "select": { + "govee_scene_select": { + "name": "Scene" + } + }, + "switch": { + "govee_plug": { + "name": "{device_name}" + }, + "govee_night_light": { + "name": "Night Light" + } + }, + "sensor": { + "rate_limit_remaining": { + "name": "API Rate Limit Remaining" + }, + "mqtt_status": { + "name": "MQTT Status" + } + }, + "button": { + "refresh_scenes": { + "name": "Refresh Scenes" + } + } + }, + "services": { + "refresh_scenes": { + "name": "Refresh Scenes", + "description": "Refresh available scenes from the Govee API", + "fields": { + "device_id": { + "name": "Device ID", + "description": "Specific device to refresh (optional)" + } + } + }, + "set_segment_color": { + "name": "Set Segment Color", + "description": "Set color for specific segments on RGBIC devices", + "fields": { + "device_id": { + "name": "Device ID", + "description": "Device ID of the RGBIC light" + }, + "segments": { + "name": "Segments", + "description": "List of segment indices (0-based)" }, - "title": "Options", - "description": "Configure the Govee integration. For Details see https://github.com/LaggAt/hacs-govee/blob/master/README.md" + "rgb_color": { + "name": "RGB Color", + "description": "Color as [R, G, B] values" + } } } + }, + "issues": { + "auth_failed": { + "title": "Govee authentication failed", + "description": "The API key for {entry_title} is no longer valid. The integration cannot communicate with Govee until you re-authenticate." + }, + "rate_limited": { + "title": "Govee API rate limited", + "description": "The Govee API has rate limited requests for {entry_title}. Normal operation will resume in approximately {reset_time}. Consider increasing the poll interval in the integration options to reduce API calls." + }, + "mqtt_disconnected": { + "title": "Govee MQTT disconnected", + "description": "Real-time updates for {entry_title} are unavailable. Reason: {reason}. The integration will continue to work using polling. Check your Govee account credentials if this persists." + } } } diff --git a/custom_components/govee/switch.py b/custom_components/govee/switch.py new file mode 100644 index 00000000..76d4ada4 --- /dev/null +++ b/custom_components/govee/switch.py @@ -0,0 +1,138 @@ +"""Switch platform for Govee integration. + +Provides switch entities for: +- Smart plugs (on/off control) +- Night light toggle (for lights with night light mode) +""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import GoveeCoordinator +from .entity import GoveeEntity +from .models import GoveeDevice, PowerCommand, create_night_light_command + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Govee switches from a config entry.""" + coordinator: GoveeCoordinator = entry.runtime_data + + entities: list[SwitchEntity] = [] + + for device in coordinator.devices.values(): + # Create switch for smart plugs (power on/off) + if device.is_plug and device.supports_power: + entities.append(GoveePlugSwitchEntity(coordinator, device)) + + # Create switch for night light toggle (lights with night light mode) + if device.supports_night_light: + entities.append(GoveeNightLightSwitchEntity(coordinator, device)) + + async_add_entities(entities) + _LOGGER.debug("Set up %d Govee switch entities", len(entities)) + + +class GoveePlugSwitchEntity(GoveeEntity, SwitchEntity): + """Govee smart plug switch entity. + + Controls power state for Govee smart plugs. + """ + + _attr_device_class = SwitchDeviceClass.OUTLET + _attr_translation_key = "govee_plug" + + def __init__( + self, + coordinator: GoveeCoordinator, + device: GoveeDevice, + ) -> None: + """Initialize the plug switch entity.""" + super().__init__(coordinator, device) + + # Use device name as entity name + self._attr_name = None + + @property + def is_on(self) -> bool | None: + """Return True if plug is on.""" + state = self.device_state + return state.power_state if state else None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the plug on.""" + await self.coordinator.async_control_device( + self._device_id, + PowerCommand(power_on=True), + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the plug off.""" + await self.coordinator.async_control_device( + self._device_id, + PowerCommand(power_on=False), + ) + + +class GoveeNightLightSwitchEntity(GoveeEntity, SwitchEntity): + """Govee night light toggle switch entity. + + Controls night light mode for devices that support it. + Uses optimistic state since API may not return night light status. + """ + + _attr_translation_key = "govee_night_light" + + def __init__( + self, + coordinator: GoveeCoordinator, + device: GoveeDevice, + ) -> None: + """Initialize the night light switch entity.""" + super().__init__(coordinator, device) + + # Unique ID for night light switch + self._attr_unique_id = f"{device.device_id}_night_light" + + # Name as "Night Light" + self._attr_name = "Night Light" + + # Optimistic state + self._is_on = False + + @property + def is_on(self) -> bool: + """Return True if night light is on.""" + return self._is_on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn night light on.""" + success = await self.coordinator.async_control_device( + self._device_id, + create_night_light_command(enabled=True), + ) + if success: + self._is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn night light off.""" + success = await self.coordinator.async_control_device( + self._device_id, + create_night_light_command(enabled=False), + ) + if success: + self._is_on = False + self.async_write_ha_state() diff --git a/custom_components/govee/translations/de.json b/custom_components/govee/translations/de.json deleted file mode 100644 index 899a6ff4..00000000 --- a/custom_components/govee/translations/de.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "title": "Govee", - "config": { - "abort": { - "already_configured": "Bereits eingerichtet. Es ist nur eine Konfiguration möglich." - }, - "error": { - "cannot_connect": "Keine Verbindung möglich. Ist der API-Key richtig und die Internet Verbindung in Ordnung?", - "unknown": "Unbekannter Fehler." - }, - "step": { - "user": { - "data": { - "api_key": "API Key", - "delay": "Abfrage-Intervall" - }, - "description": "Den API Key bekommen Sie in der Govee Home App. Details dazu hier: https://github.com/LaggAt/hacs-govee/blob/master/README.md" - } - } - }, - "options": { - "error": { - "cannot_connect": "Keine Verbindung möglich. Ist der API-Key richtig und die Internet Verbindung in Ordnung?", - "unknown": "Unbekannter Fehler.", - "disabled_attribute_updates_wrong": "Format ist inkorrekt, bitte README lesen." - }, - "step": { - "user": { - "data": { - "api_key": "API Key (benötigt Neustart)", - "delay": "Abfrage-Intervall (benötigt Neustart)", - "use_assumed_state": "Verwende 'angenommenen Zustand' (zwei Buttons). Standard: True", - "offline_is_off": "Wenn eine LED offline ist, zeige sie als Aus (Standard ändert den Status nicht). Standard: False", - "disable_attribute_updates": "Status updates verhindern. Leertaste zum ausschalten. Bitte das README oben dazu lesen." - }, - "title": "Einstellungen", - "description": "Einstellen der Govee Integration. Details dazu hier: https://github.com/LaggAt/hacs-govee/blob/master/README.md" - } - } - } -} \ No newline at end of file diff --git a/custom_components/govee/translations/en.json b/custom_components/govee/translations/en.json index 35503b7e..2ffd9778 100644 --- a/custom_components/govee/translations/en.json +++ b/custom_components/govee/translations/en.json @@ -1,41 +1,145 @@ { - "title": "Govee", "config": { - "abort": { - "already_configured": "Already configured. Only a single configuration possible." - }, - "error": { - "cannot_connect": "Cannot connect. Is the API-Key correct and the internet connection working?", - "unknown": "Unknown Error." - }, "step": { "user": { + "title": "Govee API Key", + "description": "Enter your Govee API key from {api_url}", + "data": { + "api_key": "API Key" + } + }, + "account": { + "title": "Govee Account (Optional)", + "description": "Enter Govee account credentials for real-time MQTT updates. Leave empty to skip.", + "data": { + "email": "Email", + "password": "Password" + } + }, + "reauth_confirm": { + "title": "Re-authenticate Govee", + "description": "Your API key is invalid. Please enter a new one.", + "data": { + "api_key": "API Key" + } + }, + "reconfigure": { + "title": "Reconfigure Govee", + "description": "Update your Govee credentials. Current email: {current_email}", "data": { "api_key": "API Key", - "delay": "Poll Interval" + "email": "Email (optional)", + "password": "Password (optional)" }, - "description": "Get your API Key from the Govee Home App. For Details see https://github.com/LaggAt/hacs-govee/blob/master/README.md" + "data_description": { + "api_key": "Your Govee API key", + "email": "Govee account email for real-time MQTT updates", + "password": "Leave empty to keep existing password, or enter new password" + } } + }, + "error": { + "invalid_auth": "Invalid API key or credentials", + "invalid_account": "Invalid Govee account credentials", + "cannot_connect": "Failed to connect to Govee API", + "unknown": "Unexpected error occurred" + }, + "abort": { + "reauth_successful": "Re-authentication was successful", + "reconfigure_successful": "Reconfiguration was successful" } }, "options": { - "error": { - "cannot_connect": "Cannot connect. Is the API-Key correct and the internet connection working?", - "unknown": "Unknown Error.", - "disabled_attribute_updates_wrong": "Wrong format, see README above." - }, "step": { - "user": { + "init": { + "title": "Govee Options", "data": { - "api_key": "API Key (requires restart)", - "delay": "Poll Interval (requires restart)", - "use_assumed_state": "Use 'assumed state' (two buttons). Default: True", - "offline_is_off": "When a led is offline, show it as off (default doesn't change state). Default: False", - "disable_attribute_updates": "DISABLE state updates. Space to disable. Read the README above!" + "poll_interval": "Polling interval (seconds)", + "enable_groups": "Enable group devices", + "enable_scenes": "Enable scene selector", + "enable_segments": "Enable segment entities" + } + } + } + }, + "entity": { + "light": { + "govee_light": { + "name": "{device_name}" + }, + "govee_segment": { + "name": "{device_name} Segment {segment_index}" + } + }, + "select": { + "govee_scene_select": { + "name": "Scene" + } + }, + "switch": { + "govee_plug": { + "name": "{device_name}" + }, + "govee_night_light": { + "name": "Night Light" + } + }, + "sensor": { + "rate_limit_remaining": { + "name": "API Rate Limit Remaining" + }, + "mqtt_status": { + "name": "MQTT Status" + } + }, + "button": { + "refresh_scenes": { + "name": "Refresh Scenes" + } + } + }, + "services": { + "refresh_scenes": { + "name": "Refresh Scenes", + "description": "Refresh available scenes from the Govee API", + "fields": { + "device_id": { + "name": "Device ID", + "description": "Specific device to refresh (optional)" + } + } + }, + "set_segment_color": { + "name": "Set Segment Color", + "description": "Set color for specific segments on RGBIC devices", + "fields": { + "device_id": { + "name": "Device ID", + "description": "Device ID of the RGBIC light" + }, + "segments": { + "name": "Segments", + "description": "List of segment indices (0-based)" }, - "title": "Options", - "description": "Configure the Govee integration. For Details see https://github.com/LaggAt/hacs-govee/blob/master/README.md" + "rgb_color": { + "name": "RGB Color", + "description": "Color as [R, G, B] values" + } } } + }, + "issues": { + "auth_failed": { + "title": "Govee authentication failed", + "description": "The API key for {entry_title} is no longer valid. The integration cannot communicate with Govee until you re-authenticate." + }, + "rate_limited": { + "title": "Govee API rate limited", + "description": "The Govee API has rate limited requests for {entry_title}. Normal operation will resume in approximately {reset_time}. Consider increasing the poll interval in the integration options to reduce API calls." + }, + "mqtt_disconnected": { + "title": "Govee MQTT disconnected", + "description": "Real-time updates for {entry_title} are unavailable. Reason: {reason}. The integration will continue to work using polling. Check your Govee account credentials if this persists." + } } } diff --git a/custom_components/govee/translations/fr.json b/custom_components/govee/translations/fr.json deleted file mode 100644 index dd7f3a4e..00000000 --- a/custom_components/govee/translations/fr.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "title": "Govee", - "config": { - "abort": { - "already_configured": "Déjà configuré. Une seule configuration possible." - }, - "error": { - "cannot_connect": "Impossible de se connecter. La clé d'API est-elle correcte et la connexion Internet fonctionne-t-elle?", - "unknown": "Erreure inconnue." - }, - "step": { - "user": { - "data": { - "api_key": "clé d'API", - "delay": "Intervalle d'interrogation" - }, - "description": "Obtenez votre clé API à partir de l'application Govee Home. Pour plus de détails, visitez https://github.com/LaggAt/hacs-govee/blob/master/README.md" - } - } - }, - "options": { - "error": { - "cannot_connect": "Impossible de se connecter. La clé d'API est-elle correcte et la connexion Internet fonctionne-t-elle?", - "unknown": "Erreure inconnue.", - "disabled_attribute_updates_wrong": "Format incorrect, voir le 'lisez-moi' ci-dessus." - }, - "step": { - "user": { - "data": { - "api_key": "Clé d'API (nécessite un redémarrage)", - "delay": "Intervalle d'interrogation (nécessite un redémarrage)", - "use_assumed_state": "Utiliser 'état supposé' (deux boutons). Par défaut : Vrai", - "offline_is_off": "Lorsqu'une DEL est hors ligne, affichez-la comme éteinte (la valeur par défaut ne change pas d'état). Par défaut : Faux", - "disable_attribute_updates": "DÉSACTIVER les mises à jour d'état. Espace pour désactiver. Lisez le 'lisez-moi' ci-dessus !" - }, - "title": "Options", - "description": "Configurez l'intégration Govee. Pour plus de détails, visitez https://github.com/LaggAt/hacs-govee/blob/master/README.md" - } - } - } -} diff --git a/custom_components/govee/translations/pt-BR.json b/custom_components/govee/translations/pt-BR.json deleted file mode 100644 index 44809f98..00000000 --- a/custom_components/govee/translations/pt-BR.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "title": "Govee", - "config": { - "abort": { - "already_configured": "Já configurado. Apenas uma única configuração é possível." - }, - "error": { - "cannot_connect": "Não pode conectar. A API-Key está correta e a conexão com a Internet está funcionando?", - "unknown": "Erro desconhecido." - }, - "step": { - "user": { - "data": { - "api_key": "Chave de API", - "delay": "Intervalo de escaneamento" - }, - "description": "Obtenha sua chave de API do aplicativo Govee Home. Para detalhes consulte https://github.com/LaggAt/hacs-govee/blob/master/README.md" - } - } - }, - "options": { - "error": { - "cannot_connect": "Não pode conectar. A API-Key está correta e a conexão com a Internet está funcionando?", - "unknown": "Erro desconhecido.", - "disabled_attribute_updates_wrong": "Formato errado, veja README acima." - }, - "step": { - "user": { - "data": { - "api_key": "Chave de API (requer reinicialização)", - "delay": "Intervalo de escaneamento (requer reinicialização)", - "use_assumed_state": "Use 'estado presumido' (dois botões). Padrão: true", - "offline_is_off": "Quando um led estiver offline, mostre-o como desligado (o padrão não muda de estado). Padrão: False", - "disable_attribute_updates": "DESATIVAR atualizações de estado. Espaço para desativar. Leia o README acima!" - }, - "title": "Opções", - "description": "Configure a integração do Govee. Para detalhes consulte https://github.com/LaggAt/hacs-govee/blob/master/README.md" - } - } - } -} diff --git a/docs/govee-protocol-reference.md b/docs/govee-protocol-reference.md new file mode 100644 index 00000000..d6e28b23 --- /dev/null +++ b/docs/govee-protocol-reference.md @@ -0,0 +1,1591 @@ +# Govee Protocol Reference + +A comprehensive technical reference for Govee device communication protocols, compiled from official documentation, PCAP analysis of the Android app, and community reverse engineering efforts. + +**Last Updated:** January 2026 +**PCAP Source:** `logs/PCAPdroid_09_Jan_19_27_26.pcap` (Android app capture) + +--- + +## Table of Contents + +1. [Protocol Overview](#1-protocol-overview) +2. [Official Platform API v2.0](#2-official-platform-api-v20) +3. [Curl Testing Reference](#3-curl-testing-reference) +4. [AWS IoT MQTT (Undocumented)](#4-aws-iot-mqtt-undocumented) +5. [Undocumented Internal API](#5-undocumented-internal-api-app2goveecom) +6. [LAN API (UDP)](#6-lan-api-udp) +7. [BLE Protocol](#7-ble-protocol) +8. [State Management](#8-state-management) +9. [Device Capabilities](#9-device-capabilities) +10. [Scene & DIY Modes](#10-scene--diy-modes) +11. [PCAP Analysis Details](#11-pcap-analysis-details) +12. [References](#12-references) + +--- + +## 1. Protocol Overview + +Govee devices support multiple communication protocols, each with distinct characteristics: + +| Protocol | Latency | Auth Method | Use Case | Rate Limits | +|----------|---------|-------------|----------|-------------| +| **Platform API v2** | 2-4s | API Key | Device control, state query | 10K/day | +| **AWS IoT MQTT** | ~50ms | Certificates | Real-time state push | None known | +| **Official MQTT** | ~100ms | API Key | Event notifications | None known | +| **LAN UDP** | <10ms | None | Local control | None | +| **BLE** | <50ms | Pairing | Direct control | None | + +### Communication Flow (from PCAP) + +``` +┌─────────────────┐ HTTPS/443 ┌──────────────────┐ +│ Govee App │◄─────────────────►│ app2.govee.com │ +│ (Android) │ │ (Auth + API) │ +└────────┬────────┘ └──────────────────┘ + │ + │ MQTT/8883 (TLS + Mutual Auth) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ AWS IoT Core (us-east-1) │ +│ aqm3wd1qlc3dy-ats.iot.us-east-1.amazonaws.com │ +│ │ +│ Topic: GA/{account-uuid} │ +│ - Device state push notifications │ +│ - Bidirectional command/response │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────┐ UDP 4001-4003 ┌──────────────────┐ +│ Home Network │◄─────────────────►│ Govee Devices │ +│ │ (LAN API) │ │ +└─────────────────┘ └──────────────────┘ +``` + +--- + +## 2. Official Platform API v2.0 + +### 2.1 Base Configuration + +| Parameter | Value | +|-----------|-------| +| **Base URL** | `https://openapi.api.govee.com/router/api/v1` | +| **Auth Header** | `Govee-API-Key: {your-api-key}` | +| **Content-Type** | `application/json` | + +### 2.2 Endpoints + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/user/devices` | GET | List all devices | +| `/device/state` | POST | Query device state | +| `/device/control` | POST | Send control command | +| `/device/scenes` | POST | Get dynamic scenes | +| `/device/diy-scenes` | POST | Get DIY scenes | + +### 2.3 Request Format + +All POST requests use this structure: + +```json +{ + "requestId": "550e8400-e29b-41d4-a716-446655440000", + "payload": { + "sku": "H618E", + "device": "8C:2E:9C:04:A0:03:82:D1", + "capability": { + "type": "devices.capabilities.TYPE", + "instance": "INSTANCE_NAME", + "value": "VALUE" + } + } +} +``` + +### 2.4 Response Format + +```json +{ + "requestId": "550e8400-e29b-41d4-a716-446655440000", + "code": 200, + "msg": "success", + "payload": { + "sku": "H618E", + "device": "8C:2E:9C:04:A0:03:82:D1", + "capabilities": [ + { + "type": "devices.capabilities.on_off", + "instance": "powerSwitch", + "state": { "value": 1 } + }, + { + "type": "devices.capabilities.range", + "instance": "brightness", + "state": { "value": 75 } + }, + { + "type": "devices.capabilities.color_setting", + "instance": "colorRgb", + "state": { "value": 16711680 } + } + ] + } +} +``` + +### 2.5 Rate Limiting + +**Limits:** +- 10,000 requests per day per account +- Per-minute limits (undocumented, ~10/min per device) + +**Response Headers:** +``` +API-RateLimit-Remaining: 95 # Per-minute remaining +API-RateLimit-Reset: 1704812400 # Per-minute reset timestamp +X-RateLimit-Remaining: 9500 # Per-day remaining +X-RateLimit-Reset: 1704844800 # Per-day reset timestamp +``` + +### 2.6 Control Examples + +**Power On/Off:** +```json +{ + "type": "devices.capabilities.on_off", + "instance": "powerSwitch", + "value": 1 +} +``` + +**Brightness (0-100):** +```json +{ + "type": "devices.capabilities.range", + "instance": "brightness", + "value": 75 +} +``` + +**RGB Color (packed integer):** +```json +{ + "type": "devices.capabilities.color_setting", + "instance": "colorRgb", + "value": 16711680 +} +``` +*Note: RGB packed as `(R << 16) + (G << 8) + B`. 16711680 = RGB(255, 0, 0)* + +**Color Temperature (Kelvin):** +```json +{ + "type": "devices.capabilities.color_setting", + "instance": "colorTemperatureK", + "value": 4500 +} +``` + +**Segment Color (RGBIC devices):** +```json +{ + "type": "devices.capabilities.segment_color_setting", + "instance": "segmentedColorRgb", + "value": { + "segment": [0, 1, 2, 3], + "rgb": 255 + } +} +``` + +**Scene Activation:** +```json +{ + "type": "devices.capabilities.dynamic_scene", + "instance": "lightScene", + "value": { + "id": 3853, + "paramId": 4280 + } +} +``` + +### 2.7 Error Codes + +| Code | Description | +|------|-------------| +| 200 | Success | +| 400 | Missing/invalid parameters | +| 401 | Authentication failure | +| 404 | Device/instance not found | +| 429 | Rate limit exceeded | +| 500 | Internal server error | + +--- + +## 3. Curl Testing Reference + +Validated API testing with real device responses (H601F floor lamps with 7 segments). + +### 3.1 Environment Setup + +```bash +# Store your API key +export GOVEE_API_KEY="your-api-key-here" + +# Base URL +export GOVEE_API="https://openapi.api.govee.com/router/api/v1" +``` + +### 3.2 List Devices + +```bash +curl -s -X GET "$GOVEE_API/user/devices" \ + -H "Govee-API-Key: $GOVEE_API_KEY" \ + -H "Content-Type: application/json" | jq . +``` + +**Real Response (H601F - 7-segment floor lamp):** +```json +{ + "code": 200, + "message": "success", + "data": [ + { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C", + "deviceName": "Master F Left", + "type": "devices.types.light", + "capabilities": [ + { + "type": "devices.capabilities.on_off", + "instance": "powerSwitch", + "parameters": { + "dataType": "ENUM", + "options": [ + {"name": "on", "value": 1}, + {"name": "off", "value": 0} + ] + } + }, + { + "type": "devices.capabilities.range", + "instance": "brightness", + "parameters": { + "unit": "unit.percent", + "dataType": "INTEGER", + "range": {"min": 1, "max": 100, "precision": 1} + } + }, + { + "type": "devices.capabilities.color_setting", + "instance": "colorRgb", + "parameters": { + "dataType": "INTEGER", + "range": {"min": 0, "max": 16777215, "precision": 1} + } + }, + { + "type": "devices.capabilities.color_setting", + "instance": "colorTemperatureK", + "parameters": { + "dataType": "INTEGER", + "range": {"min": 2700, "max": 6500, "precision": 1} + } + }, + { + "type": "devices.capabilities.segment_color_setting", + "instance": "segmentedColorRgb", + "parameters": { + "dataType": "STRUCT", + "fields": [ + { + "fieldName": "segment", + "size": {"min": 1, "max": 7}, + "dataType": "Array", + "elementRange": {"min": 0, "max": 6}, + "elementType": "INTEGER", + "required": true + }, + { + "fieldName": "rgb", + "dataType": "INTEGER", + "range": {"min": 0, "max": 16777215, "precision": 1}, + "required": true + } + ] + } + }, + { + "type": "devices.capabilities.dynamic_scene", + "instance": "lightScene", + "parameters": {"dataType": "ENUM", "options": []} + }, + { + "type": "devices.capabilities.dynamic_scene", + "instance": "diyScene", + "parameters": {"dataType": "ENUM", "options": []} + }, + { + "type": "devices.capabilities.dynamic_scene", + "instance": "snapshot", + "parameters": {"dataType": "ENUM", "options": []} + } + ] + } + ] +} +``` + +### 3.3 Get Device State + +```bash +curl -s -X POST "$GOVEE_API/device/state" \ + -H "Govee-API-Key: $GOVEE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "requestId": "state-001", + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C" + } + }' | jq . +``` + +**Real Response:** +```json +{ + "requestId": "state-001", + "msg": "success", + "code": 200, + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C", + "capabilities": [ + { + "type": "devices.capabilities.online", + "instance": "online", + "state": {"value": true} + }, + { + "type": "devices.capabilities.on_off", + "instance": "powerSwitch", + "state": {"value": 0} + }, + { + "type": "devices.capabilities.range", + "instance": "brightness", + "state": {"value": 20} + }, + { + "type": "devices.capabilities.color_setting", + "instance": "colorRgb", + "state": {"value": 0} + }, + { + "type": "devices.capabilities.color_setting", + "instance": "colorTemperatureK", + "state": {"value": 0} + }, + { + "type": "devices.capabilities.segment_color_setting", + "instance": "segmentedColorRgb", + "state": {"value": ""} + }, + { + "type": "devices.capabilities.dynamic_scene", + "instance": "lightScene", + "state": {"value": ""} + } + ] + } +} +``` + +**Note:** Segment colors and active scenes return empty strings - this is a known API limitation. + +### 3.4 Control Commands + +**Power On:** +```bash +curl -s -X POST "$GOVEE_API/device/control" \ + -H "Govee-API-Key: $GOVEE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "requestId": "power-on-001", + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C", + "capability": { + "type": "devices.capabilities.on_off", + "instance": "powerSwitch", + "value": 1 + } + } + }' | jq . +``` + +**Response:** +```json +{ + "requestId": "power-on-001", + "msg": "success", + "code": 200, + "capability": { + "type": "devices.capabilities.on_off", + "instance": "powerSwitch", + "state": {"status": "success"}, + "value": 1 + } +} +``` + +**Set Brightness (50%):** +```bash +curl -s -X POST "$GOVEE_API/device/control" \ + -H "Govee-API-Key: $GOVEE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "requestId": "brightness-001", + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C", + "capability": { + "type": "devices.capabilities.range", + "instance": "brightness", + "value": 50 + } + } + }' | jq . +``` + +**Set RGB Color (Red = 16711680):** +```bash +curl -s -X POST "$GOVEE_API/device/control" \ + -H "Govee-API-Key: $GOVEE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "requestId": "color-001", + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C", + "capability": { + "type": "devices.capabilities.color_setting", + "instance": "colorRgb", + "value": 16711680 + } + } + }' | jq . +``` + +**Set Segment Colors (segments 0-2 = blue):** +```bash +curl -s -X POST "$GOVEE_API/device/control" \ + -H "Govee-API-Key: $GOVEE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "requestId": "segment-001", + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C", + "capability": { + "type": "devices.capabilities.segment_color_setting", + "instance": "segmentedColorRgb", + "value": { + "segment": [0, 1, 2], + "rgb": 255 + } + } + } + }' | jq . +``` + +**Response:** +```json +{ + "requestId": "segment-001", + "msg": "success", + "code": 200, + "capability": { + "type": "devices.capabilities.segment_color_setting", + "instance": "segmentedColorRgb", + "state": {"status": "success"}, + "value": {"segment": [0, 1, 2], "rgb": 255} + } +} +``` + +### 3.5 Get Dynamic Scenes + +```bash +curl -s -X POST "$GOVEE_API/device/scenes" \ + -H "Govee-API-Key: $GOVEE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "requestId": "scenes-001", + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C" + } + }' | jq . +``` + +**Real Response (82 scenes for H601F):** +```json +{ + "requestId": "scenes-001", + "msg": "success", + "code": 200, + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C", + "capabilities": [ + { + "type": "devices.capabilities.dynamic_scene", + "instance": "lightScene", + "parameters": { + "dataType": "ENUM", + "options": [ + {"name": "Rainbow", "value": {"id": 17936, "paramId": 28098}}, + {"name": "Aurora", "value": {"id": 17937, "paramId": 28099}}, + {"name": "Glacier", "value": {"id": 17938, "paramId": 28100}}, + {"name": "Wave", "value": {"id": 17939, "paramId": 28101}}, + {"name": "Deep sea", "value": {"id": 17940, "paramId": 28102}}, + {"name": "Cherry blossoms", "value": {"id": 17941, "paramId": 28103}}, + {"name": "Firefly", "value": {"id": 17942, "paramId": 28104}}, + {"name": "Christmas", "value": {"id": 17961, "paramId": 28123}}, + {"name": "Halloween", "value": {"id": 17958, "paramId": 28120}}, + {"name": "Sunrise", "value": {"id": 17771, "paramId": 27933}}, + {"name": "Sunset", "value": {"id": 17772, "paramId": 27934}}, + {"name": "Sleep", "value": {"id": 17983, "paramId": 28145}}, + {"name": "Reading", "value": {"id": 17776, "paramId": 27938}}, + {"name": "Romantic", "value": {"id": 17998, "paramId": 28160}} + ] + } + } + ] + } +} +``` +*Note: Response truncated - actual response contains 82 scenes including nature, holidays, moods, activities, and space themes.* + +### 3.6 Activate a Scene + +```bash +curl -s -X POST "$GOVEE_API/device/control" \ + -H "Govee-API-Key: $GOVEE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "requestId": "scene-activate-001", + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C", + "capability": { + "type": "devices.capabilities.dynamic_scene", + "instance": "lightScene", + "value": {"id": 17937, "paramId": 28099} + } + } + }' | jq . +``` + +### 3.7 Get DIY Scenes + +```bash +curl -s -X POST "$GOVEE_API/device/diy-scenes" \ + -H "Govee-API-Key: $GOVEE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "requestId": "diy-scenes-001", + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C" + } + }' | jq . +``` + +**Real Response:** +```json +{ + "requestId": "diy-scenes-001", + "msg": "success", + "code": 200, + "payload": { + "sku": "H601F", + "device": "03:9C:DC:06:75:4B:10:7C", + "capabilities": [ + { + "type": "devices.capabilities.dynamic_scene", + "instance": "diyScene", + "parameters": { + "dataType": "ENUM", + "options": [ + {"name": "tj diy", "value": 21104832} + ] + } + } + ] + } +} +``` + +### 3.8 RGB Color Values Reference + +| Color | RGB | Packed Integer | +|-------|-----|----------------| +| Red | (255, 0, 0) | 16711680 | +| Green | (0, 255, 0) | 65280 | +| Blue | (0, 0, 255) | 255 | +| White | (255, 255, 255) | 16777215 | +| Yellow | (255, 255, 0) | 16776960 | +| Cyan | (0, 255, 255) | 65535 | +| Magenta | (255, 0, 255) | 16711935 | +| Orange | (255, 165, 0) | 16753920 | +| Purple | (128, 0, 128) | 8388736 | +| Pink | (255, 192, 203) | 16761035 | + +**Python conversion:** +```python +def rgb_to_int(r, g, b): + return (r << 16) + (g << 8) + b + +def int_to_rgb(color_int): + r = (color_int >> 16) & 0xFF + g = (color_int >> 8) & 0xFF + b = color_int & 0xFF + return (r, g, b) +``` + +### 3.9 Device Type Notes + +**H601F (Floor Lamp):** +- 7 addressable segments (0-6) +- Color temp range: 2700K - 6500K +- Brightness: 1-100% +- 82 dynamic scenes available +- Supports DIY scenes and snapshots + +**SameModeGroup:** +- Virtual device for group control +- Only supports powerSwitch capability +- Cannot query state (no response) + +--- + +## 4. AWS IoT MQTT (Undocumented) + +This protocol provides real-time device state updates and is used by the Govee mobile app for instant synchronization. + +### 3.1 Connection Details + +| Parameter | Value | +|-----------|-------| +| **Endpoint** | `aqm3wd1qlc3dy-ats.iot.us-east-1.amazonaws.com` | +| **Port** | 8883 (MQTT over TLS) | +| **Authentication** | Mutual TLS with client certificates | +| **Keepalive** | 120 seconds | + +*Endpoint confirmed via PCAP analysis: IP 54.147.158.57* + +### 3.2 Authentication Flow + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 1. Login to app2.govee.com │ +│ POST /account/rest/account/v1/login │ +│ Body: { email, password, client } │ +│ Returns: { token, accountId, topic } │ +├──────────────────────────────────────────────────────────────────┤ +│ 2. Get IoT Credentials │ +│ GET /app/v1/account/iot/key │ +│ Header: Authorization: Bearer {token} │ +│ Returns: { endpoint, p12, p12_pass } or PEM format │ +├──────────────────────────────────────────────────────────────────┤ +│ 3. Extract Certificates │ +│ Parse P12/PFX container (base64 decoded) │ +│ Extract: client_cert.pem, client_key.pem │ +│ Use Amazon Root CA 1 for server verification │ +├──────────────────────────────────────────────────────────────────┤ +│ 4. Connect to AWS IoT │ +│ Client ID: AP/{accountId}/{uuid} │ +│ Subscribe: GA/{account-topic-from-login} │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 Client ID Format + +``` +AP/{accountId}/{clientId} +``` +- `accountId`: Numeric account ID from login response (as string) +- `clientId`: 32-character UUID (generated client-side) + +### 3.4 Message Formats + +**Incoming State Update:** +```json +{ + "device": "8C:2E:9C:04:A0:03:82:D1", + "sku": "H6072", + "state": { + "onOff": 1, + "brightness": 50, + "color": { + "r": 255, + "g": 0, + "b": 0 + }, + "colorTemInKelvin": 0 + } +} +``` + +**State Request (Outbound):** +```json +{ + "msg": { + "cmd": "status", + "cmdVersion": 2, + "transaction": "v_1704812400000", + "type": 0 + } +} +``` + +**Power Control (Outbound):** +```json +{ + "msg": { + "cmd": "turn", + "data": { "val": 1 }, + "cmdVersion": 0, + "transaction": "v_1704812400000", + "type": 1 + } +} +``` + +**Brightness Control (Outbound):** +```json +{ + "msg": { + "cmd": "brightness", + "data": { "val": 75 }, + "cmdVersion": 0, + "transaction": "v_1704812400000", + "type": 1 + } +} +``` + +**Color Control (Outbound):** +```json +{ + "msg": { + "cmd": "colorwc", + "data": { + "color": { "r": 255, "g": 0, "b": 128 }, + "colorTemInKelvin": 0 + }, + "cmdVersion": 0, + "transaction": "v_1704812400000", + "type": 1 + } +} +``` + +**BLE Passthrough (ptReal):** +```json +{ + "msg": { + "cmd": "ptReal", + "data": { + "command": ["MwUEzycAAAAAAAAAAAAAAAAAANo="] + }, + "cmdVersion": 0, + "transaction": "v_1704812400000", + "type": 1 + } +} +``` + +### 3.5 PCAP Traffic Analysis + +From the captured PCAP file: + +| Metric | Value | +|--------|-------| +| Session Duration | 296.2 seconds | +| Total Packets | 253 | +| Data Transferred | 64,307 bytes | +| Outbound Messages | 61 | +| Inbound Messages | 78 | +| Typical Request Size | ~205 bytes | +| Typical Response Size | 700-2000 bytes | + +**Timing Pattern:** +- Initial TLS handshake: ~5 seconds +- State request/response: <1 second round-trip +- Idle keepalive: No activity for up to 54 seconds observed + +--- + +## 5. Undocumented Internal API (app2.govee.com) + +Used by the Govee mobile app for extended functionality not available in the public API. + +### 4.1 Base Configuration + +| Parameter | Value | +|-----------|-------| +| **Base URL** | `https://app2.govee.com` | +| **User-Agent** | `GoveeHome/5.6.01 (com.ihoment.GoVeeSensor; build:2; iOS 16.5.0) Alamofire/5.6.4` | + +### 4.2 Authentication + +**Login Request:** +```http +POST /account/rest/account/v1/login +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "password123", + "client": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +**Login Response:** +```json +{ + "status": 200, + "message": "Login successful", + "client": { + "A": "encrypted_value", + "B": "encrypted_value", + "accountId": 12345678, + "client": "550e8400-e29b-41d4-a716-446655440000", + "token": "eyJhbGciOiJIUzI1NiIs...", + "tokenExpireCycle": 604800, + "topic": "GA/a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } +} +``` + +**Authenticated Request Headers:** +```http +Authorization: Bearer {token} +appVersion: 5.6.01 +clientId: {uuid} +clientType: 1 +iotVersion: 0 +timestamp: 1704812400000 +``` + +### 4.3 Key Endpoints + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/account/rest/account/v1/login` | POST | User login | +| `/account/rest/v1/first/refresh-tokens` | POST | Refresh auth tokens | +| `/app/v1/account/iot/key` | GET | Get AWS IoT credentials | +| `/device/rest/devices/v1/list` | POST | List devices with full details | +| `/device/rest/devices/v1/control` | POST | Control devices | +| `/appsku/v1/light-effect-libraries` | GET | Get scene catalog | +| `/appsku/v2/devices/scenes/attributes` | GET | Get scene attributes | +| `/appsku/v1/diys/groups-diys` | GET | Get DIY scenes | +| `/bff-app/v1/exec-plat/home` | GET | Get One-Click/Tap-to-Run | + +### 4.4 Scene Library Request + +```http +GET /appsku/v1/light-effect-libraries?sku=H6072 +AppVersion: 5.6.01 +``` + +**Response:** +```json +{ + "data": { + "categories": [ + { + "categoryId": 1, + "categoryName": "Dynamic", + "scenes": [ + { + "sceneId": 130, + "sceneName": "Forest", + "sceneCode": 10191, + "sceneType": 1, + "lightEffects": [ + { + "scenceParamId": 123, + "scenceParam": "base64-encoded-animation-data" + } + ] + } + ] + }, + { + "categoryId": 2, + "categoryName": "Cozy", + "scenes": [...] + } + ] + } +} +``` + +### 4.5 Rate Limits + +- Login: 30 attempts per 24 hours +- API calls: Undocumented, but appears generous + +--- + +## 6. LAN API (UDP) + +Local network control without cloud dependency. Must be enabled in Govee app device settings. + +### 5.1 Network Configuration + +| Parameter | Value | +|-----------|-------| +| **Multicast Address** | `239.255.255.250` | +| **Discovery Port** | 4001 (device listens) | +| **Response Port** | 4002 (client listens) | +| **Command Port** | 4003 (device listens) | +| **Protocol** | UDP | + +### 5.2 Device Discovery + +**Scan Request (to 239.255.255.250:4001):** +```json +{ + "msg": { + "cmd": "scan", + "data": { + "account_topic": "reserve" + } + } +} +``` + +**Scan Response (from device to client:4002):** +```json +{ + "msg": { + "cmd": "scan", + "data": { + "ip": "192.168.1.23", + "device": "1F:80:C5:32:32:36:72:4E", + "sku": "H618E", + "bleVersionHard": "3.01.01", + "bleVersionSoft": "1.03.01", + "wifiVersionHard": "1.00.10", + "wifiVersionSoft": "1.02.03" + } + } +} +``` + +### 5.3 Control Commands (to device-ip:4003) + +**Power Control:** +```json +{"msg": {"cmd": "turn", "data": {"value": 1}}} +``` + +**Brightness:** +```json +{"msg": {"cmd": "brightness", "data": {"value": 75}}} +``` + +**Color/Temperature:** +```json +{ + "msg": { + "cmd": "colorwc", + "data": { + "color": {"r": 255, "g": 0, "b": 128}, + "colorTemInKelvin": 0 + } + } +} +``` + +**Status Query:** +```json +{"msg": {"cmd": "devStatus", "data": {}}} +``` + +**Status Response:** +```json +{ + "msg": { + "cmd": "devStatus", + "data": { + "onOff": 1, + "brightness": 100, + "color": {"r": 255, "g": 0, "b": 0}, + "colorTemInKelvin": 0 + } + } +} +``` + +### 5.4 BLE Passthrough (ptReal) + +Send BLE commands through WiFi for devices supporting it: + +```json +{ + "msg": { + "cmd": "ptReal", + "data": { + "command": ["MwUEzycAAAAAAAAAAAAAAAAAANo="] + } + } +} +``` + +### 5.5 Supported Devices + +Devices with confirmed LAN API support: +- H619Z, H6072, H619C, H7060, H619B +- H6066, H619D, H619E, H61A1, H61A3 +- H61A2, H618A, H619A, H61A0, H6110 +- H6117, H6159, H6163, H6141, H6052 +- H6144, H615A, H6056, H6143, H6076 +- H6062, H6061, and more + +### 5.6 Python Implementation + +```python +import socket +import json + +MULTICAST_GROUP = '239.255.255.250' +DISCOVERY_PORT = 4001 +COMMAND_PORT = 4003 + +def discover_devices(timeout=5): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.settimeout(timeout) + + # Bind to response port + sock.bind(('', 4002)) + + message = json.dumps({ + "msg": {"cmd": "scan", "data": {"account_topic": "reserve"}} + }).encode() + + sock.sendto(message, (MULTICAST_GROUP, DISCOVERY_PORT)) + + devices = [] + while True: + try: + data, addr = sock.recvfrom(1024) + devices.append(json.loads(data.decode())) + except socket.timeout: + break + + sock.close() + return devices + +def send_command(device_ip, cmd, data): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + message = json.dumps({"msg": {"cmd": cmd, "data": data}}).encode() + sock.sendto(message, (device_ip, COMMAND_PORT)) + sock.close() + +# Example usage +devices = discover_devices() +for d in devices: + ip = d['msg']['data']['ip'] + send_command(ip, "turn", {"value": 1}) # Turn on + send_command(ip, "brightness", {"value": 50}) # 50% brightness +``` + +--- + +## 7. BLE Protocol + +Direct Bluetooth Low Energy control for devices without WiFi or for local-only operation. + +### 6.1 Service Configuration + +| Parameter | Value | +|-----------|-------| +| **Service UUID** | `00010203-0405-0607-0809-0a0b0c0d1910` | +| **Write Characteristic** | `00010203-0405-0607-0809-0a0b0c0d2b11` | +| **Read Characteristic** | `00010203-0405-0607-0809-0a0b0c0d2b10` | + +### 6.2 Packet Structure + +All commands are **20 bytes** with XOR checksum: + +``` +┌──────────┬─────────┬──────────┬──────────────────┬──────────┐ +│ ID (1B) │ Cmd(1B) │ Mode(1B) │ Data (16B) │ XOR (1B) │ +└──────────┴─────────┴──────────┴──────────────────┴──────────┘ +``` + +### 6.3 Identifier Bytes + +| Byte | Purpose | +|------|---------| +| `0x33` | Standard command | +| `0xAA` | Keep-alive signal | +| `0xA1` | DIY mode data | +| `0xA3` | Multi-packet data | + +### 6.4 Command Types + +| Command | Byte | Description | +|---------|------|-------------| +| Power | `0x01` | On/Off control | +| Brightness | `0x04` | Brightness level | +| Color/Mode | `0x05` | Color and mode operations | +| Segment | `0x0B` | Segment control | +| Gradient | `0x14` | Gradient toggle | + +### 6.5 Checksum Calculation + +```python +def calculate_checksum(data: list[int]) -> int: + """XOR all bytes together""" + checksum = 0 + for byte in data: + checksum ^= byte + return checksum & 0xFF + +def build_packet(data: list[int]) -> bytes: + """Build 20-byte packet with checksum""" + packet = list(data) + while len(packet) < 19: + packet.append(0x00) + packet.append(calculate_checksum(packet)) + return bytes(packet) +``` + +### 6.6 Command Examples + +**Power On:** +``` +33 01 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 33 +``` + +**Power Off:** +``` +33 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 32 +``` + +**Brightness (50% = 0x80):** +``` +33 04 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 B7 +``` + +**RGB Color (Manual Mode):** +``` +33 05 02 [R] [G] [B] 00 00 00 00 00 00 00 00 00 00 00 00 00 [XOR] +``` + +**Enable Gradient:** +``` +33 14 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 26 +``` + +### 6.7 Color Mode Bytes (after 0x05) + +| Byte | Mode | +|------|------| +| `0x02` | Manual RGB | +| `0x01` | Music mode | +| `0x04` | Scene mode | +| `0x05` | Preset scenes | +| `0x0A` | DIY mode | +| `0x0B` | Segment color | + +### 6.8 Scene Activation + +``` +33 05 04 [SceneCode_Low] [SceneCode_High] 00...00 [XOR] +``` + +Scene codes from the scene library API are split little-endian: +- Scene code 10191 = 0x27CF +- Packet: `33 05 04 CF 27 00...00 [XOR]` + +### 6.9 Keep-Alive + +Send every 2 seconds to maintain connection: +``` +AA 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 AB +``` + +### 6.10 Python Implementation + +```python +import asyncio +from bleak import BleakClient + +WRITE_UUID = "00010203-0405-0607-0809-0a0b0c0d2b11" + +def build_packet(data: list[int]) -> bytes: + packet = list(data) + while len(packet) < 19: + packet.append(0x00) + checksum = 0 + for b in packet: + checksum ^= b + packet.append(checksum) + return bytes(packet) + +async def control_light(address: str): + async with BleakClient(address) as client: + # Power on + await client.write_gatt_char( + WRITE_UUID, + build_packet([0x33, 0x01, 0x01]) + ) + + # Set brightness to 50% + await client.write_gatt_char( + WRITE_UUID, + build_packet([0x33, 0x04, 0x80]) + ) + + # Set color to red + await client.write_gatt_char( + WRITE_UUID, + build_packet([0x33, 0x05, 0x02, 0xFF, 0x00, 0x00]) + ) + +asyncio.run(control_light("AA:BB:CC:DD:EE:FF")) +``` + +--- + +## 8. State Management + +### 7.1 State Sources + +| Source | Update Method | Latency | Completeness | +|--------|--------------|---------|--------------| +| API Polling | HTTP GET | 2-4s | Full state | +| AWS IoT MQTT | Push | ~50ms | Full state | +| Official MQTT | Push | ~100ms | Events only | +| LAN Status | UDP request | <10ms | Basic state | +| Optimistic | Assumed | 0ms | Command only | + +### 7.2 State Fields + +```typescript +interface DeviceState { + // Core state + onOff: 0 | 1; + brightness: number; // 0-100 + + // Color state (mutually exclusive with colorTemp) + color?: { + r: number; // 0-255 + g: number; // 0-255 + b: number; // 0-255 + }; + + // Color temperature (mutually exclusive with color) + colorTemInKelvin?: number; // 2000-9000 + + // Mode state + mode?: string; + scene?: { + id: number; + name: string; + }; + + // Segment state (RGBIC devices) + segments?: Array<{ + index: number; + color: { r: number; g: number; b: number }; + brightness: number; + }>; +} +``` + +### 7.3 Optimistic Updates + +After sending a command, update local state immediately: + +```python +class StateManager: + def __init__(self): + self.confirmed_state = {} + self.pending_state = {} + + async def send_command(self, device_id, command): + # Apply optimistic update + self.pending_state[device_id] = { + **self.confirmed_state.get(device_id, {}), + **command + } + + # Send command + await api.control_device(device_id, command) + + # Wait for confirmation via MQTT or poll + # On confirmation, merge to confirmed_state +``` + +### 7.4 Conflict Resolution + +When optimistic state conflicts with confirmed state: + +1. **Timestamp-based**: Prefer most recent update +2. **Source priority**: MQTT > API Poll > Optimistic +3. **Attribute-specific**: Only update changed attributes + +### 7.5 Known Limitations + +These states are NOT returned by the API: +- Active music mode settings +- Night light mode status +- Gradient mode toggle +- Individual segment colors (RGBIC) +- Active scene name/ID + +--- + +## 9. Device Capabilities + +### 8.1 Capability Types + +| Type | Description | +|------|-------------| +| `devices.capabilities.on_off` | Power control | +| `devices.capabilities.toggle` | Feature toggles | +| `devices.capabilities.range` | Ranged values (brightness) | +| `devices.capabilities.color_setting` | Color/temp control | +| `devices.capabilities.segment_color_setting` | RGBIC segment control | +| `devices.capabilities.dynamic_scene` | Scene selection | +| `devices.capabilities.diy_color_setting` | DIY scenes | +| `devices.capabilities.music_setting` | Music mode | +| `devices.capabilities.work_mode` | Appliance modes | +| `devices.capabilities.online` | Online status | +| `devices.capabilities.event` | Real-time events | + +### 8.2 Instance Names + +| Capability | Instances | +|------------|-----------| +| on_off | `powerSwitch` | +| toggle | `gradientToggle`, `nightlightToggle`, `oscillationToggle`, `warmMistToggle` | +| range | `brightness`, `humidity`, `volume` | +| color_setting | `colorRgb`, `colorTemperatureK` | +| segment_color_setting | `segmentedColorRgb`, `segmentedBrightness` | +| dynamic_scene | `lightScene`, `diyScene`, `snapshot` | +| music_setting | `musicMode` | + +### 8.3 Device Type Detection + +```python +def detect_capabilities(device_response): + capabilities = device_response.get("capabilities", []) + + features = { + "power": False, + "brightness": False, + "color": False, + "color_temp": False, + "segments": False, + "scenes": False, + "music": False, + } + + for cap in capabilities: + cap_type = cap.get("type", "") + instance = cap.get("instance", "") + + if "on_off" in cap_type: + features["power"] = True + elif "range" in cap_type and instance == "brightness": + features["brightness"] = True + elif "color_setting" in cap_type: + if instance == "colorRgb": + features["color"] = True + elif instance == "colorTemperatureK": + features["color_temp"] = True + elif "segment_color" in cap_type: + features["segments"] = True + elif "dynamic_scene" in cap_type: + features["scenes"] = True + elif "music_setting" in cap_type: + features["music"] = True + + return features +``` + +### 8.4 Device Types + +| Type | Examples | +|------|----------| +| `devices.types.light` | LED strips, bulbs, bars | +| `devices.types.socket` | Smart plugs | +| `devices.types.air_purifier` | Air purifiers | +| `devices.types.humidifier` | Humidifiers | +| `devices.types.heater` | Space heaters | +| `devices.types.thermometer` | Temp/humidity sensors | +| `devices.types.sensor` | Motion, presence sensors | + +--- + +## 10. Scene & DIY Modes + +### 9.1 Scene Types + +| Type | Source | Description | +|------|--------|-------------| +| **Dynamic Scenes** | Official API | Pre-built animations | +| **DIY Scenes** | Official API | User-created via app | +| **Light Effect Library** | app2 API | Full scene catalog | + +### 9.2 Fetching Scenes (Official API) + +```http +POST /router/api/v1/device/scenes + +{ + "requestId": "uuid", + "payload": { + "sku": "H618E", + "device": "8C:2E:9C:04:A0:03:82:D1" + } +} +``` + +### 9.3 Fetching Full Scene Catalog (Undocumented) + +```http +GET https://app2.govee.com/appsku/v1/light-effect-libraries?sku=H6072 +``` + +Response includes: +- Category organization +- Scene codes for BLE activation +- Animation parameters + +### 9.4 Activating Scenes + +**Via API:** +```json +{ + "type": "devices.capabilities.dynamic_scene", + "instance": "lightScene", + "value": {"id": 3853, "paramId": 4280} +} +``` + +**Via BLE:** +``` +33 05 04 [code_low] [code_high] 00...00 [XOR] +``` + +### 9.5 DIY Mode Creation + +DIY modes use multi-packet BLE sequences: + +1. **Start packet:** `A1 02 00 [count] ...` +2. **Color data:** `A1 02 [num] [style] [mode] [speed] ...` +3. **End packet:** `A1 02 FF ...` +4. **Activate:** `33 05 0A ...` + +DIY Styles: +- `0x00` = Fade +- `0x01` = Jumping +- `0x02` = Flicker +- `0x03` = Marquee +- `0x04` = Music reactive + +--- + +## 11. PCAP Analysis Details + +### 10.1 Capture Information + +| Field | Value | +|-------|-------| +| **File** | `PCAPdroid_09_Jan_19_27_26.pcap` | +| **Size** | 7,091,980 bytes (6.8 MB) | +| **Packets** | 3,281 total | +| **Duration** | ~5 minutes | +| **Source** | PCAPdroid (Android) | + +### 10.2 Traffic Breakdown + +| Protocol | Packets | Bytes | Purpose | +|----------|---------|-------|---------| +| HTTPS (443) | 2,976 | ~5.8 MB | App API, CDN | +| MQTT (8883) | 285 | ~64 KB | AWS IoT | +| DNS (53) | 20 | ~2 KB | Name resolution | + +### 10.3 Server IPs Observed + +| IP | Service | +|----|---------| +| 54.90.251.176 | app2.govee.com | +| 54.147.158.57 | AWS IoT MQTT | +| 99.84.237.* | CloudFront CDN | +| 3.161.193.69 | Unknown (AWS) | +| 74.125.136.95 | Google (analytics) | + +### 10.4 TLS SNI Hostnames + +- `aqm3wd1qlc3dy-ats.iot.us-east-1.amazonaws.com` +- `app2.govee.com` +- `app-h5-manifest.govee.com` +- `govee.com` +- Various Amazon Trust CRL/OCSP endpoints + +### 10.5 Connection Patterns + +**MQTT Session:** +- Connection established at t=0 +- TLS handshake: ~200ms +- State requests every 3-10 seconds during activity +- Keepalive maintains connection during idle +- Total session: 296 seconds + +**API Patterns:** +- Burst of requests during app activity +- Scene/asset downloads from CDN +- Token refresh observed + +--- + +## 12. References + +### Official Documentation +- [Govee Developer Platform](https://developer.govee.com/) +- [API Reference PDF v2.0](https://govee-public.s3.amazonaws.com/developer-docs/GoveeDeveloperAPIReference.pdf) +- [LAN API Guide](https://app-h5.govee.com/user-manual/wlan-guide) + +### Community Projects +- [wez/govee2mqtt](https://github.com/wez/govee2mqtt) - Rust, AWS IoT + LAN +- [homebridge-govee](https://github.com/bwp91/homebridge-govee) - Homebridge plugin +- [egold555/Govee-Reverse-Engineering](https://github.com/egold555/Govee-Reverse-Engineering) - BLE docs +- [BeauJBurroughs/Govee-H6127-Reverse-Engineering](https://github.com/BeauJBurroughs/Govee-H6127-Reverse-Engineering) + +### Reverse Engineering +- [coding.kiwi - Reverse Engineering Govee](https://blog.coding.kiwi/reverse-engineering-govee-smart-lights/) +- [XDA - Govee Reverse Engineering](https://www.xda-developers.com/reverse-engineered-govee-smart-lights-smart-home/) +- [LAN API Gist](https://gist.github.com/mtwilliams5/08ae4782063b57a9b430069044f443f6) + +### Home Assistant Community +- [Govee Integration Thread](https://community.home-assistant.io/t/govee-integration/228516) +- [Govee LAN API Announcement](https://community.home-assistant.io/t/govee-news-theres-a-local-api/460757) + +--- + +*This document is based on analysis of the Govee Android app via PCAP capture and community reverse engineering efforts. The undocumented APIs may change without notice.* diff --git a/hacs.json b/hacs.json index 6aae722f..fda3248c 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,5 @@ { - "name": "govee", - "homeassistant": "2023.11.2" + "name": "Govee", + "homeassistant": "2024.11.0", + "render_readme": true } diff --git a/info.md b/info.md deleted file mode 100644 index 7eb54f38..00000000 --- a/info.md +++ /dev/null @@ -1,54 +0,0 @@ -[![hacs][hacsbadge]][hacs] -![Project Maintenance][maintenance-shield] -[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] - - -_Component to integrate with [Govee][hacs-govee]._ - -**This component will set up the following platforms.** - -Platform | Description --- | -- -`light` | Control your lights. - - - -{% if not installed %} -## Installation - -1. In HACS/Integrations, search for 'Govee' and click install. -1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "govee". - -{% endif %} - -## Configuration is done in the UI - -Usually you just add the integration, enter api-key and poll interval and you are good to go. When you need further help you can look here: - -* [Documentation on GitHub](https://github.com/LaggAt/hacs-govee/blob/master/README.md) -* [Support thread on Home Assistant Community](https://community.home-assistant.io/t/govee-led-strips-integration/228516/1) -* [Is there an issue with Govee API or the library?](https://raw.githubusercontent.com/LaggAt/actions/main/output/govee-api-up.png) -* [Version statistics taken from Home Assistant Analytics](https://raw.githubusercontent.com/LaggAt/actions/main/output/goveestats_installations.png) - -## Sponsor - -A lot of effort is going into that integration. So if you can afford it and want to support us: - -Buy Me A Coffee - -Thank you! - - - -*** - -[hacs-govee]: https://github.com/LaggAt/hacs-govee -[buymecoffee]: https://www.buymeacoffee.com/LaggAt -[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge -[hacs]: https://github.com/hacs/integration -[hacsbadge]: https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge -[exampleimg]: example.png -[license-shield]: https://img.shields.io/github/license/LaggAt/hacs-govee -[maintenance-shield]: https://img.shields.io/badge/maintainer-Florian%20Lagg-blue.svg?style=for-the-badge -[releases-shield]: https://img.shields.io/github/release/custom-components/hacs-govee.svg?style=for-the-badge -[releases]: https://github.com/custom-components/hacs-govee/releases diff --git a/requirements_test.txt b/requirements_test.txt index 745d00bd..29aeb4fa 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,3 +1,9 @@ +# Runtime dependencies (from manifest.json) +aiohttp-retry>=2.8.3 +aiomqtt>=2.0.0 +cryptography>=41.0.0 + +# Test dependencies asynctest black colorlog diff --git a/setup.cfg b/setup.cfg index 9c79c525..4909647a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,13 +1,7 @@ [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build doctests = True -# To work with Black max-line-length = 88 -# E501: line too long -# W503: Line break occurred before a binary operator -# E203: Whitespace before ':' -# D202 No blank lines allowed after function docstring -# W504 line break after binary operator ignore = E501, W503, @@ -16,20 +10,95 @@ ignore = W504 [isort] -# https://github.com/timothycrosley/isort -# https://github.com/timothycrosley/isort/wiki/isort-Settings -# splits long import on multiple lines indented by 4 spaces multi_line_output = 3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +line_length = 88 indent = " " -# by default isort don't check module indexes not_skip = __init__.py -# will group `import x` and `from x import` of the same module. force_sort_within_sections = true sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER default_section = THIRDPARTY known_first_party = custom_components.govee combine_as_imports = true + +[mypy] +python_version = 3.12 +platform = linux +show_column_numbers = True +follow_imports = normal +strict = True +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = True +disallow_any_unimported = False +disallow_any_expr = False +disallow_any_decorated = False +disallow_any_explicit = False +disallow_subclassing_any = True +disallow_untyped_calls = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_untyped_decorators = False +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_no_return = True +warn_unreachable = True +strict_optional = True +show_error_context = True +show_error_codes = True +pretty = True + +[mypy-pytest.*] +ignore_missing_imports = True + +[mypy-aiohttp.*] +ignore_missing_imports = True + +[mypy-async_timeout.*] +ignore_missing_imports = True + +[mypy-homeassistant.*] +ignore_missing_imports = True + +[mypy-pytest_homeassistant_custom_component.*] +ignore_missing_imports = True + +[mypy-aiohttp_retry.*] +ignore_missing_imports = True + +[mypy-aiomqtt.*] +ignore_missing_imports = True + +[mypy-tests.*] +ignore_errors = True + +[tool:pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --cov=custom_components.govee + --cov-report=term-missing + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + -v + --strict-markers + --strict-config +markers = + unit: Unit tests that don't require Home Assistant + integration: Integration tests with Home Assistant + api: Tests for API client + coordinator: Tests for data coordinator + entity: Tests for entity implementations + config_flow: Tests for configuration flow + slow: Tests that take longer to run +norecursedirs = .git .tox build dist *.egg .venv +filterwarnings = + error + ignore::DeprecationWarning + ignore::PendingDeprecationWarning diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..fe2eebf3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,300 @@ +"""Test fixtures for Govee integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from custom_components.govee.api import ( + GoveeApiClient, + GoveeIotCredentials, +) +from custom_components.govee.models import ( + GoveeCapability, + GoveeDevice, + GoveeDeviceState, + RGBColor, +) +from custom_components.govee.models.device import ( + CAPABILITY_COLOR_SETTING, + CAPABILITY_DYNAMIC_SCENE, + CAPABILITY_ON_OFF, + CAPABILITY_RANGE, + CAPABILITY_SEGMENT_COLOR, + INSTANCE_BRIGHTNESS, + INSTANCE_COLOR_RGB, + INSTANCE_COLOR_TEMP, + INSTANCE_POWER, + INSTANCE_SCENE, +) + +# Capability constants for test devices +DEVICE_TYPE_LIGHT = "devices.types.light" +DEVICE_TYPE_PLUG = "devices.types.socket" + + +@pytest.fixture +def mock_api_client() -> Generator[AsyncMock, None, None]: + """Create a mock API client.""" + client = AsyncMock(spec=GoveeApiClient) + client.rate_limit_remaining = 100 + client.rate_limit_total = 100 + client.rate_limit_reset = 0 + client.get_devices = AsyncMock(return_value=[]) + client.get_device_state = AsyncMock() + client.control_device = AsyncMock(return_value=True) + client.get_dynamic_scenes = AsyncMock(return_value=[]) + client.close = AsyncMock() + yield client + + +@pytest.fixture +def mock_iot_credentials() -> GoveeIotCredentials: + """Create mock IoT credentials.""" + return GoveeIotCredentials( + token="test_token", + refresh_token="test_refresh", + account_topic="GA/test_account", + iot_cert="-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + iot_key="-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + iot_ca=None, + client_id="AP/12345/testclient", + endpoint="test.iot.amazonaws.com", + ) + + +@pytest.fixture +def light_capabilities() -> tuple[GoveeCapability, ...]: + """Create capabilities for a typical light device.""" + return ( + GoveeCapability( + type=CAPABILITY_ON_OFF, + instance=INSTANCE_POWER, + parameters={}, + ), + GoveeCapability( + type=CAPABILITY_RANGE, + instance=INSTANCE_BRIGHTNESS, + parameters={"range": {"min": 0, "max": 100}}, + ), + GoveeCapability( + type=CAPABILITY_COLOR_SETTING, + instance=INSTANCE_COLOR_RGB, + parameters={}, + ), + GoveeCapability( + type=CAPABILITY_COLOR_SETTING, + instance=INSTANCE_COLOR_TEMP, + parameters={"range": {"min": 2000, "max": 9000}}, + ), + GoveeCapability( + type=CAPABILITY_DYNAMIC_SCENE, + instance=INSTANCE_SCENE, + parameters={}, + ), + ) + + +@pytest.fixture +def rgbic_capabilities(light_capabilities) -> tuple[GoveeCapability, ...]: + """Create capabilities for an RGBIC device. + + Matches real API response structure with fields/elementRange. + """ + return light_capabilities + ( + GoveeCapability( + type=CAPABILITY_SEGMENT_COLOR, + instance="segmentedColorRgb", + parameters={ + "dataType": "STRUCT", + "fields": [ + { + "fieldName": "segment", + "size": {"min": 1, "max": 15}, + "dataType": "Array", + "elementRange": {"min": 0, "max": 14}, + "elementType": "INTEGER", + "required": True, + }, + { + "fieldName": "rgb", + "dataType": "INTEGER", + "range": {"min": 0, "max": 16777215}, + "required": True, + }, + ], + }, + ), + ) + + +@pytest.fixture +def plug_capabilities() -> tuple[GoveeCapability, ...]: + """Create capabilities for a smart plug.""" + return ( + GoveeCapability( + type=CAPABILITY_ON_OFF, + instance=INSTANCE_POWER, + parameters={}, + ), + ) + + +@pytest.fixture +def mock_light_device(light_capabilities) -> GoveeDevice: + """Create a mock light device.""" + return GoveeDevice( + device_id="AA:BB:CC:DD:EE:FF:00:11", + sku="H6072", + name="Living Room Light", + device_type=DEVICE_TYPE_LIGHT, + capabilities=light_capabilities, + is_group=False, + ) + + +@pytest.fixture +def mock_rgbic_device(rgbic_capabilities) -> GoveeDevice: + """Create a mock RGBIC LED strip device.""" + return GoveeDevice( + device_id="AA:BB:CC:DD:EE:FF:00:22", + sku="H6167", + name="Bedroom LED Strip", + device_type=DEVICE_TYPE_LIGHT, + capabilities=rgbic_capabilities, + is_group=False, + ) + + +@pytest.fixture +def mock_plug_device(plug_capabilities) -> GoveeDevice: + """Create a mock smart plug device.""" + return GoveeDevice( + device_id="AA:BB:CC:DD:EE:FF:00:33", + sku="H5080", + name="Office Plug", + device_type=DEVICE_TYPE_PLUG, + capabilities=plug_capabilities, + is_group=False, + ) + + +@pytest.fixture +def mock_group_device(light_capabilities) -> GoveeDevice: + """Create a mock group device.""" + return GoveeDevice( + device_id="GROUP:AA:BB:CC:DD:EE:FF", + sku="GROUP", + name="All Lights", + device_type="devices.types.group", + capabilities=light_capabilities, + is_group=True, + ) + + +@pytest.fixture +def mock_device_state() -> GoveeDeviceState: + """Create a mock device state.""" + return GoveeDeviceState( + device_id="AA:BB:CC:DD:EE:FF:00:11", + online=True, + power_state=True, + brightness=75, + color=RGBColor(r=255, g=128, b=64), + color_temp_kelvin=None, + active_scene=None, + source="api", + ) + + +@pytest.fixture +def mock_device_state_off() -> GoveeDeviceState: + """Create a mock device state (off).""" + return GoveeDeviceState( + device_id="AA:BB:CC:DD:EE:FF:00:11", + online=True, + power_state=False, + brightness=0, + color=None, + color_temp_kelvin=None, + active_scene=None, + source="api", + ) + + +@pytest.fixture +def mock_scenes() -> list[dict[str, Any]]: + """Create mock scene data.""" + return [ + {"name": "Sunrise", "value": {"id": 1}}, + {"name": "Sunset", "value": {"id": 2}}, + {"name": "Party", "value": {"id": 3}}, + {"name": "Movie", "value": {"id": 4}}, + ] + + +@pytest.fixture +def api_device_response() -> dict[str, Any]: + """Create a mock API device response.""" + return { + "device": "AA:BB:CC:DD:EE:FF:00:11", + "sku": "H6072", + "deviceName": "Living Room Light", + "type": "devices.types.light", + "capabilities": [ + {"type": CAPABILITY_ON_OFF, "instance": INSTANCE_POWER, "parameters": {}}, + { + "type": CAPABILITY_RANGE, + "instance": INSTANCE_BRIGHTNESS, + "parameters": {"range": {"min": 0, "max": 100}}, + }, + {"type": CAPABILITY_COLOR_SETTING, "instance": INSTANCE_COLOR_RGB, "parameters": {}}, + ], + } + + +@pytest.fixture +def api_state_response() -> dict[str, Any]: + """Create a mock API state response.""" + return { + "capabilities": [ + { + "type": "devices.capabilities.online", + "instance": "online", + "state": {"value": True}, + }, + { + "type": CAPABILITY_ON_OFF, + "instance": INSTANCE_POWER, + "state": {"value": 1}, + }, + { + "type": CAPABILITY_RANGE, + "instance": INSTANCE_BRIGHTNESS, + "state": {"value": 75}, + }, + { + "type": CAPABILITY_COLOR_SETTING, + "instance": INSTANCE_COLOR_RGB, + "state": {"value": 16744512}, # RGB(255, 128, 64) + }, + ], + } + + +@pytest.fixture +def mqtt_state_message() -> dict[str, Any]: + """Create a mock MQTT state message.""" + return { + "device": "AA:BB:CC:DD:EE:FF:00:11", + "sku": "H6072", + "state": { + "onOff": 1, + "brightness": 75, + "color": {"r": 255, "g": 128, "b": 64}, + "colorTemInKelvin": 0, + }, + } diff --git a/tests/test_api_client.py b/tests/test_api_client.py new file mode 100644 index 00000000..1c9767b7 --- /dev/null +++ b/tests/test_api_client.py @@ -0,0 +1,288 @@ +"""Test Govee API client.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import pytest + +from custom_components.govee.api.client import GoveeApiClient +from custom_components.govee.api.exceptions import ( + GoveeApiError, + GoveeAuthError, + GoveeConnectionError, + GoveeDeviceNotFoundError, + GoveeRateLimitError, +) +from custom_components.govee.models import PowerCommand + + +# ============================================================================== +# Exception Tests +# ============================================================================== + + +class TestExceptions: + """Test API exceptions.""" + + def test_govee_api_error(self): + """Test base API error.""" + err = GoveeApiError("Test error", code=500) + assert str(err) == "Test error" + assert err.code == 500 + + def test_govee_api_error_no_code(self): + """Test API error without code.""" + err = GoveeApiError("Test error") + assert err.code is None + + def test_govee_auth_error(self): + """Test auth error.""" + err = GoveeAuthError() + assert "Invalid API key" in str(err) + assert err.code == 401 + + def test_govee_auth_error_custom_message(self): + """Test auth error with custom message.""" + err = GoveeAuthError("Token expired") + assert str(err) == "Token expired" + assert err.code == 401 + + def test_govee_rate_limit_error(self): + """Test rate limit error.""" + err = GoveeRateLimitError() + assert "Rate limit" in str(err) + assert err.code == 429 + assert err.retry_after is None + + def test_govee_rate_limit_error_with_retry(self): + """Test rate limit error with retry_after.""" + err = GoveeRateLimitError(retry_after=30.0) + assert err.retry_after == 30.0 + + def test_govee_connection_error(self): + """Test connection error.""" + err = GoveeConnectionError() + assert "connect" in str(err).lower() + assert err.code is None + + def test_govee_device_not_found_error(self): + """Test device not found error.""" + err = GoveeDeviceNotFoundError("AA:BB:CC:DD") + assert "AA:BB:CC:DD" in str(err) + assert err.code == 400 + assert err.device_id == "AA:BB:CC:DD" + + +# ============================================================================== +# API Client Tests +# ============================================================================== + + +class TestGoveeApiClient: + """Test GoveeApiClient.""" + + @pytest.fixture + def mock_session(self): + """Create a mock aiohttp session.""" + session = MagicMock(spec=aiohttp.ClientSession) + session.close = AsyncMock() + return session + + @pytest.fixture + def client(self, mock_session): + """Create an API client with mock session.""" + return GoveeApiClient("test_api_key", session=mock_session) + + def test_client_creation(self): + """Test creating a client.""" + client = GoveeApiClient("test_key") + assert client._api_key == "test_key" + + def test_get_headers(self): + """Test request headers.""" + client = GoveeApiClient("test_api_key") + headers = client._get_headers() + assert headers["Govee-API-Key"] == "test_api_key" + assert headers["Content-Type"] == "application/json" + assert headers["Accept"] == "application/json" + + def test_rate_limit_tracking(self): + """Test rate limit header parsing.""" + client = GoveeApiClient("test_key") + headers = { + "X-RateLimit-Remaining": "50", + "X-RateLimit-Limit": "100", + "X-RateLimit-Reset": "1699999999", + } + client._update_rate_limits(headers) + assert client.rate_limit_remaining == 50 + assert client.rate_limit_total == 100 + assert client.rate_limit_reset == 1699999999 + + def test_rate_limit_tracking_invalid(self): + """Test rate limit with invalid values.""" + client = GoveeApiClient("test_key") + original_remaining = client.rate_limit_remaining + headers = { + "X-RateLimit-Remaining": "invalid", + "X-RateLimit-Limit": "not_a_number", + } + client._update_rate_limits(headers) + # Should not change on invalid values + assert client.rate_limit_remaining == original_remaining + + def test_rate_limit_initial_values(self): + """Test initial rate limit values.""" + client = GoveeApiClient("test_key") + assert client.rate_limit_remaining == 100 + assert client.rate_limit_total == 100 + assert client.rate_limit_reset == 0 + + +# ============================================================================== +# Response Handling Tests +# ============================================================================== + + +class TestResponseHandling: + """Test API response handling patterns.""" + + def test_device_response_structure(self): + """Test expected device response structure.""" + response = { + "code": 200, + "data": [ + { + "device": "AA:BB:CC:DD:EE:FF:00:11", + "sku": "H6072", + "deviceName": "Living Room Light", + "type": "devices.types.light", + "capabilities": [], + }, + ], + } + + assert response["code"] == 200 + assert len(response["data"]) == 1 + assert response["data"][0]["device"] == "AA:BB:CC:DD:EE:FF:00:11" + + def test_state_response_structure(self): + """Test expected state response structure.""" + response = { + "code": 200, + "payload": { + "capabilities": [ + { + "type": "devices.capabilities.online", + "instance": "online", + "state": {"value": True}, + }, + { + "type": "devices.capabilities.on_off", + "instance": "powerSwitch", + "state": {"value": 1}, + }, + ], + }, + } + + assert response["code"] == 200 + assert "capabilities" in response["payload"] + + def test_scenes_response_structure(self): + """Test expected scenes response structure.""" + response = { + "code": 200, + "payload": { + "capabilities": [ + { + "type": "devices.capabilities.dynamic_scene", + "instance": "lightScene", + "parameters": { + "options": [ + {"name": "Sunrise", "value": {"id": 1}}, + {"name": "Sunset", "value": {"id": 2}}, + ], + }, + }, + ], + }, + } + + scenes = response["payload"]["capabilities"][0]["parameters"]["options"] + assert len(scenes) == 2 + assert scenes[0]["name"] == "Sunrise" + + +# ============================================================================== +# Command Payload Tests +# ============================================================================== + + +class TestCommandPayloads: + """Test command payload generation.""" + + def test_power_command_payload(self): + """Test power command payload structure matches Govee API v2.0.""" + cmd = PowerCommand(power_on=True) + payload = cmd.to_api_payload() + + assert payload["type"] == "devices.capabilities.on_off" + assert payload["instance"] == "powerSwitch" + assert payload["value"] == 1 + + def test_power_off_command_payload(self): + """Test power off command payload.""" + cmd = PowerCommand(power_on=False) + assert cmd.get_value() == 0 + + def test_power_on_command_payload(self): + """Test power on command payload.""" + cmd = PowerCommand(power_on=True) + assert cmd.get_value() == 1 + + +# ============================================================================== +# Error Response Tests +# ============================================================================== + + +class TestErrorResponses: + """Test error response handling patterns.""" + + def test_auth_error_response(self): + """Test 401 auth error response.""" + response_code = 401 + assert response_code == 401 + + # This should trigger GoveeAuthError + err = GoveeAuthError("Invalid API key") + assert err.code == 401 + + def test_rate_limit_response(self): + """Test 429 rate limit response.""" + retry_after = 60 + + err = GoveeRateLimitError(retry_after=float(retry_after)) + assert err.code == 429 + assert err.retry_after == 60.0 + + def test_device_not_found_response(self): + """Test 400 device not found response.""" + message = "devices not exist" + + # Check if message indicates device not found + is_device_not_found = "not exist" in message.lower() + assert is_device_not_found + + err = GoveeDeviceNotFoundError("test_device") + assert err.code == 400 + + def test_server_error_response(self): + """Test 500 server error response.""" + response_code = 500 + + err = GoveeApiError("Server error", code=response_code) + assert err.code == 500 diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 84e05d44..11eb1257 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,77 +1,488 @@ """Test the Govee config flow.""" -from homeassistant import config_entries, setup -from custom_components.govee.const import DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_DELAY -from homeassistant.core import HomeAssistant - -# from tests.async_mock import patch -from unittest.mock import patch - - -async def test_form(hass: HomeAssistant): - """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - with patch( - "custom_components.govee.config_flow.Govee.get_devices", - return_value=([], None), - ), patch( - "custom_components.govee.async_setup", return_value=True - ) as mock_setup, patch( - "custom_components.govee.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "api_key", CONF_DELAY: 7}, - ) - assert result2["type"] == "create_entry" - assert result2["title"] == "govee" - assert result2["data"] == {"api_key": "api_key", "delay": 7} - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_cannot_connect(hass: HomeAssistant): - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "custom_components.govee.config_flow.Govee.get_devices", - return_value=(None, "connection error"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "api_key", CONF_DELAY: 7}, - ) +from __future__ import annotations + +from unittest.mock import MagicMock +import pytest + +from custom_components.govee.api.exceptions import GoveeApiError, GoveeAuthError +from custom_components.govee.const import ( + CONF_API_KEY, + CONF_EMAIL, + CONF_ENABLE_GROUPS, + CONF_ENABLE_SCENES, + CONF_ENABLE_SEGMENTS, + CONF_PASSWORD, + CONF_POLL_INTERVAL, + DEFAULT_ENABLE_GROUPS, + DEFAULT_ENABLE_SCENES, + DEFAULT_ENABLE_SEGMENTS, + DEFAULT_POLL_INTERVAL, + DOMAIN, +) + + +# ============================================================================== +# Config Flow Logic Tests (without Home Assistant dependencies) +# ============================================================================== + + +class TestConfigFlowConstants: + """Test config flow constants.""" + + def test_domain(self): + """Test domain constant.""" + assert DOMAIN == "govee" + + def test_default_poll_interval(self): + """Test default poll interval.""" + assert DEFAULT_POLL_INTERVAL == 60 + + def test_default_enable_groups(self): + """Test default enable groups.""" + assert DEFAULT_ENABLE_GROUPS is False + + def test_default_enable_scenes(self): + """Test default enable scenes.""" + assert DEFAULT_ENABLE_SCENES is True + + def test_default_enable_segments(self): + """Test default enable segments.""" + assert DEFAULT_ENABLE_SEGMENTS is True + + +class TestApiKeyValidation: + """Test API key validation logic.""" + + def test_api_key_required(self): + """Test API key is required.""" + data = {} + assert CONF_API_KEY not in data + + def test_api_key_present(self): + """Test API key is present.""" + data = {CONF_API_KEY: "test_key"} + assert CONF_API_KEY in data + assert data[CONF_API_KEY] == "test_key" + + +class TestAccountCredentials: + """Test account credentials logic.""" + + def test_optional_email(self): + """Test email is optional.""" + data = {CONF_API_KEY: "test_key"} + assert CONF_EMAIL not in data + + def test_optional_password(self): + """Test password is optional.""" + data = {CONF_API_KEY: "test_key"} + assert CONF_PASSWORD not in data + + def test_with_account_credentials(self): + """Test with email and password.""" + data = { + CONF_API_KEY: "test_key", + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "secret", + } + assert data[CONF_EMAIL] == "test@example.com" + assert data[CONF_PASSWORD] == "secret" + + +class TestOptionsDefaults: + """Test options defaults.""" + + def test_default_options(self): + """Test default options are correct.""" + options = { + CONF_POLL_INTERVAL: DEFAULT_POLL_INTERVAL, + CONF_ENABLE_GROUPS: DEFAULT_ENABLE_GROUPS, + CONF_ENABLE_SCENES: DEFAULT_ENABLE_SCENES, + CONF_ENABLE_SEGMENTS: DEFAULT_ENABLE_SEGMENTS, + } + + assert options[CONF_POLL_INTERVAL] == 60 + assert options[CONF_ENABLE_GROUPS] is False + assert options[CONF_ENABLE_SCENES] is True + assert options[CONF_ENABLE_SEGMENTS] is True + + +class TestEntryDataStructure: + """Test config entry data structure.""" + + def test_minimal_entry_data(self): + """Test minimal entry data with just API key.""" + data = {CONF_API_KEY: "test_key"} + + assert CONF_API_KEY in data + assert data[CONF_API_KEY] == "test_key" + # Optional fields not present + assert CONF_EMAIL not in data + assert CONF_PASSWORD not in data + + def test_full_entry_data(self): + """Test full entry data with account credentials.""" + data = { + CONF_API_KEY: "test_key", + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "secret", + } + + assert data[CONF_API_KEY] == "test_key" + assert data[CONF_EMAIL] == "test@example.com" + assert data[CONF_PASSWORD] == "secret" + + +class TestErrorHandling: + """Test error handling patterns.""" + + def test_auth_error_code(self): + """Test auth error has correct code.""" + err = GoveeAuthError("Invalid API key") + assert err.code == 401 + + def test_api_error_code(self): + """Test API error can have custom code.""" + err = GoveeApiError("Server error", code=500) + assert err.code == 500 + + def test_api_error_no_code(self): + """Test API error without code.""" + err = GoveeApiError("Network error") + assert err.code is None + + +class TestReauthFlow: + """Test reauth flow logic.""" + + def test_reauth_data_structure(self): + """Test reauth data structure.""" + existing_data = { + CONF_API_KEY: "old_key", + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "secret", + } + + # On reauth, update just the API key + new_data = {**existing_data, CONF_API_KEY: "new_key"} + + assert new_data[CONF_API_KEY] == "new_key" + # Other data preserved + assert new_data[CONF_EMAIL] == "test@example.com" + assert new_data[CONF_PASSWORD] == "secret" + + +class TestOptionsFlow: + """Test options flow logic.""" + + def test_options_update(self): + """Test options can be updated.""" + # Original options + original = { + CONF_POLL_INTERVAL: 60, + CONF_ENABLE_GROUPS: False, + CONF_ENABLE_SCENES: True, + CONF_ENABLE_SEGMENTS: True, + } + assert original[CONF_POLL_INTERVAL] == 60 + + # Update options + new_options = { + CONF_POLL_INTERVAL: 120, + CONF_ENABLE_GROUPS: True, + CONF_ENABLE_SCENES: False, + CONF_ENABLE_SEGMENTS: False, + } + + assert new_options[CONF_POLL_INTERVAL] == 120 + assert new_options[CONF_ENABLE_GROUPS] is True + assert new_options[CONF_ENABLE_SCENES] is False + assert new_options[CONF_ENABLE_SEGMENTS] is False + + def test_poll_interval_validation(self): + """Test poll interval bounds.""" + min_interval = 30 + max_interval = 300 + + # Valid intervals + for interval in [30, 60, 120, 300]: + assert min_interval <= interval <= max_interval + + # Invalid intervals would be rejected + assert 10 < min_interval + assert 600 > max_interval + + +class TestConfigFlowSteps: + """Test config flow step transitions.""" + + def test_user_step_to_account_step(self): + """Test user step transitions to account step.""" + # After valid API key, should proceed to account step + step_order = ["user", "account"] + assert step_order[0] == "user" + assert step_order[1] == "account" + + def test_account_step_skippable(self): + """Test account step can be skipped.""" + # Empty email means skip + user_input = {CONF_EMAIL: "", CONF_PASSWORD: ""} + skip_mqtt = not user_input.get(CONF_EMAIL) + assert skip_mqtt is True + + def test_account_step_with_credentials(self): + """Test account step with credentials.""" + user_input = { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "secret", + } + skip_mqtt = not user_input.get(CONF_EMAIL) + assert skip_mqtt is False - assert result2["type"] == "form" - assert result2["errors"] == {"api_key": "cannot_connect"} +class TestCreateEntryData: + """Test entry creation data structure.""" -async def test_form_unknown_exception(hass: HomeAssistant): - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + def test_create_entry_api_only(self): + """Test creating entry with API key only.""" + api_key = "test_key" + email = None + password = None - with patch( - "custom_components.govee.config_flow.Govee.get_devices", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "api_key", CONF_DELAY: 7}, + data = {CONF_API_KEY: api_key} + if email and password: + data[CONF_EMAIL] = email + data[CONF_PASSWORD] = password + + assert data == {CONF_API_KEY: "test_key"} + + def test_create_entry_with_account(self): + """Test creating entry with account credentials.""" + api_key = "test_key" + email = "test@example.com" + password = "secret" + + data = {CONF_API_KEY: api_key} + if email and password: + data[CONF_EMAIL] = email + data[CONF_PASSWORD] = password + + assert data == { + CONF_API_KEY: "test_key", + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "secret", + } + + +class TestConfigFlowVersion: + """Test config flow version.""" + + def test_config_version(self): + """Test config version is 1.""" + from custom_components.govee.const import CONFIG_VERSION + + assert CONFIG_VERSION == 1 + + +class TestFormValidation: + """Test form validation patterns.""" + + def test_api_key_empty_invalid(self): + """Test empty API key is invalid.""" + api_key = "" + is_valid = bool(api_key and api_key.strip()) + assert is_valid is False + + def test_api_key_whitespace_invalid(self): + """Test whitespace-only API key is invalid.""" + api_key = " " + is_valid = bool(api_key and api_key.strip()) + assert is_valid is False + + def test_api_key_valid(self): + """Test valid API key passes.""" + api_key = "valid_api_key_here" + is_valid = bool(api_key and api_key.strip()) + assert is_valid is True + + +class TestErrorMessages: + """Test error message mapping.""" + + def test_error_keys(self): + """Test error keys are valid.""" + error_keys = ["invalid_auth", "cannot_connect", "unknown"] + + for key in error_keys: + assert isinstance(key, str) + assert len(key) > 0 + + def test_error_mapping(self): + """Test error type to key mapping.""" + error_mapping = { + "auth_failed": "invalid_auth", + "connection_failed": "cannot_connect", + "unexpected": "unknown", + } + + assert error_mapping["auth_failed"] == "invalid_auth" + assert error_mapping["connection_failed"] == "cannot_connect" + assert error_mapping["unexpected"] == "unknown" + + +class TestDescriptionPlaceholders: + """Test description placeholders.""" + + def test_api_url_placeholder(self): + """Test API URL placeholder.""" + placeholders = { + "api_url": "https://developer.govee.com/", + } + + assert "api_url" in placeholders + assert "govee.com" in placeholders["api_url"] + + +class TestConfigFlowAsync: + """Test async patterns used in config flow.""" + + @pytest.mark.asyncio + async def test_async_validate_api_key_mock(self): + """Test async API key validation mock.""" + async def mock_validate(api_key: str) -> bool: + if api_key == "valid_key": + return True + raise GoveeAuthError("Invalid key") + + result = await mock_validate("valid_key") + assert result is True + + with pytest.raises(GoveeAuthError): + await mock_validate("invalid_key") + + @pytest.mark.asyncio + async def test_async_validate_credentials_mock(self): + """Test async credentials validation mock.""" + async def mock_validate(email: str, password: str): + if email == "valid@test.com" and password == "correct": + return MagicMock() # Return mock IoT credentials + raise GoveeAuthError("Invalid credentials") + + result = await mock_validate("valid@test.com", "correct") + assert result is not None + + with pytest.raises(GoveeAuthError): + await mock_validate("invalid@test.com", "wrong") + + +class TestReconfigureFlow: + """Test reconfigure flow logic.""" + + def test_reconfigure_data_update(self): + """Test reconfigure updates data correctly.""" + existing_data = { + CONF_API_KEY: "old_key", + CONF_EMAIL: "old@example.com", + CONF_PASSWORD: "old_password", + } + + # User provides new API key + new_api_key = "new_key" + + updated_data = {**existing_data, CONF_API_KEY: new_api_key} + + assert updated_data[CONF_API_KEY] == "new_key" + assert updated_data[CONF_EMAIL] == "old@example.com" + assert updated_data[CONF_PASSWORD] == "old_password" + + def test_reconfigure_with_new_account(self): + """Test reconfigure with new account credentials.""" + existing_data = { + CONF_API_KEY: "old_key", + } + + new_data = { + **existing_data, + CONF_API_KEY: "new_key", + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new_password", + } + + assert new_data[CONF_API_KEY] == "new_key" + assert new_data[CONF_EMAIL] == "new@example.com" + assert new_data[CONF_PASSWORD] == "new_password" + + def test_reconfigure_remove_account(self): + """Test reconfigure removes account when empty.""" + existing_data = { + CONF_API_KEY: "old_key", + CONF_EMAIL: "old@example.com", + CONF_PASSWORD: "old_password", + } + assert existing_data[CONF_EMAIL] == "old@example.com" + + # User clears email and password + new_data = {CONF_API_KEY: "new_key"} + + assert new_data[CONF_API_KEY] == "new_key" + assert CONF_EMAIL not in new_data + assert CONF_PASSWORD not in new_data + + +class TestRepairsFramework: + """Test repairs framework logic.""" + + def test_issue_ids(self): + """Test issue ID constants.""" + from custom_components.govee.repairs import ( + ISSUE_AUTH_FAILED, + ISSUE_MQTT_DISCONNECTED, + ISSUE_RATE_LIMITED, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} + assert ISSUE_AUTH_FAILED == "auth_failed" + assert ISSUE_RATE_LIMITED == "rate_limited" + assert ISSUE_MQTT_DISCONNECTED == "mqtt_disconnected" + + def test_issue_id_format(self): + """Test issue ID format with entry ID.""" + from custom_components.govee.repairs import ISSUE_AUTH_FAILED + + entry_id = "test_entry_123" + issue_id = f"{ISSUE_AUTH_FAILED}_{entry_id}" + + assert issue_id == "auth_failed_test_entry_123" + assert issue_id.startswith(ISSUE_AUTH_FAILED) + + def test_rate_limit_reset_time_format(self): + """Test rate limit reset time formatting.""" + retry_after = 120.0 + reset_time = f"{int(retry_after)} seconds" + + assert reset_time == "120 seconds" + + def test_issue_severity_mapping(self): + """Test issue severity levels.""" + # These would be ir.IssueSeverity values in actual code + severity_mapping = { + "auth_failed": "ERROR", + "rate_limited": "WARNING", + "mqtt_disconnected": "WARNING", + } + + assert severity_mapping["auth_failed"] == "ERROR" + assert severity_mapping["rate_limited"] == "WARNING" + assert severity_mapping["mqtt_disconnected"] == "WARNING" + + def test_fixable_issues(self): + """Test which issues are fixable.""" + fixable_issues = { + "auth_failed": True, + "rate_limited": False, + "mqtt_disconnected": False, + } + + assert fixable_issues["auth_failed"] is True + assert fixable_issues["rate_limited"] is False + assert fixable_issues["mqtt_disconnected"] is False diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 00000000..f04b30f8 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,633 @@ +"""Test Govee coordinator.""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from custom_components.govee.api.exceptions import ( + GoveeApiError, + GoveeAuthError, + GoveeDeviceNotFoundError, + GoveeRateLimitError, +) +from custom_components.govee.models import ( + GoveeCapability, + GoveeDevice, + GoveeDeviceState, + PowerCommand, + BrightnessCommand, + ColorCommand, + ColorTempCommand, + SceneCommand, + RGBColor, +) +from custom_components.govee.models.device import ( + CAPABILITY_ON_OFF, + CAPABILITY_RANGE, + INSTANCE_POWER, + INSTANCE_BRIGHTNESS, +) +from custom_components.govee.protocols import IStateObserver + + +# ============================================================================== +# Fixtures +# ============================================================================== + + +@pytest.fixture +def sample_capabilities(): + """Create sample light capabilities.""" + return ( + GoveeCapability(type=CAPABILITY_ON_OFF, instance=INSTANCE_POWER, parameters={}), + GoveeCapability( + type=CAPABILITY_RANGE, + instance=INSTANCE_BRIGHTNESS, + parameters={"range": {"min": 0, "max": 100}}, + ), + ) + + +@pytest.fixture +def sample_device(sample_capabilities): + """Create a sample device.""" + return GoveeDevice( + device_id="AA:BB:CC:DD:EE:FF:00:11", + sku="H6072", + name="Test Light", + device_type="devices.types.light", + capabilities=sample_capabilities, + is_group=False, + ) + + +@pytest.fixture +def sample_group_device(sample_capabilities): + """Create a sample group device.""" + return GoveeDevice( + device_id="GROUP:AA:BB:CC:DD", + sku="GROUP", + name="All Lights", + device_type="devices.types.group", + capabilities=sample_capabilities, + is_group=True, + ) + + +@pytest.fixture +def sample_state(): + """Create a sample device state.""" + return GoveeDeviceState( + device_id="AA:BB:CC:DD:EE:FF:00:11", + online=True, + power_state=True, + brightness=75, + color=RGBColor(r=255, g=128, b=64), + color_temp_kelvin=None, + active_scene=None, + source="api", + ) + + +# ============================================================================== +# Coordinator Logic Tests (without Home Assistant dependencies) +# ============================================================================== + + +class TestCoordinatorLogic: + """Test coordinator logic that doesn't require HA.""" + + def test_sample_device_creation(self, sample_device): + """Test sample device fixture.""" + assert sample_device.device_id == "AA:BB:CC:DD:EE:FF:00:11" + assert sample_device.sku == "H6072" + assert sample_device.is_group is False + + def test_sample_group_device_creation(self, sample_group_device): + """Test sample group device fixture.""" + assert sample_group_device.is_group is True + + def test_sample_state_creation(self, sample_state): + """Test sample state fixture.""" + assert sample_state.power_state is True + assert sample_state.brightness == 75 + + def test_state_optimistic_power(self, sample_state): + """Test optimistic power update.""" + sample_state.apply_optimistic_power(False) + assert sample_state.power_state is False + assert sample_state.source == "optimistic" + + def test_state_optimistic_brightness(self, sample_state): + """Test optimistic brightness update.""" + sample_state.apply_optimistic_brightness(50) + assert sample_state.brightness == 50 + assert sample_state.source == "optimistic" + + def test_state_optimistic_color(self, sample_state): + """Test optimistic color update.""" + color = RGBColor(r=0, g=255, b=0) + sample_state.apply_optimistic_color(color) + assert sample_state.color == color + assert sample_state.color_temp_kelvin is None + assert sample_state.source == "optimistic" + + def test_state_optimistic_color_temp(self, sample_state): + """Test optimistic color temperature update.""" + sample_state.apply_optimistic_color_temp(4000) + assert sample_state.color_temp_kelvin == 4000 + assert sample_state.color is None + assert sample_state.source == "optimistic" + + +class TestObserverPattern: + """Test observer pattern for state updates.""" + + def test_observer_registration(self): + """Test observer can be registered.""" + observers: list[IStateObserver] = [] + + mock_observer = MagicMock(spec=IStateObserver) + observers.append(mock_observer) + + assert mock_observer in observers + + def test_observer_unregistration(self): + """Test observer can be unregistered.""" + observers: list[IStateObserver] = [] + + mock_observer = MagicMock(spec=IStateObserver) + observers.append(mock_observer) + observers.remove(mock_observer) + + assert mock_observer not in observers + + def test_observer_notification(self, sample_state): + """Test observers are notified of state changes.""" + mock_observer = MagicMock(spec=IStateObserver) + observers = [mock_observer] + + device_id = "AA:BB:CC:DD:EE:FF:00:11" + for observer in observers: + observer.on_state_changed(device_id, sample_state) + + mock_observer.on_state_changed.assert_called_once_with(device_id, sample_state) + + def test_observer_exception_handling(self, sample_state): + """Test that observer exceptions don't propagate.""" + bad_observer = MagicMock(spec=IStateObserver) + bad_observer.on_state_changed.side_effect = Exception("Observer error") + + good_observer = MagicMock(spec=IStateObserver) + observers = [bad_observer, good_observer] + + device_id = "AA:BB:CC:DD:EE:FF:00:11" + + for observer in observers: + try: + observer.on_state_changed(device_id, sample_state) + except Exception: + pass # Coordinator swallows observer exceptions + + bad_observer.on_state_changed.assert_called_once() + good_observer.on_state_changed.assert_called_once() + + +class TestCommandGeneration: + """Test command creation for coordinator.""" + + def test_power_command(self): + """Test power command for coordinator.""" + cmd = PowerCommand(power_on=True) + assert cmd.power_on is True + assert cmd.get_value() == 1 + + def test_brightness_command(self): + """Test brightness command for coordinator.""" + cmd = BrightnessCommand(brightness=50) + assert cmd.brightness == 50 + assert cmd.get_value() == 50 + + def test_color_command(self): + """Test color command for coordinator.""" + color = RGBColor(r=255, g=0, b=0) + cmd = ColorCommand(color=color) + # Red packed = (255 << 16) + (0 << 8) + 0 = 16711680 + assert cmd.get_value() == 16711680 + + def test_color_temp_command(self): + """Test color temp command for coordinator.""" + cmd = ColorTempCommand(kelvin=4000) + assert cmd.kelvin == 4000 + assert cmd.get_value() == 4000 + + def test_scene_command(self): + """Test scene command for coordinator.""" + cmd = SceneCommand(scene_id=123, scene_name="Test") + value = cmd.get_value() + assert value["id"] == 123 + assert value["name"] == "Test" + + +class TestDeviceFiltering: + """Test device filtering logic.""" + + def test_filter_groups_when_disabled(self, sample_device, sample_group_device): + """Test group devices filtered when groups disabled.""" + devices = [sample_device, sample_group_device] + enable_groups = False + + filtered = [d for d in devices if not d.is_group or enable_groups] + + assert len(filtered) == 1 + assert filtered[0] == sample_device + + def test_include_groups_when_enabled(self, sample_device, sample_group_device): + """Test group devices included when groups enabled.""" + devices = [sample_device, sample_group_device] + enable_groups = True + + filtered = [d for d in devices if not d.is_group or enable_groups] + + assert len(filtered) == 2 + + +class TestSceneCaching: + """Test scene caching logic.""" + + def test_cache_empty_initially(self): + """Test scene cache starts empty.""" + cache: dict[str, list[dict[str, Any]]] = {} + assert "device_id" not in cache + + def test_cache_stores_scenes(self): + """Test scenes are cached.""" + cache: dict[str, list[dict[str, Any]]] = {} + scenes = [{"name": "Sunrise", "value": {"id": 1}}] + + cache["device_id"] = scenes + + assert cache["device_id"] == scenes + + def test_cache_returns_existing(self): + """Test cached scenes are returned.""" + cache: dict[str, list[dict[str, Any]]] = { + "device_id": [{"name": "Sunset", "value": {"id": 2}}] + } + + device_id = "device_id" + refresh = False + + if not refresh and device_id in cache: + result = cache[device_id] + else: + result = [] + + assert len(result) == 1 + assert result[0]["name"] == "Sunset" + + def test_cache_refresh_bypasses(self): + """Test refresh bypasses cache.""" + cache: dict[str, list[dict[str, Any]]] = { + "device_id": [{"name": "Old", "value": {"id": 1}}] + } + + device_id = "device_id" + refresh = True + + should_fetch = refresh or device_id not in cache + + assert should_fetch is True + + +class TestStateManagement: + """Test state management logic.""" + + def test_state_registry(self, sample_state): + """Test state registry operations.""" + states: dict[str, GoveeDeviceState] = {} + + states["device_id"] = sample_state + + assert states.get("device_id") == sample_state + assert states.get("unknown") is None + + def test_state_update_from_api(self): + """Test state update from API response.""" + state = GoveeDeviceState.create_empty("device_id") + + api_data = { + "capabilities": [ + { + "type": "devices.capabilities.online", + "instance": "online", + "state": {"value": True}, + }, + { + "type": "devices.capabilities.on_off", + "instance": "powerSwitch", + "state": {"value": 1}, + }, + ], + } + + state.update_from_api(api_data) + + assert state.online is True + assert state.power_state is True + assert state.source == "api" + + def test_state_update_from_mqtt(self): + """Test state update from MQTT message.""" + state = GoveeDeviceState.create_empty("device_id") + + mqtt_data = { + "onOff": 1, + "brightness": 50, + "color": {"r": 100, "g": 150, "b": 200}, + } + + state.update_from_mqtt(mqtt_data) + + assert state.power_state is True + assert state.brightness == 50 + assert state.color.as_tuple == (100, 150, 200) + assert state.source == "mqtt" + + def test_preserve_active_scene_on_api_update(self, sample_state): + """Test active scene is preserved when API doesn't return it.""" + sample_state.active_scene = "scene_123" + + new_state = GoveeDeviceState.create_empty(sample_state.device_id) + new_state.power_state = True + new_state.brightness = 80 + + if sample_state.active_scene: + new_state.active_scene = sample_state.active_scene + + assert new_state.active_scene == "scene_123" + + +class TestErrorHandling: + """Test error handling patterns.""" + + def test_auth_error_raises(self): + """Test auth error is raised appropriately.""" + err = GoveeAuthError("Invalid key") + assert err.code == 401 + + def test_rate_limit_keeps_state(self, sample_state): + """Test rate limit error preserves existing state.""" + states = {"device_id": sample_state} + + try: + raise GoveeRateLimitError() + except GoveeRateLimitError: + result = states.get("device_id") + + assert result == sample_state + + def test_device_not_found_for_groups(self): + """Test device not found is expected for groups.""" + err = GoveeDeviceNotFoundError("GROUP:ID") + + is_group_error = "not exist" in str(err).lower() or "not found" in str(err).lower() + + assert is_group_error or err.code == 400 + + def test_api_error_logs_debug(self): + """Test general API errors are logged but don't crash.""" + err = GoveeApiError("Server error", code=500) + + should_keep_state = True + assert should_keep_state + assert err.code == 500 + + +class TestMqttIntegration: + """Test MQTT integration patterns.""" + + def test_mqtt_state_update_flow(self, sample_state): + """Test MQTT state update is applied correctly.""" + states = {"device_id": sample_state} + devices = {"device_id": MagicMock()} + + device_id = "device_id" + mqtt_data = {"onOff": 0, "brightness": 25} + + if device_id in devices: + state = states.get(device_id) + if state: + state.update_from_mqtt(mqtt_data) + + assert sample_state.power_state is False + assert sample_state.brightness == 25 + assert sample_state.source == "mqtt" + + def test_mqtt_unknown_device_ignored(self): + """Test MQTT updates for unknown devices are ignored.""" + devices = {"known_device": MagicMock()} + + unknown_device_id = "unknown_device" + + if unknown_device_id not in devices: + handled = False + else: + handled = True + + assert handled is False + + +class TestParallelStateFetching: + """Test parallel state fetching patterns.""" + + @pytest.mark.asyncio + async def test_parallel_fetch_creates_tasks(self, sample_device): + """Test parallel fetch creates tasks for all devices.""" + devices = { + "device1": sample_device, + "device2": sample_device, + "device3": sample_device, + } + + async def mock_fetch(device_id, device): + return GoveeDeviceState.create_empty(device_id) + + tasks = [ + mock_fetch(device_id, device) + for device_id, device in devices.items() + ] + + results = await asyncio.gather(*tasks) + + assert len(results) == 3 + assert all(isinstance(r, GoveeDeviceState) for r in results) + + @pytest.mark.asyncio + async def test_parallel_fetch_handles_exceptions(self, sample_device): + """Test parallel fetch handles individual failures.""" + + async def mock_fetch(device_id: str): + if device_id == "failing": + raise GoveeApiError("Fetch failed") + return GoveeDeviceState.create_empty(device_id) + + tasks = [ + mock_fetch("success1"), + mock_fetch("failing"), + mock_fetch("success2"), + ] + + results = await asyncio.gather(*tasks, return_exceptions=True) + + assert isinstance(results[0], GoveeDeviceState) + assert isinstance(results[1], GoveeApiError) + assert isinstance(results[2], GoveeDeviceState) + + +class TestOptimisticUpdates: + """Test optimistic state update patterns.""" + + def test_apply_optimistic_power_on(self, sample_state): + """Test applying optimistic power on.""" + sample_state.power_state = False + sample_state.apply_optimistic_power(True) + + assert sample_state.power_state is True + assert sample_state.source == "optimistic" + + def test_apply_optimistic_power_off(self, sample_state): + """Test applying optimistic power off.""" + sample_state.power_state = True + sample_state.apply_optimistic_power(False) + + assert sample_state.power_state is False + assert sample_state.source == "optimistic" + + def test_apply_optimistic_brightness(self, sample_state): + """Test applying optimistic brightness.""" + sample_state.apply_optimistic_brightness(100) + + assert sample_state.brightness == 100 + assert sample_state.source == "optimistic" + + def test_apply_optimistic_color_clears_temp(self, sample_state): + """Test applying color clears color temp.""" + sample_state.color_temp_kelvin = 4000 + color = RGBColor(r=255, g=0, b=0) + sample_state.apply_optimistic_color(color) + + assert sample_state.color == color + assert sample_state.color_temp_kelvin is None + + def test_apply_optimistic_temp_clears_color(self, sample_state): + """Test applying color temp clears color.""" + sample_state.color = RGBColor(r=255, g=0, b=0) + sample_state.apply_optimistic_color_temp(5000) + + assert sample_state.color_temp_kelvin == 5000 + assert sample_state.color is None + + +class TestDeviceStateCreation: + """Test device state creation patterns.""" + + def test_create_empty_state(self): + """Test creating empty state.""" + state = GoveeDeviceState.create_empty("test_id") + + assert state.device_id == "test_id" + assert state.online is True + assert state.power_state is False + assert state.brightness == 100 + + def test_state_with_all_attributes(self): + """Test state with all attributes set.""" + color = RGBColor(r=100, g=150, b=200) + state = GoveeDeviceState( + device_id="test_id", + online=True, + power_state=True, + brightness=50, + color=color, + color_temp_kelvin=4000, + active_scene="scene_1", + source="mqtt", + ) + + assert state.device_id == "test_id" + assert state.online is True + assert state.power_state is True + assert state.brightness == 50 + assert state.color == color + assert state.color_temp_kelvin == 4000 + assert state.active_scene == "scene_1" + assert state.source == "mqtt" + + +class TestCoordinatorDeviceRegistry: + """Test device registry patterns.""" + + def test_get_device_by_id(self, sample_device): + """Test getting device by ID.""" + devices = {sample_device.device_id: sample_device} + + result = devices.get(sample_device.device_id) + assert result == sample_device + + def test_get_device_unknown_returns_none(self, sample_device): + """Test getting unknown device returns None.""" + devices = {sample_device.device_id: sample_device} + + result = devices.get("unknown_id") + assert result is None + + def test_device_count(self, sample_device, sample_group_device): + """Test device count.""" + devices = { + sample_device.device_id: sample_device, + sample_group_device.device_id: sample_group_device, + } + + assert len(devices) == 2 + + +class TestCoordinatorSceneManagement: + """Test scene management patterns.""" + + def test_scene_cache_miss_fetches(self): + """Test cache miss triggers fetch.""" + cache: dict[str, list[dict[str, Any]]] = {} + + device_id = "device_id" + if device_id not in cache: + # Would fetch from API + should_fetch = True + else: + should_fetch = False + + assert should_fetch is True + + def test_scene_cache_hit_returns_cached(self): + """Test cache hit returns cached scenes.""" + scenes = [{"name": "Test", "value": {"id": 1}}] + cache = {"device_id": scenes} + + device_id = "device_id" + result = cache.get(device_id, []) + + assert result == scenes + + def test_refresh_clears_and_fetches(self): + """Test refresh clears cache and fetches.""" + cache = {"device_id": [{"name": "Old", "value": {"id": 1}}]} + + # Simulate refresh + if "device_id" in cache: + del cache["device_id"] + + assert "device_id" not in cache diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 00000000..8b5b1f63 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,345 @@ +"""Test Govee data models.""" + +from __future__ import annotations + +import pytest + +from custom_components.govee.models import ( + GoveeDevice, + GoveeDeviceState, + GoveeCapability, + RGBColor, + PowerCommand, + BrightnessCommand, + ColorCommand, + ColorTempCommand, + SceneCommand, + SegmentColorCommand, +) +from custom_components.govee.models.device import ( + CAPABILITY_ON_OFF, + CAPABILITY_RANGE, + CAPABILITY_COLOR_SETTING, + CAPABILITY_DYNAMIC_SCENE, + INSTANCE_POWER, + INSTANCE_BRIGHTNESS, + INSTANCE_COLOR_RGB, + INSTANCE_COLOR_TEMP, + INSTANCE_SCENE, +) + + +# ============================================================================== +# RGBColor Tests +# ============================================================================== + + +class TestRGBColor: + """Test RGBColor model.""" + + def test_create_color(self): + """Test creating an RGB color.""" + color = RGBColor(r=255, g=128, b=64) + assert color.r == 255 + assert color.g == 128 + assert color.b == 64 + + def test_color_clamping(self): + """Test that color values are clamped to 0-255.""" + color = RGBColor(r=300, g=-10, b=128) + assert color.r == 255 + assert color.g == 0 + assert color.b == 128 + + def test_as_tuple(self): + """Test getting color as tuple.""" + color = RGBColor(r=255, g=128, b=64) + assert color.as_tuple == (255, 128, 64) + + def test_as_packed_int(self): + """Test packing color as integer.""" + color = RGBColor(r=255, g=128, b=64) + # (255 << 16) + (128 << 8) + 64 = 16744512 + assert color.as_packed_int == 16744512 + + def test_from_packed_int(self): + """Test creating color from packed integer.""" + color = RGBColor.from_packed_int(16744512) + assert color.r == 255 + assert color.g == 128 + assert color.b == 64 + + def test_from_dict(self): + """Test creating color from dict.""" + color = RGBColor.from_dict({"r": 255, "g": 128, "b": 64}) + assert color.as_tuple == (255, 128, 64) + + def test_immutable(self): + """Test that RGBColor is immutable (frozen).""" + color = RGBColor(r=255, g=128, b=64) + with pytest.raises(AttributeError): + color.r = 100 + + +# ============================================================================== +# GoveeCapability Tests +# ============================================================================== + + +class TestGoveeCapability: + """Test GoveeCapability model.""" + + def test_is_power(self): + """Test power capability detection.""" + cap = GoveeCapability(type=CAPABILITY_ON_OFF, instance=INSTANCE_POWER, parameters={}) + assert cap.is_power is True + assert cap.is_brightness is False + + def test_is_brightness(self): + """Test brightness capability detection.""" + cap = GoveeCapability( + type=CAPABILITY_RANGE, + instance=INSTANCE_BRIGHTNESS, + parameters={"range": {"min": 0, "max": 100}}, + ) + assert cap.is_brightness is True + assert cap.brightness_range == (0, 100) + + def test_is_color_rgb(self): + """Test RGB color capability detection.""" + cap = GoveeCapability(type=CAPABILITY_COLOR_SETTING, instance=INSTANCE_COLOR_RGB, parameters={}) + assert cap.is_color_rgb is True + assert cap.is_color_temp is False + + def test_is_color_temp(self): + """Test color temperature capability detection.""" + cap = GoveeCapability(type=CAPABILITY_COLOR_SETTING, instance=INSTANCE_COLOR_TEMP, parameters={}) + assert cap.is_color_temp is True + assert cap.is_color_rgb is False + + def test_is_scene(self): + """Test scene capability detection.""" + cap = GoveeCapability(type=CAPABILITY_DYNAMIC_SCENE, instance=INSTANCE_SCENE, parameters={}) + assert cap.is_scene is True + + def test_immutable(self): + """Test that GoveeCapability is immutable (frozen).""" + cap = GoveeCapability(type=CAPABILITY_ON_OFF, instance=INSTANCE_POWER, parameters={}) + with pytest.raises(AttributeError): + cap.type = "other" + + +# ============================================================================== +# GoveeDevice Tests +# ============================================================================== + + +class TestGoveeDevice: + """Test GoveeDevice model.""" + + def test_create_device(self, light_capabilities): + """Test creating a device.""" + device = GoveeDevice( + device_id="AA:BB:CC:DD:EE:FF:00:11", + sku="H6072", + name="Living Room Light", + device_type="devices.types.light", + capabilities=light_capabilities, + is_group=False, + ) + assert device.device_id == "AA:BB:CC:DD:EE:FF:00:11" + assert device.sku == "H6072" + assert device.name == "Living Room Light" + assert device.is_group is False + + def test_supports_power(self, mock_light_device): + """Test power support detection.""" + assert mock_light_device.supports_power is True + + def test_supports_brightness(self, mock_light_device): + """Test brightness support detection.""" + assert mock_light_device.supports_brightness is True + + def test_supports_rgb(self, mock_light_device): + """Test RGB support detection.""" + assert mock_light_device.supports_rgb is True + + def test_supports_color_temp(self, mock_light_device): + """Test color temperature support detection.""" + assert mock_light_device.supports_color_temp is True + + def test_supports_scenes(self, mock_light_device): + """Test scene support detection.""" + assert mock_light_device.supports_scenes is True + + def test_supports_segments(self, mock_rgbic_device): + """Test segment support detection.""" + assert mock_rgbic_device.supports_segments is True + + def test_is_plug(self, mock_plug_device): + """Test plug detection.""" + assert mock_plug_device.is_plug is True + + def test_is_group(self, mock_group_device): + """Test group device detection.""" + assert mock_group_device.is_group is True + + def test_from_api_response(self, api_device_response): + """Test creating device from API response.""" + device = GoveeDevice.from_api_response(api_device_response) + assert device.device_id == "AA:BB:CC:DD:EE:FF:00:11" + assert device.sku == "H6072" + assert device.name == "Living Room Light" + assert device.supports_power is True + assert device.supports_brightness is True + assert device.supports_rgb is True + + def test_immutable(self, mock_light_device): + """Test that GoveeDevice is immutable (frozen).""" + with pytest.raises(AttributeError): + mock_light_device.name = "New Name" + + +# ============================================================================== +# GoveeDeviceState Tests +# ============================================================================== + + +class TestGoveeDeviceState: + """Test GoveeDeviceState model.""" + + def test_create_state(self): + """Test creating a device state.""" + state = GoveeDeviceState( + device_id="AA:BB:CC:DD:EE:FF:00:11", + online=True, + power_state=True, + brightness=75, + ) + assert state.device_id == "AA:BB:CC:DD:EE:FF:00:11" + assert state.online is True + assert state.power_state is True + assert state.brightness == 75 + + def test_create_empty(self): + """Test creating empty state.""" + state = GoveeDeviceState.create_empty("test_id") + assert state.device_id == "test_id" + assert state.online is True + assert state.power_state is False + assert state.brightness == 100 + + def test_update_from_api(self, api_state_response): + """Test updating state from API response.""" + state = GoveeDeviceState.create_empty("AA:BB:CC:DD:EE:FF:00:11") + state.update_from_api(api_state_response) + assert state.online is True + assert state.power_state is True + assert state.brightness == 75 + assert state.color is not None + assert state.color.as_tuple == (255, 128, 64) + assert state.source == "api" + + def test_update_from_mqtt(self, mqtt_state_message): + """Test updating state from MQTT message.""" + state = GoveeDeviceState.create_empty("AA:BB:CC:DD:EE:FF:00:11") + state.update_from_mqtt(mqtt_state_message["state"]) + assert state.power_state is True + assert state.brightness == 75 + assert state.color is not None + assert state.color.as_tuple == (255, 128, 64) + assert state.source == "mqtt" + + def test_optimistic_power(self): + """Test optimistic power update.""" + state = GoveeDeviceState.create_empty("test_id") + state.apply_optimistic_power(True) + assert state.power_state is True + assert state.source == "optimistic" + + def test_optimistic_brightness(self): + """Test optimistic brightness update.""" + state = GoveeDeviceState.create_empty("test_id") + state.apply_optimistic_brightness(50) + assert state.brightness == 50 + assert state.source == "optimistic" + + def test_optimistic_color(self): + """Test optimistic color update.""" + state = GoveeDeviceState.create_empty("test_id") + color = RGBColor(r=255, g=0, b=0) + state.apply_optimistic_color(color) + assert state.color == color + assert state.color_temp_kelvin is None # Reset color temp + assert state.source == "optimistic" + + def test_optimistic_color_temp(self): + """Test optimistic color temperature update.""" + state = GoveeDeviceState.create_empty("test_id") + state.apply_optimistic_color_temp(4000) + assert state.color_temp_kelvin == 4000 + assert state.color is None # Reset RGB + assert state.source == "optimistic" + + +# ============================================================================== +# Command Tests +# ============================================================================== + + +class TestCommands: + """Test command models.""" + + def test_power_command(self): + """Test power command.""" + cmd = PowerCommand(power_on=True) + assert cmd.power_on is True + assert cmd.get_value() == 1 + payload = cmd.to_api_payload() + assert payload["type"] == "devices.capabilities.on_off" + assert payload["instance"] == "powerSwitch" + assert payload["value"] == 1 + + def test_power_command_off(self): + """Test power off command.""" + cmd = PowerCommand(power_on=False) + assert cmd.get_value() == 0 + + def test_brightness_command(self): + """Test brightness command.""" + cmd = BrightnessCommand(brightness=75) + assert cmd.brightness == 75 + assert cmd.get_value() == 75 + + def test_color_command(self): + """Test color command.""" + color = RGBColor(r=255, g=128, b=64) + cmd = ColorCommand(color=color) + assert cmd.get_value() == 16744512 # Packed integer + + def test_color_temp_command(self): + """Test color temperature command.""" + cmd = ColorTempCommand(kelvin=4000) + assert cmd.kelvin == 4000 + assert cmd.get_value() == 4000 + + def test_scene_command(self): + """Test scene command.""" + cmd = SceneCommand(scene_id=123, scene_name="Sunrise") + value = cmd.get_value() + assert value["id"] == 123 + assert value["name"] == "Sunrise" + + def test_segment_color_command(self): + """Test segment color command.""" + color = RGBColor(r=255, g=0, b=0) + cmd = SegmentColorCommand(segment_indices=(0, 1, 2), color=color) + value = cmd.get_value() + assert value["segment"] == [0, 1, 2] + assert value["rgb"] == 16711680 # Red + + def test_command_immutable(self): + """Test that commands are immutable.""" + cmd = PowerCommand(power_on=True) + with pytest.raises(AttributeError): + cmd.power_on = False diff --git a/tox.ini b/tox.ini index f86fb7d6..1d019bf7 100644 --- a/tox.ini +++ b/tox.ini @@ -12,10 +12,14 @@ basepython = py313: python3.13 deps = flake8 + mypy -r{toxinidir}/requirements_test.txt commands = flake8 . - pytest + # Skip mypy on py313 due to HA core using PEP 696 type parameter defaults + # that cause mypy syntax errors. Mypy check is done in separate workflow. + py312: mypy custom_components/govee + pytest --cov=custom_components.govee --cov-report=term-missing --cov-report=html --cov-report=xml --cov-fail-under=30 [flake8] max-line-length = 119 diff --git a/workspace.code-workspace b/workspace.code-workspace deleted file mode 100644 index df8ff78d..00000000 --- a/workspace.code-workspace +++ /dev/null @@ -1,64 +0,0 @@ -{ - "folders": [ - { - "name": "hacs-govee", - "path": "." - }, - { - "name": "python-govee-api", - "path": ".git-subtree/python-govee-api" - } - ], - "settings": { - "files.associations": { - "*.yaml": "home-assistant" - }, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, - "files.associations": { - "*.yaml": "home-assistant" - }, - "files.watcherExclude": { - "**/.git/objects/**": true, - "**/.git/subtree-cache/**": true, - "**/.tox/**": true - }, - "jupyter.debugJustMyCode": false, - "python.testing.unittestEnabled": false, - "python.testing.nosetestsEnabled": false, - "python.testing.pytestEnabled": true, - "python.testing.pytestArgs": [ - "tests" - ], - "docker.host": "ssh://root@192.168.144.5", - "docker.imageBuildContextPath": "/usr/share/hassio/share/dev/hacs-govee", - "launch": { - "configurations": [], - "compounds": [ - { - "name": "Launch Home Assistant UI in Chrome", - "configurations": [ - "Launch Home Assistant UI in Chrome" - ] - }, - { - "name": "Python: Remote Attach", - "configurations": [ - "Python: Remote Attach" - ] - }, - { - "name": "Library: launch readme_example.py", - "configurations": [ - "run /example/readme_example.py" - ] - } - ] - }, - "tasks": { - "version": "2.0.0", - "tasks": [] - }, - "python.pythonPath": "/usr/local/bin/python" - }, -} \ No newline at end of file