pax_global_header00006660000000000000000000000064147633037340014524gustar00rootroot0000000000000052 comment=9858b5b53667c67a27798aaca2c8b838d662a0e9 govee-local-api-2.1.0/000077500000000000000000000000001476330373400144705ustar00rootroot00000000000000govee-local-api-2.1.0/.github/000077500000000000000000000000001476330373400160305ustar00rootroot00000000000000govee-local-api-2.1.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001476330373400202135ustar00rootroot00000000000000govee-local-api-2.1.0/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000015671476330373400227160ustar00rootroot00000000000000--- name: Bug report about: A light is supported but one or more functionalities are missing title: '' labels: bug assignees: Galorhallen --- If you are brought here a by this log line in Home Assistant: `Device %s is not supported. Only power control is available. Please open an issue at 'https://github.com/Galorhallen/govee-local-api/issues'` please open a feature request ### πŸ’‘ Bug - **Model**: - **Bugged functionalities**: - [ ] RGB control is missing - [ ] Brightness control is missing - [ ] White Temperature control is missing - [ ] Scenes are missing - [ ] Segments ### πŸ”§ Bug Description Describe the issue you’re experiencing with your Govee light. If the bug is related to segments please specify the correct number of supported segments. If the bug related to scenes a screenshot of the application scene view for the device could be useful. --- govee-local-api-2.1.0/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000010441476330373400237370ustar00rootroot00000000000000--- name: Feature request about: Request support for a light model title: "" labels: enhancement assignees: Galorhallen --- ## πŸš€ Light Support Template for Govee LAN API Library --- ### πŸ’‘ Light Details Provide information about the Govee light(s) features: - **Model**: - **Supported Functionalities**: - [ ] RGB - [ ] Brightness - [ ] White Temperature - [ ] Scenes - [ ] Segments (Please, specify the number of segments showed in the app, otherwise the feature will not be added to the device) - **Number of Segments**: --- govee-local-api-2.1.0/.github/dependabot.yml000066400000000000000000000007171476330373400206650ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 10 govee-local-api-2.1.0/.github/workflows/000077500000000000000000000000001476330373400200655ustar00rootroot00000000000000govee-local-api-2.1.0/.github/workflows/deploy.yml000066400000000000000000000051521476330373400221070ustar00rootroot00000000000000# This workflow will upload a Python Package using Twine when a release is created # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. name: Upload Python Package on: release: types: [published] push: pull_request: permissions: contents: read jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: psf/black@stable test: needs: - lint runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Display Python version run: python -c "import sys; print(sys.version)" - uses: snok/install-poetry@v1 - name: Install dependencies run: | poetry install --no-interaction --no-root - name: Test with pytest run: | poetry run pytest publish: needs: - test runs-on: ubuntu-latest permissions: id-token: write if: ${{ github.event_name == 'release' }} steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v3 with: python-version: "3.13" - uses: snok/install-poetry@v1 - name: Install dependencies run: | poetry install --no-interaction --no-root - name: Build package run: poetry build - name: Publish package uses: pypa/gh-action-pypi-publish@v1.8.14 publish-test: needs: - test runs-on: ubuntu-latest permissions: id-token: write if: ${{ github.event_name == 'push' && github.ref_name == 'test-publish' }} steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v3 with: python-version: "3.13" - uses: snok/install-poetry@v1 - name: Install dependencies run: | poetry install --no-interaction --no-root - name: Build package run: poetry build - name: Publish package uses: pypa/gh-action-pypi-publish@v1.8.14 with: repository-url: https://test.pypi.org/legacy/ govee-local-api-2.1.0/.gitignore000066400000000000000000000034451476330373400164660ustar00rootroot00000000000000junit/ coverage.xml # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version .vscode # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ govee-local-api-2.1.0/.pre-commit-config.yaml000066400000000000000000000024631476330373400207560ustar00rootroot00000000000000repos: - repo: https://github.com/psf/black rev: 24.10.0 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.7.0 hooks: - id: ruff types_or: ["python"] args: ["--fix"] - id: ruff-format files: ^.+\.py$ - repo: https://github.com/codespell-project/codespell rev: v2.3.0 hooks: - id: codespell args: - --quiet-level=2 exclude_types: [csv, json] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: check-executables-have-shebangs stages: [manual] - repo: https://github.com/cdce8p/python-typing-update rev: v0.7.0 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. # Will require manual work, before submitting changes! # pre-commit run --hook-stage manual python-typing-update --all-files - id: python-typing-update stages: [manual] args: - --py312-plus - --force - --keep-updates files: ^.+\.py$ - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.0.3 hooks: - id: prettier - repo: "https://github.com/pre-commit/mirrors-mypy" rev: "v1.12.0" hooks: - id: mypy govee-local-api-2.1.0/LICENSE000066400000000000000000000261351476330373400155040ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. govee-local-api-2.1.0/README.md000066400000000000000000000007201476330373400157460ustar00rootroot00000000000000# Govee Local API [![Upload Python Package](https://github.com/Galorhallen/govee-local-api/actions/workflows/deploy.yml/badge.svg?event=release)](https://github.com/Galorhallen/govee-local-api/actions/workflows/deploy.yml) # Requirements - Python >= 3.9 - Govee Local API enabled. Refer to https://app-h5.govee.com/user-manual/wlan-guide # Installaction From your terminal, run pip install govee-local-api or python3 -m pip install govee-local-api govee-local-api-2.1.0/example/000077500000000000000000000000001476330373400161235ustar00rootroot00000000000000govee-local-api-2.1.0/example/main.py000066400000000000000000000210741476330373400174250ustar00rootroot00000000000000import asyncio from prompt_toolkit import PromptSession from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.shortcuts import clear from govee_local_api import GoveeController, GoveeDevice, GoveeLightFeatures def update_device_callback(device: GoveeDevice) -> None: # print(f"Goveee device update callback: {device}") pass def discovered_callback(device: GoveeDevice, is_new: bool) -> bool: if is_new: # print(f"Discovered: {device}. New: {is_new}") device.set_update_callback(update_device_callback) return True async def create_controller( discovery_enabled: bool, manual_device_ip: str | None = None ) -> GoveeController: controller = GoveeController( loop=asyncio.get_event_loop(), listening_address="0.0.0.0", discovery_enabled=discovery_enabled, discovered_callback=discovered_callback, evicted_callback=lambda device: print(f"Evicted {device}"), ) await controller.start() if discovery_enabled: while not controller.devices: print("Waiting for devices... ") await asyncio.sleep(1) else: if not manual_device_ip: raise ValueError( "Manual device IP must be provided if discovery is disabled." ) print(f"Discovery not enabled. Adding {manual_device_ip} to discovery.") controller.add_device_to_discovery(manual_device_ip) while not controller.devices: print(f"Waiting for device {manual_device_ip} to be discovered...") await asyncio.sleep(1) return controller async def handle_turn_on(device: GoveeDevice) -> None: print("Turning on the LED strip...") await device.turn_on() async def handle_turn_off(device: GoveeDevice) -> None: print("Turning off the LED strip...") await device.turn_off() async def handle_set_brightness(device: GoveeDevice, session: PromptSession) -> None: while True: brightness = await session.prompt_async("Enter brightness (0-100): ") try: brightness = int(brightness) if 0 <= brightness <= 100: print(f"Setting brightness to {brightness}%") await device.set_brightness(brightness) break else: print("Please enter a value between 0 and 100.") except ValueError: print("Invalid input, please enter an integer.") async def handle_set_color(device: GoveeDevice, session: PromptSession) -> None: while True: color = await session.prompt_async("Enter color (R G B, values 0-255): ") try: r, g, b = map(int, color.split()) if all(0 <= v <= 255 for v in [r, g, b]): print(f"Setting color to RGB({r}, {g}, {b})") await device.set_rgb_color(r, g, b) break else: print("RGB values must be between 0 and 255.") except ValueError: print("Invalid input, please enter three integers separated by spaces.") async def handle_set_segment_color(device: GoveeDevice, session: PromptSession) -> None: if device.capabilities.features & GoveeLightFeatures.SEGMENT_CONTROL == 0: print("This device does not support segment control.") return while True: segment = await session.prompt_async( f"Enter segment number (1-{device.capabilities.segments_count}): " ) try: segment = int(segment) if 1 <= segment <= device.capabilities.segments_count: color = await session.prompt_async( "Enter segment color (R G B, values 0-255): " ) r, g, b = map(int, color.split()) if all(0 <= v <= 255 for v in [r, g, b]): print(f"Setting segment {segment} color to RGB({r}, {g}, {b})") await device.set_segment_rgb_color(segment, r, g, b) break else: print("RGB values must be between 0 and 255.") else: print( f"Segment number must be between 1 and {device.capabilities.segments_count}." ) except ValueError: print( "Invalid input. Please enter an integer for the segment and three integers for the color." ) async def handle_set_scene(device: GoveeDevice, session: PromptSession): while True: scenes = device.capabilities.available_scenes for idx, scene in enumerate(scenes): print(f"{idx}: {scene}") scene = await session.prompt_async(f"Enter scene number (0-{len(scenes)}): ") scene_name = scenes[int(scene)] if scene_name in device.capabilities.available_scenes: print(f"Setting scene to {scene}") await device.set_scene(scene_name) break else: print("Invalid scene. Please choose from the available scenes.") async def handle_clear_screen(device): clear() async def handle_exit(device): print("Exiting...") raise SystemExit() async def devices_menu(session, devices: list[GoveeDevice]) -> GoveeDevice: selection = None devices = sorted(devices, key=lambda d: d.sku) while ( selection is None or selection == "" or not selection.isnumeric() or int(selection) >= len(devices) ): for idx, device in enumerate(devices): print(f"{idx}: {device.ip} - {device.sku}") selection = await session.prompt_async( f"Choose an option (0-{len(devices) - 1}): " ) if not selection.isnumeric(): continue print(f"Selected device: {selection}") return devices[int(selection)] async def handle_send_hex(device: GoveeDevice, session: PromptSession) -> None: hex_data = await session.prompt_async("Enter hex data: ") await device.send_raw_command(hex_data) async def handle_manual_device( device: GoveeDevice, controller: GoveeController, session: PromptSession ) -> None: ip = await session.prompt_async("Enter device IP: ") controller.add_device_to_discovery(ip) async def menu(device: GoveeDevice) -> None: print("\nDevice: ", device) print("Select an option:") print("0. Exit") print("1. Turn on") print("2. Turn off") print("3. Set brightness (0-100)") print("4. Set color (R G B)") print("5. Set segment color (Segment, R G B)") print("6. Set scene") print("7. Send raw hex") print("8. Clear screen") print("9. Clear Device") print("10. Add device") async def repl() -> None: session = PromptSession() print("Welcome to the LED Control REPL.") # Dictionary of command handlers command_handlers = { "0": handle_exit, "1": handle_turn_on, "2": handle_turn_off, "3": lambda device: handle_set_brightness(device, session), "4": lambda device: handle_set_color(device, session), "5": lambda device: handle_set_segment_color(device, session), "6": lambda device: handle_set_scene(device, session), "7": lambda device: handle_send_hex(device, session), "8": handle_clear_screen, "10": lambda device: handle_manual_device(device, controller, session), } enable_discovery = await session.prompt_async("Enable device discovery? (y/n): ") discovery_enabled: bool = enable_discovery.lower() == "y" selected_device: GoveeDevice | None = None manual_device_ip = None if not discovery_enabled: manual_device_ip = await session.prompt_async("Enter device IP: ") controller: GoveeController = await create_controller( discovery_enabled, manual_device_ip ) while True: if not selected_device: selected_device = await devices_menu(session, controller.devices) await menu(selected_device) with patch_stdout(): user_choice = await session.prompt_async( f"Choose an option (0-{len(command_handlers)}): " ) if user_choice == "9": selected_device = None continue # Get the command handler from the dictionary, or return an invalid message if not found handler = command_handlers.get(user_choice) if handler: await handler(selected_device) # Call the appropriate command handler else: print("Invalid option, please try again.") async def main(): await repl() if __name__ == "__main__": try: asyncio.run(main()) except (EOFError, KeyboardInterrupt): print("REPL exited.") govee-local-api-2.1.0/poetry.lock000066400000000000000000000073261476330373400166740ustar00rootroot00000000000000# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] [[package]] name = "packaging" version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] [[package]] name = "prompt-toolkit" version = "3.0.50" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8.0" groups = ["dev"] files = [ {file = "prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198"}, {file = "prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab"}, ] [package.dependencies] wcwidth = "*" [[package]] name = "pytest" version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "wcwidth" version = "0.2.13" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = "*" groups = ["dev"] files = [ {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, ] [metadata] lock-version = "2.1" python-versions = "^3.11" content-hash = "71fb077d78fdb56fae6000ea000e646457eab17e61f16f90190e9b440ad968c9" govee-local-api-2.1.0/pyproject.toml000066400000000000000000000025371476330373400174130ustar00rootroot00000000000000[project] name = "govee-local-api" version = "2.1.0" license = "Apache-2.0" authors = [{ name = "Galorhallen", email = "andrea.ponte1987@gmail.com" }] description = "Library to communicate with Govee local API" readme = "README.md" requires-python = ">=3.11" dynamic = ["classifiers", "dependencies", "optional-dependencies"] [project.urls] "Repository" = "https://github.com/Galorhallen/govee-local-api" "Bug Tracker" = "https://github.com/Galorhallen/govee-local-api/issues" [tool.poetry] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries", ] packages = [ { include = "govee_local_api", from = "src" }, ] [tool.poetry_bumpversion.file."src/govee_local_api/__init__.py"] # Duplicate the line above to add more files [tool.poetry.dependencies] python = "^3.11" [tool.poetry.group.dev.dependencies] pytest = "^8.0.0" prompt_toolkit = "^3.0.0" [tool.ruff.isort] force-sort-within-sections = true known-first-party = [ "govee_local_api", ] combine-as-imports = true split-on-trailing-comma = false [tool.pytest.ini_options] pythonpath = "src" addopts = [ "--import-mode=importlib", "-v", ] [build-system] requires = ["poetry-core>=2.0.0"] build-backend = "poetry.core.masonry.api" govee-local-api-2.1.0/setup.cfg000066400000000000000000000010441476330373400163100ustar00rootroot00000000000000[metadata] name = "govee-local-api" version = attr: govee_local_api.__version__ author = "Galorhallen" author_email = "andrea.ponte1987@gmail.com" url = description = "Library to communicate with Govee devices via local API" long_description = file: README.md long_description_content_type = text/markdown readme = "README.md" requires-python = ">=3.9" classifiers = "Development Status :: 5 - Production/Stable" "Programming Language :: Python :: 3" "License :: OSI Approved :: MIT License" "Operating System :: OS Independent" govee-local-api-2.1.0/src/000077500000000000000000000000001476330373400152575ustar00rootroot00000000000000govee-local-api-2.1.0/src/govee_local_api/000077500000000000000000000000001476330373400203675ustar00rootroot00000000000000govee-local-api-2.1.0/src/govee_local_api/__init__.py000066400000000000000000000004341476330373400225010ustar00rootroot00000000000000from .controller import GoveeController from .device import GoveeDevice from .light_capabilities import GoveeLightFeatures, GoveeLightCapabilities __all__ = [ "GoveeController", "GoveeDevice", "GoveeLightFeatures", "GoveeLightCapabilities", ] __version__ = "2.1.0" govee-local-api-2.1.0/src/govee_local_api/controller.py000066400000000000000000000415361476330373400231350ustar00rootroot00000000000000from __future__ import annotations import asyncio import ipaddress import logging import socket from collections.abc import Callable from datetime import datetime, timedelta from typing import Any, cast from .device import GoveeDevice from .device_registry import DeviceRegistry from .light_capabilities import ( GOVEE_LIGHT_CAPABILITIES, ON_OFF_CAPABILITIES, GoveeLightFeatures, ) from .message import ( HexMessage, BrightnessMessage, ColorMessage, SceneMessages, GoveeMessage, MessageResponseFactory, OnOffMessage, ScanMessage, ScanResponse, SegmentColorMessages, DevStatusMessage, DevStatusResponse, ) BROADCAST_ADDRESS = "239.255.255.250" BROADCAST_PORT = 4001 LISTENING_PORT = 4002 COMMAND_PORT = 4003 DISCOVERY_INTERVAL = 10 EVICT_INTERVAL = DISCOVERY_INTERVAL * 3 UPDATE_INTERVAL = 5 class GoveeController: def __init__( self, loop=None, broadcast_address: str = BROADCAST_ADDRESS, broadcast_port: int = BROADCAST_PORT, listening_address: str = "0.0.0.0", listening_port: int = LISTENING_PORT, device_command_port: int = COMMAND_PORT, discovery_enabled: bool = False, discovery_interval: int = DISCOVERY_INTERVAL, evict_enabled: bool = False, evict_interval: int = EVICT_INTERVAL, update_enabled: bool = True, update_interval: int = UPDATE_INTERVAL, discovered_callback: Callable[[GoveeDevice, bool], bool] | None = None, evicted_callback: Callable[[GoveeDevice], None] | None = None, logger: logging.Logger | None = None, ) -> None: """Build a controller that handle Govee devices that support local API on local network. Args: loop: The asyncio event loop. If None the loop is retrieved by calling ``asyncio.get_running_loop()`` broadcast_address (str): The multicast address to use to send discovery messages. Default: 239.255.255.250 broadcast_port (int): Devices port where discovery messages are sent. Default: 4001 listening_port (int): Local UDP port on which the controller listen for incoming devices' messages device_command_port (int): The devices' port where the commands should be sent discovery_enabled (bool): If true a discovery message is sent every ``discovery_interval`` seconds. Default: False discovery_interval (int): Interval between discovery messages (if discovery is enabled). Default: 10 seconds evict_enabled (bool): If true the controller automatically remove devices not seen for ``evict_interval`` seconds (requires discovery to be enabled) evict_interval (int): Interval after which a device is evicted. Default 30 seconds update_enabled (bool): If true the devices status is updated automatically every ``update_interval`` seconds. A successful device update reset the eviction timer for the device. Default: True update_interval (int): Interval between a status update is requested to devices. discovered_callback (Callable[GoveeDevice, bool]): An optional function to call when a device is discovered (or rediscovered). Default None evicted_callback (Callable[GoveeDevice]): An optional function to call when a device is evicted. """ self._logger = logger or logging.getLogger(__name__) self._transport: Any = None self._protocol = None self._broadcast_address = broadcast_address self._broadcast_port = broadcast_port self._listening_address = listening_address self._listening_port = listening_port self._device_command_port = device_command_port self._loop = loop or asyncio.get_running_loop() self._cleanup_done: asyncio.Event = asyncio.Event() self._message_factory = MessageResponseFactory() self._registry: DeviceRegistry = DeviceRegistry(self._logger) self._discovery_enabled = discovery_enabled self._discovery_interval = discovery_interval self._update_enabled = update_enabled self._update_interval = update_interval self._evict_enabled = evict_enabled self._evict_interval = evict_interval self._device_discovered_callback = discovered_callback self._device_evicted_callback = evicted_callback self._discovery_handle: asyncio.TimerHandle | None = None self._update_handle: asyncio.TimerHandle | None = None self._response_handler: dict[str, Callable] = { ScanResponse.command: self._handle_scan_response, DevStatusResponse.command: self._handle_status_update_response, } async def start(self): self._transport, self._protocol = await self._loop.create_datagram_endpoint( lambda: self, local_addr=(self._listening_address, self._listening_port) ) if self._discovery_enabled or self._registry.has_queued_devices: self.send_discovery_message() if self._update_enabled: self.send_update_message() def cleanup(self) -> asyncio.Event: self._cleanup_done.clear() self.set_update_enabled(False) self.set_discovery_enabled(False) if self._transport: self._transport.close() self._registry.cleanup() return self._cleanup_done def add_device_to_discovery_queue(self, ip: str) -> bool: ip_added: bool = self._registry.add_device_to_queue(ip) if not self._discovery_enabled and ip_added: self.send_discovery_message() return ip_added def remove_device_from_discovery_queue(self, ip: str) -> bool: return self._registry.remove_device_from_queue(ip) @property def discovery_queue(self) -> set[str]: return self._registry.devices_queue def remove_device(self, device: str | GoveeDevice) -> None: if isinstance(device, GoveeDevice): device = device.fingerprint self._registry.remove_discovered_device(device) @property def evict_enabled(self) -> bool: return self._evict_enabled def set_evict_enabled(self, enabled: bool) -> None: self._evict_enabled = enabled def set_discovery_enabled(self, enabled: bool) -> None: if self._discovery_enabled == enabled: return self._discovery_enabled = enabled if enabled: self.send_discovery_message() elif self._discovery_handle: self._discovery_handle.cancel() self._discovery_handle = None @property def discovery(self) -> bool: return self._discovery_enabled def set_discovery_interval(self, interval: int) -> None: self._discovery_interval = interval @property def discovery_interval(self) -> int: return self._discovery_interval def set_device_discovered_callback( self, callback: Callable[[GoveeDevice, bool], bool] | None ) -> Callable[[GoveeDevice, bool], bool] | None: old_callback = self._device_discovered_callback self._device_discovered_callback = callback return old_callback def set_update_enabled(self, enabled: bool) -> None: if self._update_enabled == enabled: return self._update_enabled = enabled if enabled: self.send_update_message() elif self._update_handle: self._update_handle.cancel() self._update_handle = None @property def update_enabled(self) -> bool: return self._update_enabled def send_discovery_message(self) -> None: message: bytes = bytes(ScanMessage()) call_later: bool = False if not self._transport: return if self._discovery_enabled: call_later = True self._transport.sendto( message, (self._broadcast_address, self._broadcast_port) ) if self._registry.has_queued_devices: call_later = True for ip in self._registry.devices_queue: self._transport.sendto(message, (ip, self._broadcast_port)) manually_added_devices = [ device.ip for device in self._registry.discovered_devices.values() if device.is_manual ] if manually_added_devices: call_later = True for ip in manually_added_devices: self._transport.sendto(message, (ip, self._broadcast_port)) if call_later: self._discovery_handle = self._loop.call_later( self._discovery_interval, self.send_discovery_message ) def send_update_message(self) -> None: if self._transport: for d in self._registry.discovered_devices.values(): self._send_update_message(device=d) if self._update_enabled: self._update_handle = self._loop.call_later( self._update_interval, self.send_update_message ) async def turn_on_off(self, device: GoveeDevice, status: bool) -> None: self._send_message(OnOffMessage(status), device) async def set_segment_rgb_color( self, device: GoveeDevice, segment: int, rgb: tuple[int, int, int] ) -> None: if not device.capabilities: self._logger.warning("Capabilities not available for device %s", device) return if device.capabilities.features & GoveeLightFeatures.SEGMENT_CONTROL == 0: self._logger.warning( "Segment control is not supported by device %s", device ) return if segment < 1 or segment > len(device.capabilities.segments): self._logger.warning( "Segment index %s is not valid for device %s", segment, device ) return segment_data: bytes = device.capabilities.segments[segment - 1] if not segment_data: self._logger.warning( "Segment %s is not supported by device %s", segment, device ) return message = SegmentColorMessages(segment_data, rgb) self._logger.debug(f"Sending message {message} to device {device}") self._send_message(message, device) async def set_scene(self, device: GoveeDevice, scene: str) -> None: if ( not device.capabilities or device.capabilities.features & GoveeLightFeatures.SCENES == 0 ): self._logger.warning("Scenes are not supported by device %s", device) return scene_code: bytes | None = device.capabilities.scenes.get(scene.lower(), None) if not scene_code: self._logger.warning( "Scene %s is not available for device %s", scene, device ) return self._send_message(SceneMessages(scene_code), device) async def set_brightness(self, device: GoveeDevice, brightness: int) -> None: self._send_message(BrightnessMessage(brightness), device) async def set_color( self, device: GoveeDevice, *, rgb: tuple[int, int, int] | None, temperature: int | None, ) -> None: if rgb: self._send_message(ColorMessage(rgb=rgb, temperature=None), device) else: self._send_message(ColorMessage(rgb=None, temperature=temperature), device) async def send_raw_command(self, device: GoveeDevice, command: str) -> None: self._send_message(HexMessage([command]), device) def get_device_by_ip(self, ip: str) -> GoveeDevice | None: return self._registry.get_device_by_ip(ip) def get_device_by_sku(self, sku: str) -> GoveeDevice | None: return self._registry.get_device_by_sku(sku) def get_device_by_fingerprint(self, fingerprint: str) -> GoveeDevice | None: return self._registry.get_device_by_fingerprint(fingerprint) @property def devices(self) -> list[GoveeDevice]: return list(self._registry.discovered_devices.values()) def connection_made(self, transport): self._transport = transport sock = self._transport.get_extra_info("socket") sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) broadcast_ip = ipaddress.ip_address(self._broadcast_address) if broadcast_ip.is_multicast: sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) sock.setsockopt( socket.SOL_IP, socket.IP_MULTICAST_IF, socket.inet_aton(self._listening_address), ) sock.setsockopt( socket.SOL_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton(self._broadcast_address) + socket.inet_aton(self._listening_address), ) def connection_lost(self, *args, **kwargs): if self._transport: broadcast_ip = ipaddress.ip_address(self._broadcast_address) if broadcast_ip.is_multicast: sock = self._transport.get_extra_info("socket") sock.setsockopt( socket.SOL_IP, socket.IP_DROP_MEMBERSHIP, socket.inet_aton(self._broadcast_address) + socket.inet_aton(self._listening_address), ) self._cleanup_done.set() self._logger.debug("Disconnected") def datagram_received(self, data: bytes, addr: tuple): if data: self._loop.create_task(self._handle_datagram_received(data, addr)) async def _handle_datagram_received(self, data: bytes, addr: tuple): message = self._message_factory.create_message(data) if not message: if self._logger.isEnabledFor(logging.DEBUG): self._logger.debug( "Unknown message received from %s. Message: %s", addr, data ) self._logger.warning( "Unknown message received from %s. Message: %s", addr, data[:50] ) return if message.command == ScanResponse.command: await self._handle_scan_response(cast(ScanResponse, message)) elif message.command == DevStatusResponse.command: await self._handle_status_update_response( cast(DevStatusResponse, message), addr ) async def _handle_status_update_response(self, message: DevStatusResponse, addr): self._logger.debug("Status update received from {}: {}", addr, message) ip = addr[0] if device := self.get_device_by_ip(ip): device.update(message) async def _handle_scan_response(self, message: ScanResponse) -> None: fingerprint = message.device if device := self.get_device_by_fingerprint(fingerprint): if self._call_discovered_callback(device, False): device.update_lastseen() self._logger.debug("Device updated: %s", device) else: capabilities = GOVEE_LIGHT_CAPABILITIES.get(message.sku, None) if not capabilities: capabilities = ON_OFF_CAPABILITIES self._logger.warning( "Device %s is not supported. Only power control is available. Please open an issue at 'https://github.com/Galorhallen/govee-local-api/issues'", message.sku, ) device = GoveeDevice( self, message.ip, fingerprint, message.sku, capabilities ) if self._call_discovered_callback(device, True): device = self._registry.add_discovered_device(device) self._logger.debug("Device discovered: %s", device) else: self._logger.debug("Device %s ignored", device) if self._evict_enabled: self._evict() def _call_discovered_callback(self, device: GoveeDevice, is_new: bool) -> bool: if not self._device_discovered_callback: return True return self._device_discovered_callback(device, is_new) def _send_message(self, message: GoveeMessage, device: GoveeDevice) -> None: self._transport.sendto(bytes(message), (device.ip, self._device_command_port)) def _send_update_message(self, device: GoveeDevice): self._send_message(DevStatusMessage(), device) def _evict(self) -> None: now = datetime.now() devices = dict(self._registry.discovered_devices) for fingerprint, device in devices.items(): diff: timedelta = now - device.lastseen if diff.total_seconds() >= self._evict_interval: device._controller = None self._registry.remove_discovered_device(fingerprint) self._logger.debug("Device evicted: %s", device) if self._device_evicted_callback and callable( self._device_evicted_callback ): self._device_evicted_callback(device) govee-local-api-2.1.0/src/govee_local_api/device.py000066400000000000000000000117601476330373400222050ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Callable from datetime import datetime from typing import Any from .light_capabilities import GoveeLightCapabilities, ON_OFF_CAPABILITIES from .message import DevStatusResponse class GoveeSegment: def __init__(self, is_on: bool, color: tuple[int, int, int]) -> None: self.is_on = is_on self.color = color def as_dict(self) -> dict[str, Any]: return {"is_on": self.is_on, "color": self.color} def __str__(self) -> str: return f"" class GoveeDevice: def __init__( self, controller, ip: str, fingerprint: str, sku: str, capabilities: GoveeLightCapabilities = ON_OFF_CAPABILITIES, ) -> None: self._controller = controller self._fingerprint = fingerprint self._sku = sku self._ip = ip self._lastseen: datetime = datetime.now() self._capabilities: GoveeLightCapabilities = capabilities self._is_on: bool = False self._rgb_color = (0, 0, 0) self._temperature_color = 0 self._brightness = 0 self._update_callback: Callable[[GoveeDevice], None] | None = None self.is_manual: bool = False @property def controller(self): return self._controller @property def capabilities(self) -> GoveeLightCapabilities: return self._capabilities @property def ip(self) -> str: return self._ip @property def fingerprint(self) -> str: return self._fingerprint @property def sku(self) -> str: return self._sku @property def lastseen(self) -> datetime: return self._lastseen @property def on(self) -> bool: return self._is_on @property def rgb_color(self) -> tuple[int, int, int]: return self._rgb_color @property def brightness(self) -> int: return self._brightness @property def temperature_color(self) -> int: return self._temperature_color @property def update_callback(self) -> Callable[[GoveeDevice], None] | None: return self._update_callback def set_update_callback( self, callback: Callable[[GoveeDevice], None] | None ) -> Callable[[GoveeDevice], None] | None: old_callback = self._update_callback self._update_callback = callback return old_callback async def turn_on(self) -> None: await self._controller.turn_on_off(self, True) self._is_on = True async def set_segment_rgb_color( self, segment: int, red: int, green: int, blue: int ) -> None: rgb: tuple[int, int, int] = (red, green, blue) await self._controller.set_segment_rgb_color(self, segment, rgb) async def turn_segment_off(self, segment: int) -> None: await self._controller.set_segment_rgb_color(self, segment, (0, 0, 0)) async def turn_off(self) -> None: await self._controller.turn_on_off(self, False) self._is_on = False async def set_brightness(self, value: int) -> None: await self._controller.set_brightness(self, value) self._brightness = value async def set_rgb_color(self, red: int, green: int, blue: int) -> None: rgb = (red, green, blue) await self._controller.set_color(self, rgb=rgb, temperature=None) self._rgb_color = rgb async def set_temperature(self, temperature: int) -> None: await self._controller.set_color(self, temperature=temperature, rgb=None) self._temperature_color = temperature async def set_scene(self, scene: str) -> None: await self._controller.set_scene(self, scene) async def send_raw_command(self, command: str) -> None: await self._controller.send_raw_command(self, command) def update(self, message: DevStatusResponse) -> None: self._is_on = message.is_on self._brightness = message.brightness self._rgb_color = message.color self._temperature_color = message.color_temperature self.update_lastseen() if self._update_callback and callable(self._update_callback): self._update_callback(self) def update_lastseen(self) -> None: self._lastseen = datetime.now() def as_dict(self) -> dict[str, Any]: return { "ip": self._ip, "fingerprint": self._fingerprint, "sku": self._sku, "lastseen": self._lastseen, "on": self._is_on, "brightness": self._brightness, "color": self._rgb_color, "colorTemperature": self._temperature_color, } def __str__(self) -> str: result = f"" if self._is_on else ">" ) govee-local-api-2.1.0/src/govee_local_api/device_registry.py000066400000000000000000000050751476330373400241370ustar00rootroot00000000000000from .device import GoveeDevice import logging from typing import Optional class DeviceRegistry: def __init__(self, logger: Optional[logging.Logger] = None) -> None: self._discovered_devices: dict[str, GoveeDevice] = {} self._custom_devices_queue: set[str] = set() self._logger = logger or logging.getLogger(__name__) def add_discovered_device(self, device: GoveeDevice) -> GoveeDevice: if device.ip in self._custom_devices_queue: self._logger.debug( f"Found manullay added device {device}. Removing from queue." ) self._custom_devices_queue.remove(device.ip) device.is_manual = True self._discovered_devices[device.fingerprint] = device return device def remove_discovered_device(self, device: str | GoveeDevice) -> None: if isinstance(device, GoveeDevice): device = device.fingerprint if device in self._discovered_devices: del self._discovered_devices[device] def add_device_to_queue(self, ip: str) -> bool: if ip not in self._custom_devices_queue: self._custom_devices_queue.add(ip) return True return False def remove_device_from_queue(self, ip: str) -> bool: if ip in self._custom_devices_queue: self._custom_devices_queue.remove(ip) if device := self.get_device_by_ip(ip): if device.is_manual: self.remove_discovered_device(device) return True return False def cleanup(self) -> None: self._discovered_devices.clear() self._custom_devices_queue.clear() def get_device_by_ip(self, ip: str) -> GoveeDevice | None: return next( (device for device in self._discovered_devices.values() if device.ip == ip), None, ) def get_device_by_sku(self, sku: str) -> GoveeDevice | None: return next( ( device for device in self._discovered_devices.values() if device.sku == sku ), None, ) def get_device_by_fingerprint(self, fingerprint: str) -> GoveeDevice | None: return self._discovered_devices.get(fingerprint, None) @property def discovered_devices(self) -> dict[str, GoveeDevice]: return self._discovered_devices @property def devices_queue(self) -> set[str]: return self._custom_devices_queue @property def has_queued_devices(self) -> bool: return bool(self._custom_devices_queue) govee-local-api-2.1.0/src/govee_local_api/light_capabilities.py000066400000000000000000000205511476330373400245640ustar00rootroot00000000000000from enum import IntFlag class GoveeLightFeatures(IntFlag): """Govee Lights capabilities.""" COLOR_RGB = 0x01 COLOR_KELVIN_TEMPERATURE = 0x02 BRIGHTNESS = 0x04 SEGMENT_CONTROL = 0x08 SCENES = 0x10 COMMON_FEATURES: GoveeLightFeatures = ( GoveeLightFeatures.COLOR_RGB | GoveeLightFeatures.COLOR_KELVIN_TEMPERATURE | GoveeLightFeatures.BRIGHTNESS ) class GoveeLightCapabilities: def __init__( self, features: GoveeLightFeatures, segments: list[bytes] = [], scenes: dict[str, bytes] = {}, ) -> None: self.features = features self.segments = ( segments if features & GoveeLightFeatures.SEGMENT_CONTROL else [] ) self.scenes = scenes if features & GoveeLightFeatures.SCENES else {} @property def segments_count(self) -> int: return len(self.segments) @property def available_scenes(self) -> list[str]: return list(self.scenes.keys()) def __repr__(self) -> str: return f"GoveeLightCapabilities(features={self.features!r}, segments={self.segments!r}, scenes={self.scenes!r})" def __str__(self) -> str: return f"GoveeLightCapabilities(features={self.features!r}, segments={len(self.segments)}, scenes={len(self.scenes)})" SEGMENT_CODES: list[bytes] = [ b"\x01\x00", # 1 b"\x02\x00", # 2 b"\x04\x00", # 3 b"\x08\x00", # 4 b"\x10\x00", # 5 b"\x20\x00", # 6 b"\x40\x00", # 7 b"\x80\x00", # 8 b"\x00\x01", # 9 b"\x00\x02", # 10 b"\x00\x04", # 11 b"\x00\x08", # 12 b"\x00\x10", # 13 b"\x00\x20", # 14 b"\x00\x40", # 15 ] SCENE_CODES: dict[str, bytes] = { "sunrise": b"\x00", "sunset": b"\x01", "movie": b"\x04", "dating": b"\x05", "romantic": b"\x07", "twinkle": b"\x08", "candlelight": b"\x09", "snowflake": b"\x0f", "energetic": b"\x10", "breathe": b"\x0a", "crossing": b"\x15", } def create_with_capabilities( rgb: bool, temperature: bool, brightness: bool, segments: int, scenes: bool ) -> GoveeLightCapabilities: features: GoveeLightFeatures = GoveeLightFeatures(0) segments_codes = [] if rgb: features = features | GoveeLightFeatures.COLOR_RGB if temperature: features = features | GoveeLightFeatures.COLOR_KELVIN_TEMPERATURE if brightness: features = features | GoveeLightFeatures.BRIGHTNESS if segments > 0: features = features | GoveeLightFeatures.SEGMENT_CONTROL segments_codes = SEGMENT_CODES[:segments] if scenes: features = features | GoveeLightFeatures.SCENES return GoveeLightCapabilities( features, segments_codes, SCENE_CODES if scenes else {} ) BASIC_CAPABILITIES = create_with_capabilities( rgb=True, temperature=True, brightness=True, segments=0, scenes=True ) ON_OFF_CAPABILITIES = create_with_capabilities( rgb=False, temperature=False, brightness=False, segments=0, scenes=False ) GOVEE_LIGHT_CAPABILITIES: dict[str, GoveeLightCapabilities] = { # Models with common features "H6008": BASIC_CAPABILITIES, "H600D": BASIC_CAPABILITIES, "H6022": BASIC_CAPABILITIES, "H6042": create_with_capabilities(True, True, True, 5, True), "H6046": BASIC_CAPABILITIES, "H6047": BASIC_CAPABILITIES, "H6051": BASIC_CAPABILITIES, "H6052": BASIC_CAPABILITIES, "H6056": BASIC_CAPABILITIES, "H6059": BASIC_CAPABILITIES, "H605D": BASIC_CAPABILITIES, "H6061": BASIC_CAPABILITIES, "H6062": BASIC_CAPABILITIES, "H6065": BASIC_CAPABILITIES, "H6066": BASIC_CAPABILITIES, "H6069": create_with_capabilities(True, False, True, 20, True), "H6067": BASIC_CAPABILITIES, "H606A": BASIC_CAPABILITIES, "H6088": BASIC_CAPABILITIES, "H608A": BASIC_CAPABILITIES, "H6079": BASIC_CAPABILITIES, "H607C": BASIC_CAPABILITIES, "H608B": BASIC_CAPABILITIES, "H608D": BASIC_CAPABILITIES, "H60A1": BASIC_CAPABILITIES, "H6072": BASIC_CAPABILITIES, "H6073": BASIC_CAPABILITIES, "H6076": BASIC_CAPABILITIES, "H6078": BASIC_CAPABILITIES, "H6087": BASIC_CAPABILITIES, "H610A": BASIC_CAPABILITIES, "H610B": BASIC_CAPABILITIES, "H6110": BASIC_CAPABILITIES, "H6117": BASIC_CAPABILITIES, "H612A": BASIC_CAPABILITIES, "H612B": BASIC_CAPABILITIES, "H612F": create_with_capabilities(True, True, True, 5, True), "H6144": BASIC_CAPABILITIES, "H6159": BASIC_CAPABILITIES, "H615A": create_with_capabilities(True, True, True, 0, True), "H615B": create_with_capabilities(True, True, True, 0, True), "H615C": create_with_capabilities(True, True, True, 0, True), "H615D": create_with_capabilities(True, True, True, 0, True), "H615E": create_with_capabilities(True, True, True, 0, True), "H6163": BASIC_CAPABILITIES, "H6167": BASIC_CAPABILITIES, "H6168": BASIC_CAPABILITIES, "H6172": BASIC_CAPABILITIES, "H6173": BASIC_CAPABILITIES, "H6175": BASIC_CAPABILITIES, "H6176": BASIC_CAPABILITIES, "H618A": create_with_capabilities(True, True, True, 15, True), "H618C": create_with_capabilities(True, True, True, 15, True), "H618E": create_with_capabilities(True, True, True, 15, True), "H618F": create_with_capabilities(True, True, True, 15, True), "H619A": create_with_capabilities(True, True, True, 10, True), "H619B": create_with_capabilities(True, True, True, 10, True), "H619C": create_with_capabilities(True, True, True, 10, True), "H619D": create_with_capabilities(True, True, True, 10, True), "H619E": create_with_capabilities(True, True, True, 10, True), "H619Z": BASIC_CAPABILITIES, "H61A0": BASIC_CAPABILITIES, "H61A1": BASIC_CAPABILITIES, "H61A2": BASIC_CAPABILITIES, "H61A3": BASIC_CAPABILITIES, "H61A5": BASIC_CAPABILITIES, "H61A8": BASIC_CAPABILITIES, "H61B2": BASIC_CAPABILITIES, "H61B3": create_with_capabilities(True, True, True, 15, True), "H61B5": BASIC_CAPABILITIES, "H61BA": BASIC_CAPABILITIES, "H61BE": BASIC_CAPABILITIES, "H61BC": BASIC_CAPABILITIES, "H61C2": BASIC_CAPABILITIES, "H61C5": BASIC_CAPABILITIES, "H61C3": BASIC_CAPABILITIES, "H61D3": BASIC_CAPABILITIES, "H61D5": BASIC_CAPABILITIES, "H61E0": BASIC_CAPABILITIES, "H61E1": BASIC_CAPABILITIES, "H61E5": create_with_capabilities(True, True, True, 12, True), "H61E6": create_with_capabilities(True, True, True, 15, True), "H61F5": BASIC_CAPABILITIES, "H6609": create_with_capabilities(True, True, True, 18, True), "H6640": create_with_capabilities(True, True, True, 8, True), "H6641": create_with_capabilities(True, True, True, 14, True), "H7012": create_with_capabilities(False, False, True, 0, False), "H7013": create_with_capabilities(False, False, True, 0, False), "H7021": BASIC_CAPABILITIES, "H7028": BASIC_CAPABILITIES, "H7041": BASIC_CAPABILITIES, "H7042": BASIC_CAPABILITIES, "H7050": BASIC_CAPABILITIES, "H7051": BASIC_CAPABILITIES, "H7055": BASIC_CAPABILITIES, "H705A": BASIC_CAPABILITIES, "H705B": BASIC_CAPABILITIES, "H705C": BASIC_CAPABILITIES, "H705D": create_with_capabilities(True, True, True, 9, True), "H705E": create_with_capabilities(True, True, True, 18, True), "H705F": create_with_capabilities(True, True, True, 27, True), "H7060": BASIC_CAPABILITIES, "H7063": BASIC_CAPABILITIES, "H7061": BASIC_CAPABILITIES, "H7062": BASIC_CAPABILITIES, "H7065": BASIC_CAPABILITIES, "H7066": BASIC_CAPABILITIES, "H706A": BASIC_CAPABILITIES, "H706B": BASIC_CAPABILITIES, "H706C": BASIC_CAPABILITIES, "H7033": BASIC_CAPABILITIES, "H70C1": BASIC_CAPABILITIES, "H70C2": BASIC_CAPABILITIES, "H70C4": create_with_capabilities(True, True, True, 10, True), "H70C5": create_with_capabilities(True, True, True, 10, True), "H7020": BASIC_CAPABILITIES, "H7037": BASIC_CAPABILITIES, "H7038": BASIC_CAPABILITIES, "H7039": BASIC_CAPABILITIES, "H7052": BASIC_CAPABILITIES, "H7075": BASIC_CAPABILITIES, "H70A1": create_with_capabilities(True, True, True, 15, True), "H70A2": create_with_capabilities(True, True, True, 20, True), "H70A3": create_with_capabilities(True, True, True, 15, True), "H70B1": BASIC_CAPABILITIES, "H70B3": BASIC_CAPABILITIES, "H70BC": BASIC_CAPABILITIES, "H70D1": BASIC_CAPABILITIES, "H805C": BASIC_CAPABILITIES, "H8072": create_with_capabilities(True, True, True, 8, True), } govee-local-api-2.1.0/src/govee_local_api/message.py000066400000000000000000000130541476330373400223700ustar00rootroot00000000000000from __future__ import annotations import base64 import json from typing import Any, TypeVar class GoveeMessage: command: str = "" _data: dict[str, Any] def __init__(self, data: dict[str, Any]) -> None: self._data = data def as_dict(self) -> dict[str, Any]: return {"msg": {"cmd": self.command, "data": self.data}} def as_json(self) -> str: return json.dumps(self.as_dict(), separators=(",", ":")) def __bytes__(self) -> bytearray | bytes: return self.as_json().encode("utf-8") def __str__(self) -> str: return self.as_json() @property def data(self) -> dict[str, Any]: return self._data M = TypeVar("M", bound=GoveeMessage) class ScanMessage(GoveeMessage): command = "scan" def __init__(self) -> None: super().__init__({"account_topic": "reserve"}) class DevStatusMessage(GoveeMessage): command = "devStatus" def __init__(self) -> None: super().__init__({}) class StatusMessage(GoveeMessage): command = "status" def __init__(self) -> None: super().__init__({}) class OnOffMessage(GoveeMessage): command = "turn" def __init__(self, on: bool) -> None: super().__init__({"value": int(on)}) class BrightnessMessage(GoveeMessage): command = "brightness" def __init__(self, brightness_pct: int) -> None: super().__init__({"value": max(0, min(brightness_pct, 100))}) class ColorMessage(GoveeMessage): TEMPERATURE_MAX_KELVIN = 9000 TEMPERATURE_MIN_KELVIN = 2000 command = "colorwc" def __init__( self, *, rgb: tuple[int, int, int] | None, temperature: int | None ) -> None: if rgb: nrgb = [max(0, min(c, 255)) for c in rgb] data = { "color": {"r": nrgb[0], "g": nrgb[1], "b": nrgb[2]}, "colorTemInKelvin": 0, } elif temperature: data = { "color": {"r": 0, "g": 0, "b": 0}, "colorTemInKelvin": max( self.TEMPERATURE_MIN_KELVIN, min(temperature, self.TEMPERATURE_MAX_KELVIN), ), } super().__init__(data) class PtRealMessage(GoveeMessage): command = "ptReal" def __init__(self, data: list[bytes], do_checksum: bool = True) -> None: checksumed_data: list[str] = ( [ base64.b64encode(PtRealMessage._with_checksum(d)).decode("utf-8") for d in data ] if do_checksum else [base64.b64encode(d).decode("utf-8") for d in data] ) super().__init__({"command": checksumed_data}) @staticmethod def _with_checksum(data: bytes) -> bytes: xor_result: int = 0 for byte in data: xor_result ^= byte return data + xor_result.to_bytes(1, "big") class HexMessage(PtRealMessage): def __init__(self, data: list[str]) -> None: super().__init__([bytes.fromhex(d) for d in data], do_checksum=False) class SegmentColorMessages(PtRealMessage): def __init__(self, segment: bytes, color: tuple[int, int, int]) -> None: capped_color = [max(0, min(c, 255)) for c in color] data = ( b"\x33\x05\x15\x01" + bytes(capped_color) + b"\x00\x00\x00\x00\x00" + segment + b"\x00\x00\x00\x00\x00" ) super().__init__([data]) class SceneMessages(PtRealMessage): def __init__(self, scene: bytes) -> None: data = ( b"\x33\x05\x04" + scene + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) super().__init__([data]) class ScanResponse(GoveeMessage): command = "scan" def __init__(self, data: dict[str, Any]) -> None: super().__init__(data) @property def device(self): return self._data["device"] @property def sku(self): return self._data["sku"] @property def ip(self): return self._data["ip"] class DevStatusResponse(GoveeMessage): command = "devStatus" def __init__(self, data: dict[str, Any]) -> None: super().__init__(data) @property def is_on(self) -> bool: return bool(self._data["onOff"]) @property def color(self) -> tuple[int, int, int]: color = self._data["color"] return (color["r"], color["g"], color["b"]) @property def brightness(self) -> int: return self._data["brightness"] @property def color_temperature(self) -> int: return self._data["colorTemInKelvin"] class StatusResponse(GoveeMessage): command = "status" def __init__(self, data: dict[str, Any]) -> None: super().__init__(data) def hex(self) -> str: return base64.b64decode(self._data["pt"]).hex() class MessageResponseFactory: def __init__(self) -> None: self._messages: set[type[GoveeMessage]] = { ScanResponse, DevStatusResponse, StatusResponse, } def create_message(self, data: bytes | bytearray | str) -> GoveeMessage | None: msg_json = json.loads(data) if "msg" not in msg_json or ( "cmd" not in msg_json["msg"] and "data" not in msg_json["msg"] ): return None cmd: str = msg_json["msg"]["cmd"] message_data: dict[str, Any] = msg_json["msg"]["data"] message: type[GoveeMessage] = next( m for m in self._messages if m.command == cmd ) if not message: return None return message(message_data) govee-local-api-2.1.0/tests/000077500000000000000000000000001476330373400156325ustar00rootroot00000000000000govee-local-api-2.1.0/tests/__init__.py000066400000000000000000000000001476330373400177310ustar00rootroot00000000000000govee-local-api-2.1.0/tests/test_message.py000066400000000000000000000051431476330373400206720ustar00rootroot00000000000000from govee_local_api.message import ( ScanMessage, ColorMessage, BrightnessMessage, OnOffMessage, ) def test_scan_message() -> None: msg: ScanMessage = ScanMessage() assert msg.as_dict() == { "msg": {"cmd": "scan", "data": {"account_topic": "reserve"}} } def test_color_message_ok(): msg: ColorMessage = ColorMessage(rgb=(64, 128, 255), temperature=None) assert msg.as_dict() == { "msg": { "cmd": "colorwc", "data": {"color": {"r": 64, "g": 128, "b": 255}, "colorTemInKelvin": 0}, } } msg = ColorMessage(rgb=None, temperature=5000) assert msg.as_dict() == { "msg": { "cmd": "colorwc", "data": {"color": {"r": 0, "g": 0, "b": 0}, "colorTemInKelvin": 5000}, } } msg: ColorMessage = ColorMessage(rgb=(64, 128, 255), temperature=5000) assert msg.as_dict() == { "msg": { "cmd": "colorwc", "data": {"color": {"r": 64, "g": 128, "b": 255}, "colorTemInKelvin": 0}, } } def test_color_clipping(): msg: ColorMessage = ColorMessage(rgb=(-500, 42, 500), temperature=None) assert msg.as_dict() == { "msg": { "cmd": "colorwc", "data": {"color": {"r": 0, "g": 42, "b": 255}, "colorTemInKelvin": 0}, } } msg = ColorMessage(rgb=None, temperature=1) assert msg.as_dict() == { "msg": { "cmd": "colorwc", "data": {"color": {"r": 0, "g": 0, "b": 0}, "colorTemInKelvin": 2000}, } } msg = ColorMessage(rgb=None, temperature=9999) assert msg.as_dict() == { "msg": { "cmd": "colorwc", "data": {"color": {"r": 0, "g": 0, "b": 0}, "colorTemInKelvin": 9000}, } } def test_brightness(): msg: BrightnessMessage = BrightnessMessage(42) assert msg.as_dict() == { "msg": { "cmd": "brightness", "data": {"value": 42}, } } def test_brightness_clipping(): msg: BrightnessMessage = BrightnessMessage(-5) assert msg.as_dict() == { "msg": { "cmd": "brightness", "data": {"value": 0}, } } msg: BrightnessMessage = BrightnessMessage(101) assert msg.as_dict() == { "msg": { "cmd": "brightness", "data": {"value": 100}, } } def test_on_off(): msg: OnOffMessage = OnOffMessage(True) assert msg.as_dict() == {"msg": {"cmd": "turn", "data": {"value": 1}}} msg: OnOffMessage = OnOffMessage(False) assert msg.as_dict() == {"msg": {"cmd": "turn", "data": {"value": 0}}} govee-local-api-2.1.0/tests/test_registry.py000066400000000000000000000042211476330373400211120ustar00rootroot00000000000000import unittest from unittest.mock import Mock from govee_local_api.device_registry import DeviceRegistry from govee_local_api.device import GoveeDevice from govee_local_api.controller import GoveeController class TestDeviceRegistry(unittest.TestCase): def setUp(self): self.registry = DeviceRegistry() self._mock_controller = Mock(spec=GoveeController) self.device = GoveeDevice( self._mock_controller, "192.168.1.1", "device1", "sku1", None ) def test_add_discovered_device(self): self.registry.add_discovered_device(self.device) assert self.device.fingerprint in self.registry.discovered_devices def test_remove_discovered_device(self): self.registry.add_discovered_device(self.device) self.registry.remove_discovered_device(self.device) assert self.device.fingerprint not in self.registry.discovered_devices def test_add_custom_device(self): self.registry.add_device_to_queue("192.168.1.2") assert "192.168.1.2" in self.registry.devices_queue def test_discovery_custom_device(self): self.registry.add_device_to_queue("192.168.1.2") GoveeDevice(self._mock_controller, "192.168.1.2", "device2", "sku2", None) assert "192.168.1.2" in self.registry.devices_queue def test_cleanup(self): self.registry.add_discovered_device(self.device) self.registry.add_device_to_queue("192.168.1.2") self.registry.cleanup() assert len(self.registry.discovered_devices) == 0 assert len(self.registry.devices_queue) == 0 def test_get_device_by_ip(self): self.registry.add_discovered_device(self.device) device = self.registry.get_device_by_ip("192.168.1.1") assert device == self.device def test_get_device_by_sku(self): self.registry.add_discovered_device(self.device) device = self.registry.get_device_by_sku("sku1") assert device == self.device def test_get_device_by_fingerprint(self): self.registry.add_discovered_device(self.device) device = self.registry.get_device_by_fingerprint("device1") assert device == self.device