Repository: cdnninja/yoto_ha Branch: master Commit: 7e4682da9af4 Files: 34 Total size: 87.1 KB Directory structure: gitextract_70ydyid_/ ├── .github/ │ ├── dependabot.yml │ ├── funding.yml │ └── workflows/ │ ├── inactiveIssues.yml │ ├── lintPR.yaml │ ├── lock-threads.yml │ ├── release.yml │ └── validate.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── custom_components/ │ └── yoto/ │ ├── __init__.py │ ├── binary_sensor.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── entity.py │ ├── icons.json │ ├── light.py │ ├── manifest.json │ ├── media_player.py │ ├── media_source.py │ ├── number.py │ ├── quality_scale.yaml │ ├── sensor.py │ ├── services.py │ ├── services.yaml │ ├── strings.json │ ├── switch.py │ ├── time.py │ ├── translations/ │ │ ├── en.json │ │ └── pt.json │ └── utils.py └── hacs.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: # Enable version updates for Python - package-ecosystem: "github-actions" directory: "/" # Check for updates once a week schedule: interval: "weekly" ================================================ FILE: .github/funding.yml ================================================ github: cdnninja buy_me_a_coffee: cdnninja ================================================ FILE: .github/workflows/inactiveIssues.yml ================================================ name: Close inactive issues on: workflow_dispatch: schedule: - cron: "30 1 * * *" jobs: close-issues: runs-on: ubuntu-latest permissions: issues: write actions: write pull-requests: write steps: - uses: actions/stale@v10 with: days-before-issue-stale: 60 days-before-issue-close: 20 stale-issue-label: "stale" stale-issue-message: "This issue is stale because it has been open for 60 days with no activity. Are you still experiencing this issue? " close-issue-message: "This issue was closed because it has been inactive for 20 days since being marked as stale." days-before-pr-stale: -1 days-before-pr-close: -1 repo-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/lintPR.yaml ================================================ name: "Lint PR" on: pull_request_target: types: - opened - edited - synchronize jobs: main: runs-on: ubuntu-latest steps: # Please look up the latest version from # https://github.com/amannn/action-semantic-pull-request/releases - uses: amannn/action-semantic-pull-request@v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/lock-threads.yml ================================================ name: "Lock Threads" on: schedule: - cron: "0 0 * * *" workflow_dispatch: permissions: issues: write pull-requests: write discussions: write concurrency: group: lock-threads jobs: action: runs-on: ubuntu-latest steps: - uses: dessant/lock-threads@v6 with: issue-inactive-days: "30" pr-inactive-days: "365" ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: workflow_dispatch: schedule: - cron: "0 8 * * Wed,Sun" jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Gets semantic release info id: semantic_release_info uses: jossef/action-semantic-release-info@v3.0.0 env: GITHUB_TOKEN: ${{ github.token }} - name: Update Version and Commit if: ${{steps.semantic_release_info.outputs.version != ''}} run: | echo "Version: ${{steps.semantic_release_info.outputs.version}}" sed -i "s/\"version\": \".*\"/\"version\": \"${{steps.semantic_release_info.outputs.version}}\"/g" custom_components/yoto/manifest.json git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add -A git commit -m "chore: bumping version to ${{steps.semantic_release_info.outputs.version}}" git tag ${{ steps.semantic_release_info.outputs.git_tag }} - name: Push changes if: ${{steps.semantic_release_info.outputs.version != ''}} uses: ad-m/github-push-action@v1.1.0 with: github_token: ${{ github.token }} tags: true - name: Create GitHub Release if: ${{steps.semantic_release_info.outputs.version != ''}} uses: ncipollo/release-action@v1 env: GITHUB_TOKEN: ${{ github.token }} with: tag: ${{ steps.semantic_release_info.outputs.git_tag }} name: ${{ steps.semantic_release_info.outputs.git_tag }} body: ${{ steps.semantic_release_info.outputs.notes }} draft: false prerelease: false ================================================ FILE: .github/workflows/validate.yml ================================================ name: Validate on: push: pull_request: jobs: validate-hassfest: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Hassfest validation uses: home-assistant/actions/hassfest@master validate-hacs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: HACS validation uses: hacs/action@main with: category: integration ================================================ FILE: .gitignore ================================================ __pycache__/ *.py[cod] *$py.class *.so .Python ================================================ FILE: .pre-commit-config.yaml ================================================ --- ci: autoupdate_commit_msg: "chore: pre-commit autoupdate" repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.12 hooks: - id: ruff args: - --fix - id: ruff-format - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell args: - --ignore-words-list=fro,hass - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 exclude_types: [csv, json] - repo: https://github.com/pycqa/isort rev: 9.0.0a3 hooks: - id: isort args: ["--profile", "black"] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-executables-have-shebangs stages: [manual] - id: check-json exclude: (.vscode|.devcontainer) - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: - id: pyupgrade - repo: https://github.com/adrienverge/yamllint.git rev: v1.38.0 hooks: - id: yamllint exclude: (.github|.vscode|.devcontainer) - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: - id: prettier - repo: https://github.com/cdce8p/python-typing-update rev: v0.8.1 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. # Will require manual work, before submitting changes! # pre-commit run --hook-stage manual python-typing-update --all-files - id: python-typing-update stages: [manual] args: - --py311-plus - --force - --keep-updates files: ^(/.+)?[^/]+\.py$ - repo: https://github.com/pre-commit/mirrors-mypy rev: v2.0.0 hooks: - id: mypy args: [--strict, --ignore-missing-imports] files: ^(/.+)?[^/]+\.py$ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 cdnninja Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # yoto_ha Home Assistant Integration for Yoto. PRs are appreciated to add more. ![image](https://github.com/cdnninja/yoto_ha/assets/6373468/a02dac1e-609c-4536-9588-9bf5c7bba013) # Supported Device Features Not all devices expose all sensors/entities. Only sensors/entities supported by your device will be available in the integration. # Installing The easiest way to install this integration is via HACS. https://hacs.xyz/ # Services Working - Play/Pause - Play Media/Card via service call (format of media id is cardid+chapterid+trackid+seconds, if you leave off chapterid/trackid/seconds will start at chapter and track 1.) - Stop Media via service call - Set Time for Day/Night Modes - Set display brightness Day/Night including auto - Set Day/Night light color, this can be any color not just in app! - Set Day/Night max volume # Troubleshooting You can enable logging for this integration specifically and share your logs, so I can have a deep dive investigation. To enable logging, enable via the gui or update your configuration.yaml like this, we can get more information in Configuration -> Logs page Debug can also be enabled via the interface. ```yaml config logger: default: warning logs: custom_components.yoto: debug yoto_api: debug ``` ================================================ FILE: custom_components/yoto/__init__.py ================================================ """Yoto integration.""" import asyncio import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.typing import ConfigType from yoto_api import AuthenticationError from .const import CONF_TOKEN, DOMAIN from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator from .media_source import YotoMediaSource from .services import async_setup_services _LOGGER = logging.getLogger(__name__) PLATFORMS: list[str] = [ Platform.BINARY_SENSOR, Platform.SENSOR, Platform.MEDIA_PLAYER, Platform.TIME, Platform.LIGHT, Platform.NUMBER, Platform.SWITCH, ] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Yoto component.""" async_setup_services(hass) return True async def async_setup_entry(hass: HomeAssistant, config_entry: YotoConfigEntry) -> bool: """Set up Yoto from a config entry.""" coordinator = YotoDataUpdateCoordinator(hass, config_entry) try: await coordinator.async_config_entry_first_refresh() await asyncio.sleep(3) except AuthenticationError as ex: _LOGGER.error(f"Authentication error: {ex}") raise ConfigEntryAuthFailed from ex config_entry.runtime_data = coordinator async def _handle_shutdown(event): new_data = dict(config_entry.data) new_data[CONF_TOKEN] = coordinator.yoto_manager.token.refresh_token _LOGGER.debug("Storing token on HA shutdown.") hass.config_entries.async_update_entry(config_entry, data=new_data) hass.bus.async_listen_once("homeassistant_stop", _handle_shutdown) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) hass.data.setdefault("media_source", {}) hass.data["media_source"][DOMAIN] = YotoMediaSource(hass) return True async def async_unload_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool: """Handle removal of an entry.""" coordinator = entry.runtime_data if coordinator.yoto_manager.token.refresh_token != entry.data.get(CONF_TOKEN): new_data = dict(entry.data) new_data[CONF_TOKEN] = coordinator.yoto_manager.token.refresh_token _LOGGER.debug("Storing token on unload") hass.config_entries.async_update_entry(entry, data=new_data) if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): await coordinator.release() return unload_ok async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" if entry.version < 2: _LOGGER.debug("Migrating entry to version 2") data = dict(entry.data) data.pop(CONF_USERNAME, None) data.pop(CONF_PASSWORD, None) hass.config_entries.async_update_entry(entry=entry, data=data, version=2) _LOGGER.debug("Migration to version 2 successful") if entry.version < 3: _LOGGER.debug("Migrating entry to version 3") options = dict(entry.options) options.pop(CONF_SCAN_INTERVAL, None) hass.config_entries.async_update_entry(entry=entry, options=options, version=3) _LOGGER.debug("Migration to version 3 successful") return True async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: YotoConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a config entry from a device.""" return True ================================================ FILE: custom_components/yoto/binary_sensor.py ================================================ """Sensor for Yoto integration.""" from __future__ import annotations import logging from collections.abc import Callable from dataclasses import dataclass from typing import Final from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from yoto_api import YotoPlayer from .const import DOMAIN from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator from .entity import YotoEntity _LOGGER = logging.getLogger(__name__) @dataclass class YotoBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes custom binary sensor entities.""" is_on: Callable[[YotoPlayer], bool] | None = None SENSOR_DESCRIPTIONS: Final[tuple[YotoBinarySensorEntityDescription, ...]] = ( YotoBinarySensorEntityDescription( key="online", translation_key="online", is_on=lambda player: player.online, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), YotoBinarySensorEntityDescription( key="day_mode_on", translation_key="day_mode_on", is_on=lambda player: player.day_mode_on, entity_category=EntityCategory.DIAGNOSTIC, ), YotoBinarySensorEntityDescription( key="bluetooth_audio_connected", translation_key="bluetooth_audio_connected", is_on=lambda player: player.bluetooth_audio_connected, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), YotoBinarySensorEntityDescription( key="charging", translation_key="charging", is_on=lambda player: player.charging, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, entity_category=EntityCategory.DIAGNOSTIC, ), YotoBinarySensorEntityDescription( key="audio_device_connected", translation_key="audio_device_connected", is_on=lambda player: player.audio_device_connected, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), YotoBinarySensorEntityDescription( key="sleep_timer_active", translation_key="sleep_timer_active", is_on=lambda player: player.sleep_timer_active, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, ), YotoBinarySensorEntityDescription( key="night_light_mode", translation_key="night_light_mode", is_on=lambda player: player.night_light_mode != "off", entity_category=EntityCategory.DIAGNOSTIC, ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: YotoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up binary_sensor platform.""" coordinator = config_entry.runtime_data entities: list[YotoBinarySensor] = [] for player_id in coordinator.yoto_manager.players.keys(): player: YotoPlayer = coordinator.yoto_manager.players[player_id] for description in SENSOR_DESCRIPTIONS: if getattr(player, description.key, None) is not None: entities.append(YotoBinarySensor(coordinator, description, player)) async_add_entities(entities) class YotoBinarySensor(BinarySensorEntity, YotoEntity): """Yoto binary sensor class.""" def __init__( self, coordinator: YotoDataUpdateCoordinator, description: YotoBinarySensorEntityDescription, player: YotoPlayer, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, player) self._description = description self._attr_unique_id = f"{DOMAIN}_{player.id}_{self._description.key}" self._attr_device_class = self._description.device_class self._attr_entity_category = self._description.entity_category self._attr_translation_key = self._description.translation_key @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" if self._description.is_on is not None: return self._description.is_on(self.player) return None ================================================ FILE: custom_components/yoto/config_flow.py ================================================ """Config flow for Yoto integration.""" from __future__ import annotations import asyncio import logging from collections.abc import Mapping from typing import Any from homeassistant import config_entries from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.exceptions import HomeAssistantError from yoto_api import YotoManager from .const import CONF_TOKEN, DOMAIN _LOGGER = logging.getLogger(__name__) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Yoto.""" VERSION = 3 login_task: asyncio.Task | None = None token = None ym: YotoManager | None = None async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth on credential failure.""" return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Prepare reauth.""" if user_input is None: return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the device code login flow.""" if self.ym is None: _LOGGER.debug("Initiating device activation") self.ym = await self.hass.async_add_executor_job( YotoManager, "KFLTf5PCpTh0yOuDuyQ5C3LEU9PSbult" ) assert self.ym is not None urlObject = await self.hass.async_add_executor_job( self.ym.device_code_flow_start ) yoto_device_url = urlObject["verification_uri_complete"] async def _wait_for_login() -> None: """Wait for the user to login and validate the resulting token.""" assert self.ym is not None _LOGGER.debug("Waiting for device activation") await self.hass.async_add_executor_job(self.ym.device_code_flow_complete) if self.ym.token is None: raise HomeAssistantError("Device activation failed") # Validate the token by hitting the players endpoint. Surfaces a # bad/expired token before the entry is created. await self.hass.async_add_executor_job(self.ym.update_players_status) if not self.ym.players: raise HomeAssistantError("No Yoto players found on this account") _LOGGER.debug("Checking login task") if self.login_task is None: _LOGGER.debug("Creating task for device activation") self.login_task = self.hass.async_create_task(_wait_for_login()) if self.login_task.done(): _LOGGER.debug("Login task is done, checking results") if self.login_task.exception(): return self.async_show_progress_done(next_step_id="timeout") self.token = self.ym.token.refresh_token return self.async_show_progress_done(next_step_id="finish_login") return self.async_show_progress( step_id="user", progress_action="wait_for_device", description_placeholders={ "url": yoto_device_url, }, progress_task=self.login_task, ) async def async_step_finish_login( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Create or update the config entry once the login has succeeded.""" _LOGGER.debug("Finalizing login") assert self.ym is not None unique_id = next(iter(self.ym.players)) if self.source != SOURCE_REAUTH: await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return self.async_create_entry( title=unique_id, data={CONF_TOKEN: self.token}, ) self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") return self.async_update_reload_and_abort( self._get_reauth_entry(), data={CONF_TOKEN: self.token}, ) async def async_step_timeout( self, user_input: dict[str, Any] | None = None, ) -> ConfigFlowResult: """Handle issues that need transition await from progress step.""" if user_input is None: return self.async_show_form( step_id="timeout", ) del self.login_task return await self.async_step_user() class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" ================================================ FILE: custom_components/yoto/const.py ================================================ """Constants for the yoto integration""" from datetime import timedelta DOMAIN: str = "yoto" # MQTT delivers real-time updates while a player is online but never pushes a # disconnect event, so polling is what surfaces the online -> offline transition. SCAN_INTERVAL = timedelta(minutes=5) DYNAMIC_UNIT: str = "dynamic_unit" CONF_TOKEN = "token" ================================================ FILE: custom_components/yoto/coordinator.py ================================================ """Coordinator for yoto integration.""" from __future__ import annotations import logging from datetime import time from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from yoto_api import AuthenticationError, YotoManager, YotoPlayerConfig from .const import CONF_TOKEN, DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) type YotoConfigEntry = ConfigEntry["YotoDataUpdateCoordinator"] class YotoDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" def __init__(self, hass: HomeAssistant, config_entry: YotoConfigEntry) -> None: """Initialize.""" self.platforms: set[str] = set() self.config_entry = config_entry self.yoto_manager = YotoManager(client_id="KFLTf5PCpTh0yOuDuyQ5C3LEU9PSbult") if config_entry.data.get(CONF_TOKEN): _LOGGER.debug("Using stored token") self.yoto_manager.set_refresh_token(config_entry.data.get(CONF_TOKEN)) else: raise ConfigEntryAuthFailed("No token configured") super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL, ) async def _async_update_data(self) -> dict | None: """Update data via library. Called by update_coordinator periodically. Allow to update for the first time without further checking """ try: await self.async_check_and_refresh_token() if self.yoto_manager.token.refresh_token != self.config_entry.data.get( CONF_TOKEN ): new_data = dict(self.config_entry.data) new_data[CONF_TOKEN] = self.yoto_manager.token.refresh_token _LOGGER.debug("Storing updated token") self.hass.config_entries.async_update_entry( self.config_entry, data=new_data ) except AuthenticationError as ex: _LOGGER.error(f"Authentication error: {ex}") raise ConfigEntryAuthFailed await self.hass.async_add_executor_job(self.yoto_manager.update_players_status) if len(self.yoto_manager.library.keys()) == 0: await self.hass.async_add_executor_job(self.yoto_manager.update_library) if self.yoto_manager.mqtt_client is None: await self.hass.async_add_executor_job( self.yoto_manager.connect_to_events, self.api_callback ) return self.data def api_callback(self) -> None: """Handle API callback for media player updates.""" for player in self.yoto_manager.players.values(): if player.card_id and player.chapter_key: if ( player.card_id not in self.yoto_manager.library or not self.yoto_manager.library[player.card_id].chapters ): self.hass.add_job(self.async_update_card_detail, player.card_id) else: if ( player.chapter_key not in self.yoto_manager.library[player.card_id].chapters ): self.hass.add_job(self.async_update_card_detail, player.card_id) self.async_update_listeners() async def release(self) -> None: """Disconnect from API.""" self.yoto_manager.disconnect() async def async_update_all(self) -> None: """Update yoto data.""" await self.async_refresh() async def async_check_and_refresh_token(self) -> None: """Refresh token if needed via library.""" await self.hass.async_add_executor_job( self.yoto_manager.check_and_refresh_token ) async def async_pause_player(self, player_id: str) -> None: """Pause playback on the player.""" await self.async_check_and_refresh_token() await self.hass.async_add_executor_job( self.yoto_manager.pause_player, player_id ) async def async_resume_player(self, player_id: str) -> None: """Resume playback on the player.""" await self.async_check_and_refresh_token() await self.hass.async_add_executor_job( self.yoto_manager.resume_player, player_id ) async def async_stop_player(self, player_id: str) -> None: """Stop playback on the player.""" await self.async_check_and_refresh_token() await self.hass.async_add_executor_job(self.yoto_manager.stop_player, player_id) async def async_set_time(self, player_id: str, key: str, value: time) -> None: """Set time for day/night mode.""" await self.async_check_and_refresh_token() config = YotoPlayerConfig() if key == "day_mode_time": config.day_mode_time = value if key == "night_mode_time": config.night_mode_time = value await self.hass.async_add_executor_job( self.yoto_manager.set_player_config, player_id, config ) async def async_set_max_volume(self, player_id: str, key: str, value: int) -> None: """Set maximum volume for day/night mode.""" await self.async_check_and_refresh_token() config = YotoPlayerConfig() if key == "config.night_max_volume_limit": config.night_max_volume_limit = int(value) if key == "config.day_max_volume_limit": config.day_max_volume_limit = int(value) await self.hass.async_add_executor_job( self.yoto_manager.set_player_config, player_id, config ) async def async_set_brightness(self, player_id: str, key: str, value: str) -> None: """Set display brightness for day/night mode.""" await self.async_check_and_refresh_token() config = YotoPlayerConfig() if ( key == "config.night_display_brightness" or key == "night_display_brightness" ): if value == "auto": config.night_display_brightness = value else: config.night_display_brightness = int(value) if key == "config.day_display_brightness" or key == "day_display_brightness": if value == "auto": config.day_display_brightness = value else: config.day_display_brightness = int(value) await self.hass.async_add_executor_job( self.yoto_manager.set_player_config, player_id, config ) async def async_play_card( self, player_id: str, cardid: str, secondsin: int = None, cutoff: int = None, chapter: int = None, trackkey: int = None, ) -> None: """Play a card on the player.""" await self.async_check_and_refresh_token() await self.hass.async_add_executor_job( self.yoto_manager.play_card, player_id, cardid, secondsin, cutoff, chapter, trackkey, ) async def async_seek(self, player_id: str, position: int) -> None: """Seek to a position in the current track.""" await self.async_check_and_refresh_token() await self.hass.async_add_executor_job( self.yoto_manager.seek, player_id, position ) async def async_next_track(self, player_id: str) -> None: """Skip to the next track.""" await self.async_check_and_refresh_token() await self.hass.async_add_executor_job(self.yoto_manager.next_track, player_id) async def async_previous_track(self, player_id: str) -> None: """Skip to the previous track.""" await self.async_check_and_refresh_token() await self.hass.async_add_executor_job( self.yoto_manager.previous_track, player_id ) async def async_set_volume(self, player_id: str, volume: float) -> None: """Set player volume level.""" volume = volume * 100 volume = int(round(volume, 0)) await self.async_check_and_refresh_token() await self.hass.async_add_executor_job( self.yoto_manager.set_volume, player_id, volume ) async def async_set_sleep_timer(self, player_id: str, time: int) -> None: """Set sleep timer on the player.""" await self.async_check_and_refresh_token() await self.hass.async_add_executor_job( self.yoto_manager.set_sleep, player_id, int(time) ) async def async_set_light(self, player_id: str, key: str, color: str) -> None: """Set light color for day/night ambient mode.""" await self.async_check_and_refresh_token() config = YotoPlayerConfig() if key == "config.day_ambient_colour": config.day_ambient_colour = color elif key == "config.night_ambient_colour": config.night_ambient_colour = color await self.hass.async_add_executor_job( self.yoto_manager.set_player_config, player_id, config ) async def async_enable_disable_alarm( self, player_id: str, alarm: int, enable: bool ) -> None: """Enable or disable an alarm.""" await self.async_check_and_refresh_token() config = YotoPlayerConfig() config.alarms = self.yoto_manager.players[player_id].config.alarms config.alarms[alarm].enabled = enable await self.hass.async_add_executor_job( self.yoto_manager.set_player_config, player_id, config ) async def async_update_card_detail(self, cardId: str) -> None: """Get chapter and titles for the card""" _LOGGER.debug(f"{DOMAIN} - Updating Card details for: {cardId}") await self.hass.async_add_executor_job( self.yoto_manager.update_card_detail, cardId ) async def async_update_library(self) -> None: """Update library details.""" _LOGGER.debug(f"{DOMAIN} - Updating library details") await self.hass.async_add_executor_job(self.yoto_manager.update_library) ================================================ FILE: custom_components/yoto/entity.py ================================================ """Base entity for Yoto integration.""" from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN class YotoEntity(CoordinatorEntity): """Base entity for Yoto integration.""" _attr_has_entity_name = True def __init__(self, coordinator, player): """Initialize the base entity.""" super().__init__(coordinator) self.player = player @property def device_info(self) -> DeviceInfo: """Return device information to use for this entity.""" return DeviceInfo( identifiers={(DOMAIN, self.player.id)}, manufacturer="Yoto", model=self.player.device_type, name=self.player.name, sw_version=self.player.firmware_version, ) @callback def _handle_coordinator_update(self) -> None: # Coordinator's api_callback fires from the paho-mqtt thread, so route # the state write through the thread-safe scheduler instead of the # default async_write_ha_state. self.schedule_update_ha_state() ================================================ FILE: custom_components/yoto/icons.json ================================================ { "entity": { "binary_sensor": { "online": { "default": "mdi:cloud" }, "day_mode_on": { "default": "mdi:white-balance-sunny", "state": { "off": "mdi:weather-night" } }, "bluetooth_audio_connected": { "default": "mdi:bluetooth-off", "state": { "on": "mdi:headphones-bluetooth" } }, "charging": { "default": "mdi:battery" }, "audio_device_connected": { "default": "mdi:headphones-off", "state": { "on": "mdi:headphones" } }, "sleep_timer_active": { "default": "mdi:timer" }, "night_light_mode": { "default": "mdi:lightbulb-night", "state": { "off": "mdi:lightbulb-off" } } }, "sensor": { "last_updated_at": { "default": "mdi:update" }, "battery_temperature": { "default": "mdi:thermometer" } }, "switch": { "night_display_brightness": { "default": "mdi:brightness-auto" }, "day_display_brightness": { "default": "mdi:brightness-auto" }, "end_of_track_sleep": { "default": "mdi:sleep" }, "alarm": { "default": "mdi:alarm" } }, "number": { "night_max_volume_limit": { "default": "mdi:volume-high" }, "day_max_volume_limit": { "default": "mdi:volume-high" }, "day_display_brightness": { "default": "mdi:brightness-percent" }, "night_display_brightness": { "default": "mdi:brightness-percent" }, "sleep_timer": { "default": "mdi:timer" } }, "time": { "day_mode_time": { "default": "mdi:sun-clock" }, "night_mode_time": { "default": "mdi:moon-waning-crescent" } }, "light": { "day_ambient_colour": { "default": "mdi:lightbulb" }, "night_ambient_colour": { "default": "mdi:lightbulb-night" } } } } ================================================ FILE: custom_components/yoto/light.py ================================================ """Light for Yoto integration.""" from __future__ import annotations import logging from typing import Final from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ColorMode, LightEntity, LightEntityDescription, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from yoto_api import YotoPlayer from .const import DOMAIN from .coordinator import YotoConfigEntry from .entity import YotoEntity from .utils import rgetattr _LOGGER = logging.getLogger(__name__) SENSOR_DESCRIPTIONS: Final[tuple[LightEntityDescription, ...]] = ( LightEntityDescription( key="config.day_ambient_colour", translation_key="day_ambient_colour", entity_category=EntityCategory.CONFIG, ), LightEntityDescription( key="config.night_ambient_colour", translation_key="night_ambient_colour", entity_category=EntityCategory.CONFIG, ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: YotoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor platform.""" coordinator = config_entry.runtime_data entities: list[YotoLight] = [] for player_id in coordinator.yoto_manager.players.keys(): player: YotoPlayer = coordinator.yoto_manager.players[player_id] for description in SENSOR_DESCRIPTIONS: if rgetattr(player, description.key) is not None: entities.append(YotoLight(coordinator, description, player)) async_add_entities(entities) class YotoLight(LightEntity, YotoEntity): """Yoto sensor class.""" def __init__( self, coordinator, description: LightEntityDescription, player: YotoPlayer ) -> None: """Initialize the sensor.""" super().__init__(coordinator, player) self._description = description self._key = self._description.key self._attr_unique_id = f"{DOMAIN}_{player.id}_{self._key}" self._attr_translation_key = self._description.translation_key self._attr_entity_category = description.entity_category @property def color_mode(self) -> ColorMode: """Return the color mode.""" return ColorMode.RGB @property def supported_color_modes(self) -> list[ColorMode]: """Return the color modes the sensor supports.""" return [ColorMode.RGB] @property def rgb_color(self) -> tuple[int, int, int]: """Return the RGB color""" hex_val = rgetattr(self.player, self._key).lstrip("#") rgb_val = tuple(int(hex_val[i : i + 2], 16) for i in (0, 2, 4)) return rgb_val @property def is_on(self) -> bool: """Return if the light is on.""" status = rgetattr(self.player, self._key) if status != "#0": return True else: return False async def async_turn_off(self, **kwargs) -> None: """Turn device off.""" await self.coordinator.async_set_light(self.player.id, self._key, "#0") self.async_write_ha_state() async def async_turn_on(self, **kwargs) -> None: """Turn device on.""" _LOGGER.debug(f"{DOMAIN} - Turn on light Args: {kwargs}") if ATTR_RGB_COLOR in kwargs: rgb = kwargs[ATTR_RGB_COLOR] hex_color = "#%02x%02x%02x" % rgb elif ATTR_BRIGHTNESS in kwargs: # Placeholder for now. Not sure we can use this yet. Need to see how my v3 handles dimmed rgb values # brightness = kwargs[ATTR_BRIGHTNESS] hex_color = "#ffffff" else: hex_color = "#ffffff" await self.coordinator.async_set_light(self.player.id, self._key, hex_color) self.async_write_ha_state() ================================================ FILE: custom_components/yoto/manifest.json ================================================ { "domain": "yoto", "name": "Yoto", "codeowners": ["@cdnninja"], "config_flow": true, "documentation": "https://github.com/cdnninja/yoto_ha", "integration_type": "hub", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/cdnninja/yoto_ha/issues", "loggers": ["yoto", "yoto_api", "paho_mqtt"], "requirements": ["yoto-api==2.3.0"], "version": "3.2.1" } ================================================ FILE: custom_components/yoto/media_player.py ================================================ """Media Player for Yoto integration.""" from __future__ import annotations import logging from datetime import datetime from typing import Any from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaPlayerDeviceClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from yoto_api import YotoPlayer from .const import DOMAIN from .coordinator import YotoConfigEntry from .entity import YotoEntity from .utils import split_media_id _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: YotoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Media Player platform.""" coordinator = config_entry.runtime_data entities: list[YotoMediaPlayer] = [] for player_id in coordinator.yoto_manager.players.keys(): player: YotoPlayer = coordinator.yoto_manager.players[player_id] entities.append(YotoMediaPlayer(coordinator, player)) async_add_entities(entities) class YotoMediaPlayer(MediaPlayerEntity, YotoEntity): """Yoto Media Player class.""" _attr_has_entity_name = True _attr_media_image_remotely_accessible = True _attr_name = None _attr_translation_key = "Yoto Media Player" def __init__( self, coordinator, player: YotoPlayer, ) -> None: """Initialize the media player.""" super().__init__(coordinator, player) self._id = f"{player.name}" # self.data = data self._key = "media_player" self._attr_unique_id = f"{DOMAIN}_{player.id}_media_player" self._attr_name = None self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._currently_playing: dict | None = {} self._attr_volume_step = 0.0625 self._restricted_device: bool = False async def async_media_pause(self) -> None: """Pause playback.""" await self.coordinator.async_pause_player(self.player.id) async def async_media_play(self) -> None: """Play media.""" await self.coordinator.async_resume_player(self.player.id) async def async_media_stop(self) -> None: """Stop playback.""" await self.coordinator.async_stop_player(self.player.id) async def async_media_next_track(self) -> None: """Skip to next track.""" await self.coordinator.async_next_track(self.player.id) async def async_media_previous_track(self) -> None: """Skip to previous track.""" await self.coordinator.async_previous_track(self.player.id) async def async_play_media( self, media_type: str, media_id: str, enqueue: MediaPlayerEnqueue | None = None, announce: bool | None = None, **kwargs: Any, ) -> None: """Play media.""" cardid, chapterid, trackid, time = split_media_id(media_id) _LOGGER.debug( f"{DOMAIN} - Media requested: {media_id} Cardid: {cardid}, chapterid: {chapterid}, trackid: {trackid}" ) await self.coordinator.async_play_card( player_id=self.player.id, cardid=cardid, chapter=chapterid, trackkey=trackid, secondsin=int(time), ) async def async_media_seek(self, position: float) -> None: """Send seek command.""" await self.coordinator.async_seek(self.player.id, int(position)) async def async_set_volume_level(self, volume: float) -> None: """Set volume level.""" await self.coordinator.async_set_volume(self.player.id, volume) async def async_browse_media( self, media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" _LOGGER.debug( f"{DOMAIN} - Browse Media id: {media_content_id} content type: {media_content_type}" ) await self.coordinator.async_update_library() if media_content_id in (None, "library"): return await self.async_convert_library_to_browse_media() else: return await self.async_convert_chapter_to_browse_media(media_content_id) async def async_convert_library_to_browse_media(self) -> BrowseMedia: """Browse library content.""" children = [] for item in self.coordinator.yoto_manager.library.values(): children.append( BrowseMedia( media_content_id=item.id, media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, title=item.title, can_expand=True, can_play=True, thumbnail=item.cover_image_large, ) ) return BrowseMedia( media_content_id="Root", media_class=MediaClass.DIRECTORY, media_content_type=MediaType.MUSIC, title="Yoto Library", can_expand=True, can_play=False, children=children, children_media_class=MediaClass.MUSIC, ) async def async_convert_chapter_to_browse_media(self, cardid: str) -> BrowseMedia: """Browse chapter content for a card.""" children = [] _LOGGER.debug( f"{DOMAIN} - Chapters: {self.coordinator.yoto_manager.library[cardid].chapters}" ) await self.coordinator.async_update_card_detail(cardid) for item in self.coordinator.yoto_manager.library[cardid].chapters.values(): _LOGGER.debug(f"{DOMAIN} - Chapter processing: {item}") children.append( BrowseMedia( media_content_id=cardid + "+" + item.key, media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, title=item.title, can_expand=False, can_play=True, thumbnail=item.icon, ) ) _LOGGER.debug(f"{DOMAIN} - Browse media: {children}") return BrowseMedia( media_content_id=cardid, media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, title=self.coordinator.yoto_manager.library[cardid].title, can_expand=False, can_play=True, children=children, children_media_class=MediaClass.MUSIC, ) async def async_convert_track_to_browse_media( self, cardid: str, chapterid: str ) -> BrowseMedia: """Browse track content for a chapter.""" children = [] if self.coordinator.yoto_manager.library[cardid].chapters[chapterid].tracks: for item in ( self.coordinator.yoto_manager.library[cardid] .chapters[chapterid] .tracks.values() ): children.append( BrowseMedia( media_content_id=cardid + "+" + chapterid + "+" + item.key, media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, title=item.title, can_expand=False, can_play=True, thumbnail=item.icon, ) ) return BrowseMedia( media_content_id=cardid, media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, title=self.coordinator.yoto_manager.library[cardid] .chapters[chapterid] .title, can_expand=False, can_play=True, children=children, children_media_class=MediaClass.MUSIC, ) @property def supported_features(self) -> MediaPlayerEntityFeature: """Return the supported features.""" return ( MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SEEK ) @property def state(self) -> MediaPlayerState: """Return the playback state.""" if self.player.playback_status == "paused": return MediaPlayerState.PAUSED if self.player.playback_status == "playing": return MediaPlayerState.PLAYING if self.player.playback_status == "stopped": return MediaPlayerState.IDLE if not self.player.online: return MediaPlayerState.OFF if self.player.online: return MediaPlayerState.ON @property def volume_level(self) -> float | None: """Return the volume level.""" if self.player.volume: return self.player.volume / 16 else: return None @property def media_duration(self) -> int | None: """Return the duration of the current media in seconds.""" return self.player.track_length @property def media_position_updated_at(self) -> datetime | None: """Return the last time the media position was updated.""" if self.player.track_position is None: return None return self.player.last_updated_at @property def media_artist(self) -> str | None: """Return the artist of the current media.""" if self.player.card_id in self.coordinator.yoto_manager.library: return self.coordinator.yoto_manager.library[self.player.card_id].author else: return None @property def media_image_remotely_accessible(self) -> bool: """If the image url is remotely accessible.""" return True @property def media_album_name(self) -> str | None: """Return the album name of the current media.""" if self.player.card_id in self.coordinator.yoto_manager.library: return self.coordinator.yoto_manager.library[self.player.card_id].title else: return None @property def media_image_url(self) -> str | None: """Return the image URL of the current media.""" if self.player.card_id in self.coordinator.yoto_manager.library: return self.coordinator.yoto_manager.library[ self.player.card_id ].cover_image_large else: return None @property def media_position(self) -> int | None: """Return the current position of the playback.""" return self.player.track_position @property def media_content_id(self) -> str | None: """Return the current media content ID.""" if self.player.card_id and self.player.chapter_key and self.player.track_key: return ( self.player.card_id + "+" + self.player.chapter_key + "+" + self.player.track_key ) else: return None @property def media_title(self) -> str | None: """Return the current media title.""" if self.player.chapter_title == self.player.track_title: return self.player.chapter_title elif self.player.chapter_title and self.player.track_title: return self.player.chapter_title + " - " + self.player.track_title else: return self.player.chapter_title @property def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" state_attributes: dict[str, Any] = {} if self.player.card_id and self.player.chapter_key: if ( self.player.card_id in self.coordinator.yoto_manager.library and self.coordinator.yoto_manager.library[self.player.card_id].chapters ): if ( self.player.chapter_key in self.coordinator.yoto_manager.library[ self.player.card_id ].chapters ): if ( self.player.track_key in self.coordinator.yoto_manager.library[self.player.card_id] .chapters[self.player.chapter_key] .tracks ): if ( self.coordinator.yoto_manager.library[self.player.card_id] .chapters[self.player.chapter_key] .icon ): state_attributes["media_chapter_icon"] = ( self.coordinator.yoto_manager.library[ self.player.card_id ] .chapters[self.player.chapter_key] .icon ) if ( self.coordinator.yoto_manager.library[self.player.card_id] .chapters[self.player.chapter_key] .tracks[self.player.track_key] .icon ): state_attributes["media_track_icon"] = ( self.coordinator.yoto_manager.library[ self.player.card_id ] .chapters[self.player.chapter_key] .tracks[self.player.track_key] .icon ) return state_attributes ================================================ FILE: custom_components/yoto/media_source.py ================================================ """Provide the Yoto Media Source.""" import logging from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from .const import DOMAIN from .utils import split_media_id _LOGGER = logging.getLogger(__name__) class YotoMediaSource(MediaSource): """Provide media sources for Yoto Media Player.""" name: str = "Yoto Media" def __init__(self, hass: HomeAssistant) -> None: """Initialize YotoMediaSource.""" super().__init__(DOMAIN) self.hass = hass self.coordinator = None async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Provides the URL to play the media.""" cardid, chapterid, trackid, time = split_media_id(item.identifier) if len(self.coordinator.yoto_manager.library[cardid].chapters.keys()) == 0: await self.coordinator.async_update_card_detail(cardid) if chapterid is None: chapterid = next( iter(self.coordinator.yoto_manager.library[cardid].chapters) ) if trackid is None: trackid = next( iter( self.coordinator.yoto_manager.library[cardid] .chapters[chapterid] .tracks ) ) track = ( self.coordinator.yoto_manager.library[cardid] .chapters[chapterid] .tracks[trackid] ) if track.format == "aac": mime = "audio/aac" elif track.format == "mp3": mime = "audio/mpeg" elif track.format == "opus": mime = "audio/opus" else: _LOGGER.error( f"Unknown track format: {track.format}. Report this to the developer on GitHub." ) return PlayMedia(track.trackUrl, mime) async def async_browse_media( self, item: MediaSourceItem | None, ) -> BrowseMediaSource: """Browse media for Yoto.""" if self.coordinator is None: entries = [ entry for entry in self.hass.config_entries.async_entries(DOMAIN) if entry.state == ConfigEntryState.LOADED ] self.coordinator = entries[0].runtime_data if item.identifier is None: return await self.async_convert_library_to_browse_media() else: return await self.async_convert_chapter_to_browse_media(item.identifier) async def async_convert_library_to_browse_media(self) -> BrowseMediaSource: """Build media source for the library.""" children = [] for item in self.coordinator.yoto_manager.library.values(): children.append( BrowseMediaSource( domain=DOMAIN, identifier=item.id, media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, title=item.title, can_play=True, can_expand=True, thumbnail=item.cover_image_large, ) ) return BrowseMediaSource( domain=DOMAIN, identifier=None, media_class=MediaClass.DIRECTORY, media_content_type=MediaType.MUSIC, title="Yoto Library", can_play=False, can_expand=True, children=children, children_media_class=MediaClass.MUSIC, ) async def async_convert_chapter_to_browse_media( self, cardid: str ) -> BrowseMediaSource: children = [] if len(self.coordinator.yoto_manager.library[cardid].chapters.keys()) == 0: await self.coordinator.async_update_card_detail(cardid) for item in self.coordinator.yoto_manager.library[cardid].chapters.values(): _LOGGER.debug(f"{DOMAIN} - Chapter processing: {item}") children.append( BrowseMediaSource( domain=DOMAIN, identifier=cardid + "+" + item.key, media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, title=item.title, can_expand=False, can_play=True, thumbnail=item.icon, ) ) return BrowseMediaSource( domain=DOMAIN, identifier=cardid, media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, title=self.coordinator.yoto_manager.library[cardid].title, can_expand=False, can_play=True, children=children, children_media_class=MediaClass.MUSIC, ) async def async_convert_track_to_browse_media( self, cardid: str, chapterid: str ) -> BrowseMediaSource: """Build media source for tracks of a chapter.""" children = [] if self.coordinator.yoto_manager.library[cardid].chapters[chapterid].tracks: for item in ( self.coordinator.yoto_manager.library[cardid] .chapters[chapterid] .tracks.values() ): children.append( BrowseMediaSource( domain=DOMAIN, identifier=cardid + "+" + chapterid + "+" + item.key, media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, title=item.title, can_expand=False, can_play=True, thumbnail=item.icon, ) ) return BrowseMediaSource( domain=DOMAIN, identifier=cardid, media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, title=self.coordinator.yoto_manager.library[cardid] .chapters[chapterid] .title, can_expand=False, can_play=True, children=children, children_media_class=MediaClass.MUSIC, ) async def async_get_media_source(hass: HomeAssistant) -> YotoMediaSource: """Return the Yoto media source instance.""" return YotoMediaSource(hass) ================================================ FILE: custom_components/yoto/number.py ================================================ """Sensor for Yoto integration.""" from __future__ import annotations import logging from typing import Final from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from yoto_api import YotoPlayer from .const import DOMAIN from .coordinator import YotoConfigEntry from .entity import YotoEntity from .utils import rgetattr _LOGGER = logging.getLogger(__name__) SENSOR_DESCRIPTIONS: Final[tuple[NumberEntityDescription, ...]] = ( NumberEntityDescription( key="config.night_max_volume_limit", translation_key="night_max_volume_limit", native_min_value=0, native_max_value=16, native_step=1, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key="config.day_max_volume_limit", translation_key="day_max_volume_limit", native_min_value=0, native_max_value=16, native_step=1, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key="config.day_display_brightness", translation_key="day_display_brightness", native_min_value=0, native_max_value=100, native_step=1, native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key="config.night_display_brightness", translation_key="night_display_brightness", native_min_value=0, native_max_value=100, native_step=1, native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key="sleep_timer_seconds_remaining", translation_key="sleep_timer", device_class=NumberDeviceClass.DURATION, native_min_value=0, native_max_value=46500, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, entity_category=EntityCategory.CONFIG, ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: YotoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor platform.""" coordinator = config_entry.runtime_data entities: list[YotoNumber] = [] for player_id in coordinator.yoto_manager.players.keys(): player: YotoPlayer = coordinator.yoto_manager.players[player_id] for description in SENSOR_DESCRIPTIONS: if rgetattr(player, description.key) is not None: entities.append(YotoNumber(coordinator, description, player)) async_add_entities(entities) class YotoNumber(NumberEntity, YotoEntity): """Yoto sensor class.""" def __init__( self, coordinator, description: NumberEntityDescription, player: YotoPlayer ) -> None: """Initialize the sensor.""" super().__init__(coordinator, player) self._description = description self._key = self._description.key self._attr_unique_id = f"{DOMAIN}_{player.id}_{self._key}" self._attr_device_class = self._description.device_class self._attr_translation_key = self._description.translation_key self._attr_entity_category = description.entity_category @property def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" if ( self._key == "config.day_display_brightness" or self._key == "config.night_display_brightness" ) and rgetattr(self.player, self._key) == "auto": return 100 else: return rgetattr(self.player, self._key) @property def native_min_value(self) -> float: """Return native_min_value as reported in by the sensor""" return self._description.native_min_value @property def native_max_value(self) -> float: """Return native_max_value as reported in by the sensor""" return self._description.native_max_value @property def native_step(self) -> float: """Return step value as reported in by the sensor""" return self._description.native_step @property def native_unit_of_measurement(self) -> str | None: """Return the unit the value was reported in by the sensor""" return self._description.native_unit_of_measurement async def async_set_native_value(self, value: float) -> None: """Update the current value.""" if ( self._key == "config.day_max_volume_limit" or self._key == "config.night_max_volume_limit" ): await self.coordinator.async_set_max_volume( self.player.id, self._key, value ) elif ( self._key == "config.day_display_brightness" or self._key == "config.night_display_brightness" ): await self.coordinator.async_set_brightness( self.player.id, self._key, value ) elif self._key == "sleep_timer_seconds_remaining": await self.coordinator.async_set_sleep_timer(self.player.id, value) self.async_write_ha_state() ================================================ FILE: custom_components/yoto/quality_scale.yaml ================================================ rules: # Bronze action-setup: done appropriate-polling: done brands: todo common-modules: done config-flow-test-coverage: todo config-flow: done dependency-transparency: done docs-actions: todo docs-high-level-description: todo docs-installation-instructions: todo docs-removal-instructions: todo entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done # Silver action-exceptions: todo config-entry-unloading: todo docs-configuration-parameters: todo docs-installation-parameters: todo entity-unavailable: todo integration-owner: todo log-when-unavailable: todo parallel-updates: todo reauthentication-flow: todo test-coverage: todo # Gold devices: todo diagnostics: todo discovery-update-info: todo discovery: todo docs-data-update: todo docs-examples: todo docs-known-limitations: todo docs-supported-devices: todo docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: todo entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done exception-translations: todo icon-translations: done reconfiguration-flow: todo repair-issues: todo stale-devices: todo # Platinum async-dependency: todo inject-websession: todo strict-typing: todo ================================================ FILE: custom_components/yoto/sensor.py ================================================ """Sensor for Yoto integration.""" from __future__ import annotations import logging from dataclasses import dataclass from typing import Final from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, ) from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from yoto_api import YotoPlayer from .const import DOMAIN from .coordinator import YotoConfigEntry from .entity import YotoEntity _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class YotoSensorEntityDescription(SensorEntityDescription): """Describe Yoto sensor entity.""" always_load: bool = False SENSOR_DESCRIPTIONS: Final[tuple[YotoSensorEntityDescription, ...]] = ( YotoSensorEntityDescription( key="last_updated_at", translation_key="last_updated_at", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), YotoSensorEntityDescription( key="battery_level_percentage", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, always_load=True, ), YotoSensorEntityDescription( key="temperature_celcius", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, ), YotoSensorEntityDescription( key="ambient_light_sensor_reading", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, ), YotoSensorEntityDescription( key="wifi_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_category=EntityCategory.DIAGNOSTIC, ), YotoSensorEntityDescription( key="battery_temperature", translation_key="battery_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: YotoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor platform.""" coordinator = config_entry.runtime_data entities: list[YotoSensor] = [] for player_id in coordinator.yoto_manager.players.keys(): player: YotoPlayer = coordinator.yoto_manager.players[player_id] for description in SENSOR_DESCRIPTIONS: if ( getattr(player, description.key, None) is not None or description.always_load ): entities.append(YotoSensor(coordinator, description, player)) async_add_entities(entities) class YotoSensor(SensorEntity, YotoEntity): """Yoto sensor class.""" def __init__( self, coordinator, description: SensorEntityDescription, player: YotoPlayer ) -> None: """Initialize the sensor.""" super().__init__(coordinator, player) self._description = description self._key = self._description.key self._attr_unique_id = f"{DOMAIN}_{player.id}_{self._key}" self._attr_state_class = self._description.state_class self._attr_device_class = self._description.device_class self._attr_entity_category = self._description.entity_category self._attr_entity_registry_enabled_default = ( description.entity_registry_enabled_default ) self._attr_translation_key = self._description.translation_key @property def native_value(self): """Return the value reported by the sensor.""" return getattr(self.player, self._key) @property def native_unit_of_measurement(self) -> str | None: """Return the unit the value was reported in by the sensor""" return self._description.native_unit_of_measurement ================================================ FILE: custom_components/yoto/services.py ================================================ """Yoto integration services.""" from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry from .const import DOMAIN from .coordinator import YotoDataUpdateCoordinator SERVICE_UPDATE = "update" SUPPORTED_SERVICES = (SERVICE_UPDATE,) _LOGGER = logging.getLogger(__name__) @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Yoto.""" async def async_handle_update(call: ServiceCall) -> None: _LOGGER.debug(f"Call:{call.data}") coordinator = _get_coordinator_from_device(hass, call) await coordinator.async_update_all() services = {SERVICE_UPDATE: async_handle_update} for service in SUPPORTED_SERVICES: hass.services.async_register(DOMAIN, service, services[service]) def _get_coordinator_from_device( hass: HomeAssistant, call: ServiceCall ) -> YotoDataUpdateCoordinator: """Get the coordinator targeted by the service call.""" entries = [ entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.state == ConfigEntryState.LOADED ] if not entries: raise ServiceValidationError("No loaded Yoto config entry found") if len(entries) == 1: return entries[0].runtime_data device_entry = device_registry.async_get(hass).async_get(call.data[ATTR_DEVICE_ID]) if device_entry is None: raise ServiceValidationError("Device not found") for entry in entries: if entry.entry_id in device_entry.config_entries: return entry.runtime_data raise ServiceValidationError("No Yoto config entry for the requested device") ================================================ FILE: custom_components/yoto/services.yaml ================================================ update: fields: device_id: required: false selector: device: integration: yoto ================================================ FILE: custom_components/yoto/strings.json ================================================ { "config": { "step": { "reauth_confirm": { "title": "[%key:component::yoto::config::step::reauth_confirm::title%]", "description": "[%key:component::yoto::config::step::reauth_confirm::description%]" } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { "binary_sensor": { "online": { "name": "Online" }, "day_mode_on": { "name": "Day mode" }, "bluetooth_audio_connected": { "name": "Bluetooth audio" }, "charging": { "name": "Charging" }, "audio_device_connected": { "name": "Headphones" }, "sleep_timer_active": { "name": "Sleep timer" }, "night_light_mode": { "name": "Night light" } }, "sensor": { "last_updated_at": { "name": "Last updated" }, "battery_temperature": { "name": "Battery temperature" } }, "switch": { "night_display_brightness": { "name": "Night auto display brightness" }, "day_display_brightness": { "name": "Day auto display brightness" }, "end_of_track_sleep": { "name": "End of track sleep" }, "alarm": { "name": "Alarm {number}" } }, "number": { "night_max_volume_limit": { "name": "Night max volume" }, "day_max_volume_limit": { "name": "Day max volume" }, "day_display_brightness": { "name": "Day display brightness" }, "night_display_brightness": { "name": "Night display brightness" }, "sleep_timer": { "name": "Sleep timer" } }, "time": { "day_mode_time": { "name": "Day mode start" }, "night_mode_time": { "name": "Night mode start" } }, "light": { "day_ambient_colour": { "name": "Day ambient colour" }, "night_ambient_colour": { "name": "Night ambient colour" } } } } ================================================ FILE: custom_components/yoto/switch.py ================================================ """Sensor for Yoto integration.""" from __future__ import annotations import logging from typing import Final from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from yoto_api import YotoPlayer from .const import DOMAIN from .coordinator import YotoConfigEntry from .entity import YotoEntity from .utils import parse_key _LOGGER = logging.getLogger(__name__) SENSOR_DESCRIPTIONS: Final[tuple[SwitchEntityDescription, ...]] = ( SwitchEntityDescription( key="night_display_brightness", translation_key="night_display_brightness", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key="day_display_brightness", translation_key="day_display_brightness", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key="end_of_track_sleep", translation_key="end_of_track_sleep", entity_category=EntityCategory.CONFIG, ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: YotoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor platform.""" coordinator = config_entry.runtime_data entities: list[YotoSwitch] = [] for player_id in coordinator.yoto_manager.players.keys(): player: YotoPlayer = coordinator.yoto_manager.players[player_id] for index in range(len(player.config.alarms)): alarm_description = SwitchEntityDescription( key="alarms[" + str(index) + "]", translation_key="alarm", translation_placeholders={"number": str(index + 1)}, entity_category=EntityCategory.CONFIG, ) entities.append(YotoSwitch(coordinator, alarm_description, player)) for description in SENSOR_DESCRIPTIONS: entities.append(YotoSwitch(coordinator, description, player)) async_add_entities(entities) class YotoSwitch(SwitchEntity, YotoEntity): """Yoto sensor class.""" def __init__( self, coordinator, description: SwitchEntityDescription, player: YotoPlayer ) -> None: """Initialize the sensor.""" super().__init__(coordinator, player) self._description = description self._key = self._description.key self._attr_unique_id = f"{DOMAIN}_{player.id}_switch_{self._key}" if self._key.startswith("alarms"): self._attribute, self._index = parse_key(self._key) self._attr_translation_key = self._description.translation_key if description.translation_placeholders: self._attr_translation_placeholders = description.translation_placeholders self._attr_entity_category = description.entity_category @property def is_on(self) -> bool | None: """Return the entity value to represent the entity state.""" if ( self._key == "night_display_brightness" or self._key == "day_display_brightness" ): if getattr(self.player.config, self._key) == "auto": return True else: return False elif self._key == "end_of_track_sleep": if ( self.player.track_length is not None and self.player.track_position is not None ): seconds_to_end = self.player.track_length - self.player.track_position if abs(self.player.sleep_timer_seconds_remaining - seconds_to_end) <= 5: return True return False elif self._key.startswith("alarms"): return getattr(self.player.config, self._attribute)[self._index].enabled async def async_turn_off(self, **kwargs) -> None: """Turn the entity off.""" if ( self._key == "night_display_brightness" or self._key == "day_display_brightness" ): await self.coordinator.async_set_brightness(self.player.id, self._key, "0") elif self._key == "end_of_track_sleep": await self.coordinator.async_set_sleep_timer(self.player.id, 0) elif self._key.startswith("alarms"): await self.coordinator.async_enable_disable_alarm( self.player.id, self._index, False ) self.async_write_ha_state() async def async_turn_on(self, **kwargs) -> None: """Turn the entity on.""" if ( self._key == "night_display_brightness" or self._key == "day_display_brightness" ): await self.coordinator.async_set_brightness( self.player.id, self._key, "auto" ) elif self._key == "end_of_track_sleep": if ( self.player.track_length is not None and self.player.track_position is not None ): seconds_to_end = self.player.track_length - self.player.track_position await self.coordinator.async_set_sleep_timer( self.player.id, seconds_to_end ) elif self._key.startswith("alarms"): await self.coordinator.async_enable_disable_alarm( self.player.id, self._index, True ) self.async_write_ha_state() ================================================ FILE: custom_components/yoto/time.py ================================================ """Time for Yoto integration.""" from __future__ import annotations from datetime import time from typing import Final from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from yoto_api import YotoPlayer from .const import DOMAIN from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator from .entity import YotoEntity TIME_DESCRIPTIONS: Final[tuple[TimeEntityDescription, ...]] = ( TimeEntityDescription( key="day_mode_time", translation_key="day_mode_time", entity_category=EntityCategory.CONFIG, ), TimeEntityDescription( key="night_mode_time", translation_key="night_mode_time", entity_category=EntityCategory.CONFIG, ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: YotoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up time platform.""" coordinator = config_entry.runtime_data entities: list[YotoTime] = [] for player_id in coordinator.yoto_manager.players.keys(): player: YotoPlayer = coordinator.yoto_manager.players[player_id] for description in TIME_DESCRIPTIONS: if getattr(player.config, description.key, None) is not None: entities.append(YotoTime(coordinator, description, player)) async_add_entities(entities) class YotoTime(TimeEntity, YotoEntity): """Yoto time entity.""" def __init__( self, coordinator: YotoDataUpdateCoordinator, description: TimeEntityDescription, player: YotoPlayer, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, player) self._description = description self._key = self._description.key self._attr_unique_id = f"{DOMAIN}_{player.id}_{self._description.key}" self._attr_translation_key = self._description.translation_key self._attr_entity_category = description.entity_category @property def native_value(self) -> time | None: """Return the value reported by the sensor.""" return getattr(self.player.config, self._key) async def async_set_value(self, value: time) -> None: """Update the current time.""" await self.coordinator.async_set_time(self.player.id, self._key, value) self.async_write_ha_state() ================================================ FILE: custom_components/yoto/translations/en.json ================================================ { "title": "Yoto", "config": { "progress": { "wait_for_device": "To authenticate, open the following URL and login at Yoto:\n{url}" }, "step": { "device_code": { "title": "Yoto - Device Code", "description": "Browse to the following URL, and confirm the code matches." }, "reauth_confirm": { "title": "Yoto - Reauthentication", "description": "Your account is unable to authenticate. Click Submit to re-setup." } }, "abort": { "already_configured": "Device is already configured", "reauth_successful": "Sucessfully reauthenticated" }, "error": { "invalid_auth": "Login failed into Yoto Servers. Please use official app to logout and log back in and try again!", "unknown": "Unexpected error" } }, "services": { "update": { "name": "Update", "description": "Update player data from service cache", "fields": { "device_id": { "name": "Player", "description": "Target Player" } } } }, "entity": { "binary_sensor": { "online": { "name": "Online" }, "day_mode_on": { "name": "Day mode" }, "bluetooth_audio_connected": { "name": "Bluetooth audio" }, "charging": { "name": "Charging" }, "audio_device_connected": { "name": "Headphones" }, "sleep_timer_active": { "name": "Sleep timer" }, "night_light_mode": { "name": "Night light" } }, "sensor": { "last_updated_at": { "name": "Last updated" }, "battery_temperature": { "name": "Battery temperature" } }, "switch": { "night_display_brightness": { "name": "Night auto display brightness" }, "day_display_brightness": { "name": "Day auto display brightness" }, "end_of_track_sleep": { "name": "End of track sleep" }, "alarm": { "name": "Alarm {number}" } }, "number": { "night_max_volume_limit": { "name": "Night max volume" }, "day_max_volume_limit": { "name": "Day max volume" }, "day_display_brightness": { "name": "Day display brightness" }, "night_display_brightness": { "name": "Night display brightness" }, "sleep_timer": { "name": "Sleep timer" } }, "time": { "day_mode_time": { "name": "Day mode start" }, "night_mode_time": { "name": "Night mode start" } }, "light": { "day_ambient_colour": { "name": "Day ambient colour" }, "night_ambient_colour": { "name": "Night ambient colour" } } } } ================================================ FILE: custom_components/yoto/translations/pt.json ================================================ { "title": "Yoto", "config": { "step": { "user": { "title": "Yoto - Autenticação", "description": "Configure o seu Yoto para integrar com o Home Assistant.", "data": { "username": "Nome de Utilizador", "password": "Palavra-passe" } }, "reauth_confirm": { "title": "Yoto - Reautenticação", "description": "A sua conta não conseguiu autenticar. Clique em Enviar para reconfigurar." } }, "abort": { "already_configured": "Dispositivo já configurado" }, "error": { "invalid_auth": "Falha no login nos servidores Yoto. Utilize a aplicação oficial para sair e voltar a entrar e tente novamente!", "unknown": "Erro desconhecido" } }, "services": { "update": { "name": "Atualizar", "description": "Atualizar dados do leitor a partir da cache do serviço", "fields": { "device_id": { "name": "Leitor", "description": "Leitor Alvo" } } } } } ================================================ FILE: custom_components/yoto/utils.py ================================================ """utils.py""" import logging import re _LOGGER = logging.getLogger(__name__) def rgetattr(obj: object, attr: str) -> object: """Recursively get nested attributes.""" _this_func = rgetattr sp = attr.split(".", 1) if len(sp) == 1: left, right = sp[0], "" else: left, right = sp obj = getattr(obj, left) if right: obj = _this_func(obj, right) return obj def split_media_id(text: str) -> tuple[str, str | None, str | None, int]: """Split media id into components. Format: cardid+chapterid+trackid+seconds """ if text.count("-") > 1: _LOGGER.error("Switch Media ID format to use + as separator instead of -") parts = text.split("+") if len(parts) == 4: cardid, chapterid, trackid, time_str = parts time = int(time_str) elif len(parts) == 3: cardid, chapterid, trackid = parts time = 0 elif len(parts) == 2: cardid, chapterid = parts trackid = None time = 0 else: cardid = text chapterid = None trackid = None time = 0 return cardid, chapterid, trackid, time def parse_key(text: str) -> tuple[str, int] | None: """Parse a key string in format 'name[index]'. Returns tuple of (name, index) or None if format doesn't match. """ match = re.match(r"(\w+)\[(\d+)\]", text) if match: object1 = match.group(1) # This will be 'alarms' object2 = int(match.group(2)) # This will be 1 return object1, object2 return None ================================================ FILE: hacs.json ================================================ { "name": "Yoto", "render_readme": true, "homeassistant": "2024.11", "content_in_root": false }