Skip to content
Open
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
490 changes: 448 additions & 42 deletions custom_components/toniebox/__init__.py

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions custom_components/toniebox/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ def is_tng_box(box: dict) -> bool:
STORAGE_VERSION = 1
STORAGE_KEY_SHUFFLE = f"{DOMAIN}_shuffle" # actual key gets the entry_id suffixed

# ── Volume boost per Tonie ─────────────────────────────────────────────────────
# Local-only per-Tonie multiplier: while that Tonie sits on a box, the box's max
# volume and current volume are raised by this factor; when it is removed (or
# replaced) the change is undone. Applied/active state is persisted so the undo
# survives a restart.
STORAGE_KEY_VOLUME_BOOST = f"{DOMAIN}_volume_boost"
VOLUME_MULTIPLIER_MIN = 1.0
VOLUME_MULTIPLIER_MAX = 3.0
MAX_VOLUME_STEPS = [25, 50, 75, 100] # allowed values of the box's maxVolume
# Order matters: the box clamps the level to its *current* max, and a new max
# only reaches the box a few seconds after the cloud PATCH. So: raise max →
# wait for the box's settings confirmation → then raise the level and verify.
BOOST_SETTINGS_TIMEOUT = 20.0 # s to wait for the box to confirm new settings
BOOST_SETTLE_DELAY = 2.0 # s grace after confirmation / between retries
BOOST_LEVEL_WAIT = 5.0 # s to wait for the box to report its volume
BOOST_LEVEL_ATTEMPTS = 3 # level sends before giving up

# ICI (MQTT v5 real-time push)
ICI_HOST = "ici.tonie.cloud"
ICI_PORT = 443
Expand Down
87 changes: 80 additions & 7 deletions custom_components/toniebox/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.core import callback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .device_info import toniebox_device_info
from .const import DOMAIN, VOLUME_MULTIPLIER_MIN, VOLUME_MULTIPLIER_MAX
from .device_info import (
toniebox_device_info,
creative_tonie_device_info,
content_tonie_device_info,
disc_device_info,
)

_LOGGER = logging.getLogger(__name__)

Expand All @@ -30,8 +36,8 @@ class _TbNumberDesc(NumberEntityDescription):


# ── All number entities ───────────────────────────────────────────────────────
# Volume / headphone limits (enum: 25 / 50 / 75 / 100 per API)
_VOLUME_LEVELS = [25, 50, 75, 100]
# Volume / headphone limits: any percentage 1–100 (a live box reported 52,
# so the API is not restricted to 25/50/75/100).

_TB_NUMBERS: list[_TbNumberDesc] = [
# ── TNG-generation (lightring, bedtime) ───────────────────────────────────
Expand Down Expand Up @@ -79,12 +85,12 @@ class _TbNumberDesc(NumberEntityDescription):
api_key="bedtimeMaxHeadphoneVolume",
feature_required="tngSettings",
),
# ── Classic / all boxes (volume enum: 25/50/75/100) ───────────────────────
# ── All boxes (volume limits in 1 % steps) ────────────────────────────────
_TbNumberDesc(
key="max_volume",
name="Max. Box Volume",
icon="mdi:volume-high",
native_min_value=25, native_max_value=100, native_step=25,
native_min_value=1, native_max_value=100, native_step=1,
native_unit_of_measurement="%",
mode=NumberMode.SLIDER,
data_key="max_volume",
Expand All @@ -94,7 +100,7 @@ class _TbNumberDesc(NumberEntityDescription):
key="max_headphone_volume",
name="Max. Headphone Volume",
icon="mdi:headphones",
native_min_value=25, native_max_value=100, native_step=25,
native_min_value=1, native_max_value=100, native_step=1,
native_unit_of_measurement="%",
mode=NumberMode.SLIDER,
data_key="max_headphone_volume",
Expand All @@ -119,8 +125,31 @@ async def async_setup_entry(
continue
entities.append(TonieboxNumber(coordinator, hh_id, tb_id, desc))

# ── Volume multiplier per Tonie (Creative, Content, Disc) ─────────────────
known: set[tuple[str, str]] = set()

def _collect_multipliers() -> list:
new: list = []
for hh_id, hh in coordinator.data.get("households", {}).items():
for kind in ("creativetonies", "contenttonies", "discs"):
for t_id in hh.get(kind, {}):
if (kind, t_id) in known:
continue
known.add((kind, t_id))
new.append(TonieVolumeMultiplierNumber(coordinator, hh_id, t_id, kind))
return new

entities += _collect_multipliers()
async_add_entities(entities)

@callback
def _async_add_new() -> None:
new = _collect_multipliers()
if new:
async_add_entities(new)

entry.async_on_unload(coordinator.async_add_listener(_async_add_new))


class TonieboxNumber(CoordinatorEntity, NumberEntity):
"""A writable numeric setting of a Toniebox."""
Expand Down Expand Up @@ -177,3 +206,47 @@ async def async_set_native_value(self, value: float) -> None:
self._hh_id, self._tb_id, {self._desc.api_key: int(value)}
)
await self.coordinator.async_request_refresh()


_DEVICE_INFO = {
"creativetonies": creative_tonie_device_info,
"contenttonies": content_tonie_device_info,
"discs": disc_device_info,
}
_UID_PREFIX = {"creativetonies": "ct", "contenttonies": "content", "discs": "disc"}


class TonieVolumeMultiplierNumber(CoordinatorEntity, NumberEntity):
"""Per-Tonie volume multiplier (local-only, persisted by the coordinator).

While this Tonie is on a box, the box's max volume and current volume are
raised by this factor; removing/replacing the Tonie undoes it.
"""

_attr_has_entity_name = True
_attr_icon = "mdi:volume-plus"
_attr_translation_key = "volume_multiplier"
_attr_native_min_value = VOLUME_MULTIPLIER_MIN
_attr_native_max_value = VOLUME_MULTIPLIER_MAX
_attr_native_step = 0.1
_attr_native_unit_of_measurement = "×"
_attr_mode = NumberMode.BOX

def __init__(self, coordinator, hh_id: str, t_id: str, kind: str) -> None:
super().__init__(coordinator)
self._hh_id = hh_id
self._t_id = t_id
self._kind = kind
self._attr_unique_id = f"{_UID_PREFIX[kind]}_{t_id}_volume_multiplier"

@property
def device_info(self) -> dict:
return _DEVICE_INFO[self._kind](self.coordinator, self._hh_id, self._t_id)

@property
def native_value(self) -> float:
return self.coordinator.get_volume_multiplier(self._t_id)

async def async_set_native_value(self, value: float) -> None:
await self.coordinator.async_set_volume_multiplier(self._t_id, value)
self.async_write_ha_state()
3 changes: 2 additions & 1 deletion custom_components/toniebox/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@
"bedtime_max_volume": {"name": "Bedtime Max. Box Volume"},
"bedtime_max_headphone_volume": {"name": "Bedtime Max. Headphone Volume"},
"max_volume": {"name": "Max. Box Volume"},
"max_headphone_volume": {"name": "Max. Headphone Volume"}
"max_headphone_volume": {"name": "Max. Headphone Volume"},
"volume_multiplier": {"name": "Volume multiplier"}
},
"select": {
"led_level": {
Expand Down
8 changes: 7 additions & 1 deletion custom_components/toniebox/tonie_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,13 @@ async def sort_chapters(self, household_id: str, tonie_id: str, sort_by: str = "
# Nothing to shuffle with fewer than 2 chapters.
if len(chapters) < 2:
return
random.shuffle(chapters)
# Guarantee an order that differs from the current one (with only a
# few chapters a plain shuffle often returns the same order).
original = [c.get("id") for c in chapters]
for _ in range(10):
random.shuffle(chapters)
if [c.get("id") for c in chapters] != original:
break
await self.patch_creative_tonie(household_id, tonie_id, {"chapters": chapters})

async def clear_chapters(self, household_id: str, tonie_id: str) -> None:
Expand Down
3 changes: 2 additions & 1 deletion custom_components/toniebox/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@
"bedtime_max_volume": {"name": "Schlafenszeit Max. Lautstärke Box"},
"bedtime_max_headphone_volume": {"name": "Schlafenszeit Max. Kopfhörerlautstärke"},
"max_volume": {"name": "Max. Lautstärke Box"},
"max_headphone_volume": {"name": "Max. Kopfhörerlautstärke"}
"max_headphone_volume": {"name": "Max. Kopfhörerlautstärke"},
"volume_multiplier": {"name": "Lautstärke-Multiplikator"}
},
"select": {
"led_level": {
Expand Down
3 changes: 2 additions & 1 deletion custom_components/toniebox/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@
"bedtime_max_volume": {"name": "Bedtime Max. Box Volume"},
"bedtime_max_headphone_volume": {"name": "Bedtime Max. Headphone Volume"},
"max_volume": {"name": "Max. Box Volume"},
"max_headphone_volume": {"name": "Max. Headphone Volume"}
"max_headphone_volume": {"name": "Max. Headphone Volume"},
"volume_multiplier": {"name": "Volume multiplier"}
},
"select": {
"led_level": {
Expand Down
3 changes: 2 additions & 1 deletion custom_components/toniebox/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@
},
"max_headphone_volume": {
"name": "Volumen máx. auriculares"
}
},
"volume_multiplier": {"name": "Multiplicador de volumen"}
},
"select": {
"led_level": {
Expand Down
3 changes: 2 additions & 1 deletion custom_components/toniebox/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@
},
"max_headphone_volume": {
"name": "Volume max. casque"
}
},
"volume_multiplier": {"name": "Multiplicateur de volume"}
},
"select": {
"led_level": {
Expand Down
3 changes: 2 additions & 1 deletion custom_components/toniebox/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@
},
"max_headphone_volume": {
"name": "Volume max. cuffie"
}
},
"volume_multiplier": {"name": "Moltiplicatore di volume"}
},
"select": {
"led_level": {
Expand Down