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:
Edgar P. Burkhart 2025-03-09 18:51:38 +01:00
parent 45f7fc4741
commit e54223e361
Signed by: edpibu
GPG key ID: 9833D3C5A25BD227
6 changed files with 148 additions and 26 deletions

View file

@ -20,4 +20,4 @@ repos:
hooks:
- id: mypy
args: [--strict]
additional_dependencies: [paho-mqtt]
additional_dependencies: [paho-mqtt, pillow]

View file

@ -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}")

View file

@ -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:

View file

@ -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")

View file

@ -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
View file

@ -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"