├── tests ├── __init__.py ├── bandit.yaml └── test_init.py ├── custom_components ├── __init__.py └── govee_lan │ ├── const.py │ ├── manifest.json │ ├── strings.json │ ├── translations │ └── en.json │ ├── __init__.py │ ├── config_flow.py │ └── light.py ├── .gitignore ├── requirements.test.txt ├── .github ├── FUNDING.yml └── workflows │ ├── hassfest.yml │ └── hacs.yml ├── hacs.json ├── LICENSE ├── setup.cfg ├── .pre-commit-config.yaml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.coverage 2 | *.pyc 3 | .*.sw? 4 | -------------------------------------------------------------------------------- /custom_components/govee_lan/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "govee_lan" 2 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov==2.9.0 3 | pytest-homeassistant-custom-component 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: wez 2 | patreon: WezFurlong 3 | ko_fi: wezfurlong 4 | liberapay: wez 5 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Govee LAN Control", 3 | "homeassistant": "2022.12.0", 4 | "render_readme": true 5 | } 6 | -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B325 16 | - B602 17 | - B604 18 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test component setup.""" 2 | from homeassistant.setup import async_setup_component 3 | 4 | from custom_components.govee_lan.const import DOMAIN 5 | 6 | 7 | async def test_async_setup(hass): 8 | """Test the component gets setup.""" 9 | assert await async_setup_component(hass, DOMAIN, {}) is True 10 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | #schedule: 7 | # - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /custom_components/govee_lan/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "govee_lan", 3 | "name": "Govee LAN Control", 4 | "codeowners": ["@wez"], 5 | "config_flow": true, 6 | "dependencies": ["network", "bluetooth"], 7 | "documentation": "https://github.com/wez/govee-lan-hass", 8 | "integration_type": "hub", 9 | "iot_class": "local_polling", 10 | "issue_tracker": "https://github.com/wez/govee-lan-hass/issues", 11 | "loggers": ["govee_led_wez"], 12 | "requirements": ["govee-led-wez>=0.0.15"], 13 | "version": "1.0.0" 14 | } 15 | -------------------------------------------------------------------------------- /custom_components/govee_lan/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Govee Lights", 3 | "config": { 4 | "abort": { 5 | }, 6 | "error": { 7 | }, 8 | "step": { 9 | "user": { 10 | "data": { 11 | "api_key": "API Key" 12 | }, 13 | "title": "API Key (optional)", 14 | "description": "Get your API Key from the Govee Home App." 15 | } 16 | } 17 | }, 18 | "options": { 19 | "error": { 20 | }, 21 | "step": { 22 | "user": { 23 | "data": { 24 | "api_key": "API Key" 25 | }, 26 | "title": "Options", 27 | "description": "Optionally enter your API Key. You can request it via the Govee mobile application by going to Account | Settings | Request API Key" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /custom_components/govee_lan/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Govee Lights", 3 | "config": { 4 | "abort": { 5 | }, 6 | "error": { 7 | }, 8 | "step": { 9 | "user": { 10 | "data": { 11 | "api_key": "API Key" 12 | }, 13 | "title": "API Key (optional)", 14 | "description": "Get your API Key from the Govee Home App." 15 | } 16 | } 17 | }, 18 | "options": { 19 | "error": { 20 | }, 21 | "step": { 22 | "user": { 23 | "data": { 24 | "api_key": "API Key" 25 | }, 26 | "title": "Options", 27 | "description": "Optionally enter your API Key. You can request it via the Govee mobile application by going to Account | Settings | Request API Key" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Wez Furlong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/govee_lan/__init__.py: -------------------------------------------------------------------------------- 1 | """ Govee LAN Control """ 2 | from __future__ import annotations 3 | 4 | import voluptuous as vol 5 | import logging 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.typing import ConfigType 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import Platform 11 | from .const import DOMAIN 12 | from typing import Dict 13 | 14 | from govee_led_wez import GoveeController, GoveeDevice 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | CONFIG_SCHEMA = vol.Schema({vol.Optional(DOMAIN): {}}, extra=vol.ALLOW_EXTRA) 18 | PLATFORMS: list[Platform] = [Platform.LIGHT] 19 | 20 | 21 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 22 | _LOGGER.info("async_setup called!") 23 | hass.data[DOMAIN] = {} 24 | return True 25 | 26 | 27 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 28 | """Set up Govee from a config entry.""" 29 | _LOGGER.info("async_setup_entry called!") 30 | 31 | for component in PLATFORMS: 32 | hass.async_create_task( 33 | hass.config_entries.async_forward_entry_setup(entry, component) 34 | ) 35 | return True 36 | 37 | 38 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 39 | """Unload a config entry.""" 40 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 41 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | show_missing = true 12 | 13 | [tool:pytest] 14 | testpaths = tests 15 | norecursedirs = .git 16 | addopts = 17 | --strict 18 | --cov=custom_components 19 | 20 | [flake8] 21 | # https://github.com/ambv/black#line-length 22 | max-line-length = 88 23 | # E501: line too long 24 | # W503: Line break occurred before a binary operator 25 | # E203: Whitespace before ':' 26 | # D202 No blank lines allowed after function docstring 27 | # W504 line break after binary operator 28 | ignore = 29 | E501, 30 | W503, 31 | E203, 32 | D202, 33 | W504 34 | 35 | [isort] 36 | # https://github.com/timothycrosley/isort 37 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 38 | # splits long import on multiple lines indented by 4 spaces 39 | multi_line_output = 3 40 | include_trailing_comma=True 41 | force_grid_wrap=0 42 | use_parentheses=True 43 | line_length=88 44 | indent = " " 45 | # by default isort don't check module indexes 46 | not_skip = __init__.py 47 | # will group `import x` and `from x import` of the same module. 48 | force_sort_within_sections = true 49 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 50 | default_section = THIRDPARTY 51 | known_first_party = custom_components,tests 52 | forced_separate = tests 53 | combine_as_imports = true 54 | 55 | [mypy] 56 | python_version = 3.7 57 | ignore_errors = true 58 | follow_imports = silent 59 | ignore_missing_imports = true 60 | warn_incomplete_stub = true 61 | warn_redundant_casts = true 62 | warn_unused_configs = true 63 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v2.3.0 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py37-plus] 7 | - repo: https://github.com/psf/black 8 | rev: 19.10b0 9 | hooks: 10 | - id: black 11 | args: 12 | - --safe 13 | - --quiet 14 | files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ 15 | - repo: https://github.com/codespell-project/codespell 16 | rev: v1.16.0 17 | hooks: 18 | - id: codespell 19 | args: 20 | - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing 21 | - --skip="./.*,*.csv,*.json" 22 | - --quiet-level=2 23 | exclude_types: [csv, json] 24 | - repo: https://gitlab.com/pycqa/flake8 25 | rev: 3.8.1 26 | hooks: 27 | - id: flake8 28 | additional_dependencies: 29 | - flake8-docstrings==1.5.0 30 | - pydocstyle==5.0.2 31 | files: ^(homeassistant|script|tests)/.+\.py$ 32 | - repo: https://github.com/PyCQA/bandit 33 | rev: 1.6.2 34 | hooks: 35 | - id: bandit 36 | args: 37 | - --quiet 38 | - --format=custom 39 | - --configfile=tests/bandit.yaml 40 | files: ^(homeassistant|script|tests)/.+\.py$ 41 | - repo: https://github.com/pre-commit/mirrors-isort 42 | rev: v4.3.21 43 | hooks: 44 | - id: isort 45 | - repo: https://github.com/pre-commit/pre-commit-hooks 46 | rev: v2.4.0 47 | hooks: 48 | - id: check-executables-have-shebangs 49 | stages: [manual] 50 | - id: check-json 51 | - repo: https://github.com/pre-commit/mirrors-mypy 52 | rev: v0.770 53 | hooks: 54 | - id: mypy 55 | args: 56 | - --pretty 57 | - --show-error-codes 58 | - --show-error-context 59 | -------------------------------------------------------------------------------- /custom_components/govee_lan/config_flow.py: -------------------------------------------------------------------------------- 1 | import homeassistant.helpers.config_validation as cv 2 | from homeassistant.const import CONF_API_KEY 3 | import logging 4 | from typing import Any 5 | from .const import DOMAIN 6 | from homeassistant.core import callback 7 | import voluptuous as vol 8 | from homeassistant import config_entries, core, exceptions 9 | from homeassistant.data_entry_flow import FlowResult 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | @config_entries.HANDLERS.register(DOMAIN) 15 | class GoveeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 16 | VERSION = 1 17 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 18 | 19 | async def async_step_user( 20 | self, user_input: dict[str, Any] | None = None 21 | ) -> FlowResult: 22 | errors = {} 23 | 24 | if user_input is not None: 25 | return self.async_create_entry(title=DOMAIN, data=user_input) 26 | 27 | return self.async_show_form( 28 | step_id="user", 29 | data_schema=vol.Schema({vol.Optional(CONF_API_KEY): cv.string}), 30 | errors=errors, 31 | ) 32 | 33 | @staticmethod 34 | @callback 35 | def async_get_options_flow(config_entry): 36 | return GoveeOptionsFlowHandler(config_entry) 37 | 38 | 39 | class GoveeOptionsFlowHandler(config_entries.OptionsFlow): 40 | VERSION = 1 41 | 42 | def __init__(self, config_entry): 43 | self.config_entry = config_entry 44 | self.options = dict(config_entry.options) 45 | 46 | async def async_step_init(self, user_input=None): 47 | return await self.async_step_user() 48 | 49 | async def async_step_user(self, user_input=None): 50 | current_api_key = self.config_entry.options.get( 51 | CONF_API_KEY, self.config_entry.data.get(CONF_API_KEY, None) 52 | ) 53 | 54 | errors = {} 55 | if user_input is not None: 56 | api_key = user_input[CONF_API_KEY] 57 | self.options.update(user_input) 58 | return await self._update_options() 59 | 60 | options_schema = vol.Schema( 61 | {vol.Optional(CONF_API_KEY, default=current_api_key): cv.string} 62 | ) 63 | 64 | return self.async_show_form( 65 | step_id="user", data_schema=options_schema, errors=errors 66 | ) 67 | 68 | async def _update_options(self): 69 | return self.async_create_entry(title=DOMAIN, data=self.options) 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Govee LAN Control for Home Assistant 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) 4 | 5 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=wez&repository=govee-lan-hass&category=integration) 6 | 7 | ## ⚠️ Consider using the [Govee2MQTT AddOn](https://github.com/wez/govee2mqtt) Instead ⚠️ 8 | 9 | > Before you get too invested in setting up `govee-lan-hass`, I wanted to note 10 | that I've shifted my development focus to my [Govee2MQTT AddOn](https://github.com/wez/govee2mqtt). 11 | That has support for new APIs and devices, and is more flexible than the `govee-lan-hass` integration. 12 | > 13 | > Note that due to how Govee's LAN API works, you cannot run both the addon and the integration at the same time. 14 | 15 | ## Govee LAN Control for Home Assistant 16 | 17 | This works in conjunction with my 18 | [govee-led-wez](https://github.com/wez/govee-py) python library to provide 19 | control over Govee-manufactured lights, preferentially using the LAN protocol 20 | for local control. 21 | 22 | ## Installation 23 | 24 | **Note: you need to [enable the LAN API for each individual device](#tips-on-enabling-the-lan-api)!** 25 | 26 | Recommended first step: Obtain an HTTP API key from the Govee API: 27 | * Open the Account Page of the Govee mobile app (the person icon in the bottom right) 28 | * Click the settings "cog" icon in the top right 29 | * Click Apply for API Key and fill out the form 30 | * Your key will be emailed to you. 31 | 32 | It is recommended to wait until you have the key before configuring the 33 | integration, as the HTTP API is used to retrieve the names of the devices from 34 | your account, and those names influence the entity ids that are set up for the 35 | devices. 36 | 37 | You don't require an HTTP API key to use this integration if all of the devices 38 | that you want to control are supported by the LAN API, but having your names 39 | set up from the app is nice, so I recommend getting that set up anyway. 40 | 41 | * Install [HACS - the Home Assistant Community Store](https://hacs.xyz/docs/setup/download/) 42 | * Add this repo to HACS by: 43 | 1. Open the HACS integrations page 44 | 2. In the bottom right corner click the "Explore & Download Repositories" button 45 | 3. Type in "Govee LAN Control" and select it and add it 46 | 47 | * Once added, restart Home Assistant 48 | * Then go to Settings -> Devices & Services and click "Add Integration" 49 | * Type "Govee LAN Control" and add the integration 50 | * Enter your HTTP API key where prompted 51 | 52 | ## Notes 53 | 54 | * The `govee-led-wez` library doesn't perform immediate *read-after-write* of 55 | the device state after controlling a device. When using the HTTP API, doing 56 | so would double the number of calls made to the web service and increase the 57 | chances of hitting a rate limit. For the LAN API, while the devices generally 58 | respond immediate to a control request, they don't reliably return the 59 | updated device state for several seconds. As such, this integration 60 | assumes that successful control requests result in the state reflecting 61 | the request. If you are using other software to also control the lights, 62 | then you may experience incorrect information being reported in Home Assistant 63 | until the devices are polled. 64 | * LAN devices have their individual state polled once per minute 65 | * HTTP devices have their individual state polled once every 10 minutes 66 | * New LAN devices are discovered every 10 seconds 67 | * New HTTP devices are discovered every 10 minutes 68 | * You can force re-discovery/updating of HTTP device and their names by 69 | reloading the integration 70 | 71 | ## Tips on Enabling the LAN API 72 | 73 | **Note: you need to enable the LAN API for each individual device! 74 | Repeat these steps for each of your devices!** 75 | 76 | The [LAN API](https://app-h5.govee.com/user-manual/wlan-guide) docs have a list 77 | of supported models. The Govee app sometimes needs coaxing to show the LAN 78 | Control option for supported devices. Here's what works for me: 79 | 80 | * Open the app and ensure that the device(s) are fully up to date and have WiFi configured 81 | * Close/kill the Govee app 82 | * Turn off wifi on your mobile device; this seems to help encourage the app to show the LAN Control option. 83 | * Open the app and go to the settings for the device 84 | * The LAN Control option should appear for supported devices 85 | * Turn it on 86 | * Once done enabling LAN Control for your Govee devices, re-enable wifi on your mobile device 87 | 88 | ## Requirements of the LAN API 89 | 90 | * Home Assistant must be running on the same network as your Govee devices. 91 | If you are running it in docker, you will need to use `network_mode: host` 92 | or use a macvlan network. 93 | * UDP port 4001 much be reachable from the integration. The LAN discovery 94 | protocol sends a multicast packet to 239.255.255.250 port 4001. 95 | * UDP port 4002 must be available for the integration to receive UDP packets 96 | from the discovery protocol ping. 97 | * UDP port 4003 must be reachable from the integration. Govee devices will 98 | listen for commands on this port. 99 | * These fix port requirements are unfortunately part of the LAN API protocol. 100 | That means that you cannot run two different implementations of the 101 | Govee LAN API from the same IP address (eg: homebridge's govee plugin cannot 102 | run on the same IP as this HASS integration). If you need to do that for 103 | some reason, you will need to configure each of them to run on separate IP 104 | addresses. 105 | * Your network needs to support *multicast UDP* over wifi. Your wifi router may 106 | require some specific configuration to allow this to work reliably. Note that 107 | this is NOT the same thing as *multicast DNS*, although there is some relation 108 | between them. 109 | 110 | ## Troubleshooting 111 | 112 | If you add this to your `configuration.yaml` and restart home assistant, you'll get verbose logging that might reveal more about what's happening: 113 | 114 | ```yaml 115 | logger: 116 | logs: 117 | custom_components.govee_lan: debug 118 | govee_led_wez: debug 119 | ``` 120 | 121 | In addition, some diagnostics are recorded as extended attribute data associated 122 | with each entity. In HASS, go to "Developer Tools" -> "State", then type in the name 123 | of the light you were trying to control; it should show something like this screenshot: 124 | 125 | ![image](https://user-images.githubusercontent.com/117777/212545829-e0d2dc54-20f3-44bf-ac25-6bc679c76583.png) 126 | 127 | -------------------------------------------------------------------------------- /custom_components/govee_lan/light.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import asyncio 3 | import random 4 | import math 5 | import json 6 | import logging 7 | import socket 8 | import time 9 | 10 | from typing import Any, Dict 11 | 12 | from homeassistant import core 13 | from homeassistant.components import network 14 | from homeassistant.components.light import ( 15 | ColorMode, 16 | ATTR_BRIGHTNESS, 17 | ATTR_BRIGHTNESS_PCT, 18 | ATTR_COLOR_TEMP, 19 | ATTR_COLOR_TEMP_KELVIN, 20 | ATTR_HS_COLOR, 21 | ATTR_RGB_COLOR, 22 | SUPPORT_BRIGHTNESS, 23 | SUPPORT_COLOR, 24 | SUPPORT_COLOR_TEMP, 25 | LightEntity, 26 | PLATFORM_SCHEMA, 27 | ) 28 | import homeassistant.helpers.config_validation as cv 29 | from homeassistant.config_entries import ConfigEntry 30 | from homeassistant.const import CONF_API_KEY, Platform 31 | from homeassistant.core import callback 32 | from homeassistant.helpers.entity import DeviceInfo, Entity 33 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 34 | from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 35 | from homeassistant.util import color 36 | from homeassistant.util.timeout import TimeoutManager 37 | from .const import DOMAIN 38 | import voluptuous as vol 39 | from bleak import BleakClient, BleakError 40 | from homeassistant.components import bluetooth 41 | from govee_led_wez import ( 42 | GoveeController, 43 | GoveeDevice, 44 | GoveeDeviceState, 45 | GoveeColor, 46 | GoveeHttpDeviceDefinition, 47 | GoveeLanDeviceDefinition, 48 | ) 49 | 50 | # Serialize async_update calls, even though they are async capable. 51 | # For LAN control, we want to avoid a burst of UDP traffic causing 52 | # lost responses. 53 | # This is read by HA. 54 | PARALLEL_UPDATES = 1 55 | 56 | _LOGGER = logging.getLogger(__name__) 57 | 58 | # This is read by HA 59 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_API_KEY): cv.string}) 60 | 61 | # TODO: move to option flow 62 | HTTP_POLL_INTERVAL = 600 63 | LAN_POLL_INTERVAL = 10 64 | 65 | SKU_NAMES = { 66 | "H610A": "Glide Lively", 67 | "H61A2": "Neon LED Strip", 68 | "H6072": "Lyra Floor Lamp", 69 | "H619A": "LED Strip", 70 | } 71 | 72 | 73 | class DeviceRegistry: 74 | def __init__(self, add_entities: AddEntitiesCallback): 75 | self.devices: Dict[str, GoveLightEntity] = {} 76 | self.add_entities = add_entities 77 | 78 | def handle_device_update( 79 | self, 80 | hass: core.HomeAssistant, 81 | entry: ConfigEntry, 82 | controller: GoveeController, 83 | device: GoveeDevice, 84 | ): 85 | entity = self.devices.get(device.device_id, None) 86 | if entity: 87 | # Update entity name in case we found the entity 88 | # via the LAN API before we found it via HTTP 89 | if ( 90 | device.http_definition 91 | and entity._attr_name == entity._govee_fallback_name 92 | ): 93 | entity._attr_name = device.http_definition.device_name 94 | 95 | entity._govee_device = device 96 | entity._govee_device_updated() 97 | else: 98 | entity = GoveLightEntity(controller, device) 99 | self.devices[device.device_id] = entity 100 | entity._govee_device_updated() 101 | _LOGGER.info("Adding %s %s", device.device_id, entity._attr_name) 102 | self.add_entities([entity]) 103 | 104 | 105 | async def async_get_interfaces(hass: core.HomeAssistant): 106 | """Get list of interface to use.""" 107 | interfaces = [] 108 | 109 | adapters = await network.async_get_adapters(hass) 110 | for adapter in adapters: 111 | ipv4s = adapter.get("ipv4", None) 112 | if ipv4s: 113 | ip4 = ipv4s[0]["address"] 114 | if adapter["enabled"]: 115 | interfaces.append(ip4) 116 | 117 | if len(interfaces) == 0: 118 | interfaces.append("0.0.0.0") 119 | 120 | return interfaces 121 | 122 | 123 | async def async_setup_entry( 124 | hass: core.HomeAssistant, entry: ConfigEntry, add_entities: AddEntitiesCallback 125 | ): 126 | _LOGGER.info("async_setup_entry was called") 127 | 128 | registry = DeviceRegistry(add_entities) 129 | controller = GoveeController() 130 | controller.set_device_control_timeout(3) # TODO: configurable 131 | controller.set_device_change_callback( 132 | lambda device: registry.handle_device_update(hass, entry, controller, device) 133 | ) 134 | hass.data[DOMAIN]["controller"] = controller 135 | hass.data[DOMAIN]["registry"] = registry 136 | 137 | api_key = entry.options.get(CONF_API_KEY, entry.data.get(CONF_API_KEY, None)) 138 | 139 | entry.async_on_unload(controller.stop) 140 | 141 | async def update_config(hass: core.HomeAssistant, entry: ConfigEntry): 142 | _LOGGER.info("config options were changed") 143 | # TODO: how to propagate? 144 | 145 | entry.async_on_unload(entry.add_update_listener(update_config)) 146 | 147 | if api_key: 148 | controller.set_http_api_key(api_key) 149 | try: 150 | await controller.query_http_devices() 151 | except RuntimeError as exc: 152 | # The consequence of this is that the user-friendly names 153 | # won't be populated immediately for devices that we 154 | # do manage to discover via the LAN API. 155 | _LOGGER.error( 156 | "failed to get device list from Govee HTTP API. Will retry in the background", 157 | exc_info=exc, 158 | ) 159 | 160 | async def http_poller(interval): 161 | await asyncio.sleep(interval) 162 | controller.start_http_poller(interval) 163 | 164 | hass.loop.create_task(http_poller(HTTP_POLL_INTERVAL)) 165 | 166 | interfaces = await async_get_interfaces(hass) 167 | controller.start_lan_poller(interfaces) 168 | 169 | @callback 170 | def _async_discovered_ble( 171 | service_info: bluetooth.BluetoothServiceInfoBleak, 172 | change: bluetooth.BluetoothChange, 173 | ) -> None: 174 | """Subscribe to bluetooth changes.""" 175 | 176 | _LOGGER.info( 177 | "New service_info: %s name=%s address=%s source=%s rssi=%s", 178 | change, 179 | service_info.name, 180 | service_info.address, 181 | service_info.source, 182 | service_info.rssi, 183 | ) 184 | controller.register_ble_device(service_info.device) 185 | 186 | for mfr in [34817, 34818]: 187 | entry.async_on_unload( 188 | bluetooth.async_register_callback( 189 | hass, 190 | _async_discovered_ble, 191 | {"manufacturer_id": mfr}, 192 | bluetooth.BluetoothScanningMode.ACTIVE, 193 | ) 194 | ) 195 | 196 | 197 | class GoveLightEntity(LightEntity): 198 | _govee_controller: GoveeController 199 | _govee_device: GoveeDevice 200 | _attr_min_color_temp_kelvin = 2000 201 | _attr_max_color_temp_kelvin = 9000 202 | _attr_supported_color_modes = { 203 | ColorMode.BRIGHTNESS, 204 | ColorMode.COLOR_TEMP, 205 | ColorMode.RGB, 206 | } 207 | 208 | def __init__(self, controller: GoveeController, device: GoveeDevice): 209 | self._attr_extra_state_attributes = {} 210 | self._govee_controller = controller 211 | self._govee_device = device 212 | self._last_poll = None 213 | 214 | ident = device.device_id.replace(":", "") 215 | self._attr_unique_id = f"{device.model}_{ident}" 216 | 217 | fallback_name = None 218 | if device.model in SKU_NAMES: 219 | fallback_name = ( 220 | f"{SKU_NAMES[device.model]} {device.model.upper()}_{ident[-4:].upper()}" 221 | ) 222 | else: 223 | fallback_name = f"{device.model.upper()}_{ident[-4:].upper()}" 224 | 225 | self._govee_fallback_name = fallback_name 226 | 227 | if device.http_definition is not None: 228 | self._attr_name = device.http_definition.device_name 229 | # TODO: apply properties colorTem range? 230 | else: 231 | self._attr_name = fallback_name 232 | 233 | def __repr__(self): 234 | return str(self.__dict__) 235 | 236 | @property 237 | def device_info(self) -> DeviceInfo: 238 | return DeviceInfo( 239 | identifiers={(DOMAIN, self._govee_device.device_id)}, 240 | name=self.name, 241 | manufacturer="Govee", 242 | model=self._govee_device.model, 243 | sw_version=self._govee_device.lan_definition.wifi_software_version 244 | if self._govee_device.lan_definition 245 | else None, 246 | hw_version=self._govee_device.lan_definition.wifi_hardware_version 247 | if self._govee_device.lan_definition 248 | else None, 249 | ) 250 | 251 | @property 252 | def entity_registry_enabled_default(self): 253 | """Return if the entity should be enabled when first added to the entity registry.""" 254 | return True 255 | 256 | def _govee_device_updated(self): 257 | device = self._govee_device 258 | state = device.state 259 | _LOGGER.debug( 260 | "device state updated: %s entity_id=%s --> %r %r", 261 | device.device_id, 262 | self.entity_id, 263 | state, 264 | device, 265 | ) 266 | 267 | if state: 268 | self._attr_color_temp_kelvin = state.color_temperature 269 | if state.color_temperature and state.color_temperature > 0: 270 | self._attr_color_temp = color.color_temperature_kelvin_to_mired( 271 | state.color_temperature 272 | ) 273 | self._attr_color_mode = ColorMode.COLOR_TEMP 274 | self._attr_rgb_color = None 275 | elif state.color is not None: 276 | self._attr_color_temp_kelvin = None 277 | self._attr_color_temp = None 278 | self._attr_color_mode = ColorMode.RGB 279 | self._attr_rgb_color = state.color.as_tuple() 280 | 281 | self._attr_brightness = max( 282 | min(int(255 * state.brightness_pct / 100), 255), 0 283 | ) 284 | self._attr_is_on = state.turned_on 285 | 286 | self._attr_extra_state_attributes["http_enabled"] = device.http_definition is not None 287 | self._attr_extra_state_attributes["ble_enabled"] = device.ble_device is not None 288 | self._attr_extra_state_attributes["lan_enabled"] = device.lan_definition is not None 289 | 290 | if self.entity_id: 291 | self.schedule_update_ha_state() 292 | 293 | async def async_turn_on(self, **kwargs: Any) -> None: 294 | _LOGGER.debug( 295 | "turn on %s %s with %s", 296 | self._govee_device.device_id, 297 | self.entity_id, 298 | kwargs, 299 | ) 300 | 301 | try: 302 | turn_on = True 303 | 304 | if ATTR_RGB_COLOR in kwargs: 305 | r, g, b = kwargs.pop(ATTR_RGB_COLOR) 306 | await self._govee_controller.set_color( 307 | self._govee_device, GoveeColor(red=r, green=g, blue=b) 308 | ) 309 | turn_on = False 310 | 311 | if ATTR_BRIGHTNESS_PCT in kwargs: 312 | brightness = max(min(kwargs.pop(ATTR_BRIGHTNESS_PCT), 100), 0) 313 | await self._govee_controller.set_brightness( 314 | self._govee_device, brightness 315 | ) 316 | turn_on = False 317 | elif ATTR_BRIGHTNESS in kwargs: 318 | brightness = int(kwargs.pop(ATTR_BRIGHTNESS) * 100 / 255) 319 | await self._govee_controller.set_brightness( 320 | self._govee_device, brightness 321 | ) 322 | turn_on = False 323 | 324 | if ATTR_COLOR_TEMP_KELVIN in kwargs: 325 | color_temp_kelvin = kwargs.pop(ATTR_COLOR_TEMP_KELVIN) 326 | color_temp_kelvin = max( 327 | min(color_temp_kelvin, self._attr_max_color_temp_kelvin), 328 | self._attr_min_color_temp_kelvin, 329 | ) 330 | await self._govee_controller.set_color_temperature( 331 | self._govee_device, color_temp_kelvin 332 | ) 333 | turn_on = False 334 | elif ATTR_COLOR_TEMP in kwargs: 335 | color_temp = kwargs.pop(ATTR_COLOR_TEMP) 336 | color_temp_kelvin = color.color_temperature_mired_to_kelvin(color_temp) 337 | color_temp_kelvin = max( 338 | min(color_temp_kelvin, self._attr_max_color_temp_kelvin), 339 | self._attr_min_color_temp_kelvin, 340 | ) 341 | await self._govee_controller.set_color_temperature( 342 | self._govee_device, color_temp_kelvin 343 | ) 344 | turn_on = False 345 | 346 | if turn_on: 347 | await self._govee_controller.set_power_state(self._govee_device, True) 348 | 349 | # Update the last poll time to now to prevent the next poll from resetting the state 350 | # from the assumed state to an old state (because Govee returns the wrong state after a 351 | # write for some time) 352 | self._last_poll = time.monotonic() 353 | self.async_write_ha_state() 354 | 355 | except (asyncio.CancelledError, asyncio.TimeoutError) as exc: 356 | _LOGGER.debug( 357 | "timeout while modifying device state for %s %s", 358 | self._govee_device.device_id, 359 | self.entity_id, 360 | exc_info=exc, 361 | ) 362 | 363 | async def async_turn_off(self, **kwargs: Any) -> None: 364 | _LOGGER.debug( 365 | "turn OFF %s %s with %s", 366 | self._govee_device.device_id, 367 | self.entity_id, 368 | kwargs, 369 | ) 370 | try: 371 | await self._govee_controller.set_power_state(self._govee_device, False) 372 | 373 | # Update the last poll time to now to prevent the next poll from resetting the state 374 | # from the assumed state to an old state (because Govee returns the wrong state after a 375 | # write for some time) 376 | self._last_poll = time.monotonic() 377 | self.async_write_ha_state() 378 | except (asyncio.CancelledError, asyncio.TimeoutError) as exc: 379 | _LOGGER.debug( 380 | "timeout while modifying device state for %s %s", 381 | self._govee_device.device_id, 382 | self.entity_id, 383 | exc_info=exc, 384 | ) 385 | 386 | async def async_update(self): 387 | interval = ( 388 | HTTP_POLL_INTERVAL 389 | if not self._govee_device.lan_definition 390 | else LAN_POLL_INTERVAL 391 | ) 392 | 393 | # Can only poll via http; use our own poll interval for this, 394 | # as HA may poll too frequently and trip the miserly rate limit 395 | # set by Govee 396 | now = time.monotonic() 397 | if self._last_poll is not None: 398 | elapsed = math.ceil(now - self._last_poll) 399 | if elapsed < interval: 400 | _LOGGER.debug( 401 | "skip async_update for %s %s as elapsed %s < %s", 402 | self._govee_device, 403 | self.entity_id, 404 | elapsed, 405 | interval, 406 | ) 407 | return 408 | 409 | _LOGGER.debug( 410 | "async_update will poll %s %s", self._govee_device.device_id, self.entity_id 411 | ) 412 | self._last_poll = now 413 | 414 | current_time_string = time.strftime("%c") 415 | 416 | try: 417 | # A little random jitter to avoid getting a storm of UDP 418 | # responses from the LAN interface all at once 419 | # await asyncio.sleep(random.uniform(0.0, 3.2)) 420 | await self._govee_controller.update_device_state(self._govee_device) 421 | self._attr_available = True 422 | self._attr_extra_state_attributes["update_status"] = f"ok at {current_time_string}" 423 | self._attr_extra_state_attributes["timeout_count"] = 0 424 | except (asyncio.CancelledError, asyncio.TimeoutError) as exc: 425 | _LOGGER.debug( 426 | "timeout while querying device state for %s %s", 427 | self._govee_device.device_id, 428 | self.entity_id, 429 | exc_info=exc, 430 | ) 431 | timeout_count = self._attr_extra_state_attributes.get("timeout_count", 0) + 1 432 | self._attr_extra_state_attributes["update_status"] = f"timed out at {current_time_string}" 433 | self._attr_extra_state_attributes["timeout_count"] = timeout_count 434 | if timeout_count > 1: 435 | self._attr_available = False 436 | --------------------------------------------------------------------------------