From eb5cda17b716f38af6cbbb5e1a7b40b527f0b6e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:51:09 +0000 Subject: [PATCH] Address HACS review feedback, bump to 4.1.0 - Register toniebox.upload_audio as an admin service: it reads an arbitrary host path and uploads it to the Tonie Cloud, so non-admin users must not be able to call it. - Move the os.path.isfile() check into the executor job with the read so it no longer blocks the event loop; a missing file now raises ServiceValidationError. - Run the ICI MQTT disconnect()/loop_stop() in the executor. loop_stop() joins paho's network thread and blocked the event loop on every token refresh, unexpected disconnect and unload. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSAsYTC3THUC3kLoYc7sSf --- CHANGELOG.json | 14 +++++++++++++ README.md | 4 ++++ custom_components/toniebox/__init__.py | 26 ++++++++++++++++-------- custom_components/toniebox/ici_client.py | 12 +++++++++-- custom_components/toniebox/manifest.json | 2 +- 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.json b/CHANGELOG.json index 91ec3de..48d934f 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -1,4 +1,18 @@ [ + { + "version": "4.1.0", + "date": "2026-11-02", + "changes": { + "changed": [ + "Action 'toniebox.upload_audio' ist jetzt nur noch für Administratoren aufrufbar (Automationen/Skripte weiterhin möglich), da sie beliebige Dateien vom Host liest und in die Tonie Cloud hochlädt", + "upload_audio: fehlende Datei wirft jetzt einen ServiceValidationError statt nur ins Log zu schreiben" + ], + "fixed": [ + "upload_audio: Dateiprüfung (isfile) läuft nicht mehr blockierend im Event-Loop, sondern zusammen mit dem Lesen im Executor", + "ICI-MQTT: disconnect()/loop_stop() blockierte den Event-Loop (Join auf den paho-Netzwerk-Thread) bei jedem Token-Refresh, Verbindungsabbruch und Entladen — läuft jetzt im Executor" + ] + } + }, { "version": "4.0.0", "date": "2026-08-17", diff --git a/README.md b/README.md index d80d455..23fe987 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,8 @@ data: Supported formats: `mp3`, `ogg`, `wav`, `flac`, `m4a`, `aac`, `opus`. +This action can only be called by Home Assistant administrators (automations and scripts are allowed), because it reads a file from the host. + ### `toniebox.rename_tonie` — Rename Tonie ```yaml @@ -793,6 +795,8 @@ data: Unterstützte Formate: `mp3`, `ogg`, `wav`, `flac`, `m4a`, `aac`, `opus`. +Diese Action dürfen nur Home-Assistant-Administratoren aufrufen (Automationen und Skripte funktionieren weiterhin), da sie eine Datei vom Host liest. + ### `toniebox.rename_tonie` — Tonie umbenennen ```yaml diff --git a/custom_components/toniebox/__init__.py b/custom_components/toniebox/__init__.py index 198ef95..814f4f4 100644 --- a/custom_components/toniebox/__init__.py +++ b/custom_components/toniebox/__init__.py @@ -19,6 +19,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -169,8 +170,13 @@ async def _find_toniebox_target(hass: HomeAssistant, entity_id: str): return None, None, None, None -def _read_file(path: str) -> bytes: - """Read a file outside the event loop (used via async_add_executor_job).""" +def _read_file(path: str) -> bytes | None: + """Read a file outside the event loop (used via async_add_executor_job). + + Returns None if the path is not a regular file. + """ + if not os.path.isfile(path): + return None with open(path, "rb") as f: return f.read() @@ -314,10 +320,6 @@ async def handle_upload_audio(call): file_path = call.data["file_path"] title = call.data.get("title", "") - if not os.path.isfile(file_path): - _LOGGER.error("upload_audio: file not found: %s", file_path) - return - client, hh_id, t_id = await _find_creative_tonie(hass, entity_id) if not client: _LOGGER.error("upload_audio: entity not found: %s", entity_id) @@ -327,16 +329,22 @@ async def handle_upload_audio(call): if not title: title = os.path.splitext(filename)[0] + # isfile() is blocking I/O too, so it runs in the executor with the read. + file_data = await hass.async_add_executor_job(_read_file, file_path) + if file_data is None: + raise ServiceValidationError(f"upload_audio: file not found: {file_path}") + try: - file_data = await hass.async_add_executor_job(_read_file, file_path) await client.upload_and_add_chapter(hh_id, t_id, file_data, filename, title) await _refresh_all(hass) _LOGGER.info("upload_audio: successfully uploaded '%s' to %s", filename, entity_id) except Exception as err: _LOGGER.error("upload_audio failed for %s: %s", file_path, err) - hass.services.async_register( - DOMAIN, "upload_audio", handle_upload_audio, + # Admin-only: the service reads an arbitrary path on the host and sends it + # to the Tonie Cloud, so non-admin users must not be able to call it. + async_register_admin_service( + hass, DOMAIN, "upload_audio", handle_upload_audio, schema=vol.Schema({ vol.Required("entity_id"): cv.string, vol.Required("file_path"): cv.string, diff --git a/custom_components/toniebox/ici_client.py b/custom_components/toniebox/ici_client.py index ab9b72c..db4dd0b 100644 --- a/custom_components/toniebox/ici_client.py +++ b/custom_components/toniebox/ici_client.py @@ -42,6 +42,12 @@ ] +def _stop_client(client: mqtt.Client) -> None: + """Disconnect a paho client and stop its network thread (blocking).""" + client.disconnect() + client.loop_stop() + + class TonieboxIciClient: """MQTT v5 client for ICI real-time push data.""" @@ -136,9 +142,11 @@ async def disconnect(self) -> None: client, self._client = self._client, None self._connected = False if client: + # loop_stop() joins paho's network thread and blocks until it + # exits, so run the teardown in the executor like connect does. + loop = self._loop or asyncio.get_running_loop() try: - client.disconnect() - client.loop_stop() + await loop.run_in_executor(None, _stop_client, client) except Exception: _LOGGER.debug("Error during ICI disconnect", exc_info=True) diff --git a/custom_components/toniebox/manifest.json b/custom_components/toniebox/manifest.json index 8e09bcb..5421190 100644 --- a/custom_components/toniebox/manifest.json +++ b/custom_components/toniebox/manifest.json @@ -11,5 +11,5 @@ "iot_class": "cloud_push", "issue_tracker": "https://github.com/git4sim/HA-Toniebox/issues", "requirements": ["paho-mqtt>=2.0"], - "version": "4.0.9" + "version": "4.1.0" }