feat: Add cover art support for user client
This commit adds support for displaying cover art from the currently playing media in the Home Assistant user client. - The `HassUserClient` now publishes cover art to the `user/image/cover` MQTT topic. - The `publish_cover` method uses `playerctl` to retrieve the art URL and `pillow` to convert the image to WebP format before publishing. - The `components` property now includes an `image` component for cover art. - The `cover` attribute on `HassUserClient` is used to avoid resending the same cover multiple times. - The `publish_state` method has been extended to spawn a thread for updating the cover.
This commit is contained in:
parent
45f7fc4741
commit
e54223e361
6 changed files with 148 additions and 26 deletions
|
@ -20,4 +20,4 @@ repos:
|
|||
hooks:
|
||||
- id: mypy
|
||||
args: [--strict]
|
||||
additional_dependencies: [paho-mqtt]
|
||||
additional_dependencies: [paho-mqtt, pillow]
|
||||
|
|
2
PKGBUILD
2
PKGBUILD
|
@ -15,7 +15,7 @@ arch=(any)
|
|||
url="https://git.edgarpierre.fr/edpibu/hasspy"
|
||||
license=('GPL-3.0-or-later')
|
||||
groups=()
|
||||
depends=('python-paho-mqtt')
|
||||
depends=('python-paho-mqtt' 'python-pillow')
|
||||
makedepends=('git' 'uv' 'python-installer') # 'bzr', 'git', 'mercurial' or 'subversion'
|
||||
provides=("${pkgname%-git}")
|
||||
conflicts=("${pkgname%-git}")
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import logging
|
||||
import logging.config
|
||||
import signal
|
||||
import tomllib
|
||||
from argparse import ArgumentParser
|
||||
|
@ -10,7 +11,6 @@ log = logging.getLogger(__name__)
|
|||
|
||||
|
||||
def main() -> int:
|
||||
log.info("Starting HassPy")
|
||||
parser = ArgumentParser(
|
||||
prog="HassPy",
|
||||
description="Home Assistant MQTT client",
|
||||
|
@ -42,9 +42,11 @@ def main() -> int:
|
|||
|
||||
if isinstance(config.get("log_level"), str):
|
||||
config["log_level"] = getattr(logging, config["log_level"])
|
||||
logging.basicConfig(
|
||||
level=config.get("log_level", logging.INFO) - (args.verbose * 10)
|
||||
)
|
||||
config["log_level"] = config.get("log_level", logging.INFO) - (args.verbose * 10)
|
||||
|
||||
logging.basicConfig(level=config["log_level"])
|
||||
|
||||
log.info("Starting HassPy")
|
||||
|
||||
ha: HassClient
|
||||
if not args.user:
|
||||
|
|
126
hasspy/mqtt.py
126
hasspy/mqtt.py
|
@ -1,11 +1,14 @@
|
|||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from subprocess import run
|
||||
from threading import Timer
|
||||
from threading import Thread, Timer
|
||||
from typing import Any, Mapping
|
||||
|
||||
from paho.mqtt.client import Client, MQTTMessage, MQTTMessageInfo
|
||||
from paho.mqtt.enums import CallbackAPIVersion, MQTTErrorCode
|
||||
from PIL import Image
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
@ -23,7 +26,9 @@ class HassClient(Client):
|
|||
|
||||
self.interval = self.config.get("interval", 60)
|
||||
self.power_on = True
|
||||
self.timer = Timer(self.interval, self.publish_state)
|
||||
self.timer = Timer(0, self.publish_state)
|
||||
|
||||
self.cover = ""
|
||||
|
||||
self.connect()
|
||||
|
||||
|
@ -58,7 +63,6 @@ class HassClient(Client):
|
|||
self.message_callback_add(self.command_topic, self.on_command)
|
||||
|
||||
def publish_state(self) -> MQTTMessageInfo:
|
||||
self.timer.cancel()
|
||||
self.timer = Timer(self.interval, self.publish_state)
|
||||
self.timer.start()
|
||||
|
||||
|
@ -74,7 +78,9 @@ class HassClient(Client):
|
|||
|
||||
self.do_command(*payload.split(":"))
|
||||
|
||||
self.publish_state()
|
||||
self.timer.cancel()
|
||||
self.timer = Timer(1, self.publish_state)
|
||||
self.timer.start()
|
||||
|
||||
def do_command(self, cmd: str, value: str = "") -> None:
|
||||
pass
|
||||
|
@ -85,7 +91,7 @@ class HassClient(Client):
|
|||
self.publish_availability()
|
||||
self.init_subs()
|
||||
|
||||
self.publish_state()
|
||||
self.timer.start()
|
||||
|
||||
@property
|
||||
def state_topic(self) -> str:
|
||||
|
@ -139,9 +145,7 @@ class HassSystemClient(HassClient):
|
|||
def do_command(self, cmd: str, value: str = "") -> None:
|
||||
if cmd in self.commands:
|
||||
log.debug(f"Executing command: {cmd}")
|
||||
proc = run(self.commands[cmd])
|
||||
if proc.returncode != 0:
|
||||
log.error(f"Failed to execute command: {cmd}")
|
||||
run_command(self.commands[cmd])
|
||||
|
||||
if cmd == "POWER_ON":
|
||||
self.power_on = True
|
||||
|
@ -179,6 +183,9 @@ class HassSystemClient(HassClient):
|
|||
class HassUserClient(HassClient):
|
||||
commands = {
|
||||
"PLAY_PAUSE": ["playerctl", "play-pause"],
|
||||
"PLAY_NEXT": ["playerctl", "next"],
|
||||
"PLAY_PREV": ["playerctl", "previous"],
|
||||
"PLAY_STOP": ["playerctl", "stop"],
|
||||
}
|
||||
|
||||
def __init__(self, node_id: str, config: Mapping[str, Any]) -> None:
|
||||
|
@ -187,14 +194,13 @@ class HassUserClient(HassClient):
|
|||
def do_command(self, cmd: str, value: str = "") -> None:
|
||||
if cmd in self.commands:
|
||||
log.debug(f"Executing command: {cmd}")
|
||||
proc = run(self.commands[cmd])
|
||||
if proc.returncode != 0:
|
||||
log.error(f"Failed to execute command: {cmd}")
|
||||
run_command(self.commands[cmd])
|
||||
|
||||
match [cmd, value]:
|
||||
case ["VOLUME", value]:
|
||||
log.debug(f"Executing command: {cmd}:{value}")
|
||||
proc = run(
|
||||
|
||||
run_command(
|
||||
[
|
||||
"wpctl",
|
||||
"set-volume",
|
||||
|
@ -202,8 +208,6 @@ class HassUserClient(HassClient):
|
|||
f"{int(value) / 100:.2f}",
|
||||
]
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
log.error(f"Failed to set volume: {value}")
|
||||
|
||||
@property
|
||||
def availability_topic(self) -> str:
|
||||
|
@ -219,6 +223,44 @@ class HassUserClient(HassClient):
|
|||
"icon": "mdi:play-pause",
|
||||
"payload_press": "PLAY_PAUSE",
|
||||
},
|
||||
"next": {
|
||||
"unique_id": f"{self.node_id}_next",
|
||||
"p": "button",
|
||||
"name": "Next",
|
||||
"icon": "mdi:skip-next",
|
||||
"payload_press": "PLAY_NEXT",
|
||||
},
|
||||
"prev": {
|
||||
"unique_id": f"{self.node_id}_prev",
|
||||
"p": "button",
|
||||
"name": "Previous",
|
||||
"icon": "mdi:skip-previous",
|
||||
"payload_press": "PLAY_PREV",
|
||||
},
|
||||
"stop": {
|
||||
"unique_id": f"{self.node_id}_stop",
|
||||
"p": "button",
|
||||
"name": "Stop",
|
||||
"icon": "mdi:stop",
|
||||
"payload_press": "PLAY_STOP",
|
||||
},
|
||||
"player": {
|
||||
"unique_id": f"{self.node_id}_player",
|
||||
"p": "sensor",
|
||||
"name": "Player",
|
||||
"icon": "mdi:music",
|
||||
"value_template": "{{ value_json.player.value }}",
|
||||
"json_attributes_topic": self.state_topic,
|
||||
"json_attributes_template": "{{ value_json.player.attributes | to_json }}",
|
||||
},
|
||||
"cover": {
|
||||
"unique_id": f"{self.node_id}_cover",
|
||||
"p": "image",
|
||||
"name": "Cover",
|
||||
"icon": "mdi:disc-player",
|
||||
"content_type": "image/webp",
|
||||
"image_topic": self.cover_topic,
|
||||
},
|
||||
"volume": {
|
||||
"unique_id": f"{self.node_id}_volume",
|
||||
"p": "number",
|
||||
|
@ -237,13 +279,59 @@ class HassUserClient(HassClient):
|
|||
def state_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"volume": self.volume_value,
|
||||
"player": self.player_value,
|
||||
}
|
||||
|
||||
@property
|
||||
def volume_value(self) -> int:
|
||||
proc = run(["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"], capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
log.error("Failed to get volume")
|
||||
return 0
|
||||
vol = run_command(["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"])
|
||||
|
||||
return int(float(proc.stdout.decode("utf-8").split(": ")[1]) * 100)
|
||||
return int(float(vol.split(": ")[1]) * 100)
|
||||
|
||||
@property
|
||||
def player_value(self) -> str | dict[str, str | dict[str, str]]:
|
||||
return {
|
||||
"value": run_command(["playerctl", "status"]),
|
||||
"attributes": {
|
||||
k: run_command(["playerctl", "metadata", k])
|
||||
for k in ["title", "album", "artist"]
|
||||
},
|
||||
}
|
||||
|
||||
def publish_state(self) -> MQTTMessageInfo:
|
||||
Thread(target=self.publish_cover).start()
|
||||
return super().publish_state()
|
||||
|
||||
@property
|
||||
def cover_topic(self) -> str:
|
||||
return f"{self.node_id}/image/cover"
|
||||
|
||||
def publish_cover(self) -> None:
|
||||
log.debug("Publishing cover image")
|
||||
out = run_command(["playerctl", "metadata"])
|
||||
|
||||
artUrl = re.compile(r"mpris:artUrl\s+file://(.*)").search(out)
|
||||
if not artUrl:
|
||||
return
|
||||
|
||||
art = artUrl.group(1)
|
||||
|
||||
if art == self.cover:
|
||||
return
|
||||
|
||||
self.cover = art
|
||||
by = io.BytesIO()
|
||||
with Image.open(art) as im:
|
||||
im.save(by, format="webp")
|
||||
|
||||
by.seek(0)
|
||||
self.publish(self.cover_topic, by.read())
|
||||
|
||||
|
||||
def run_command(cmd: list[str]) -> str:
|
||||
proc = run(cmd, capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
log.error(f"Failed to execute command: {cmd}")
|
||||
return "null"
|
||||
|
||||
return proc.stdout.decode("utf-8")
|
||||
|
|
|
@ -6,6 +6,7 @@ readme = "README.md"
|
|||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"paho-mqtt>=2.1.0",
|
||||
"pillow>=11.1.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
|
33
uv.lock
generated
33
uv.lock
generated
|
@ -8,6 +8,7 @@ version = "0.1.0"
|
|||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "paho-mqtt" },
|
||||
{ name = "pillow" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
|
@ -17,7 +18,10 @@ dev = [
|
|||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "paho-mqtt", specifier = ">=2.1.0" }]
|
||||
requires-dist = [
|
||||
{ name = "paho-mqtt", specifier = ">=2.1.0" },
|
||||
{ name = "pillow", specifier = ">=11.1.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
|
@ -62,6 +66,33 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "11.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20", size = 46742715 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/31/9ca79cafdce364fd5c980cd3416c20ce1bebd235b470d262f9d24d810184/pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc", size = 3226640 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0f/ff07ad45a1f172a497aa393b13a9d81a32e1477ef0e869d030e3c1532521/pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0", size = 3101437 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/2f/9906fca87a68d29ec4530be1f893149e0cb64a86d1f9f70a7cfcdfe8ae44/pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1", size = 4326605 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/0f/f3547ee15b145bc5c8b336401b2d4c9d9da67da9dcb572d7c0d4103d2c69/pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec", size = 4411173 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/df/bf8176aa5db515c5de584c5e00df9bab0713548fd780c82a86cba2c2fedb/pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5", size = 4369145 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/7c/7433122d1cfadc740f577cb55526fdc39129a648ac65ce64db2eb7209277/pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114", size = 4496340 },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/46/dd94b93ca6bd555588835f2504bd90c00d5438fe131cf01cfa0c5131a19d/pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352", size = 4296906 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/28/2f9d32014dfc7753e586db9add35b8a41b7a3b46540e965cb6d6bc607bd2/pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3", size = 4431759 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/48/19c2cbe7403870fbe8b7737d19eb013f46299cdfe4501573367f6396c775/pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9", size = 2291657 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ad/285c556747d34c399f332ba7c1a595ba245796ef3e22eae190f5364bb62b/pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c", size = 2626304 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7b/ef35a71163bf36db06e9c8729608f78dedf032fc8313d19bd4be5c2588f3/pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65", size = 2375117 },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/30/77f54228401e84d6791354888549b45824ab0ffde659bafa67956303a09f/pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861", size = 3230060 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b1/56723b74b07dd64c1010fee011951ea9c35a43d8020acd03111f14298225/pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081", size = 3106192 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/cd/7bf7180e08f80a4dcc6b4c3a0aa9e0b0ae57168562726a05dc8aa8fa66b0/pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c", size = 4446805 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/42/87c856ea30c8ed97e8efbe672b58c8304dee0573f8c7cab62ae9e31db6ae/pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547", size = 4530623 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/41/026879e90c84a88e33fb00cc6bd915ac2743c67e87a18f80270dfe3c2041/pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab", size = 4465191 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/fb/a7960e838bc5df57a2ce23183bfd2290d97c33028b96bde332a9057834d3/pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9", size = 2295494 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/6c/6ec83ee2f6f0fda8d4cf89045c6be4b0373ebfc363ba8538f8c999f63fcd/pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe", size = 2631595 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.9.10"
|
||||
|
|
Loading…
Add table
Reference in a new issue