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