From 313a150dd67accf403cbdf6fabadf682edc568a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Tue, 4 Jun 2024 16:08:08 +0200 Subject: [PATCH] Initial commit --- .envrc | 1 + .gitignore | 3 + README.md | 1 + flake.lock | 175 +++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 69 ++++++++++++++++++ huecli/__init__.py | 0 huecli/__main__.py | 175 +++++++++++++++++++++++++++++++++++++++++++++ poetry.lock | 151 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 19 +++++ 9 files changed, 594 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 huecli/__init__.py create mode 100644 huecli/__main__.py create mode 100644 poetry.lock create mode 100644 pyproject.toml diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24d7f18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.direnv/ +**/__pycache__/* +result diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f71b0c --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# huecli diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6c30e10 --- /dev/null +++ b/flake.lock @@ -0,0 +1,175 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nix-github-actions": { + "inputs": { + "nixpkgs": [ + "poetry2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1703863825, + "narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=", + "owner": "nix-community", + "repo": "nix-github-actions", + "rev": "5163432afc817cf8bd1f031418d1869e4c9d5547", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nix-github-actions", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1716948383, + "narHash": "sha256-SzDKxseEcHR5KzPXLwsemyTR/kaM9whxeiJohbL04rs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ad57eef4ef0659193044870c731987a6df5cf56b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "poetry2nix": { + "inputs": { + "flake-utils": "flake-utils_2", + "nix-github-actions": "nix-github-actions", + "nixpkgs": [ + "nixpkgs" + ], + "systems": "systems_3", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1717171636, + "narHash": "sha256-SwqzDI7ddN8SkfJ0moYMRu9iastqI25YAHPpmI2PlYM=", + "owner": "nix-community", + "repo": "poetry2nix", + "rev": "3bad7d0f33e6fd09205a19aab01e10af532198f9", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "poetry2nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "poetry2nix": "poetry2nix" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "id": "systems", + "type": "indirect" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "poetry2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1715940852, + "narHash": "sha256-wJqHMg/K6X3JGAE9YLM0LsuKrKb4XiBeVaoeMNlReZg=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "2fba33a182602b9d49f0b2440513e5ee091d838b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..6ee322c --- /dev/null +++ b/flake.nix @@ -0,0 +1,69 @@ +{ + description = "Application packaged using poetry2nix"; + + inputs = { + flake-utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + poetry2nix = { + url = "github:nix-community/poetry2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, flake-utils, poetry2nix }: + flake-utils.lib.eachDefaultSystem (system: + let + # see https://github.com/nix-community/poetry2nix/tree/master#api for more functions and examples. + pkgs = nixpkgs.legacyPackages.${system}; + inherit (poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }) mkPoetryApplication defaultPoetryOverrides; + pypkgs-build-requirements = { + paho-mqtt = [ "hatch-vcs" ]; + }; + p2n-overrides = defaultPoetryOverrides.extend (final: prev: + builtins.mapAttrs + (package: build-requirements: + (builtins.getAttr package prev).overridePythonAttrs (old: { + buildInputs = (old.buildInputs or [ ]) ++ (builtins.map (pkg: if builtins.isString pkg then builtins.getAttr pkg prev else pkg) build-requirements); + }) + ) + pypkgs-build-requirements + ); + in + { + packages = { + huecli = mkPoetryApplication { + projectDir = ./.; + overrides = p2n-overrides; + nativeBuildInputs = [ pkgs.installShellFiles ]; + postInstall = '' + installShellCompletion --cmd huecli \ + --bash <($out/bin/huecli --install-completion bash) \ + --zsh <($out/bin/huecli --install-completion zsh) \ + --fish <($out/bin/huecli --install-completion fish) + ''; + }; + default = self.packages.${system}.huecli; + }; + + # Shell for app dependencies. + # + # nix develop + # + # Use this shell for developing your app. + devShells.default = pkgs.mkShell { + inputsFrom = [ self.packages.${system}.huecli ]; + packages = [ + pkgs.ruff + ]; + }; + + # Shell for poetry. + # + # nix develop .#poetry + # + # Use this shell for changes to pyproject.toml and poetry.lock. + devShells.poetry = pkgs.mkShell { + packages = [ pkgs.poetry ]; + }; + }); +} diff --git a/huecli/__init__.py b/huecli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/huecli/__main__.py b/huecli/__main__.py new file mode 100644 index 0000000..b7e42d2 --- /dev/null +++ b/huecli/__main__.py @@ -0,0 +1,175 @@ +from enum import Enum +from typing_extensions import Annotated +import typer +import json +import paho.mqtt.publish as pub + + +class LightState(str, Enum): + on = "off" + off = "on" + + +NAME_TO_ID = { + "bedroom": "0x001788010d1b599a", + "hallway": "0x001788010d253b99", + "office": "0x001788010e371aa4", + "all": "all_lights", + "infuse": "infuse_group", +} +COLOR_MAP = { + "red": (0.6942, 0.2963), + "blue": (0.1355, 0.0399), + "green": (0.1704, 0.709), + "yellow": (0.4339, 0.5008), + "coolest": (0.3131, 0.3232), + "cool": (0.3804, 0.3767), + "neutral": (0.4599, 0.4106), + "warm": (0.5056, 0.4152), + "warmest": (0.5267, 0.4133), +} +MQTT_BROKER = "mqtt://ha1:1183" + + +class LightID(str, Enum): + bedroom = "bedroom" + hallway = "hallway" + office = "office" + all = "all" + infuse = "infuse" + + +def complete_color(incomplete: str): + completion = [] + for color in COLOR_MAP: + if color.startswith(incomplete): + completion.append(color) + return completion + + +def complete_id(incomplete: str): + completion = [] + for id in NAME_TO_ID: + if id.startswith(incomplete): + completion.append(id) + return completion + +def complete_state(incomplete: str): + states = ["on", "off"] + completion = [] + for id in states: + if id.startswith(incomplete): + completion.append(id) + return completion + + +def rgb_to_xy(red, green, blue): + """conversion of RGB colors to CIE1931 XY colors + Formulas implemented from: https://gist.github.com/popcorn245/30afa0f98eea1c2fd34d + + Args: + red (float): a number between 0.0 and 1.0 representing red in the RGB space + green (float): a number between 0.0 and 1.0 representing green in the RGB space + blue (float): a number between 0.0 and 1.0 representing blue in the RGB space + + Returns: + xy (list): x and y + """ + + # gamma correction + red = pow((red + 0.055) / (1.0 + 0.055), 2.4) if red > 0.04045 else (red / 12.92) + green = ( + pow((green + 0.055) / (1.0 + 0.055), 2.4) + if green > 0.04045 + else (green / 12.92) + ) + blue = ( + pow((blue + 0.055) / (1.0 + 0.055), 2.4) if blue > 0.04045 else (blue / 12.92) + ) + + # convert rgb to xyz + x = red * 0.649926 + green * 0.103455 + blue * 0.197109 + y = red * 0.234327 + green * 0.743075 + blue * 0.022598 + z = green * 0.053077 + blue * 1.035763 + + # convert xyz to xy + x = x / (x + y + z) + y = y / (x + y + z) + + # TODO check color gamut if known + + return [x, y] + + +app = typer.Typer() + + +@app.command() +def set_color( + id: Annotated[ + LightID, typer.Option(help="ID of light.", autocompletion=complete_id) + ], + color: Annotated[ + str, typer.Option(help="Color to set", autocompletion=complete_color) + ], +): + """ + Set the color of ID to COLOR where COLOR is either a known color value, + or a comma separated RGB value, like "0.1,0.2,0.3". + """ + str_id = NAME_TO_ID[id] + topic = f"zigbee2mqtt/{str_id}/set" + if color in COLOR_MAP: + payload_raw = { + "color": {"x": COLOR_MAP[color][0], "y": COLOR_MAP[color][1]}, + "color_mode": "xy", + } + payload = json.dumps(payload_raw) + else: + c = color.split(",") + xy = rgb_to_xy(float(c[0]), float(c[1]), float(c[2])) + raw_payload = {"x": xy[0], "y": xy[1]} + payload = json.dumps(raw_payload) + pub.single(topic, payload, hostname="ha1") + + +@app.command() +def set_state( + id: Annotated[ + LightID, typer.Option(help="ID of light.", autocompletion=complete_id) + ], + state: Annotated[ + LightState, typer.Option(help="State of light.", autocompletion=complete_state) + ]): + """ + Set the state of ID to STATE + """ + str_id = NAME_TO_ID[id] + topic = f"zigbee2mqtt/{str_id}/set" + payload = json.dumps({"state": state.value}) + pub.single(topic, payload, hostname="ha1") + + +@app.command() +def set_brightness( + id: Annotated[ + LightID, typer.Option(help="ID of light.", autocompletion=complete_id) + ], + brightness: int): + """ + Set brigthness of ID to BRIGHTNESS + """ + if brightness < 0 or brightness > 255: + raise ValueError("Brightness must be between 0 and 255") + str_id = NAME_TO_ID[id] + topic = f"zigbee2mqtt/{str_id}/set" + payload = json.dumps({"brightness": brightness}) + pub.single(topic, payload, hostname="ha1") + + +def main(): + app() + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..fb6cdd0 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,151 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[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" +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 = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "paho-mqtt" +version = "2.1.0" +description = "MQTT version 5.0/3.1.1 client class" +optional = false +python-versions = ">=3.7" +files = [ + {file = "paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee"}, + {file = "paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834"}, +] + +[package.extras] +proxy = ["pysocks"] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "rich" +version = "13.7.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "typer" +version = "0.12.3" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +files = [ + {file = "typer-0.12.3-py3-none-any.whl", hash = "sha256:070d7ca53f785acbccba8e7d28b08dcd88f79f1fbda035ade0aecec71ca5c914"}, + {file = "typer-0.12.3.tar.gz", hash = "sha256:49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + +[[package]] +name = "typing-extensions" +version = "4.12.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.0-py3-none-any.whl", hash = "sha256:b349c66bea9016ac22978d800cfff206d5f9816951f12a7d0ec5578b0a819594"}, + {file = "typing_extensions-4.12.0.tar.gz", hash = "sha256:8cbcdc8606ebcb0d95453ad7dc5065e6237b6aa230a31e81d0f440c30fed5fd8"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "aabf9b54a7176bd8599766aff1afb6a78ae411be3e028bdd93790fcdbbf97a1b" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..37d1d33 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.poetry] +name = "huecli" +version = "0.1.0" +description = "" +authors = ["Torjus HÃ¥kestad "] +license = "MIT" +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +paho-mqtt = "^2.1.0" +typer = "^0.12.3" + +[tool.poetry.scripts] +huecli = "huecli.__main__:main" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api"