Full Code of cdnninja/yoto_ha for AI

master 7e4682da9af4 cached
34 files
87.1 KB
20.3k tokens
120 symbols
1 requests
Download .txt
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
================================================
<img src="https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.yoto.total">

# 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
}
Download .txt
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
Download .txt
SYMBOL INDEX (120 symbols across 14 files)

FILE: custom_components/yoto/__init__.py
  function async_setup (line 37) | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
  function async_setup_entry (line 43) | async def async_setup_entry(hass: HomeAssistant, config_entry: YotoConfi...
  function async_unload_entry (line 71) | async def async_unload_entry(hass: HomeAssistant, entry: YotoConfigEntry...
  function async_migrate_entry (line 85) | async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -...
  function async_remove_config_entry_device (line 103) | async def async_remove_config_entry_device(

FILE: custom_components/yoto/binary_sensor.py
  class YotoBinarySensorEntityDescription (line 28) | class YotoBinarySensorEntityDescription(BinarySensorEntityDescription):
  function async_setup_entry (line 85) | async def async_setup_entry(
  class YotoBinarySensor (line 101) | class YotoBinarySensor(BinarySensorEntity, YotoEntity):
    method __init__ (line 104) | def __init__(
    method is_on (line 119) | def is_on(self) -> bool | None:

FILE: custom_components/yoto/config_flow.py
  class ConfigFlow (line 20) | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
    method async_step_reauth (line 28) | async def async_step_reauth(
    method async_step_reauth_confirm (line 34) | async def async_step_reauth_confirm(
    method async_step_user (line 43) | async def async_step_user(
    method async_step_finish_login (line 96) | async def async_step_finish_login(
    method async_step_timeout (line 120) | async def async_step_timeout(
  class InvalidAuth (line 133) | class InvalidAuth(HomeAssistantError):

FILE: custom_components/yoto/coordinator.py
  class YotoDataUpdateCoordinator (line 21) | class YotoDataUpdateCoordinator(DataUpdateCoordinator):
    method __init__ (line 24) | def __init__(self, hass: HomeAssistant, config_entry: YotoConfigEntry)...
    method _async_update_data (line 41) | async def _async_update_data(self) -> dict | None:
    method api_callback (line 71) | def api_callback(self) -> None:
    method release (line 88) | async def release(self) -> None:
    method async_update_all (line 92) | async def async_update_all(self) -> None:
    method async_check_and_refresh_token (line 96) | async def async_check_and_refresh_token(self) -> None:
    method async_pause_player (line 102) | async def async_pause_player(self, player_id: str) -> None:
    method async_resume_player (line 109) | async def async_resume_player(self, player_id: str) -> None:
    method async_stop_player (line 116) | async def async_stop_player(self, player_id: str) -> None:
    method async_set_time (line 121) | async def async_set_time(self, player_id: str, key: str, value: time) ...
    method async_set_max_volume (line 133) | async def async_set_max_volume(self, player_id: str, key: str, value: ...
    method async_set_brightness (line 145) | async def async_set_brightness(self, player_id: str, key: str, value: ...
    method async_play_card (line 166) | async def async_play_card(
    method async_seek (line 187) | async def async_seek(self, player_id: str, position: int) -> None:
    method async_next_track (line 194) | async def async_next_track(self, player_id: str) -> None:
    method async_previous_track (line 199) | async def async_previous_track(self, player_id: str) -> None:
    method async_set_volume (line 206) | async def async_set_volume(self, player_id: str, volume: float) -> None:
    method async_set_sleep_timer (line 215) | async def async_set_sleep_timer(self, player_id: str, time: int) -> None:
    method async_set_light (line 222) | async def async_set_light(self, player_id: str, key: str, color: str) ...
    method async_enable_disable_alarm (line 234) | async def async_enable_disable_alarm(
    method async_update_card_detail (line 246) | async def async_update_card_detail(self, cardId: str) -> None:
    method async_update_library (line 253) | async def async_update_library(self) -> None:

FILE: custom_components/yoto/entity.py
  class YotoEntity (line 10) | class YotoEntity(CoordinatorEntity):
    method __init__ (line 15) | def __init__(self, coordinator, player):
    method device_info (line 21) | def device_info(self) -> DeviceInfo:
    method _handle_coordinator_update (line 32) | def _handle_coordinator_update(self) -> None:

FILE: custom_components/yoto/light.py
  function async_setup_entry (line 41) | async def async_setup_entry(
  class YotoLight (line 57) | class YotoLight(LightEntity, YotoEntity):
    method __init__ (line 60) | def __init__(
    method color_mode (line 72) | def color_mode(self) -> ColorMode:
    method supported_color_modes (line 77) | def supported_color_modes(self) -> list[ColorMode]:
    method rgb_color (line 82) | def rgb_color(self) -> tuple[int, int, int]:
    method is_on (line 89) | def is_on(self) -> bool:
    method async_turn_off (line 97) | async def async_turn_off(self, **kwargs) -> None:
    method async_turn_on (line 102) | async def async_turn_on(self, **kwargs) -> None:

FILE: custom_components/yoto/media_player.py
  function async_setup_entry (line 31) | async def async_setup_entry(
  class YotoMediaPlayer (line 45) | class YotoMediaPlayer(MediaPlayerEntity, YotoEntity):
    method __init__ (line 53) | def __init__(
    method async_media_pause (line 70) | async def async_media_pause(self) -> None:
    method async_media_play (line 74) | async def async_media_play(self) -> None:
    method async_media_stop (line 78) | async def async_media_stop(self) -> None:
    method async_media_next_track (line 82) | async def async_media_next_track(self) -> None:
    method async_media_previous_track (line 86) | async def async_media_previous_track(self) -> None:
    method async_play_media (line 90) | async def async_play_media(
    method async_media_seek (line 111) | async def async_media_seek(self, position: float) -> None:
    method async_set_volume_level (line 115) | async def async_set_volume_level(self, volume: float) -> None:
    method async_browse_media (line 119) | async def async_browse_media(
    method async_convert_library_to_browse_media (line 135) | async def async_convert_library_to_browse_media(self) -> BrowseMedia:
    method async_convert_chapter_to_browse_media (line 162) | async def async_convert_chapter_to_browse_media(self, cardid: str) -> ...
    method async_convert_track_to_browse_media (line 194) | async def async_convert_track_to_browse_media(
    method supported_features (line 230) | def supported_features(self) -> MediaPlayerEntityFeature:
    method state (line 245) | def state(self) -> MediaPlayerState:
    method volume_level (line 260) | def volume_level(self) -> float | None:
    method media_duration (line 268) | def media_duration(self) -> int | None:
    method media_position_updated_at (line 273) | def media_position_updated_at(self) -> datetime | None:
    method media_artist (line 280) | def media_artist(self) -> str | None:
    method media_image_remotely_accessible (line 288) | def media_image_remotely_accessible(self) -> bool:
    method media_album_name (line 293) | def media_album_name(self) -> str | None:
    method media_image_url (line 301) | def media_image_url(self) -> str | None:
    method media_position (line 311) | def media_position(self) -> int | None:
    method media_content_id (line 316) | def media_content_id(self) -> str | None:
    method media_title (line 330) | def media_title(self) -> str | None:
    method extra_state_attributes (line 340) | def extra_state_attributes(self) -> dict[str, Any]:

FILE: custom_components/yoto/media_source.py
  class YotoMediaSource (line 21) | class YotoMediaSource(MediaSource):
    method __init__ (line 26) | def __init__(self, hass: HomeAssistant) -> None:
    method async_resolve_media (line 32) | async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
    method async_browse_media (line 66) | async def async_browse_media(
    method async_convert_library_to_browse_media (line 83) | async def async_convert_library_to_browse_media(self) -> BrowseMediaSo...
    method async_convert_chapter_to_browse_media (line 111) | async def async_convert_chapter_to_browse_media(
    method async_convert_track_to_browse_media (line 144) | async def async_convert_track_to_browse_media(
  function async_get_media_source (line 182) | async def async_get_media_source(hass: HomeAssistant) -> YotoMediaSource:

FILE: custom_components/yoto/number.py
  function async_setup_entry (line 73) | async def async_setup_entry(
  class YotoNumber (line 89) | class YotoNumber(NumberEntity, YotoEntity):
    method __init__ (line 92) | def __init__(
    method native_value (line 105) | def native_value(self) -> float | None:
    method native_min_value (line 116) | def native_min_value(self) -> float:
    method native_max_value (line 121) | def native_max_value(self) -> float:
    method native_step (line 126) | def native_step(self) -> float:
    method native_unit_of_measurement (line 131) | def native_unit_of_measurement(self) -> str | None:
    method async_set_native_value (line 135) | async def async_set_native_value(self, value: float) -> None:

FILE: custom_components/yoto/sensor.py
  class YotoSensorEntityDescription (line 33) | class YotoSensorEntityDescription(SensorEntityDescription):
  function async_setup_entry (line 82) | async def async_setup_entry(
  class YotoSensor (line 101) | class YotoSensor(SensorEntity, YotoEntity):
    method __init__ (line 104) | def __init__(
    method native_value (line 121) | def native_value(self):
    method native_unit_of_measurement (line 126) | def native_unit_of_measurement(self) -> str | None:

FILE: custom_components/yoto/services.py
  function async_setup_services (line 24) | def async_setup_services(hass: HomeAssistant) -> None:
  function _get_coordinator_from_device (line 38) | def _get_coordinator_from_device(

FILE: custom_components/yoto/switch.py
  function async_setup_entry (line 40) | async def async_setup_entry(
  class YotoSwitch (line 64) | class YotoSwitch(SwitchEntity, YotoEntity):
    method __init__ (line 67) | def __init__(
    method is_on (line 83) | def is_on(self) -> bool | None:
    method async_turn_off (line 105) | async def async_turn_off(self, **kwargs) -> None:
    method async_turn_on (line 120) | async def async_turn_on(self, **kwargs) -> None:

FILE: custom_components/yoto/time.py
  function async_setup_entry (line 32) | async def async_setup_entry(
  class YotoTime (line 48) | class YotoTime(TimeEntity, YotoEntity):
    method __init__ (line 51) | def __init__(
    method native_value (line 66) | def native_value(self) -> time | None:
    method async_set_value (line 70) | async def async_set_value(self, value: time) -> None:

FILE: custom_components/yoto/utils.py
  function rgetattr (line 9) | def rgetattr(obj: object, attr: str) -> object:
  function split_media_id (line 24) | def split_media_id(text: str) -> tuple[str, str | None, str | None, int]:
  function parse_key (line 50) | def parse_key(text: str) -> tuple[str, int] | None:
Condensed preview — 34 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (96K chars).
[
  {
    "path": ".github/dependabot.yml",
    "chars": 192,
    "preview": "version: 2\nupdates:\n  # Enable version updates for Python\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n   "
  },
  {
    "path": ".github/funding.yml",
    "chars": 43,
    "preview": "github: cdnninja\nbuy_me_a_coffee: cdnninja\n"
  },
  {
    "path": ".github/workflows/inactiveIssues.yml",
    "chars": 788,
    "preview": "name: Close inactive issues\non:\n  workflow_dispatch:\n  schedule:\n    - cron: \"30 1 * * *\"\n\njobs:\n  close-issues:\n    run"
  },
  {
    "path": ".github/workflows/lintPR.yaml",
    "chars": 399,
    "preview": "name: \"Lint PR\"\n\non:\n  pull_request_target:\n    types:\n      - opened\n      - edited\n      - synchronize\n\njobs:\n  main:\n"
  },
  {
    "path": ".github/workflows/lock-threads.yml",
    "chars": 370,
    "preview": "name: \"Lock Threads\"\n\non:\n  schedule:\n    - cron: \"0 0 * * *\"\n  workflow_dispatch:\n\npermissions:\n  issues: write\n  pull-"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 1691,
    "preview": "name: Release\non:\n  workflow_dispatch:\n  schedule:\n    - cron: \"0 8 * * Wed,Sun\"\njobs:\n  build:\n    runs-on: ubuntu-late"
  },
  {
    "path": ".github/workflows/validate.yml",
    "chars": 428,
    "preview": "name: Validate\n\non:\n  push:\n  pull_request:\n\njobs:\n  validate-hassfest:\n    runs-on: ubuntu-latest\n    steps:\n      - us"
  },
  {
    "path": ".gitignore",
    "chars": 47,
    "preview": "__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "chars": 1950,
    "preview": "---\nci:\n  autoupdate_commit_msg: \"chore: pre-commit autoupdate\"\nrepos:\n  - repo: https://github.com/astral-sh/ruff-pre-c"
  },
  {
    "path": "LICENSE",
    "chars": 1065,
    "preview": "MIT License\n\nCopyright (c) 2024 cdnninja\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\no"
  },
  {
    "path": "README.md",
    "chars": 1513,
    "preview": "<img src=\"https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%2"
  },
  {
    "path": "custom_components/yoto/__init__.py",
    "chars": 3691,
    "preview": "\"\"\"Yoto integration.\"\"\"\n\nimport asyncio\nimport logging\n\nfrom homeassistant.config_entries import ConfigEntry\nfrom homeas"
  },
  {
    "path": "custom_components/yoto/binary_sensor.py",
    "chars": 4399,
    "preview": "\"\"\"Sensor for Yoto integration.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom collections.abc import Calla"
  },
  {
    "path": "custom_components/yoto/config_flow.py",
    "chars": 4714,
    "preview": "\"\"\"Config flow for Yoto integration.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nfrom collecti"
  },
  {
    "path": "custom_components/yoto/const.py",
    "chars": 351,
    "preview": "\"\"\"Constants for the yoto integration\"\"\"\n\nfrom datetime import timedelta\n\nDOMAIN: str = \"yoto\"\n\n# MQTT delivers real-tim"
  },
  {
    "path": "custom_components/yoto/coordinator.py",
    "chars": 10255,
    "preview": "\"\"\"Coordinator for yoto integration.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom datetime import time\n\nf"
  },
  {
    "path": "custom_components/yoto/entity.py",
    "chars": 1175,
    "preview": "\"\"\"Base entity for Yoto integration.\"\"\"\n\nfrom homeassistant.core import callback\nfrom homeassistant.helpers.entity impor"
  },
  {
    "path": "custom_components/yoto/icons.json",
    "chars": 2086,
    "preview": "{\n  \"entity\": {\n    \"binary_sensor\": {\n      \"online\": {\n        \"default\": \"mdi:cloud\"\n      },\n      \"day_mode_on\": {\n"
  },
  {
    "path": "custom_components/yoto/light.py",
    "chars": 3851,
    "preview": "\"\"\"Light for Yoto integration.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom typing import Final\n\nfrom hom"
  },
  {
    "path": "custom_components/yoto/manifest.json",
    "chars": 388,
    "preview": "{\n  \"domain\": \"yoto\",\n  \"name\": \"Yoto\",\n  \"codeowners\": [\"@cdnninja\"],\n  \"config_flow\": true,\n  \"documentation\": \"https:"
  },
  {
    "path": "custom_components/yoto/media_player.py",
    "chars": 14234,
    "preview": "\"\"\"Media Player for Yoto integration.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom datetime import dateti"
  },
  {
    "path": "custom_components/yoto/media_source.py",
    "chars": 6601,
    "preview": "\"\"\"Provide the Yoto Media Source.\"\"\"\n\nimport logging\n\nfrom homeassistant.components.media_player import MediaClass, Medi"
  },
  {
    "path": "custom_components/yoto/number.py",
    "chars": 5328,
    "preview": "\"\"\"Sensor for Yoto integration.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom typing import Final\n\nfrom ho"
  },
  {
    "path": "custom_components/yoto/quality_scale.yaml",
    "chars": 1449,
    "preview": "rules:\n  # Bronze\n  action-setup: done\n  appropriate-polling: done\n  brands: todo\n  common-modules: done\n  config-flow-t"
  },
  {
    "path": "custom_components/yoto/sensor.py",
    "chars": 4323,
    "preview": "\"\"\"Sensor for Yoto integration.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom dataclasses import dataclass"
  },
  {
    "path": "custom_components/yoto/services.py",
    "chars": 1898,
    "preview": "\"\"\"Yoto integration services.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\n\nfrom homeassistant.config_entries "
  },
  {
    "path": "custom_components/yoto/services.yaml",
    "chars": 115,
    "preview": "update:\n  fields:\n    device_id:\n      required: false\n      selector:\n        device:\n          integration: yoto\n"
  },
  {
    "path": "custom_components/yoto/strings.json",
    "chars": 2357,
    "preview": "{\n  \"config\": {\n    \"step\": {\n      \"reauth_confirm\": {\n        \"title\": \"[%key:component::yoto::config::step::reauth_co"
  },
  {
    "path": "custom_components/yoto/switch.py",
    "chars": 5460,
    "preview": "\"\"\"Sensor for Yoto integration.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nfrom typing import Final\n\nfrom ho"
  },
  {
    "path": "custom_components/yoto/time.py",
    "chars": 2511,
    "preview": "\"\"\"Time for Yoto integration.\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import time\nfrom typing import Final"
  },
  {
    "path": "custom_components/yoto/translations/en.json",
    "chars": 2831,
    "preview": "{\n  \"title\": \"Yoto\",\n  \"config\": {\n    \"progress\": {\n      \"wait_for_device\": \"To authenticate, open the following URL a"
  },
  {
    "path": "custom_components/yoto/translations/pt.json",
    "chars": 1043,
    "preview": "{\n  \"title\": \"Yoto\",\n  \"config\": {\n    \"step\": {\n      \"user\": {\n        \"title\": \"Yoto - Autenticação\",\n        \"descri"
  },
  {
    "path": "custom_components/yoto/utils.py",
    "chars": 1559,
    "preview": "\"\"\"utils.py\"\"\"\n\nimport logging\nimport re\n\n_LOGGER = logging.getLogger(__name__)\n\n\ndef rgetattr(obj: object, attr: str) -"
  },
  {
    "path": "hacs.json",
    "chars": 104,
    "preview": "{\n  \"name\": \"Yoto\",\n  \"render_readme\": true,\n  \"homeassistant\": \"2024.11\",\n  \"content_in_root\": false\n}\n"
  }
]

About this extraction

This page contains the full source code of the cdnninja/yoto_ha GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 34 files (87.1 KB), approximately 20.3k tokens, and a symbol index with 120 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!