Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 17 additions & 9 deletions custom_components/toniebox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions custom_components/toniebox/ici_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion custom_components/toniebox/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading