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
18 changes: 18 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Tests
on:
push:
pull_request:
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.14'
cache: pip
- run: pip install -r requirements_test.txt
- run: ruff check custom_components tests
- run: pytest -q
6 changes: 3 additions & 3 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
hacs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v2"
- uses: "actions/checkout@v7"
- name: HACS validation
uses: "hacs/action@main"
with:
Expand All @@ -23,7 +23,7 @@ jobs:
name: Hassfest
steps:
- name: Check out the repository
uses: "actions/checkout@v2.3.4"
uses: "actions/checkout@v7"

- name: Hassfest validation
uses: "home-assistant/actions/hassfest@master"
uses: "home-assistant/actions/hassfest@master"
51 changes: 5 additions & 46 deletions .ruff.toml
Original file line number Diff line number Diff line change
@@ -1,48 +1,7 @@
# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml
target-version = "py312"

target-version = "py310"
[lint]
select = ["E4", "E7", "E9", "F", "I"]

select = [
"B007", # Loop control variable {name} not used within loop body
"B014", # Exception handler with duplicate exception
"C", # complexity
"D", # docstrings
"E", # pycodestyle
"F", # pyflakes/autoflake
"ICN001", # import concentions; {name} should be imported as {asname}
"PGH004", # Use specific rule codes when using noqa
"PLC0414", # Useless import alias. Import alias does not rename original package.
"SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
"SIM117", # Merge with-statements that use the same scope
"SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys()
"SIM201", # Use {left} != {right} instead of not {left} == {right}
"SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a}
"SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'.
"SIM401", # Use get from dict with default instead of an if block
"T20", # flake8-print
"TRY004", # Prefer TypeError exception for invalid type
"RUF006", # Store a reference to the return value of asyncio.create_task
"UP", # pyupgrade
"W", # pycodestyle
]

ignore = [
"D202", # No blank lines allowed after function docstring
"D203", # 1 blank line required before class docstring
"D213", # Multi-line docstring summary should start at the second line
"D404", # First word of the docstring should not be This
"D406", # Section name should end with a newline
"D407", # Section name underlining
"D411", # Missing blank line before section
"E501", # line too long
"E731", # do not assign a lambda expression, use a def
]

[flake8-pytest-style]
fixture-parentheses = false

[pyupgrade]
keep-runtime-typing = true

[mccabe]
max-complexity = 25
[lint.per-file-ignores]
"tests/test_integration.py" = ["E402"]
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

## 0.2.0

- Replace inherited network methods with one validated, bounded HTTP transport.
- Remove the legacy pycasatunes dependency; use local REST data models and HA's shared aiohttp session.
- Preserve zone unique IDs and native search, TTS and doorbell services.
- Use typed runtime data and current Home Assistant service/discovery interfaces.
- Publish complete poll snapshots and cache system/source metadata.
- Correct shuffle routes, buffering states and playback position timestamps.
- Add repeat, relative volume and standard queue mappings.
- Respect enabled sources and source/zone capabilities.
- Isolate groups to one server and report members from every grouped entity.
- Handle removed zones and discover new zones during polling.
- Add address reconfiguration, redacted diagnostics and power-command debug logs.
- Add API and Home Assistant regression tests and CI.

Requires Home Assistant 2026.9+. Restart Home Assistant after installing.
This release does not claim to fix server-originated idle shutdowns.
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,66 @@ Your CasaTunes unit should be discovered automatically. If this doesn't happen,
- Work around CasaTunes REST API payload shape differences that can otherwise crash polling.
- Improve media browsing, now-playing data, artwork proxying, grouping, search, TTS, and doorbell behavior.

## Version 0.2.0

Requires Home Assistant 2026.9 or newer. Existing zone unique IDs and the
`casatunes.search`, `casatunes.tts`, and `casatunes.doorbell` actions are preserved.
Restart Home Assistant after updating Python files.

The integration uses local HTTP on port 8735. CasaTunes deliberately supports
control through GET requests. All requests have a ten-second timeout, validate
HTTP and application-level errors, and release their connections. No automatic
retry is made for commands that might already have executed.

Zone and playback data are polled every 15 seconds; system and source metadata
are cached for five minutes. Failed polls do not publish partially updated data.
Entities become unavailable when communication fails or a zone disappears.
New zones are discovered during polling. Hidden zones are disabled by default
when first created.

Supported controls depend on the selected source and zone settings. Shuffle,
repeat, buffering states, relative volume, enabled sources and group membership
follow the server API. Groups must belong to the same CasaTunes server.
Playback position timestamps represent received data, rather than property reads.

Browse Media provides CasaTunes media IDs. Generic URLs, Home Assistant media
source URLs and the standard URL announcement option are not supported.
Queue options `add`, `play`, and `replace` are supported; `next` is rejected
because the documented CasaTunes queue API has no equivalent. Native TTS and
doorbell actions remain available.

To change the server address, use **Settings → Devices & services → CasaTunes →
Reconfigure**. The new address must identify the same server.
To remove the integration, delete its entry in Devices & services; this does
not alter CasaTunes server settings.

## Troubleshooting and diagnostics

Download diagnostics from the integration entry. They include power, sleep,
source IDs and group status, but omit IP/MAC addresses, room names and media titles.
Enable debug logging for `custom_components.casatunes` to record power-command
intent. The integration does not implement a shutdown timer or a keep-on guard.
A keep-on automation is a separate, user-controlled workaround, not a server fix.

If a zone switches off, compare CasaTunes zone status with Home Assistant traces
and command logs. A missing HA logbook context does not establish who sent a
power-off request. Caller attribution may require server logs or network tracing.

## Development

Use Python 3.14 on Linux with Home Assistant 2026.9.1:

```sh
pip install -r requirements_test.txt
ruff check custom_components tests
pytest -q
```

The API contract tests also run without Home Assistant. CI additionally exercises
real Home Assistant setup, unload, discovery, failures, entity state and services.
See the server's `http://SERVER/casadev/` documentation and
`http://SERVER:8735/api/v1/resources` for its installed API definitions.

## Known CasaTunes API issue

Some CasaTunes servers can return duplicate rows for custom Internet Stations and Favorites from the CasaTunes API itself. This integration does not hide those duplicates with a Home Assistant-side filter, so the underlying CasaTunes data/cache issue remains visible and can be fixed at the source.
Expand Down
172 changes: 35 additions & 137 deletions custom_components/casatunes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,156 +1,54 @@
"""The CasaTunes integration."""
from __future__ import annotations

from datetime import timedelta
import logging

from aiohttp import ClientError
from pycasatunes.exceptions import CasaException
from pycasatunes.objects.system import CasaTunesSystem
from pycasatunes.objects.zone import CasaTunesZone
import voluptuous as vol

from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_HOST
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import service
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)

from .api import CasaTunesClient
from .const import DOMAIN
from .coordinator import CasaTunesDataUpdateCoordinator

CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)

PLATFORMS = [MEDIA_PLAYER_DOMAIN]
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=15)
UPDATE_ERRORS = (CasaException, ClientError, TimeoutError)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.MEDIA_PLAYER]
CasaTunesConfigEntry = ConfigEntry[CasaTunesDataUpdateCoordinator]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up CasaTunes from a config entry."""
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Register actions even when the server is unavailable."""
from .media_player import DOORBELL_SCHEMA, SEARCH_SCHEMA, TTS_SCHEMA

client = CasaTunesClient(async_get_clientsession(hass), entry.data[CONF_HOST])
coordinator = CasaTunesDataUpdateCoordinator(hass, client=client)
for name, schema, method in (
("search", SEARCH_SCHEMA, "search"),
("tts", TTS_SCHEMA, "async_tts"),
("doorbell", DOORBELL_SCHEMA, "async_doorbell"),
):
service.async_register_platform_entity_service(
hass,
DOMAIN,
name,
entity_domain=Platform.MEDIA_PLAYER,
schema=schema,
func=method,
)
return True

hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator

# Fetch initial data so we have data when entities subscribe
async def async_setup_entry(hass: HomeAssistant, entry: CasaTunesConfigEntry) -> bool:
"""Connect before creating entities."""
coordinator = CasaTunesDataUpdateCoordinator(
hass,
entry,
CasaTunesClient(async_get_clientsession(hass), entry.data[CONF_HOST]),
)
await coordinator.async_config_entry_first_refresh()

entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None)

return unload_ok


async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry."""
await async_unload_entry(hass, entry)
await async_setup_entry(hass, entry)


class CasaTunesDataUpdateCoordinator(DataUpdateCoordinator[CasaTunesClient]):
"""Class to manage fetching data from the API."""

def __init__(self, hass: HomeAssistant, client: CasaTunesClient) -> None:
"""Initialize."""
self.casatunes = client

super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_method=self._async_update_data,
update_interval=SCAN_INTERVAL,
)
self.entities: list[CasaTunesDeviceEntity] = []

async def _async_update_data(self) -> CasaTunesClient:
"""Update data via library."""
try:
await self.casatunes.fetch()
except UPDATE_ERRORS as exception:
raise UpdateFailed("Error communicating with CasaTunes") from exception

return self.casatunes


class CasaTunesEntity(CoordinatorEntity):
"""Defines a base CasaTunes entity."""

def __init__(
self,
coordinator: CasaTunesDataUpdateCoordinator,
zone: CasaTunesZone,
device_id: str,
zone_id: str,
) -> None:
"""Initialize the CasaTunes entity."""
super().__init__(coordinator)
self._zone_id = zone_id
self._zone = zone
self._device_id = device_id
self._name = zone.Name

@property
def zone_id(self) -> str:
"""Return the zone_id of the entity."""
return self._zone_id

@property
def name(self) -> str:
"""Return the name of the entity."""
return self._name

@property
def system(self) -> CasaTunesSystem:
"""Get the CasaTunes System."""
return self.coordinator.data.system

@property
def zone(self) -> CasaTunesZone:
"""Get the CasaTunes Zones."""
return self.coordinator.data.zones_dict[self._zone_id]


class CasaTunesDeviceEntity(CasaTunesEntity):
"""Defines a CasaTunes device entity."""

@property
def device_info(self) -> DeviceInfo | None:
"""Return device information about this CasaTunes device."""
if not self._device_id:
return None

return DeviceInfo(
identifiers={(DOMAIN, self._device_id)},
manufacturer="CasaTunes",
name=self._name,
sw_version=self.system.CasaTunesVersion,
)
async def async_unload_entry(hass: HomeAssistant, entry: CasaTunesConfigEntry) -> bool:
"""Unload through the config-entry lifecycle."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Loading
Loading