diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1fe133b..e02684c 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,12 +1,11 @@ -FROM ghcr.io/home-assistant/home-assistant:latest +FROM ghcr.io/home-assistant/home-assistant:stable -# Install Node.js and npm -RUN apk add --no-cache nodejs npm +# Node.js to build the card, wget/unzip to fetch the scheduler component and card +RUN apk add --no-cache nodejs npm wget unzip -# Install additional tools if needed -# RUN apk add --no-cache bash +# Home Assistant is run by start.sh, not by the image's s6 init: it waits for +# the seeds of setup.sh (scheduler config entry, schedules, dashboard) to be in place. +ENTRYPOINT [] +CMD ["bash", "/workspaces/homeshift_card/.devcontainer/start.sh"] -# The Home Assistant image already has an entrypoint that starts Home Assistant -# We'll let it run in the background - -WORKDIR /workspaces/homershift_card +WORKDIR /workspaces/homeshift_card diff --git a/.devcontainer/README.md b/.devcontainer/README.md index f6cd06c..5375385 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -1,124 +1,58 @@ -# DevContainer Development Environment +# DevContainer Home Assistant -Ce projet inclut une configuration DevContainer pour développer et tester une carte Home Assistant dans un environnement isolé. +Home Assistant avec les entités HomeShift simulées, Scheduler, des thermostats simulés et des planifications d'exemple pour voir la carte en situation, panneau des plannings compris. ## Prérequis -- Visual Studio Code +- Visual Studio Code + extension Dev Containers - Docker Desktop -- Extension VS Code: Dev Containers -## Démarrage rapide +## Démarrage -1. Ouvrir le dossier dans VS Code -2. F1 → "Dev Containers: Reopen in Container" -3. Attendre l’initialisation de Home Assistant (1–2 min la première fois) -4. Construire la - ```bash - npm install - npm run build - ``` -5. Accéder à Home Assistant: http://localhost:8123 +1. Ouvrir le dossier dans VS Code → F1 → **Dev Containers: Reopen in Container** (ou **Rebuild Container** après une modification du `Dockerfile` ou de `devcontainer.json`) +2. `setup.sh` (une fois) : `npm install` + build, scheduler-component v3.3.8, scheduler-card v4.0.19, planifications d'exemple, intégration Scheduler, ressources Lovelace, dashboard **HomeShift**. +3. Home Assistant est le processus principal du conteneur (`start.sh`) : il démarre dès la fin de `setup.sh`, puis à chaque démarrage du conteneur → http://localhost:8123 (1-2 min au premier lancement) +4. Au premier lancement, `onboard.py` termine l'onboarding tout seul : se connecter avec le compte de test **`admin` / `admin`** +5. Ouvrir le dashboard **HomeShift** dans la barre latérale -## Ressource de la carte +L'intégration Scheduler est préconfigurée : inutile de l'ajouter depuis l'UI. -La ressource est définie via YAML (`resource_mode: yaml` dans `configuration.yaml`) : +## Itérer sur la carte -- URL: `/local/homeshift_card/homeshift-card.js` -- Type: Module +`dist/` est monté sur `/config/www/homeshift_card` : -## Dashboard Lovelace +```bash +npm run watch +``` -La configuration utilise un **dashboard YAML dédié** (`ui-lovelace.yaml`) tout en laissant le dashboard principal en mode `storage` : +puis Ctrl+F5 dans le navigateur. -| Dashboard | Mode | Accès | Editeur graphique | -| ---------------------- | ------------------------- | ---------------------------- | ----------------- | -| **Overview** (sidebar) | YAML — `ui-lovelace.yaml` | icône dans la barre latérale | ❌ | -| **Accueil** (défaut) | Storage | icône maison | ✅ | +## Données d'exemple -- La carte `homeshift-card` est définie dans `ui-lovelace.yaml` et apparaît automatiquement dans l'onglet **Overview**. -- Le dashboard **Accueil** reste entièrement éditable via l'interface graphique de HA. +| Entité | Rôle | +| ---------------------------------------------------------- | ---------------------------------------------------------------------- | +| `select.homeshift_day_mode` / `homeshift_thermostat_mode` | Modes du jour et du thermostat (template select sur `input_select`) | +| `number.homeshift_*`, `sensor.homeshift_*`, `button.*` | Les autres entités de l'intégration HomeShift, simulées | +| `input_boolean.homeshift_window_*_backing` | Fenêtres ouvertes : font apparaître l'alerte « Non fermés » | +| `climate.bureau` / `chambre` / `salon` | `generic_thermostat` sur radiateurs `input_boolean` | +| `input_number.temperature_*_backing` | Températures mesurées, réglables | +| `switch.salon_enable_auto_start_stop` | Template switch piloté par les planifications | +| `input_boolean.force_presence` | Entité propre au mode Travail | -> Toute modification de `ui-lovelace.yaml` est prise en compte en rafraîchissant le navigateur (F5). +`scheduler.storage` contient 13 planifications taguées `Maison`, `Télétravail`, `Travail` et `Absence`, les options du mode du jour : le panneau des plannings de la carte les affiche. Il n'est copié que s'il n'existe pas encore dans `config/.storage/`. Pour revenir aux exemples : supprimer `config/.storage/scheduler.storage` puis reconstruire le conteneur. -## Entités +Le dashboard **HomeShift** est éditable dans l'UI. Il est créé au premier setup à partir de `dashboard.yaml` : la carte, une section de simulation et une scheduler-card pour modifier les planifications. Les modifications faites dans l'UI sont conservées ; pour revenir au modèle, supprimer `config/.storage/lovelace.dashboard_homeshift` puis reconstruire le conteneur. -En production, les entités proviennent de l'intégration **HomeShift** (https://github.com/Gamso/homeshift): +## Repartir de zéro -- `select.homeshift_day_mode`: options configurables par l'utilisateur (défaut: Maison, Travail, Télétravail, Absence) -- `select.homeshift_thermostat_mode`: options configurables par l'utilisateur (défaut: Eteint, Chauffage, Climatisation, Ventilation) -- `number.override_duration`: durée (en minutes) avant que HomeShift reprenne les mises à jour automatiques +Tout l'état de Home Assistant (base, `.storage`, comptes, logs) vit dans `config/`, ignoré par git sauf `configuration.yaml`. Pour une instance neuve (compte `admin` / `admin` recréé) : supprimer tout `config/` sauf `configuration.yaml`, puis **Rebuild Container**. -> **Devcontainer** : en l'absence de l'intégration HomeShift, des entités `input_select` de substitution sont définies dans `configuration.yaml` : -> -> - `input_select.homeshift_day_mode` : Maison, Travail, Télétravail, Absence -> - `input_select.homeshift_thermostat_mode` : Eteint, Chauffage, Climatisation, Ventilation +## Fichiers -Des entités `input_boolean` sont incluses pour tester le scheduler: - -- `input_boolean.test_switch`: Interrupteur de test -- `input_boolean.test_light`: Lumière de test - -## Scheduler Integration - -L'environnement de test inclut automatiquement: - -- **scheduler-component** (v3.3.8): Composant permettant de créer des planifications -- **scheduler-card** (v4.0.11): Interface UI pour gérer les planifications - -Ces composants sont automatiquement téléchargés et installés lors de la première initialisation du conteneur. - -### ⚠️ IMPORTANT: Configuration Requise - -**L'intégration Scheduler doit être ajoutée manuellement via l'interface Home Assistant:** - -1. Attendre que Home Assistant soit complètement démarré (1-2 minutes) -2. Compléter l'onboarding (créer un compte admin/admin si nécessaire) -3. Aller dans **Paramètres** → **Appareils et Services** -4. Cliquer sur **+ Ajouter une intégration** -5. Rechercher **"Scheduler"** et cliquer pour l'ajouter -6. L'intégration sera alors configurée et fonctionnelle - -### Utilisation du Scheduler - -Une fois l'intégration ajoutée: - -1. Accéder à l'onglet "Scheduler" dans le dashboard -2. Utiliser la carte scheduler-card pour créer des planifications -3. Sélectionner les entités `input_boolean.test_switch` ou `input_boolean.test_light` pour tester - -### Dépannage - -Si l'intégration "Scheduler" n'apparaît pas dans la liste: - -**⚠️ Pour les utilisateurs ayant une installation précédente:** -Si vous avez utilisé une version antérieure du DevContainer, les fichiers peuvent avoir été installés incorrectement. Solution: - -1. Fermer VS Code et arrêter le DevContainer -2. Supprimer le volume Docker associé au conteneur -3. Rouvrir dans le DevContainer (reconstruction complète) -4. Les fichiers seront installés correctement dans `/config/custom_components/scheduler/` - -**Autres vérifications:** - -- Vérifier que Home Assistant est complètement démarré (voir les logs) -- Vérifier que les fichiers sont dans `/config/custom_components/scheduler/` avec: - ```bash - ls -la /config/custom_components/scheduler/ - # Doit afficher: manifest.json, __init__.py, etc. - ``` -- Redémarrer Home Assistant: **Paramètres** → **Système** → **Redémarrer** -- Rafraîchir le navigateur (Ctrl+F5) -- Vider le cache du navigateur et recharger - -**Si les fichiers sont au mauvais endroit:** -Si vous voyez des fichiers Python directement dans `/config/custom_components/` (au lieu de `/config/custom_components/scheduler/`), c'est une installation incorrecte. Le script de setup les nettoiera automatiquement lors du prochain redémarrage du conteneur. +- `config/configuration.yaml`, `dashboard.yaml`, `scheduler.storage` : versionnés +- le reste de `config/` : état local ignoré par git ## Debug -- Logs HA: `/config/home-assistant.log` -- Console navigateur: F12 - -## Arrêt - -- Fermer VS Code ou F1 → "Dev Containers: Reopen Folder Locally" +- Logs HA : `tail -f /config/home-assistant.log` +- Redémarrer HA : depuis l'UI (Paramètres → Système → Redémarrer) ou `pkill -f "^python3 -m homeassistant"` ; `start.sh` le relance automatiquement diff --git a/.devcontainer/config/configuration.yaml b/.devcontainer/config/configuration.yaml index 1110923..fe0f21a 100644 --- a/.devcontainer/config/configuration.yaml +++ b/.devcontainer/config/configuration.yaml @@ -19,8 +19,9 @@ logger: logs: homeassistant.components.lovelace: debug -# User interface -# Resources are registered via .storage/lovelace_resources (storage mode ignores configuration.yaml resources) +# User interface: every dashboard is editable in the UI. setup.sh creates the +# resources and the "HomeShift" dashboard in .storage +# (dashboard seed: .devcontainer/dashboard.yaml). lovelace: mode: storage @@ -49,6 +50,10 @@ template: unique_id: homeshift_day_mode state: "{{ states('input_select.homeshift_day_mode_backing') }}" options: "{{ state_attr('input_select.homeshift_day_mode_backing', 'options') }}" + attributes: + option_map: "{{ {'home': 'Maison', 'work': 'Travail', 'remote': 'Télétravail', 'away': 'Absence'} }}" + option_keys: "{{ {'home': 'Maison', 'work': 'Travail', 'remote': 'Télétravail', 'away': 'Absence'}.keys() | list }}" + current_key: "{{ {'Maison': 'home', 'Travail': 'work', 'Télétravail': 'remote', 'Absence': 'away'}.get(states('input_select.homeshift_day_mode_backing')) }}" select_option: service: input_select.select_option data: @@ -58,6 +63,10 @@ template: unique_id: homeshift_thermostat_mode state: "{{ states('input_select.homeshift_thermostat_mode_backing') }}" options: "{{ state_attr('input_select.homeshift_thermostat_mode_backing', 'options') }}" + attributes: + option_map: "{{ {'off': 'Eteint', 'heating': 'Chauffage', 'cooling': 'Climatisation', 'ventilation': 'Ventilation'} }}" + option_keys: "{{ {'off': 'Eteint', 'heating': 'Chauffage', 'cooling': 'Climatisation', 'ventilation': 'Ventilation'}.keys() | list }}" + current_key: "{{ {'Eteint': 'off', 'Chauffage': 'heating', 'Climatisation': 'cooling', 'Ventilation': 'ventilation'}.get(states('input_select.homeshift_thermostat_mode_backing')) }}" select_option: service: input_select.select_option data: @@ -90,6 +99,25 @@ template: data: entity_id: input_number.homeshift_early_switch_backing value: "{{ value }}" + # Simulates button.homeshift_open_covers / button.homeshift_close_covers: + # the integration moves the daily covers; here a notification shows the press. + - button: + - name: "homeshift_open_covers" + unique_id: homeshift_open_covers + icon: mdi:roller-shade + press: + service: persistent_notification.create + data: + title: HomeShift + message: "Open covers pressed" + - name: "homeshift_close_covers" + unique_id: homeshift_close_covers + icon: mdi:roller-shade-closed + press: + service: persistent_notification.create + data: + title: HomeShift + message: "Close covers pressed" - sensor: - name: "homeshift_next_scan" unique_id: homeshift_next_scan @@ -102,6 +130,22 @@ template: unique_id: homeshift_next_mode_at state: "{{ (now() + timedelta(hours=2, minutes=15)).isoformat() }}" device_class: timestamp + # Measured room temperatures, set with the input_number helpers. + - name: Température bureau + unique_id: temperature_bureau + device_class: temperature + unit_of_measurement: "°C" + state: "{{ states('input_number.temperature_bureau_backing') }}" + - name: Température chambre + unique_id: temperature_chambre + device_class: temperature + unit_of_measurement: "°C" + state: "{{ states('input_number.temperature_chambre_backing') }}" + - name: Température salon + unique_id: temperature_salon + device_class: temperature + unit_of_measurement: "°C" + state: "{{ states('input_number.temperature_salon_backing') }}" - name: "homeshift_cover_open_time" unique_id: homeshift_cover_open_time state: "{{ states('input_text.homeshift_cover_open_time_backing') }}" @@ -110,6 +154,54 @@ template: unique_id: homeshift_cover_close_time state: "{{ states('input_text.homeshift_cover_close_time_backing') }}" icon: mdi:roller-shade-closed + # Test cover simulating the HomeShift daily cover group (the legacy + # cover_entity option calls cover.open_cover / close_cover on it). + - cover: + - name: HomeShift Daily Covers + unique_id: homeshift_daily_covers + device_class: shutter + state: "{{ 'open' if is_state('input_boolean.homeshift_cover_state_backing', 'on') else 'closed' }}" + open_cover: + action: input_boolean.turn_on + target: + entity_id: input_boolean.homeshift_cover_state_backing + close_cover: + action: input_boolean.turn_off + target: + entity_id: input_boolean.homeshift_cover_state_backing + # The covers the simulated windows belong to. They exist only so the + # card can resolve their friendly names; up while their window is open. + - name: Volet chambre + unique_id: volet_chambre + device_class: shutter + state: "{{ 'open' if is_state('input_boolean.homeshift_window_chambre_backing', 'on') else 'closed' }}" + open_cover: + - delay: 0 + close_cover: + - delay: 0 + - name: Volet salon + unique_id: volet_salon + device_class: shutter + state: "{{ 'open' if is_state('input_boolean.homeshift_window_salon_backing', 'on') else 'closed' }}" + open_cover: + - delay: 0 + close_cover: + - delay: 0 + # Simulates the auto start/stop switch of the living room air conditioner + # (driven by the sample schedules). + - switch: + - name: Salon enable auto start stop + unique_id: salon_enable_auto_start_stop + icon: mdi:air-conditioner + state: "{{ is_state('input_boolean.salon_auto_start_stop_backing', 'on') }}" + turn_on: + action: input_boolean.turn_on + target: + entity_id: input_boolean.salon_auto_start_stop_backing + turn_off: + action: input_boolean.turn_off + target: + entity_id: input_boolean.salon_auto_start_stop_backing - binary_sensor: - name: "homeshift_cover_heat_active" unique_id: homeshift_cover_heat_active @@ -167,6 +259,27 @@ input_number: step: 5 unit_of_measurement: min icon: mdi:clock-fast + temperature_bureau_backing: + name: Température bureau (simulation) + min: 10 + max: 30 + step: 0.5 + initial: 19 + unit_of_measurement: "°C" + temperature_chambre_backing: + name: Température chambre (simulation) + min: 10 + max: 30 + step: 0.5 + initial: 18 + unit_of_measurement: "°C" + temperature_salon_backing: + name: Température salon (simulation) + min: 10 + max: 30 + step: 0.5 + initial: 20.5 + unit_of_measurement: "°C" # Simulates sensor.homeshift_next_mode (text) from the HomeShift integration. input_text: @@ -182,23 +295,24 @@ input_text: initial: "21:15" icon: mdi:roller-shade-closed -# Scheduler integration -# NOTE: The scheduler integration must be added via the Home Assistant UI first! -# Steps to add: -# 1. Go to Settings → Devices & Services -# 2. Click "+ Add Integration" -# 3. Search for "Scheduler" and add it -# -# After adding the integration, you can customize it by uncommenting and editing below: -# scheduler: -# customize: -# light: -# icon: mdi:lightbulb -# switch: -# icon: mdi:power-plug +# The Scheduler integration is set up by setup.sh (config entry in .storage), +# with the sample schedules of .devcontainer/scheduler.storage. -# Test entities for scheduler input_boolean: + force_presence: + name: Forcer la présence + icon: mdi:account-lock + salon_auto_start_stop_backing: + name: Auto start/stop salon (backing) + radiateur_bureau: + name: Radiateur bureau + icon: mdi:radiator + radiateur_chambre: + name: Radiateur chambre + icon: mdi:radiator + radiateur_salon: + name: Radiateur salon + icon: mdi:radiator test_switch: name: Test Switch icon: mdi:power-plug @@ -220,38 +334,41 @@ input_boolean: name: Fenêtre salon ouverte (simulation) icon: mdi:window-open-variant -# Test cover entity simulating the HomeShift daily cover group -# (select.cover_entity in the card triggers cover.open_cover/close_cover on this entity). -cover: - - platform: template - covers: - homeshift_daily_covers: - friendly_name: "HomeShift Daily Covers" - device_class: shutter - value_template: "{{ 'open' if is_state('input_boolean.homeshift_cover_state_backing', 'on') else 'closed' }}" - open_cover: - service: input_boolean.turn_on - target: - entity_id: input_boolean.homeshift_cover_state_backing - close_cover: - service: input_boolean.turn_off - target: - entity_id: input_boolean.homeshift_cover_state_backing - # The covers the simulated windows belong to. They exist only so the - # card can resolve their friendly names; up while their window is open. - volet_chambre: - friendly_name: "Volet chambre" - device_class: shutter - value_template: "{{ 'open' if is_state('input_boolean.homeshift_window_chambre_backing', 'on') else 'closed' }}" - open_cover: - - delay: 0 - close_cover: - - delay: 0 - volet_salon: - friendly_name: "Volet salon" - device_class: shutter - value_template: "{{ 'open' if is_state('input_boolean.homeshift_window_salon_backing', 'on') else 'closed' }}" - open_cover: - - delay: 0 - close_cover: - - delay: 0 +# Thermostats driven by the sample schedules: climate.bureau, climate.chambre, climate.salon +climate: + - platform: generic_thermostat + name: Bureau + unique_id: climate_bureau + heater: input_boolean.radiateur_bureau + target_sensor: sensor.temperature_bureau + min_temp: 7 + max_temp: 28 + target_temp: 19 + away_temp: 12 + eco_temp: 17 + comfort_temp: 20 + precision: 0.5 + - platform: generic_thermostat + name: Chambre + unique_id: climate_chambre + heater: input_boolean.radiateur_chambre + target_sensor: sensor.temperature_chambre + min_temp: 7 + max_temp: 28 + target_temp: 18 + away_temp: 12 + eco_temp: 17 + comfort_temp: 20 + precision: 0.5 + - platform: generic_thermostat + name: Salon + unique_id: climate_salon + heater: input_boolean.radiateur_salon + target_sensor: sensor.temperature_salon + min_temp: 7 + max_temp: 28 + target_temp: 20 + away_temp: 12 + eco_temp: 17 + comfort_temp: 20 + precision: 0.5 diff --git a/.devcontainer/config/ui-lovelace.yaml b/.devcontainer/config/ui-lovelace.yaml deleted file mode 100644 index b4acc68..0000000 --- a/.devcontainer/config/ui-lovelace.yaml +++ /dev/null @@ -1,49 +0,0 @@ -title: HomeShift Dashboard -# NOTE: Ce fichier est une référence documentaire uniquement. -# En mode "storage", Home Assistant utilise .storage/lovelace comme configuration active. -# Pour modifier le tableau de bord, utiliser l'éditeur HA ou éditer .storage/lovelace directement. -views: - - title: Home - path: home - icon: mdi:home - cards: - - type: custom:homeshift-card - name: Thermostat - day_mode_entity: select.homeshift_day_mode - thermostat_mode_entity: select.homeshift_thermostat_mode - override_duration_entity: number.homeshift_override_duration - early_switch_entity: number.homeshift_early_switch - next_mode_entity: sensor.homeshift_next_mode - next_mode_at_entity: sensor.homeshift_next_mode_at - heat_protection_entity: binary_sensor.homeshift_cover_heat_active - cover_open_time_entity: sensor.homeshift_cover_open_time - cover_close_time_entity: sensor.homeshift_cover_close_time - cover_entity: cover.homeshift_daily_covers - show_title: true - - - type: entities - title: Entités HomeShift - entities: - - entity: select.homeshift_day_mode - - entity: select.homeshift_thermostat_mode - - entity: number.homeshift_override_duration - - entity: number.homeshift_early_switch - - entity: sensor.homeshift_next_mode - - entity: sensor.homeshift_next_mode_at - - entity: binary_sensor.homeshift_cover_heat_active - - entity: input_boolean.homeshift_window_chambre_backing - - entity: input_boolean.homeshift_window_salon_backing - - entity: binary_sensor.homeshift_covers_left_open - - - title: Scheduler - path: scheduler - icon: mdi:calendar-clock - cards: - - type: custom:scheduler-card - title: Schedules - - - type: entities - title: Test Entities - entities: - - entity: input_boolean.test_switch - - entity: input_boolean.test_light diff --git a/.devcontainer/dashboard.yaml b/.devcontainer/dashboard.yaml new file mode 100644 index 0000000..224ad76 --- /dev/null +++ b/.devcontainer/dashboard.yaml @@ -0,0 +1,56 @@ +# Seed of the "HomeShift" dashboard (storage mode, editable in the UI). +# setup.sh copies it into .storage/lovelace.dashboard_homeshift when that file does not exist yet. +title: HomeShift +views: + - title: HomeShift + path: homeshift + icon: mdi:home-clock + type: sections + max_columns: 3 + sections: + - type: grid + cards: + - type: heading + heading: Carte + icon: mdi:home-clock + # Every entity option defaults to the simulated HomeShift entities. + - type: custom:homeshift-card + + - type: grid + cards: + - type: heading + heading: Simulation + icon: mdi:tune + - type: entities + entities: + - entity: select.homeshift_day_mode + name: Mode du jour + - entity: select.homeshift_thermostat_mode + name: Mode thermostat + - number.homeshift_override_duration + - number.homeshift_early_switch + - input_text.homeshift_next_mode_backing + - input_boolean.homeshift_heat_protection_backing + - input_boolean.homeshift_window_chambre_backing + - input_boolean.homeshift_window_salon_backing + - input_number.temperature_bureau_backing + - input_number.temperature_chambre_backing + - input_number.temperature_salon_backing + - type: thermostat + entity: climate.salon + + - type: grid + cards: + - type: heading + heading: Planifications + icon: mdi:calendar-clock + # Edits the schedules shown in the card's schedules panel. + - type: custom:scheduler-card + title: false + discover_existing: false + include: + - climate.bureau + - climate.chambre + - climate.salon + - switch.salon_enable_auto_start_stop + - input_boolean.force_presence diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 41826a3..f076269 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "Home Assistant Dev Environment", + "name": "HomeShift Card - Home Assistant", "build": { "dockerfile": "Dockerfile" }, @@ -13,10 +13,17 @@ "onAutoForward": "notify" } }, + // Home Assistant est le processus principal du conteneur (CMD du Dockerfile : start.sh) ; + // VS Code ne doit pas le remplacer par sa boucle "sleep". + "overrideCommand": false, + // Installe les dépendances et prépare /config ; start.sh attend la fin de ce script. "postCreateCommand": "bash /workspaces/homeshift_card/.devcontainer/setup.sh", + // Chemin fixe dans le conteneur, quel que soit le nom du dossier sur la machine. + "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/homeshift_card,type=bind", + "workspaceFolder": "/workspaces/homeshift_card", "mounts": [ "source=${localWorkspaceFolder}/.devcontainer/config,target=/config,type=bind", - "source=${localWorkspaceFolder}/dist,target=/config/www/workspaces/homeshift_card,type=bind" + "source=${localWorkspaceFolder}/dist,target=/config/www/homeshift_card,type=bind" ], "customizations": { "vscode": { diff --git a/.devcontainer/onboard.py b/.devcontainer/onboard.py new file mode 100644 index 0000000..1fa3b0a --- /dev/null +++ b/.devcontainer/onboard.py @@ -0,0 +1,69 @@ +"""Completes the onboarding of the devcontainer's Home Assistant on its own. + +Creates the test account admin / admin and goes through the remaining +onboarding steps, so http://localhost:8123 opens on the login page. Does +nothing once the onboarding is done (an existing instance keeps its users). +""" + +import json +import time +import urllib.error +import urllib.parse +import urllib.request + +BASE = "http://127.0.0.1:8123" +CLIENT_ID = f"{BASE}/" +USER = {"name": "Admin", "username": "admin", "password": "admin", "language": "fr"} + + +def request(path, data=None, token=None, form=False): + headers = {} + body = None + if token: + headers["Authorization"] = f"Bearer {token}" + if data is not None: + if form: + body = urllib.parse.urlencode(data).encode() + headers["Content-Type"] = "application/x-www-form-urlencoded" + else: + body = json.dumps(data).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(BASE + path, data=body, headers=headers) + with urllib.request.urlopen(req, timeout=10) as response: + text = response.read().decode() + return json.loads(text) if text else None + + +def wait_for_home_assistant(timeout=900): + """Onboarding steps, once the HTTP server answers.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + return request("/api/onboarding") + except (urllib.error.URLError, ConnectionError, TimeoutError): + time.sleep(5) + raise SystemExit("onboard: Home Assistant did not answer, onboarding skipped") + + +def main(): + steps = {step["step"]: step["done"] for step in wait_for_home_assistant()} + if steps.get("user"): + return + code = request("/api/onboarding/users", {**USER, "client_id": CLIENT_ID})["auth_code"] + token = request( + "/auth/token", + {"grant_type": "authorization_code", "code": code, "client_id": CLIENT_ID}, + form=True, + )["access_token"] + request("/api/onboarding/core_config", {}, token) + request("/api/onboarding/analytics", {}, token) + request( + "/api/onboarding/integration", + {"client_id": CLIENT_ID, "redirect_uri": f"{BASE}/?auth_callback=1"}, + token, + ) + print("onboard: account admin / admin created") + + +if __name__ == "__main__": + main() diff --git a/.devcontainer/scheduler.storage b/.devcontainer/scheduler.storage index df0d78b..7870415 100644 --- a/.devcontainer/scheduler.storage +++ b/.devcontainer/scheduler.storage @@ -5,46 +5,53 @@ "data": { "schedules": [ { - "schedule_id": "ea9150", + "schedule_id": "1a2b30", "timeslots": [ { - "start": "08:30:00", - "stop": null, + "start": "00:00:00", + "stop": "07:00:00", "conditions": [], "condition_type": null, "track_conditions": false, "actions": [ { - "service": "cover.open_cover", - "entity_id": "cover.volets", - "service_data": {} + "service": "climate.set_preset_mode", + "entity_id": "climate.bureau", + "service_data": { + "preset_mode": "eco" + } } ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Ouverture volets maison", - "enabled": true - }, - { - "schedule_id": "f9cd26", - "timeslots": [ + }, { - "start": "07:52", - "stop": null, + "start": "07:00:00", + "stop": "22:00:00", "conditions": [], "condition_type": null, "track_conditions": false, "actions": [ { - "service": "cover.open_cover", - "entity_id": "cover.volets", - "service_data": {} + "service": "climate.set_preset_mode", + "entity_id": "climate.bureau", + "service_data": { + "preset_mode": "comfort" + } + } + ] + }, + { + "start": "22:00:00", + "stop": "00:00:00", + "conditions": [], + "condition_type": null, + "track_conditions": false, + "actions": [ + { + "service": "climate.set_preset_mode", + "entity_id": "climate.bureau", + "service_data": { + "preset_mode": "eco" + } } ] } @@ -55,53 +62,39 @@ "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Ouverture volets travail", + "name": "Chauffage maison bureau", "enabled": false }, { - "schedule_id": "011e2a", + "schedule_id": "1a2b31", "timeslots": [ { "start": "00:00:00", - "stop": "05:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "stop": "06:30:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { "service": "climate.set_temperature", - "entity_id": "climate.salon", + "entity_id": "climate.chambre", "service_data": { - "temperature": 17.5, + "temperature": 17, "hvac_mode": "heat" } } ] }, { - "start": "05:00:00", - "stop": "07:10:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "start": "06:30:00", + "stop": "21:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { "service": "climate.set_temperature", - "entity_id": "climate.salon", + "entity_id": "climate.chambre", "service_data": { "temperature": 18, "hvac_mode": "heat" @@ -110,48 +103,65 @@ ] }, { - "start": "07:10:00", - "stop": "15:30:00", - "conditions": [ + "start": "21:00:00", + "stop": "00:00:00", + "conditions": [], + "condition_type": null, + "track_conditions": false, + "actions": [ { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" + "service": "climate.set_temperature", + "entity_id": "climate.chambre", + "service_data": { + "temperature": 17.5, + "hvac_mode": "heat" + } } - ], - "condition_type": "or", + ] + } + ], + "weekdays": [ + "daily" + ], + "start_date": null, + "end_date": null, + "repeat_type": "repeat", + "name": "Chauffage maison chambre", + "enabled": false + }, + { + "schedule_id": "1a2b32", + "timeslots": [ + { + "start": "00:00:00", + "stop": "07:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { "service": "climate.set_temperature", "entity_id": "climate.salon", "service_data": { - "temperature": 17.5, + "temperature": 18, "hvac_mode": "heat" } } ] }, { - "start": "15:30:00", + "start": "07:00:00", "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", + "service": "climate.set_temperature", "entity_id": "climate.salon", "service_data": { - "preset_mode": "comfort" + "temperature": 20.5, + "hvac_mode": "heat" } } ] @@ -163,177 +173,194 @@ "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Chauffage travail salon", + "name": "Chauffage maison salon", "enabled": false }, { - "schedule_id": "60b115", + "schedule_id": "1a2b33", "timeslots": [ { - "start": "00:00:00", - "stop": "06:30:00", - "conditions": [ + "start": "10:00:00", + "stop": null, + "conditions": [], + "condition_type": null, + "track_conditions": false, + "actions": [ { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" + "service": "switch.turn_on", + "entity_id": "switch.salon_enable_auto_start_stop", + "service_data": {} } - ], - "condition_type": "or", + ] + }, + { + "start": "11:00:00", + "stop": "15:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", - "entity_id": "climate.bureau", + "service": "climate.set_temperature", + "entity_id": "climate.salon", "service_data": { - "preset_mode": "eco" + "temperature": 26, + "hvac_mode": "cool" } } ] }, { - "start": "06:30:00", - "stop": "07:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "start": "15:00:00", + "stop": "20:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { "service": "climate.set_temperature", - "entity_id": "climate.bureau", + "entity_id": "climate.salon", "service_data": { - "temperature": 17, - "hvac_mode": "heat" + "temperature": 25, + "hvac_mode": "cool" } } ] }, { - "start": "07:00:00", - "stop": "17:00:00", - "conditions": [ + "start": "23:00:00", + "stop": null, + "conditions": [], + "condition_type": null, + "track_conditions": false, + "actions": [ { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" + "service": "switch.turn_off", + "entity_id": "switch.salon_enable_auto_start_stop", + "service_data": {} } - ], - "condition_type": "or", + ] + } + ], + "weekdays": [ + "daily" + ], + "start_date": null, + "end_date": null, + "repeat_type": "repeat", + "name": "Climatisation maison salon", + "enabled": true + }, + { + "schedule_id": "1a2b34", + "timeslots": [ + { + "start": "00:00:00", + "stop": "08:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", + "service": "climate.set_temperature", "entity_id": "climate.bureau", "service_data": { - "preset_mode": "eco" + "temperature": 17, + "hvac_mode": "heat" } } ] }, { - "start": "17:00:00", - "stop": "22:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "start": "08:00:00", + "stop": "18:30:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { "service": "climate.set_temperature", "entity_id": "climate.bureau", "service_data": { - "temperature": 19.5, + "temperature": 20.5, "hvac_mode": "heat" } } ] }, { - "start": "22:00:00", + "start": "18:30:00", "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", + "service": "climate.set_temperature", "entity_id": "climate.bureau", "service_data": { - "preset_mode": "eco" + "temperature": 17, + "hvac_mode": "heat" } } ] } ], "weekdays": [ - "daily" + "mon", + "tue", + "wed", + "thu", + "fri" ], "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Chauffage travail bureau", - "enabled": false + "name": "Chauffage télétravail bureau", + "enabled": true }, { - "schedule_id": "7ceade", + "schedule_id": "1a2b35", "timeslots": [ { "start": "00:00:00", "stop": "06:30:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", + "service": "climate.set_temperature", "entity_id": "climate.chambre", "service_data": { - "preset_mode": "eco" + "temperature": 17, + "hvac_mode": "heat" } } ] }, { "start": "06:30:00", - "stop": "07:00:00", - "conditions": [ + "stop": "08:00:00", + "conditions": [], + "condition_type": null, + "track_conditions": false, + "actions": [ { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" + "service": "climate.set_temperature", + "entity_id": "climate.chambre", + "service_data": { + "temperature": 19, + "hvac_mode": "heat" + } } - ], - "condition_type": "or", + ] + }, + { + "start": "08:00:00", + "stop": "21:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { @@ -347,1204 +374,352 @@ ] }, { - "start": "07:00:00", + "start": "21:00:00", "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", + "service": "climate.set_temperature", "entity_id": "climate.chambre", "service_data": { - "preset_mode": "eco" + "temperature": 18, + "hvac_mode": "heat" } } ] } ], "weekdays": [ - "daily" + "mon", + "tue", + "wed", + "thu", + "fri" ], "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Chauffage travail chambre", - "enabled": false + "name": "Chauffage télétravail chambre", + "enabled": true }, { - "schedule_id": "636997", + "schedule_id": "1a2b36", "timeslots": [ { "start": "00:00:00", - "stop": "12:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "stop": "07:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", - "entity_id": "climate.bureau", + "service": "climate.set_temperature", + "entity_id": "climate.salon", "service_data": { - "preset_mode": "eco" + "temperature": 18, + "hvac_mode": "heat" } } ] }, { - "start": "12:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "start": "07:00:00", + "stop": "23:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", - "entity_id": "climate.bureau", - "service_data": { - "preset_mode": "eco" - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Chauffage absence bureau", - "enabled": false - }, - { - "schedule_id": "268dc6", - "timeslots": [ - { - "start": "00:00:00", - "stop": "12:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.chambre", - "service_data": { - "preset_mode": "eco" - } - } - ] - }, - { - "start": "12:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.chambre", - "service_data": { - "preset_mode": "eco" - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Chauffage absence chambre", - "enabled": false - }, - { - "schedule_id": "6779e2", - "timeslots": [ - { - "start": "00:00:00", - "stop": "12:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_temperature", - "entity_id": "climate.salon", - "service_data": { - "hvac_mode": "heat", - "temperature": 17.5 - } - } - ] - }, - { - "start": "12:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_temperature", - "entity_id": "climate.salon", - "service_data": { - "hvac_mode": "heat", - "temperature": 17.5 - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Chauffage absence salon", - "enabled": false - }, - { - "schedule_id": "2e86ad", - "timeslots": [ - { - "start": "00:00:00", - "stop": "05:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_temperature", - "entity_id": "climate.salon", - "service_data": { - "temperature": 17.5, - "hvac_mode": "heat" - } - } - ] - }, - { - "start": "05:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", - "service_data": { - "preset_mode": "comfort" - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Chauffage télétravail salon", - "enabled": false - }, - { - "schedule_id": "18230d", - "timeslots": [ - { - "start": "00:00:00", - "stop": "06:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.bureau", - "service_data": { - "preset_mode": "eco" - } - } - ] - }, - { - "start": "06:00:00", - "stop": "22:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_temperature", - "entity_id": "climate.bureau", - "service_data": { - "temperature": 19.5, - "hvac_mode": "heat" - } - } - ] - }, - { - "start": "22:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.bureau", - "service_data": { - "preset_mode": "eco" - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Chauffage télétravail bureau", - "enabled": false - }, - { - "schedule_id": "2e669b", - "timeslots": [ - { - "start": "00:00:00", - "stop": "06:30:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.chambre", - "service_data": { - "preset_mode": "eco" - } - } - ] - }, - { - "start": "06:30:00", - "stop": "07:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_temperature", - "entity_id": "climate.chambre", - "service_data": { - "temperature": 17, - "hvac_mode": "heat" - } - } - ] - }, - { - "start": "07:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.chambre", - "service_data": { - "preset_mode": "eco" - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Chauffage télétravail chambre", - "enabled": false - }, - { - "schedule_id": "621663", - "timeslots": [ - { - "start": "00:00:00", - "stop": "07:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.bureau", - "service_data": { - "preset_mode": "eco" - } - } - ] - }, - { - "start": "07:00:00", - "stop": "22:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_temperature", - "entity_id": "climate.bureau", - "service_data": { - "temperature": 19.5, - "hvac_mode": "heat" - } - } - ] - }, - { - "start": "22:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.bureau", - "service_data": { - "preset_mode": "eco" - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Chauffage maison bureau", - "enabled": true - }, - { - "schedule_id": "f3a989", - "timeslots": [ - { - "start": "00:00:00", - "stop": "06:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_temperature", - "entity_id": "climate.salon", - "service_data": { - "temperature": 17.5, - "hvac_mode": "heat" - } - } - ] - }, - { - "start": "06:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", - "service_data": { - "preset_mode": "comfort" - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Chauffage maison salon", - "enabled": true - }, - { - "schedule_id": "2ba6d8", - "timeslots": [ - { - "start": "00:00:00", - "stop": "07:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.chambre", - "service_data": { - "preset_mode": "eco" - } - } - ] - }, - { - "start": "07:00:00", - "stop": "08:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_temperature", - "entity_id": "climate.chambre", - "service_data": { - "temperature": 17, - "hvac_mode": "heat" - } - } - ] - }, - { - "start": "08:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.chambre", - "service_data": { - "preset_mode": "eco" - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Chauffage maison chambre", - "enabled": true - }, - { - "schedule_id": "8388cc", - "timeslots": [ - { - "start": "00:00:00", - "stop": "15:00:00", - "conditions": [], - "condition_type": null, - "track_conditions": false, - "actions": [ - { - "service": "input_boolean.turn_off", - "entity_id": "input_boolean.force_presence", - "service_data": {} - } - ] - }, - { - "start": "15:00:00", - "stop": "22:00:00", - "conditions": [], - "condition_type": null, - "track_conditions": false, - "actions": [ - { - "service": "input_boolean.turn_on", - "entity_id": "input_boolean.force_presence", - "service_data": {} - } - ] - }, - { - "start": "22:00:00", - "stop": "00:00:00", - "conditions": [], - "condition_type": null, - "track_conditions": false, - "actions": [ - { - "service": "input_boolean.turn_off", - "entity_id": "input_boolean.force_presence", - "service_data": {} - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Simuler présence travail", - "enabled": false - }, - { - "schedule_id": "741dd1", - "timeslots": [ - { - "start": "00:00:00", - "stop": "22:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", - "service_data": { - "preset_mode": "comfort" - } - } - ] - }, - { - "start": "22:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", - "service_data": { - "preset_mode": "eco" - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Climatisation maison salon", - "enabled": false - }, - { - "schedule_id": "c91e10", - "timeslots": [ - { - "start": "00:00:00", - "stop": "07:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", - "service_data": { - "preset_mode": "comfort" - } - } - ] - }, - { - "start": "07:00:00", - "stop": "22:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", - "service_data": { - "preset_mode": "comfort" - } - } - ] - }, - { - "start": "22:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", - "service_data": { - "preset_mode": "eco" - } - } - ] - } - ], - "weekdays": [ - "daily" - ], - "start_date": null, - "end_date": null, - "repeat_type": "repeat", - "name": "Climatisation télétravail salon", - "enabled": false - }, - { - "schedule_id": "4852df", - "timeslots": [ - { - "start": "00:00:00", - "stop": "12:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.turn_off", - "entity_id": "climate.salon", - "service_data": {} - } - ] - }, - { - "start": "12:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "climate.turn_off", + "service": "climate.set_temperature", "entity_id": "climate.salon", - "service_data": {} + "service_data": { + "temperature": 20, + "hvac_mode": "heat" + } } ] } ], "weekdays": [ - "daily" + "mon", + "tue", + "wed", + "thu", + "fri" ], "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Climatisation absence salon", - "enabled": false + "name": "Chauffage télétravail salon", + "enabled": true }, { - "schedule_id": "7e8c60", + "schedule_id": "1a2b37", "timeslots": [ { - "start": "sunset+00:10:00", - "stop": null, + "start": "12:00:00", + "stop": "19:00:00", "conditions": [], "condition_type": null, "track_conditions": false, "actions": [ { - "service": "cover.close_cover", - "entity_id": "cover.volets", - "service_data": {} + "service": "climate.set_temperature", + "entity_id": "climate.salon", + "service_data": { + "temperature": 26, + "hvac_mode": "cool" + } } ] } ], "weekdays": [ - "daily" + "mon", + "tue", + "wed", + "thu", + "fri" ], "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Fermeture volets soir", - "enabled": true + "name": "Climatisation télétravail salon", + "enabled": false }, { - "schedule_id": "61574f", + "schedule_id": "1a2b38", "timeslots": [ { "start": "00:00:00", - "stop": "07:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", + "stop": "06:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", + "service": "climate.set_temperature", + "entity_id": "climate.chambre", "service_data": { - "preset_mode": "comfort" + "temperature": 17, + "hvac_mode": "heat" } } ] }, { - "start": "07:00:00", - "stop": "16:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", + "start": "06:00:00", + "stop": "07:30:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", + "service": "climate.set_temperature", + "entity_id": "climate.chambre", "service_data": { - "preset_mode": "eco" + "temperature": 19, + "hvac_mode": "heat" } } ] }, { - "start": "16:00:00", - "stop": "22:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", + "start": "07:30:00", + "stop": "21:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", + "service": "climate.set_temperature", + "entity_id": "climate.chambre", "service_data": { - "preset_mode": "comfort" + "temperature": 16, + "hvac_mode": "heat" } } ] }, { - "start": "22:00:00", + "start": "21:00:00", "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Climatisation", - "match_type": "is" - } - ], - "condition_type": "or", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "climate.set_preset_mode", - "entity_id": "climate.salon", + "service": "climate.set_temperature", + "entity_id": "climate.chambre", "service_data": { - "preset_mode": "eco" + "temperature": 18, + "hvac_mode": "heat" } } ] } ], "weekdays": [ - "daily" + "mon", + "tue", + "wed", + "thu", + "fri" ], "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Climatisation travail salon", - "enabled": false + "name": "Chauffage travail chambre", + "enabled": true }, { - "schedule_id": "890bac", + "schedule_id": "1a2b39", "timeslots": [ { - "start": "00:00:00", - "stop": "17:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "start": "06:00:00", + "stop": "08:00:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "switch.turn_off", - "entity_id": "switch.salon_enable_auto_start_stop", - "service_data": {} + "service": "climate.set_temperature", + "entity_id": "climate.salon", + "service_data": { + "temperature": 20, + "hvac_mode": "heat" + } } ] }, { - "start": "17:00:00", - "stop": "00:00:00", - "conditions": [ + "start": "08:00:00", + "stop": "17:30:00", + "conditions": [], + "condition_type": null, + "track_conditions": false, + "actions": [ { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" + "service": "climate.set_temperature", + "entity_id": "climate.salon", + "service_data": { + "temperature": 17, + "hvac_mode": "heat" + } } - ], - "condition_type": "or", + ] + }, + { + "start": "17:30:00", + "stop": "22:30:00", + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "switch.turn_off", - "entity_id": "switch.salon_enable_auto_start_stop", - "service_data": {} + "service": "climate.set_temperature", + "entity_id": "climate.salon", + "service_data": { + "temperature": 20, + "hvac_mode": "heat" + } } ] } ], "weekdays": [ - "daily" + "mon", + "tue", + "wed", + "thu", + "fri" ], "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Chauffage travail auto start/stop salon", - "enabled": false + "name": "Chauffage travail salon", + "enabled": true }, { - "schedule_id": "c8c0f3", + "schedule_id": "1a2b3a", "timeslots": [ { - "start": "00:00:00", - "stop": "08:00:00", - "conditions": [ - { - "entity_id": "climate.salon", - "attribute": "state", - "value": "heat", - "match_type": "is" - } - ], - "condition_type": "or", + "start": "17:00:00", + "stop": null, + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "switch.turn_off", - "entity_id": "switch.salon_enable_auto_start_stop", + "service": "input_boolean.turn_on", + "entity_id": "input_boolean.force_presence", "service_data": {} } ] }, { - "start": "08:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "climate.salon", - "attribute": "state", - "value": "heat", - "match_type": "is" - } - ], - "condition_type": "or", + "start": "22:30:00", + "stop": null, + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "switch.turn_off", - "entity_id": "switch.salon_enable_auto_start_stop", + "service": "input_boolean.turn_off", + "entity_id": "input_boolean.force_presence", "service_data": {} } ] } ], "weekdays": [ - "daily" + "mon", + "tue", + "wed", + "thu", + "fri" ], "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Chauffage maison auto start/stop salon", + "name": "Présence forcée travail", "enabled": true }, { - "schedule_id": "56538f", + "schedule_id": "1a2b3b", "timeslots": [ { "start": "00:00:00", - "stop": "12:00:00", - "conditions": [ - { - "entity_id": "climate.salon", - "attribute": "state", - "value": "heat", - "match_type": "is" - } - ], - "condition_type": "or", + "stop": null, + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { - "service": "switch.turn_on", - "entity_id": "switch.salon_enable_auto_start_stop", - "service_data": {} - } - ] - }, - { - "start": "12:00:00", - "stop": "00:00:00", - "conditions": [ + "service": "climate.set_temperature", + "entity_id": "climate.bureau", + "service_data": { + "temperature": 12, + "hvac_mode": "heat" + } + }, { - "entity_id": "climate.salon", - "attribute": "state", - "value": "heat", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ + "service": "climate.set_temperature", + "entity_id": "climate.chambre", + "service_data": { + "temperature": 12, + "hvac_mode": "heat" + } + }, { - "service": "switch.turn_on", - "entity_id": "switch.salon_enable_auto_start_stop", - "service_data": {} + "service": "climate.set_temperature", + "entity_id": "climate.salon", + "service_data": { + "temperature": 12, + "hvac_mode": "heat" + } } ] } @@ -1555,45 +730,17 @@ "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Chauffage absence auto start/stop salon", - "enabled": false + "name": "Hors-gel absence", + "enabled": true }, { - "schedule_id": "67f8a7", + "schedule_id": "1a2b3c", "timeslots": [ { - "start": "00:00:00", - "stop": "07:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", - "track_conditions": false, - "actions": [ - { - "service": "switch.turn_off", - "entity_id": "switch.salon_enable_auto_start_stop", - "service_data": {} - } - ] - }, - { - "start": "07:00:00", - "stop": "00:00:00", - "conditions": [ - { - "entity_id": "input_select.mode_thermostat", - "attribute": "state", - "value": "Chauffage", - "match_type": "is" - } - ], - "condition_type": "or", + "start": "08:00:00", + "stop": null, + "conditions": [], + "condition_type": null, "track_conditions": false, "actions": [ { @@ -1610,137 +757,44 @@ "start_date": null, "end_date": null, "repeat_type": "repeat", - "name": "Chauffage télétravail auto start/stop salon", - "enabled": false + "name": "Arrêt clim absence", + "enabled": true } ], "tags": [ { - "name": "Chauffage", - "schedules": [ - "011e2a", - "60b115", - "7ceade", - "636997", - "268dc6", - "6779e2", - "2e86ad", - "18230d", - "2e669b", - "621663", - "f3a989", - "2ba6d8", - "8388cc", - "890bac", - "c8c0f3", - "56538f", - "67f8a7" - ] - }, - { - "name": "Salon", - "schedules": [ - "011e2a", - "6779e2", - "2e86ad", - "f3a989", - "741dd1", - "c91e10", - "4852df", - "61574f", - "8388cc", - "890bac", - "56538f", - "67f8a7" - ] - }, - { - "name": "Bureau", - "schedules": [ - "60b115", - "636997", - "18230d", - "621663" - ] - }, - { - "name": "Vacances", - "schedules": [ - "ea9150" - ] - }, - { - "name": "Chambre", - "schedules": [ - "7ceade", - "268dc6", - "2e669b", - "2ba6d8" - ] - }, - { - "name": "Absence", + "name": "Maison", "schedules": [ - "636997", - "268dc6", - "6779e2", - "4852df", - "ea9150", - "56538f" + "1a2b30", + "1a2b31", + "1a2b32", + "1a2b33" ] }, { "name": "Télétravail", "schedules": [ - "2e86ad", - "18230d", - "2e669b", - "c91e10", - "f9cd26", - "67f8a7" - ] - }, - { - "name": "Maison", - "schedules": [ - "621663", - "f3a989", - "2ba6d8", - "ea9150", - "741dd1", - "c8c0f3" + "1a2b34", + "1a2b35", + "1a2b36", + "1a2b37" ] }, { "name": "Travail", "schedules": [ - "7ceade", - "60b115", - "011e2a", - "f9cd26", - "8388cc", - "61574f", - "890bac" - ] - }, - { - "name": "Volet", - "schedules": [ - "7e8c60", - "ea9150", - "f9cd26" + "1a2b38", + "1a2b39", + "1a2b3a" ] }, { - "name": "Climatisation", + "name": "Absence", "schedules": [ - "741dd1", - "c91e10", - "4852df", - "61574f", - "8388cc" + "1a2b3b", + "1a2b3c" ] } ] } -} \ No newline at end of file +} diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index d8ba683..fc25a09 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -1,51 +1,94 @@ #!/bin/bash +# Prepares /config once, when the devcontainer is created: card build, +# scheduler component and card, sample schedules, Lovelace resources and +# the HomeShift dashboard. start.sh then runs Home Assistant. +set -u + +WORKSPACE=/workspaces/homeshift_card +SEEDS=$WORKSPACE/.devcontainer +SCHEDULER_COMPONENT_VERSION="v3.3.8" +SCHEDULER_CARD_VERSION="v4.0.19" echo "Setting up Home Assistant development environment..." -echo "" +mkdir -p /config/.storage /config/custom_components /config/www/scheduler-card -# Create config directories if they don't exist -mkdir -p /config/www/homershift_card -mkdir -p /config/.storage -mkdir -p /config/custom_components +echo "" +echo "📦 Installing npm dependencies and building the card..." +cd "$WORKSPACE" && npm install --no-audit --no-fund && npm run build -# Copy configuration.yaml from repo if not exists or is older -if [ ! -f /config/configuration.yaml ] || [ /workspaces/homershift_card/.devcontainer/config/configuration.yaml -nt /config/configuration.yaml ]; then - echo "📝 Copying configuration.yaml from repository..." - cp /workspaces/homershift_card/.devcontainer/config/configuration.yaml /config/configuration.yaml - echo " ✅ Configuration file updated!" +echo "" +echo "📦 Installing scheduler-component ${SCHEDULER_COMPONENT_VERSION}..." +if [ ! -f /config/custom_components/scheduler/manifest.json ]; then + rm -rf /config/custom_components/scheduler /tmp/scheduler + if wget -q "https://github.com/nielsfaber/scheduler-component/releases/download/${SCHEDULER_COMPONENT_VERSION}/scheduler.zip" -O /tmp/scheduler.zip; then + unzip -q -o /tmp/scheduler.zip -d /tmp/scheduler + # The archive may or may not wrap the files in a scheduler/ folder. + cp -r "$(dirname "$(find /tmp/scheduler -name manifest.json | head -n 1)")" /config/custom_components/scheduler + rm -rf /tmp/scheduler /tmp/scheduler.zip + echo " ✅ Installed in /config/custom_components/scheduler/" + else + echo " ⚠️ Download failed" + fi +else + echo " ✅ Already installed" fi -# Copy scheduler storage file -if [ -f /workspaces/homershift_card/.devcontainer/scheduler.storage ]; then - echo "📋 Copying scheduler storage file..." - mkdir -p /config/.storage - cp /workspaces/homershift_card/.devcontainer/scheduler.storage /config/.storage/scheduler.storage - echo " ✅ scheduler.storage file copied!" +echo "" +echo "📦 Installing scheduler-card ${SCHEDULER_CARD_VERSION}..." +if [ ! -f /config/www/scheduler-card/scheduler-card.js ]; then + if wget -q "https://github.com/nielsfaber/scheduler-card/releases/download/${SCHEDULER_CARD_VERSION}/scheduler-card.js" -O /config/www/scheduler-card/scheduler-card.js; then + echo " ✅ Installed" + else + echo " ⚠️ Download failed" + fi +else + echo " ✅ Already installed" fi -# Install scheduler component -echo "📦 Installing scheduler component..." -if [ ! -d /config/custom_components/scheduler ]; then - cd /tmp - rm -rf scheduler-component-* scheduler.zip - wget -q -O scheduler.zip https://github.com/nielsfaber/scheduler-component/archive/refs/heads/master.zip - unzip -o -q scheduler.zip - cp -r scheduler-component-*/custom_components/scheduler /config/custom_components/ - rm -rf scheduler-component-* scheduler.zip - echo " ✅ Scheduler component installed!" +echo "" +echo "📋 Sample schedules..." +# Kept when it exists: it holds the schedules edited in the UI. +# Delete .devcontainer/config/.storage/scheduler.storage to get the samples back. +if [ ! -f /config/.storage/scheduler.storage ]; then + cp "$SEEDS/scheduler.storage" /config/.storage/scheduler.storage + echo " ✅ scheduler.storage copied" else - echo " ℹ️ Scheduler component already installed" + echo " ℹ️ scheduler.storage already present, kept" fi -# Check if the dist folder has the card file -if [ -f /workspaces/homershift_card/dist/homershift-card.js ]; then - echo "✅ Card file found in dist/ - copying to Home Assistant..." - cp /workspaces/homershift_card/dist/homershift-card.js /config/www/homershift_card/ - echo " Card copied successfully!" - - # Pre-configure the Lovelace resource - echo "✅ Pre-configuring Lovelace resources..." - cat > /config/.storage/lovelace_resources << 'EOF' +echo "" +echo "🔌 Scheduler integration..." +# Adds the Scheduler config entry so the integration is set up without the UI +# (version 2: the component cannot migrate a version 1 entry on recent HA). +python3 - <<'PY' +import json, os, uuid +from datetime import datetime, timezone + +path = "/config/.storage/core.config_entries" +store = {"version": 1, "minor_version": 5, "key": "core.config_entries", "data": {"entries": []}} +if os.path.exists(path): + with open(path, encoding="utf-8") as f: + store = json.load(f) +entries = store["data"]["entries"] +if any(e["domain"] == "scheduler" for e in entries): + print(" ℹ️ Already configured") +else: + now = datetime.now(timezone.utc).isoformat() + entries.append({ + "created_at": now, "data": {}, "disabled_by": None, "discovery_keys": {}, + "domain": "scheduler", "entry_id": uuid.uuid4().hex, "minor_version": 1, + "modified_at": now, "options": {}, "pref_disable_new_entities": False, + "pref_disable_polling": False, "source": "user", "subentries": [], + "title": "Scheduler", "unique_id": None, "version": 2, + }) + with open(path, "w", encoding="utf-8") as f: + json.dump(store, f, indent=2) + print(" ✅ Config entry added") +PY + +echo "" +echo "🧩 Lovelace resources..." +cat > /config/.storage/lovelace_resources << 'EOF' { "version": 1, "minor_version": 1, @@ -53,8 +96,8 @@ if [ -f /workspaces/homershift_card/dist/homershift-card.js ]; then "data": { "items": [ { - "id": "homershift_card", - "url": "/local/homershift_card/homershift-card.js", + "id": "homeshift_card", + "url": "/local/homeshift_card/homeshift-card.js", "type": "module" }, { @@ -66,147 +109,57 @@ if [ -f /workspaces/homershift_card/dist/homershift-card.js ]; then } } EOF - echo " Lovelace resources pre-configured!" -else - echo "⚠️ WARNING: Card file not found in dist/ folder" - echo "" - echo " You need to build the card first!" - echo " On your HOST machine (Windows), run:" - echo " 1. Open a terminal in the project folder" - echo " 2. npm install" - echo " 3. npm run build" - echo "" - echo " The dist/ folder will be automatically mounted in Home Assistant." -fi - -echo "" -echo "📦 Installing scheduler-component..." -SCHEDULER_COMPONENT_VERSION="v3.3.8" -SCHEDULER_COMPONENT_URL="https://github.com/nielsfaber/scheduler-component/releases/download/${SCHEDULER_COMPONENT_VERSION}/scheduler.zip" - -if [ ! -d /config/custom_components/scheduler ]; then - # Install wget and unzip if not available - if ! command -v wget &> /dev/null || ! command -v unzip &> /dev/null; then - echo " Installing wget and unzip..." - apk add --no-cache wget unzip > /dev/null 2>&1 - fi - - echo " Downloading scheduler-component ${SCHEDULER_COMPONENT_VERSION}..." - if wget -q ${SCHEDULER_COMPONENT_URL} -O /tmp/scheduler.zip; then - echo " Extracting scheduler component..." - unzip -q /tmp/scheduler.zip -d /config/custom_components/ - rm /tmp/scheduler.zip - - # Verify installation - if [ -f /config/custom_components/scheduler/manifest.json ]; then - echo " ✅ Scheduler component installed successfully!" - echo " 📁 Location: /config/custom_components/scheduler/" - else - echo " ⚠️ Warning: scheduler component extracted but manifest.json not found" - echo " Checking what was extracted..." - ls -la /config/custom_components/ || true - fi - else - echo " ⚠️ Failed to download scheduler component" - fi -else - echo " ✅ Scheduler component already installed" - echo " 📁 Location: /config/custom_components/scheduler/" - # Verify it's properly installed - if [ -f /config/custom_components/scheduler/manifest.json ]; then - echo " ✓ manifest.json found" - else - echo " ⚠️ Warning: manifest.json not found in scheduler directory" - fi -fi - -echo "" -echo "📦 Installing scheduler-card..." -SCHEDULER_CARD_VERSION="v4.0.11" -SCHEDULER_CARD_URL="https://github.com/nielsfaber/scheduler-card/releases/download/${SCHEDULER_CARD_VERSION}/scheduler-card.js" - -# Create scheduler-card directory -mkdir -p /config/www/scheduler-card - -if [ ! -f /config/www/scheduler-card/scheduler-card.js ]; then - # Install wget if not available - if ! command -v wget &> /dev/null; then - echo " Installing wget..." - apk add --no-cache wget > /dev/null 2>&1 - fi - - echo " Downloading scheduler-card ${SCHEDULER_CARD_VERSION}..." - if wget -q ${SCHEDULER_CARD_URL} -O /config/www/scheduler-card/scheduler-card.js; then - echo " ✅ Scheduler card installed!" - else - echo " ⚠️ Failed to download scheduler card" - fi -else - echo " ✅ Scheduler card already installed" -fi +echo " ✅ Registered" echo "" -echo "🚀 Starting Home Assistant in the background..." - -# Start Home Assistant in the background -nohup python3 -m homeassistant --config /config > /config/home-assistant.log 2>&1 & +echo "📊 HomeShift dashboard..." +# Storage mode, so it can be edited in the UI. Kept when it exists: it holds the UI edits. +# Delete .devcontainer/config/.storage/lovelace.dashboard_homeshift to get the seed back. +python3 - "$SEEDS/dashboard.yaml" <<'PY' +import json, os, sys +import yaml + +def load(path, default): + if not os.path.exists(path): + return default + with open(path, encoding="utf-8") as f: + return json.load(f) + +def save(path, store): + with open(path, "w", encoding="utf-8") as f: + json.dump(store, f, indent=2, ensure_ascii=False) + +dashboards_path = "/config/.storage/lovelace_dashboards" +dashboards = load(dashboards_path, { + "version": 1, "minor_version": 1, "key": "lovelace_dashboards", "data": {"items": []}, +}) +items = dashboards["data"]["items"] +if not any(d["url_path"] == "dashboard-homeshift" for d in items): + items.append({ + "id": "dashboard_homeshift", "url_path": "dashboard-homeshift", + "title": "HomeShift", "icon": "mdi:home-clock", + "show_in_sidebar": True, "require_admin": False, "mode": "storage", + }) + save(dashboards_path, dashboards) + print(" ✅ Dashboard registered") + +config_path = "/config/.storage/lovelace.dashboard_homeshift" +if os.path.exists(config_path): + print(" ℹ️ Dashboard content already present, kept") +else: + with open(sys.argv[1], encoding="utf-8") as f: + config = yaml.safe_load(f) + save(config_path, { + "version": 1, "minor_version": 1, "key": "lovelace.dashboard_homeshift", + "data": {"config": config}, + }) + print(" ✅ Dashboard content seeded") +PY + +# start.sh (the container's main process) waits for this file to start Home Assistant. +touch /var/lib/devcontainer-setup-done -HASS_PID=$! -echo " Home Assistant starting with PID: $HASS_PID" -echo "" -echo "✅ Setup complete!" -echo "" -echo "⏳ IMPORTANT: Home Assistant is starting up..." -echo " This takes 1-2 minutes on first launch." -echo " Please wait before accessing: http://localhost:8123" -echo "" -echo "💡 How to check if Home Assistant is ready:" -echo " - Watch this terminal for Home Assistant logs" -echo " - Look for: 'Home Assistant initialized in XXs'" -echo " - Or check: http://localhost:8123 (refresh after 1-2 minutes)" -echo " - Monitor logs: tail -f /config/home-assistant.log" -echo "" -echo "🔧 If http://localhost:8123 doesn't work after 5 minutes:" -echo " 1. Check process: ps aux | grep homeassistant" -echo " 2. Check logs: tail -f /config/home-assistant.log" -echo " 3. Verify port forwarding in VS Code (PORTS tab)" -echo "" -echo "📦 Card Resource Configuration:" -echo " The card resource will be automatically registered in .storage/lovelace_resources" echo "" -echo "📋 To view the card:" -echo " 1. Wait for Home Assistant to fully start (1-2 minutes)" -echo " 2. Open http://localhost:8123" -echo " 3. Complete onboarding (create account: admin / admin)" -echo " 4. Enable Advanced Mode: Profile (bottom left) → Enable Advanced Mode" -echo " 5. Go to: Overview dashboard" -echo " 6. The card is pre-configured in the dashboard!" -echo "" -echo "📅 Scheduler Integration:" -echo " IMPORTANT: The Scheduler integration must be added after Home Assistant starts:" -echo " 1. Wait for Home Assistant to fully start and complete onboarding" -echo " 2. Go to: Settings → Devices & Services → Add Integration" -echo " 3. Search for 'Scheduler' and click to add it" -echo " 4. Once added, the scheduler-card in the 'Scheduler' tab will work" -echo " " -echo " If 'Scheduler' doesn't appear in integrations list:" -echo " - Make sure Home Assistant has fully started (check logs)" -echo " - Try refreshing the browser (Ctrl+F5)" -echo " - Clear browser cache and reload" -echo " - Restart Home Assistant: Settings → System → Restart" -echo "" -echo " Alternative: Manually add via UI:" -echo " - Click the 3 dots (top right) → Edit Dashboard → Add Card" -echo " - Scroll down and select 'Custom: Xiaomi Smart Pet Fountain Card'" -echo " - Choose entity: select.xiaomi_iv02_b820_mode" -echo "" - -# Give a moment for Home Assistant to start writing logs -sleep 3 - -# Show the last few lines of the log to confirm it's starting -if [ -f /config/home-assistant.log ]; then - echo "📋 Home Assistant startup logs (last 20 lines):" - echo "==============================================" - tail -n 20 /config/home-assistant.log -fi +echo "✅ Setup complete! Home Assistant starts now: http://localhost:8123 (1-2 min)" +echo " Login: admin / admin — dashboard HomeShift in the sidebar" +echo " Logs: tail -f /config/home-assistant.log" diff --git a/.devcontainer/start.sh b/.devcontainer/start.sh new file mode 100644 index 0000000..5617adf --- /dev/null +++ b/.devcontainer/start.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Main process of the devcontainer (Dockerfile CMD): runs Home Assistant and +# starts it again whenever it exits, as a restart from the UI does. +# Lifecycle commands (postStartCommand) cannot do this: VS Code kills what +# they leave running in the background. + +# In the container layer, not in /config: a rebuilt container waits for its own setup. +MARKER=/var/lib/devcontainer-setup-done + +until [ -f "$MARKER" ]; do + echo "⏳ Waiting for setup.sh to finish..." + sleep 5 +done + +# Creates the admin / admin account once Home Assistant answers (first launch only). +python3 /workspaces/homeshift_card/.devcontainer/onboard.py & + +trap 'kill "$HASS_PID" 2>/dev/null; wait "$HASS_PID"; exit 0' TERM INT + +while true; do + echo "🚀 Starting Home Assistant: http://localhost:8123 (logs: /config/home-assistant.log)" + python3 -m homeassistant --config /config & + HASS_PID=$! + wait "$HASS_PID" + echo "Home Assistant exited ($?), starting it again in 3 s..." + sleep 3 +done diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5d5f8ed..da74674 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,71 +5,38 @@ These rules guide AI agents working on this Home Assistant custom card. Focus on ## Architecture Overview - **Entry point:** [src/index.ts](src/index.ts) registers the card in `window.customCards` and imports the implementation module. -- **Main card:** [src/components/homershiftcard.ts](src/components/homershift-card.ts) defines `HomeShiftCard` (Lit v3) wrapper with `hass` context, grid layout (day mode select + thermostat gauge), and `_config` state. -- **Circular slider:** [src/components/circular-slider.ts](src/components/circular-slider.ts) defines `HomeShiftCircularSlider` (standalone Lit component) for 250° arc gauge with interactive pastilles; used by main card via `` custom element. -- **Localization:** [src/localize/localize.ts](src/localize/localize.ts) provides `localize(hass, key, params)` using [src/localize/en.json](src/localize/en.json) and [src/localize/fr.json](src/localize/fr.json). Language resolves from `hass.locale.language`. -- **Build artifacts:** Rollup outputs ES module to `dist/homershift-card.js`; HA loads it as `/local/homershift_card/homershift-card.js` in DevContainer config. +- **Config:** [src/types.ts](src/types.ts) exports `HomeShiftCardConfig` and `ENTITY_FIELDS`, the single table of entity options (key, picker domains, default entity). The card's defaults, its watched entities and the editor's pickers are all derived from it. +- **Main card:** [src/components/homeshift-card.ts](src/components/homeshift-card.ts) defines `HomeShiftCard` (Lit 3), registered as `homeshift-card`: a list of rows, i.e. thermostat mode as segmented ` + + `}_renderSegment(e){const{background:t,color:i}=function(e){switch(e.kind){case"cool":return{background:"#378ADD",color:"#fff"};case"off":return{background:"var(--divider-color, #5F5E5A)",color:"var(--secondary-text-color)"};case"on":return{background:"#1D9E75",color:"#fff"};case"other":return{background:"#888780",color:"#fff"};case"preset":return function(e){const t=e.toLowerCase();return/frost|hors|away|absen|vacation/.test(t)?{background:"#85B7EB",color:"#042C53"}:/eco|sleep|night|nuit/.test(t)?{background:"#FAC775",color:"#412402"}:/boost/.test(t)?{background:"#A32D2D",color:"#fff"}:/comfort|confort|home|activity/.test(t)?{background:"#EF9F27",color:"#412402"}:{background:"#B4B2A9",color:"#2C2C2A"}}(e.preset??"")}const t=e.temperature??19;return t<15?{background:"#85B7EB",color:"#042C53"}:t<17.5?{background:"#FAC775",color:"#412402"}:t<19.5?{background:"#EF9F27",color:"#412402"}:t<21?{background:"#D85A30",color:"#fff"}:{background:"#A32D2D",color:"#fff"}}(e.value),s=(e.end-e.start)/we*100,o=`${Ee(e.start)} – ${Ee(e.end)}`,n=this._label(e.value),r=s>=Math.max(10,2.2*n.length);return q`
+ ${r?n:F} +
`}_number(e){const t=this.hass.locale?.language??this.hass.language??"en";return e.toLocaleString(t,{maximumFractionDigits:1})}_label(e){const t=e.entity?this.hass.states[e.entity]:void 0;return null!=e.temperature?`${this._number(e.temperature)}°`:null!=e.preset?t&&this.hass.formatEntityAttributeValue?this.hass.formatEntityAttributeValue(t,"preset_mode",e.preset):e.preset:null!=e.hvacMode?t&&this.hass.formatEntityState?this.hass.formatEntityState(t,e.hvacMode):e.hvacMode:"on"===e.kind||"off"===e.kind?me(this.hass,`schedule.${e.kind}`):e.label}async _toggle(e,t,i){if(i)return void await this.hass.callService("switch","turn_off",{entity_id:e.entity_id});const s=Oe(Ie(Ce(e))),o=s?t.schedules.filter(t=>{const i=Oe(Ie(Ce(t)));return t!==e&&i&&i!==s&&this._enabled(t)}):[];o.length&&await this.hass.callService("switch","turn_off",{entity_id:o.map(e=>e.entity_id)}),await this.hass.callService("switch","turn_on",{entity_id:e.entity_id})}}Ne.styles=r` + :host { + display: flex; + flex-direction: column; + gap: 10px; + } + .room { + display: flex; + flex-direction: column; + gap: 4px; + } + .room-head { display: flex; align-items: center; - padding: 8px 0; + gap: 8px; + font-size: 13px; } - `,t([_t({attribute:!1})],xt.prototype,"hass",void 0),t([ut()],xt.prototype,"_config",void 0),customElements.get("homeshift-card-editor")||customElements.define("homeshift-card-editor",xt);class wt extends ht{constructor(){super(...arguments),this._tick=0}connectedCallback(){super.connectedCallback(),this._refreshInterval=setInterval(()=>{this._tick++},3e4)}disconnectedCallback(){super.disconnectedCallback(),clearInterval(this._refreshInterval)}static getStubConfig(){return{name:"Thermostat",day_mode_entity:"select.homeshift_day_mode",thermostat_mode_entity:"select.homeshift_thermostat_mode",override_duration_entity:"number.homeshift_override_duration",early_switch_entity:"number.homeshift_early_switch",next_mode_entity:"sensor.homeshift_next_mode",next_mode_at_entity:"sensor.homeshift_next_mode_at",heat_protection_entity:"binary_sensor.homeshift_cover_heat_active",cover_open_time_entity:"sensor.homeshift_cover_open_time",cover_close_time_entity:"sensor.homeshift_cover_close_time",cover_entity:"cover.homeshift_daily_covers",covers_left_open_entity:"binary_sensor.homeshift_covers_left_open",show_title:!0}}static getConfigElement(){return document.createElement("homeshift-card-editor")}setConfig(t){if(!t)throw new Error("Missing configuration");this._config={name:t.name??"Thermostat",day_mode_entity:t.day_mode_entity??"select.homeshift_day_mode",thermostat_mode_entity:t.thermostat_mode_entity??"select.homeshift_thermostat_mode",override_duration_entity:t.override_duration_entity??"number.homeshift_override_duration",early_switch_entity:t.early_switch_entity??"number.homeshift_early_switch",next_mode_entity:t.next_mode_entity??"sensor.homeshift_next_mode",next_mode_at_entity:t.next_mode_at_entity??"sensor.homeshift_next_mode_at",heat_protection_entity:t.heat_protection_entity??"binary_sensor.homeshift_cover_heat_active",cover_open_time_entity:t.cover_open_time_entity??"sensor.homeshift_cover_open_time",cover_close_time_entity:t.cover_close_time_entity??"sensor.homeshift_cover_close_time",cover_entity:t.cover_entity??"",covers_left_open_entity:t.covers_left_open_entity??"binary_sensor.homeshift_covers_left_open",show_title:!1!==t.show_title}}getCardSize(){return 4}shouldUpdate(t){if(t.has("_config"))return!0;if(t.has("_tick"))return!0;if(t.has("hass")){const e=t.get("hass");if(!e)return!0;return[this._config?.day_mode_entity,this._config?.thermostat_mode_entity,this._config?.override_duration_entity,this._config?.early_switch_entity,this._config?.next_mode_entity,this._config?.next_mode_at_entity,this._config?.heat_protection_entity,this._config?.cover_open_time_entity,this._config?.cover_close_time_entity,this._config?.cover_entity,this._config?.covers_left_open_entity].filter(Boolean).some(t=>e.states[t]!==this.hass.states[t])}return!1}getEntityState(t){if(t)return this.hass?.states?.[t]}getCoversLeftOpen(){const t=this.getEntityState(this._config?.covers_left_open_entity)?.attributes?.covers;return Array.isArray(t)?t.map(t=>this.hass?.states?.[t]?.attributes?.friendly_name??t):[]}onSelect(t,e){const i=e.target,o=i?.value;o&&this.hass.callService("select","select_option",{entity_id:t,option:o})}onCircularSliderSelect(t,e){this.hass.callService("select","select_option",{entity_id:t,option:e})}onOffButtonClick(t,e){e&&this.hass.callService("select","select_option",{entity_id:t,option:e})}onCoverAction(t){const e=this._config.cover_entity;e&&this.hass.callService("cover",t,{entity_id:e})}onOverrideDurationSelect(t){const e=this._config.override_duration_entity;if(!e)return;const i=Number(t.target.value);this.hass.callService("number","set_value",{entity_id:e,value:i})}onEarlySwitchSelect(t){const e=this._config.early_switch_entity;if(!e)return;const i=Number(t.target.value);this.hass.callService("number","set_value",{entity_id:e,value:i})}_formatRelativeTime(t){if(!t)return"";const e=new Date(t);if(isNaN(e.getTime()))return t;const i=e.getTime()-Date.now(),o=Math.round(i/6e4);if(o<=0)return mt(this.hass,"card.past")||"passé";if(o<60)return`${mt(this.hass,"card.in")||"dans"} ${o} min`;const s=Math.floor(o/60),n=o%60;return`${mt(this.hass,"card.in")||"dans"} ${s}h${n>0?n.toString().padStart(2,"0"):""}`}_formatAbsoluteTime(t){if(!t)return"";const e=new Date(t);if(isNaN(e.getTime()))return t;const i=new Date,o=new Date(i);o.setDate(o.getDate()+1);const s=e.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});return e.toDateString()===i.toDateString()?`${mt(this.hass,"card.today")||"Auj."} ${s}`:e.toDateString()===o.toDateString()?`${mt(this.hass,"card.tomorrow")||"Dem."} ${s}`:`${e.toLocaleDateString([],{month:"short",day:"numeric"})} ${s}`}_renderMain(t,e,i,o,s,n){const r=e.attributes?.option_map??{},a=Object.keys(r).length>0?Object.entries(r):(e.attributes?.options??[]).map(t=>[t,t]),c=t.attributes?.option_map??{},h=Object.keys(c).length>0,l=h?c.off??t.attributes?.options?.[0]??"":t.attributes?.options?.[0]??"",d=h?[l,...Object.entries(c).filter(([t])=>"off"!==t).map(([,t])=>t)]:t.attributes?.options??[],p=h?[mt(this.hass,"card.off")||l,...Object.keys(c).filter(t=>"off"!==t).map(t=>mt(this.hass,`thermostat.${t}`)||t)]:[mt(this.hass,"card.off")||(t.attributes?.options?.[0]??""),...["heating","cooling","ventilation"].map(t=>mt(this.hass,`thermostat.${t}`)||t)],_=this.getEntityState(this._config.next_mode_entity),u=this.getEntityState(this._config.next_mode_at_entity),f=Number(this.getEntityState(this._config.early_switch_entity)?.state??0),m=_?.state&&"unknown"!==_.state&&""!==_.state,v=s&&"unknown"!==s&&"unavailable"!==s,g=n&&"unknown"!==n&&"unavailable"!==n,y=Boolean(this._config.cover_entity);return W` -
- ${v||g?W`
- ${v?W`
this.onCoverAction("open_cover")} - > - - ${s} -
`:Y} - ${g?W`
this.onCoverAction("close_cover")} - > - - ${n} -
`:Y} -
`:Y} - ${i||o.length>0?W`
- ${i?W`
- -
`:Y} - ${o.length>0?W`
- -
`:Y} -
`:Y} - this.onCircularSliderSelect(t.entity_id,e.detail.option)} - > - -
- ${m||u?.state?W`
- ${m?W`${_.state}`:Y} - ${u?.state?W`${this._formatAbsoluteTime(u.state)}`:Y} -
`:Y} - ${this._config.early_switch_entity?W``:Y} + + + `})} + + ${t.size>1?q``:F} +
`:F} +
`}_refreshSchedules(){if(!this._config?.show_schedules||this.preview||!this.hass?.callWS)return;const e=Object.values(this.hass.states??{}).filter(e=>e.entity_id.startsWith("switch.schedule")).map(e=>`${e.entity_id}:${e.last_updated}`).join("|");e!==this._schedulesKey&&(this._schedulesKey=e,this.hass.callWS({type:"scheduler"}).then(e=>{this._schedules=Array.isArray(e)?e:void 0}).catch(()=>{this._schedules=void 0}))}_hasDaySchedules(e){return(this._schedules??[]).some(t=>t.tags?.some(t=>e.includes(t)))}_toggleSchedules(e){this._schedulesOpen=e,this.updateComplete.then(()=>this.shadowRoot?.querySelector(e?".schedules-close":".schedules-open")?.focus())}_renderSchedules(e,t){const i=me(this.hass,"schedule.title"),s=t=>q``;return q`${s(!1)} + ${this._schedulesOpen?q`
{"Escape"===e.key&&(e.stopPropagation(),this._toggleSchedules(!1))}} + > + ${s(!0)} +
+ +
+
`:F}`}onSelect(e,t){const i=t.target,s=i?.value;s&&this.hass.callService("select","select_option",{entity_id:e,option:s})}onThermostatSelect(e,t){this.hass.callService("select","select_option",{entity_id:e,option:t})}_coverCall(e){const t=this._config.cover_entity;if(t)return ye(this.getEntityState(t))?{domain:"cover",service:`${e}_cover`,entity_id:t}:void 0;const i="open"===e?this._config.open_covers_entity:this._config.close_covers_entity,s=this.getEntityState(i);return s&&"unavailable"!==s.state?{domain:"button",service:"press",entity_id:i}:void 0}async onCoverAction(e){const t=this._coverCall(e);if(t&&!this._coverPending){this._coverPending=e;try{await Promise.all([this.hass.callService(t.domain,t.service,{entity_id:t.entity_id}),new Promise(e=>setTimeout(e,He.COVER_FEEDBACK_MS))])}catch(t){this.dispatchEvent(new CustomEvent("hass-notification",{detail:{message:me(this.hass,`card.cover_${e}_failed`,{error:t?.message??String(t)})},bubbles:!0,composed:!0}))}finally{this._coverPending=void 0}}}_renderCoverTime(e,t){const i="open"===e?"mdi:roller-shade":"mdi:roller-shade-closed";if(!this._coverCall(e))return q` + ${t} + `;const s=this._coverPending===e,o=me(this.hass,`card.cover_${e}_action`,{time:t});return q``}_formatAbsoluteTime(e){if(!e)return"";const t=new Date(e);if(isNaN(t.getTime()))return"";const i=new Date,s=new Date(i);s.setDate(s.getDate()+1);const o=xe(t,this.hass?.locale);return t.toDateString()===i.toDateString()?`${me(this.hass,"card.today")} ${o}`:t.toDateString()===s.toDateString()?`${me(this.hass,"card.tomorrow")} ${o}`:`${function(e,t){const{locale:i}=$e(t);return e.toLocaleDateString(i,{month:"short",day:"numeric"})}(t,this.hass?.locale)} ${o}`}_settingLabel(e){return void 0===e?me(this.hass,"card.unavailable"):e?be(e):me(this.hass,"card.duration_off")}_clockIn(e){return xe(new Date(Date.now()+6e4*e),this.hass?.locale)}_clockAt(e,t=0){return xe(new Date(new Date(e).getTime()+6e4*t),this.hass?.locale)}_toggleSetting(e){this._openSetting=this._openSetting===e?null:e,this._armSettingTimeout()}_armSettingTimeout(){if(clearTimeout(this._settingTimeout),!this._openSetting)return;const e="covers"===this._openSetting?2e4:6e3;this._settingTimeout=setTimeout(()=>{this._openSetting=null},e)}_stepSetting(e,t,i,s){const o="override"===e?this._config.override_duration_entity:this._config.early_switch_entity;if(!o)return;const n=s>0?t.find(e=>e>i):[...t].reverse().find(e=>e + + ${r?q`
+
+ + ${this._settingLabel(n)} + +
+

${s}

+
`:F} + `}_minutes(e){if(!e)return null;const t=this.getEntityState(e);return t?function(e){if(!ye(e))return;const t=Number(e.state);return Number.isFinite(t)?t:void 0}(t):this.preview?0:null}_renderMain(e,t,i,s,o,n,r){const a=t.attributes?.option_map??{},c=Object.keys(a).length>0?Object.entries(a):(t.attributes?.options??[]).map(e=>[e,e]),l=e.attributes?.option_map??{},d=Object.keys(l).length>0?Object.entries(l):(e.attributes?.options??[]).map(e=>[e,e]),h=this.getEntityState(this._config.next_mode_entity),u=this.getEntityState(this._config.next_mode_at_entity),p=this._minutes(this._config.early_switch_entity),_=this._minutes(this._config.override_duration_entity),m=ye(h),f=m?h.state:"",v=ye(u)?this._formatAbsoluteTime(u.state):"",g=""!==v,y=_?me(this.hass,"card.override_effect",{duration:this._settingLabel(_),time:this._clockIn(_)}):me(this.hass,"card.override_none"),b=g?p?me(this.hass,"card.early_effect",{mode:f,time:this._clockAt(u.state),scheduled:this._clockAt(u.state,p)}):me(this.hass,"card.early_none",{mode:f,time:this._clockAt(u.state)}):me(this.hass,"card.early_no_event"),$=ye(n),x=ye(r),w=me(this.hass,"card.covers_left_open",{covers:s.join(", ")}),S=s.length>3,E=S?me(this.hass,"card.covers_left_open_count",{count:String(s.length)}):w,k=[...o.inhibited.keys()].map(e=>this._friendlyName(e)),A=k.length>2?me(this.hass,"card.covers_paused_count",{count:String(k.length)}):me(this.hass,"card.covers_paused",{covers:k.join(", ")}),C=[...o.inhibited.entries()].map(([e,t])=>`${this._friendlyName(e)} — ${this._inhibitEnd(t)}`).join("\n"),T=e.attributes?.current_key,P=ye(e);return q` +
+
+ ${d.map(([t,i])=>{const s=P&&(T?t===T:i===e.state);return q``})}
-
-
-
- this.onSelect(t.entity_id,e)} + > + ${c.map(([e,i])=>q``)} - -
+ ${i} + `)} + +
- ${(()=>{const t=Number(this.getEntityState(this._config.override_duration_entity)?.state??0);return W``})()} -
-
+ + ${E} + `:F} + ${k.length>0?q``:F} + + ${S&&this._coversExpanded?q`

${w}

`:F}`:F} - `}render(){if(!this.hass||!this._config)return Y;const t=this._config.name??mt(this.hass,"card.title"),e=this.getEntityState(this._config.day_mode_entity),i=this.getEntityState(this._config.thermostat_mode_entity),o=e??{entity_id:this._config.day_mode_entity??"select.homeshift_day_mode",state:mt(this.hass,"preview.day_mode_state")||"Travail",attributes:{options:["Maison","Travail","Télétravail","Absence"],option_map:{home:"Maison",work:"Travail",remote:"Télétravail",away:"Absence"}}},s=i??{entity_id:this._config.thermostat_mode_entity??"select.homeshift_thermostat_mode",state:mt(this.hass,"preview.thermostat_state")||"Chauffage",attributes:{options:["Eteint","Chauffage","Climatisation","Ventilation"],option_map:{off:"Eteint",heating:"Chauffage",cooling:"Climatisation",ventilation:"Ventilation"}}},n="on"===this.getEntityState(this._config.heat_protection_entity)?.state,r=this.getEntityState(this._config.cover_open_time_entity)?.state,a=this.getEntityState(this._config.cover_close_time_entity)?.state;return W` - -
- ${this._renderMain(s,o,n,this.getCoversLeftOpen(),r,a)} + `}render(){if(!this.hass||!this._config)return F;const e=this.getEntityState(this._config.day_mode_entity),t=this.getEntityState(this._config.thermostat_mode_entity);if(!this.preview){const i=[[this._config.day_mode_entity,e],[this._config.thermostat_mode_entity,t]].filter(([,e])=>!e).map(([e])=>e||"?");if(i.length>0)return q` + + ${i.map(e=>q`
+ ${me(this.hass,"card.entity_not_found",{entity:e})} +
`)} +
+ `}const i=e??{entity_id:this._config.day_mode_entity,state:me(this.hass,"preview.work"),attributes:{option_map:Object.fromEntries(["home","work","remote","away"].map(e=>[e,me(this.hass,`preview.${e}`)]))}},s=t??{entity_id:this._config.thermostat_mode_entity,state:me(this.hass,"thermostat.heating"),attributes:{option_map:Object.fromEntries(["off","heating","cooling","ventilation"].map(e=>[e,me(this.hass,`thermostat.${e}`)])),current_key:"heating"}},o="on"===this.getEntityState(this._config.heat_protection_entity)?.state,n=this.getEntityState(this._config.cover_open_time_entity)?.state,r=this.getEntityState(this._config.cover_close_time_entity)?.state,a=i.attributes?.options??[],c=ye(i)&&this._hasDaySchedules(a);return q` + +
+ ${this._renderMain(s,i,o,this.getCoversLeftOpen(),this.getCoverInhibitions(),n,r)}
+ ${c?this._renderSchedules(i.state,a):F}
- `}}wt.OVERRIDE_PRESETS=[{label:"--",value:0},{label:"15min",value:15},{label:"30min",value:30},{label:"1h",value:60},{label:"2h",value:120},{label:"4h",value:240},{label:"8h",value:480},{label:"12h",value:720}],wt.EARLY_SWITCH_PRESETS=[{label:"--",value:0},{label:"15min",value:15},{label:"30min",value:30},{label:"45min",value:45},{label:"1h",value:60},{label:"1h30",value:90},{label:"2h",value:120},{label:"3h",value:180},{label:"4h",value:240}],wt.styles=r` + `}}He.COVER_FEEDBACK_MS=600,He.OVERRIDE_PRESETS=[0,15,30,60,120,240,480,720],He.EARLY_SWITCH_PRESETS=[0,15,30,45,60,90,120,180,240],He.INHIBIT_PRESETS=[240,1440,2880,4320,10080,0],He.INHIBIT_DEFAULT_INDEX=1,He.styles=r` ha-card { - padding: 8px; + padding: 12px; position: relative; - min-height: 240px; - text-align: center; + /* Clips the schedules panel while it slides up. */ + overflow: hidden; } .container { - display: flex; - justify-content: center; width: 100%; } - /* SLIDER VIEW */ - .thermo-section { - position: relative; - width: 100%; - max-width: 240px; + .rows { display: flex; flex-direction: column; - align-items: center; } - homeshift-circular-slider { - width: 100%; + /* Thermostat presets — one segmented control, one colour per mode. */ + .presets { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + border: 1px solid var(--divider-color, #ccc); + border-radius: 8px; + overflow: hidden; + /* Same gap above the first divider as the card's padding above. */ + margin-bottom: 12px; } - .thermo-bottom { - position: absolute; - bottom: 16px; - left: 50%; - transform: translateX(-50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - z-index: 2; - width: 90%; + .preset { + border: 0; + border-left: 1px solid var(--divider-color, #ccc); + background: transparent; + color: var(--secondary-text-color, #666); + font-size: 13px; + font-family: inherit; + padding: 8px 4px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: + background 0.2s ease, + color 0.2s ease; } - .off-row { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; + .preset:first-child { + border-left: 0; } - .bottom-controls { - flex: 1; + .preset:hover { + background: var(--secondary-background-color, rgba(127, 127, 127, 0.12)); + } + + .preset:disabled { + cursor: default; + opacity: 0.6; + } + + .preset:focus-visible, + .setting-head:focus-visible, + .stepper button:focus-visible, + .cover-time.actionable:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + + .preset.on { + background: var(--secondary-text-color, #666); + color: var(--text-primary-color, #fff); + } + + .preset--heating.on { + background: var(--state-climate-heat-color, #e24b4a); + } + + .preset--cooling.on { + background: var(--state-climate-cool-color, #378add); + } + + .preset--ventilation.on { + background: var(--state-fan-active-color, #1d9e75); + } + + .preset--off.on { + background: var(--divider-color, #9e9e9e); + color: var(--primary-text-color); + } + + .row { display: flex; align-items: center; + justify-content: space-between; gap: 8px; + padding: 8px 0; + border-top: 1px solid var(--divider-color, #e0e0e0); + font-size: 13px; } - .day-section { - flex: 1; - min-width: 0; + .row-key { + color: var(--secondary-text-color, #666); } - .day-section select { - width: 100%; + /* Collapsed by default: the value is readable without opening anything. */ + .setting { + border-top: 1px solid var(--divider-color, #e0e0e0); } - .center-button { + .setting-head { display: flex; align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border-radius: 50%; - border: 1px solid var(--divider-color, #ccc); - background: var(--card-background-color, #ffffff); + gap: 8px; + width: 100%; + padding: 8px 0; + border: 0; + background: transparent; + color: var(--primary-text-color); + font-size: 13px; + font-family: inherit; + text-align: left; cursor: pointer; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - transition: - background 0.2s, - border-color 0.2s; } - .center-button ha-icon { - color: var(--secondary-text-color, #666); + .setting-head:disabled { + cursor: default; } - .center-button.active { - background: var(--primary-color); - border-color: var(--primary-color); + .setting-head:disabled .chevron { + visibility: hidden; } - .center-button.active ha-icon { - color: var(--text-primary-color, #fff); + .setting-head ha-icon { + --mdc-icon-size: 18px; + color: var(--secondary-text-color, #666); + flex-shrink: 0; } - hui-card { - --ha-card-box-shadow: none; - --ha-card-border-width: 0; + .setting-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - @keyframes fadeIn { - from { - opacity: 0; - transform: translateY(5px); - } - to { - opacity: 1; - transform: translateY(0); - } + .setting-value { + color: var(--secondary-text-color, #666); } - /* UI ELEMENTS */ - select { - padding: 8px; - border-radius: 6px; - border: 1px solid var(--divider-color, #ccc); - background: var(--card-background-color); + .setting-value.set { color: var(--primary-text-color); + font-weight: 500; } - .list-select { - flex-shrink: 0; - align-self: stretch; - padding: 0 8px; - border-radius: 6px; + .setting .chevron { + transition: transform 0.15s ease; + } + + .setting.open .chevron { + transform: rotate(180deg); + } + + .setting-body { + padding: 0 0 10px 26px; + animation: fadeIn 0.15s ease; + } + + .stepper { + display: flex; + align-items: center; + gap: 10px; + } + + .stepper button { + width: 34px; + height: 34px; + border-radius: 8px; border: 1px solid var(--divider-color, #ccc); background: var(--card-background-color); color: var(--primary-text-color); - font-size: 13px; + font-size: 18px; + line-height: 1; cursor: pointer; - transition: - background 0.2s ease, - border-color 0.2s ease, - color 0.2s ease; } - .list-select--override.active { - border-color: var(--primary-color); - background: var(--primary-color); - color: var(--text-primary-color, #fff); + .stepper button:hover:not(:disabled) { + background: var(--secondary-background-color, rgba(127, 127, 127, 0.12)); } - .list-select--early.active { - border-color: var(--accent-color, var(--primary-color)); - background: var(--accent-color, var(--primary-color)); - color: var(--text-primary-color, #fff); + .stepper button:disabled { + opacity: 0.4; + cursor: default; } - .list-select--early { - padding: 8px; + .stepper-value { + min-width: 64px; + text-align: center; + font-size: 15px; + font-weight: 500; } - .next-info-group { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + .setting-effect { + margin: 8px 0 0; + font-size: 12px; + line-height: 1.4; + color: var(--secondary-text-color, #666); + } + + .cover-times { display: flex; - flex-direction: column; - align-items: center; gap: 6px; - z-index: 2; - text-align: center; - background: rgba(128, 128, 128, 0.12); - border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1)); - border-radius: 10px; - padding: 6px 12px; } - .next-info { - display: flex; - flex-direction: column; + .cover-time { + display: inline-flex; align-items: center; - gap: 2px; - pointer-events: none; + gap: 4px; + padding: 4px 8px; + border-radius: 8px; + border: 1px solid var(--divider-color, #ccc); + background: var(--card-background-color); + color: var(--primary-text-color); + font-size: 13px; + font-family: inherit; } - .next-mode-state { - font-size: 13px; - font-weight: 600; - color: var(--primary-text-color); + .cover-time ha-icon { + --mdc-icon-size: 16px; + color: var(--secondary-text-color, #666); } - .next-mode-at-state { - font-size: 11px; - color: var(--secondary-text-color); + .cover-time.actionable { + cursor: pointer; + } + + .cover-time.actionable:hover:not(:disabled) { + border-color: var(--primary-color); + } + + .cover-time.actionable:disabled { + cursor: progress; + } + + .cover-time.pending { + border-color: var(--primary-color); + color: var(--primary-color); + animation: cover-pending 0.8s ease-in-out infinite alternate; + } + + @keyframes cover-pending { + from { + opacity: 1; + } + to { + opacity: 0.5; + } + } + + @media (prefers-reduced-motion: reduce) { + .cover-time.pending { + animation: none; + } } .error { color: var(--error-color); - padding: 16px; + padding: 4px; } - .cover-times { - position: absolute; - right: 100%; - margin-right: 8px; - top: 4%; + /* Alerts only exist while something is wrong; nothing is shown otherwise. */ + .alerts { display: flex; - flex-direction: column; - gap: 4px; - z-index: 2; - animation: fadeIn 0.3s ease; + flex-wrap: wrap; + gap: 6px; + padding-top: 10px; + border-top: 1px solid var(--divider-color, #e0e0e0); } - .cover-time-row { - display: flex; + .chip { + display: inline-flex; align-items: center; gap: 4px; - padding: 2px 6px; - border-radius: 10px; - background: rgba(128, 128, 128, 0.12); - border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1)); - font-size: 11px; - color: var(--secondary-text-color); - white-space: nowrap; + padding: 4px 8px; + border-radius: 8px; + font-size: 12px; + animation: fadeIn 0.3s ease; + } + + .chip ha-icon { + --mdc-icon-size: 16px; } - .cover-time-row.actionable { + button.chip { + border: 0; + font-family: inherit; cursor: pointer; - transition: - border-color 0.2s ease, - background 0.2s ease; } - .cover-time-row.actionable:hover { - border-color: var(--primary-color); - background: rgba(128, 128, 128, 0.22); + button.chip:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; } - .cover-time-row ha-icon { - color: var(--secondary-text-color); - --mdi-icon-size: 14px; + .covers-detail { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.4; + color: var(--primary-text-color); } - /* Badges sit beside the dial and stack downwards, so heat protection - and covers left open can be raised at the same time without one - covering the other. */ - .badge-stack { - position: absolute; - left: 100%; - margin-left: 8px; - top: 4%; + .chip--heat { + background: var(--error-color, #e7973c); + color: var(--text-primary-color, #fff); + } + + /* A paused cover is a choice, not a fault: calm colour, no animation. */ + .chip--paused { + border: 0; + font-family: inherit; + background: var(--secondary-background-color, rgba(127, 127, 127, 0.12)); + color: var(--primary-text-color); + cursor: pointer; + } + + .chip--paused ha-icon { + color: var(--secondary-text-color, #666); + } + + .cover-list { + list-style: none; + margin: 8px 0 0; + padding: 0; display: flex; flex-direction: column; + gap: 6px; + } + + .cover-item { + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; - z-index: 3; + font-size: 13px; } - .badge { + .cover-name { display: flex; + flex-direction: column; + min-width: 0; + } + + .cover-status { + font-size: 12px; + color: var(--secondary-text-color, #666); + } + + .cover-item.paused .cover-status { + color: var(--primary-color); + } + + .cover-toggle, + .resume-all { + display: inline-flex; align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: 50%; - color: var(--text-primary-color, #fff); - animation: fadeIn 0.3s ease; + gap: 4px; + flex-shrink: 0; + padding: 4px 10px; + border-radius: 8px; + border: 1px solid var(--divider-color, #ccc); + background: var(--card-background-color); + color: var(--primary-text-color); + font-size: 13px; + font-family: inherit; + cursor: pointer; } - .badge ha-icon { - color: var(--text-primary-color, #fff); - --mdi-icon-size: 20px; + .cover-toggle ha-icon { + --mdc-icon-size: 16px; } - .heat-protection-badge { - background: var(--error-color, #e7973c); - pointer-events: none; + .cover-toggle:hover, + .resume-all:hover { + border-color: var(--primary-color); + } + + .cover-item.paused .cover-toggle { + border-color: var(--primary-color); + color: var(--primary-color); + } + + .resume-all { + align-self: flex-start; + margin-top: 8px; } /* Pulses between orange and yellow: a cover left up is something to act - on tonight, not a steady status. Hoverable, so the tooltip can name - the covers. */ - .covers-left-open-badge { + on tonight, not a steady status. */ + .chip--covers { background: var(--warning-color, #ff9800); - cursor: help; + color: #412402; animation: fadeIn 0.3s ease, covers-left-open-blink 1.2s ease-in-out infinite; @@ -606,12 +1042,129 @@ function(t){return(e,i,o)=>((t,e,i)=>(i.configurable=!0,i.enumerable=!0,Reflect. } } - /* A blinking badge is exactly what reduced-motion asks us not to do; + /* A blinking chip is exactly what reduced-motion asks us not to do; the colour alone still reads as a warning. */ @media (prefers-reduced-motion: reduce) { - .covers-left-open-badge { + .chip--covers { animation: fadeIn 0.3s ease; } } - `,t([_t({attribute:!1})],wt.prototype,"hass",void 0),t([ut()],wt.prototype,"_config",void 0),t([ut()],wt.prototype,"_tick",void 0),customElements.get("homeshift-card")||customElements.define("homeshift-card",wt),window.customCards=window.customCards||[],window.customCards.push({type:"homeshift-card",name:"HomeShift Card",description:"Card to manage day mode and thermostat mode via the HomeShift integration.",preview:!0}); -//# sourceMappingURL=homeshift-card.js.map + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* Schedules: a bar at the foot of the card, and the panel it slides up. */ + .schedules-bar { + display: flex; + align-items: center; + gap: 8px; + width: calc(100% + 24px); + margin: 10px -12px -12px; + padding: 10px 12px; + border: 0; + border-top: 1px solid var(--divider-color, #e0e0e0); + background: transparent; + color: var(--secondary-text-color, #666); + font-family: inherit; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.05em; + text-transform: uppercase; + text-align: left; + cursor: pointer; + } + + .schedules-bar:hover { + background: var(--secondary-background-color, rgba(127, 127, 127, 0.08)); + } + + .schedules-bar:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + + .schedules-bar ha-icon { + --mdc-icon-size: 18px; + flex-shrink: 0; + } + + .schedules-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .schedules-mode { + color: var(--primary-text-color); + text-transform: none; + letter-spacing: normal; + } + + /* Anchored to the foot of the card and as tall as its content: the top + of the card stays in sight while the schedules are shorter than it; + longer ones scroll inside the panel. */ + .schedules-panel { + position: absolute; + left: 0; + right: 0; + bottom: 0; + max-height: 100%; + z-index: 1; + border-top: 1px solid var(--divider-color, #e0e0e0); + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + background: var(--ha-card-background, var(--card-background-color, #fff)); + animation: schedules-slide-up 0.2s ease; + } + + .schedules-panel .schedules-bar { + width: 100%; + margin: 0; + border-top: 0; + border-bottom: 1px solid var(--divider-color, #e0e0e0); + } + + .schedules-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 10px 12px 12px; + } + + @keyframes schedules-slide-up { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } + } + + @media (prefers-reduced-motion: reduce) { + .schedules-panel { + animation: none; + } + } + + select { + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--divider-color, #ccc); + background: var(--card-background-color); + color: var(--primary-text-color); + font-size: 13px; + font-family: inherit; + } + `,e([he({attribute:!1})],He.prototype,"hass",void 0),e([he({type:Boolean})],He.prototype,"preview",void 0),e([ue()],He.prototype,"_config",void 0),e([ue()],He.prototype,"_openSetting",void 0),e([ue()],He.prototype,"_inhibitIndex",void 0),e([ue()],He.prototype,"_coverPending",void 0),e([ue()],He.prototype,"_coversExpanded",void 0),e([ue()],He.prototype,"_day",void 0),e([ue()],He.prototype,"_schedules",void 0),e([ue()],He.prototype,"_schedulesOpen",void 0),customElements.get("homeshift-card")||customElements.define("homeshift-card",He),window.customCards=window.customCards||[],window.customCards.push({type:"homeshift-card",name:"HomeShift Card",description:"Card to manage day mode and thermostat mode via the HomeShift integration.",preview:!0}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..791c2f1 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,27 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist/", "node_modules/", ".devcontainer/"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.ts"], + languageOptions: { + globals: { ...globals.browser }, + }, + rules: { + // The hass object and entity states come untyped from Home Assistant. + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, + }, + { + files: ["*.js"], + languageOptions: { globals: { ...globals.node } }, + }, +); diff --git a/package-lock.json b/package-lock.json index 8809107..a8fcc3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,86 +1,250 @@ { "name": "homeshift-card", - "version": "1.2.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "homeshift-card", - "version": "1.2.0", + "version": "2.0.0", "license": "MIT", "dependencies": { - "custom-card-helpers": "^1.9.0", "lit": "^3.3.2" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.3.0", "@types/node": "^25.0.8", + "eslint": "^10.12.0", + "globals": "^17.13.0", + "happy-dom": "^20.14.5", "rollup": "^4.55.1", "tslib": "^2.8.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "typescript-eslint": "^8.71.0", + "vitest": "^5.0.3" } }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "1.11.4", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz", - "integrity": "sha512-EBikYFp2JCdIfGEb5G9dyCkTGDmC57KSHhRQOC3aYxoPWVZvfWCDjZwkGYHN7Lis/fmuWl906bnNTJifDQ3sXw==", + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, "license": "MIT", "dependencies": { - "@formatjs/intl-localematcher": "0.2.25", - "tslib": "^2.1.0" + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" } }, - "node_modules/@formatjs/fast-memoize": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz", - "integrity": "sha512-Rg0e76nomkz3vF9IPlKeV+Qynok0r7YZjL6syLz4/urSg0IbjPZCB/iYUMNsYA643gh4mgrX3T7KEIFIxJBQeg==", + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, "license": "MIT", "dependencies": { - "tslib": "^2.1.0" + "hashery": "^1.5.1", + "keyv": "^5.6.0" } }, - "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.0.tgz", - "integrity": "sha512-Qxv/lmCN6hKpBSss2uQ8IROVnta2r9jd3ymUEIjm2UyIkUCHVcbUVRGL/KS/wv7876edvsPe+hjHVJ4z8YuVaw==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "1.11.4", - "@formatjs/icu-skeleton-parser": "1.3.6", - "tslib": "^2.1.0" + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.6.tgz", - "integrity": "sha512-I96mOxvml/YLrwU2Txnd4klA7V8fRhb6JG/4hm3VMNmeJo1F03IpV2L3wWt7EweqNLES59SZ4d6hVOPCSf80Bg==", + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@formatjs/ecma402-abstract": "1.11.4", - "tslib": "^2.1.0" + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.2.25", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz", - "integrity": "sha512-YmLcX70BxoSopLFdLr1Ds99NdlTI2oWoLbaUW2M406lxOIPzE1KQhRz2fPUkq34xVZQaihCoU29h0KK7An3bhA==", - "license": "MIT", + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.1.0" + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@formatjs/intl-utils": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-3.8.4.tgz", - "integrity": "sha512-j5C6NyfKevIxsfLK8KwO1C0vvP7k1+h4A9cFpc+cr6mEwCc1sPkr17dzh0Ke6k9U5pQccAQoXdcNBl3IYa4+ZQ==", - "deprecated": "the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package", + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "emojis-list": "^3.0.0" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@jridgewell/gen-mapping": { @@ -116,9 +280,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -133,6 +297,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", @@ -148,156 +336,111 @@ "@lit-labs/ssr-dom-shim": "^1.5.0" } }, - "node_modules/@rollup/plugin-commonjs": { - "version": "29.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz", - "integrity": "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==", + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", "dev": true, "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "fdir": "^6.2.0", - "is-reference": "1.2.1", - "magic-string": "^0.30.3", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" } }, - "node_modules/@rollup/plugin-json": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.1.0" - }, + "optional": true, + "os": [ + "android" + ], + "peer": true, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", - "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, + "optional": true, + "os": [ + "android" + ], + "peer": true, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rollup/plugin-terser": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "serialize-javascript": "^6.0.1", - "smob": "^1.0.0", - "terser": "^5.17.4" - }, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rollup/plugin-typescript": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", - "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.1.0", - "resolve": "^1.22.1" - }, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.14.0||^3.0.0||^4.0.0", - "tslib": "*", - "typescript": ">=3.7.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - }, - "tslib": { - "optional": true - } + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", - "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", "cpu": [ "arm" ], @@ -305,279 +448,143 @@ "license": "MIT", "optional": true, "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", - "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", - "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", - "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", - "cpu": [ - "x64" + "linux" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", - "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", - "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", - "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", - "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", - "cpu": [ - "arm" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", - "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", - "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", - "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", - "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", - "cpu": [ - "loong64" + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", - "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", - "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", "cpu": [ "ppc64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", - "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", - "cpu": [ - "riscv64" + "libc": [ + "glibc" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", - "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", - "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", - "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", - "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", - "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", - "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", "cpu": [ "arm64" ], @@ -586,12 +593,16 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", - "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", "cpu": [ "arm64" ], @@ -600,390 +611,2181 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", - "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", "cpu": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz", + "integrity": "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", + "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", - "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "android" ] }, - "node_modules/@rollup/rollup-win32-x64-msvc": { + "node_modules/@rollup/rollup-android-arm64": { "version": "4.56.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", - "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "android" ] }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@types/node": { - "version": "25.0.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", - "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/happy-dom": { + "version": "20.14.5", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz", + "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/custom-card-helpers": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/custom-card-helpers/-/custom-card-helpers-1.9.0.tgz", - "integrity": "sha512-5IW4OXq3MiiCqDvqeu+MYsM1NmntKW1WfJhyJFsdP2tbzqEI4BOnqRz2qzdp08lE4QLVhYfRLwe0WAqgQVNeFg==", - "license": "MIT", - "dependencies": { - "@formatjs/intl-utils": "^3.8.4", - "home-assistant-js-websocket": "^6.0.1", - "intl-messageformat": "^9.11.1", - "lit": "^2.1.1", - "rollup": "^2.63.0", - "superstruct": "^0.15.3", - "typescript": "^4.5.4" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/custom-card-helpers/node_modules/@lit/reactive-element": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", - "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.0.0" + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/custom-card-helpers/node_modules/lit": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", - "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", + "node_modules/lit": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", + "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "license": "BSD-3-Clause", "dependencies": { - "@lit/reactive-element": "^1.6.0", - "lit-element": "^3.3.0", - "lit-html": "^2.8.0" + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" } }, - "node_modules/custom-card-helpers/node_modules/lit-element": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", - "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.1.0", - "@lit/reactive-element": "^1.3.0", - "lit-html": "^2.8.0" + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" } }, - "node_modules/custom-card-helpers/node_modules/lit-html": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", - "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", + "node_modules/lit-html": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz", + "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", "license": "BSD-3-Clause", "dependencies": { "@types/trusted-types": "^2.0.2" } }, - "node_modules/custom-card-helpers/node_modules/rollup": { - "version": "2.79.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", - "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" + "dependencies": { + "p-locate": "^5.0.0" }, "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/custom-card-helpers/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "node": ">=10" }, - "engines": { - "node": ">=4.2.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "license": "MIT", + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, "engines": { - "node": ">= 4" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=12.20.0" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.8.0" } }, - "node_modules/home-assistant-js-websocket": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-6.1.1.tgz", - "integrity": "sha512-TnZFzF4mn5F/v0XKUTK2GMQXrn/+eQpgaSDSELl6U0HSwSbFwRhGWLz330YT+hiKMspDflamsye//RPL+zwhDw==", - "license": "Apache-2.0" - }, - "node_modules/intl-messageformat": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-9.13.0.tgz", - "integrity": "sha512-7sGC7QnSQGa5LZP7bXLDhVDtQOeKGeBFGHF2Y8LVBwYZoQZCgWeKoPGTa5GMG8g/TzDgeXuYJQis7Ggiw2xTOw==", - "license": "BSD-3-Clause", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "1.11.4", - "@formatjs/fast-memoize": "1.2.1", - "@formatjs/icu-messageformat-parser": "2.1.0", - "tslib": "^2.1.0" + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "p-limit": "^3.0.2" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "*" + "engines": { + "node": ">=8" } }, - "node_modules/lit": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", - "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit/reactive-element": "^2.1.0", - "lit-element": "^4.2.0", - "lit-html": "^3.3.0" - } + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" }, - "node_modules/lit-element": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", - "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.5.0", - "@lit/reactive-element": "^2.1.0", - "lit-html": "^3.3.0" + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/lit-html": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz", - "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", - "license": "BSD-3-Clause", + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, "dependencies": { - "@types/trusted-types": "^2.0.2" + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "hookified": "^2.1.1" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=20" } }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1015,6 +2817,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, "node_modules/rollup": { "version": "4.56.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", @@ -1081,6 +2918,19 @@ ], "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -1091,6 +2941,29 @@ "randombytes": "^2.1.0" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/smob": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", @@ -1108,6 +2981,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -1119,10 +3003,11 @@ "source-map": "^0.6.0" } }, - "node_modules/superstruct": { - "version": "0.15.5", - "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-0.15.5.tgz", - "integrity": "sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ==", + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, "license": "MIT" }, "node_modules/supports-preserve-symlinks-flag": { @@ -1157,12 +3042,76 @@ "node": ">=10" } }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1177,12 +3126,302 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 662e0cb..4bdd524 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,16 @@ { "name": "homeshift-card", - "version": "1.2.0", + "version": "2.0.0", "private": true, "description": "Custom card for Day Mode in Home Assistant", "type": "module", "main": "dist/homeshift-card.js", "scripts": { "build": "rollup -c", - "watch": "rollup -c -w" + "watch": "rollup -c -w", + "typecheck": "tsc --noEmit -p test/tsconfig.json", + "lint": "eslint .", + "test": "vitest run" }, "keywords": [ "home-assistant", @@ -17,18 +20,23 @@ "author": "", "license": "MIT", "devDependencies": { + "@eslint/js": "^10.0.1", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.3.0", "@types/node": "^25.0.8", + "eslint": "^10.12.0", + "globals": "^17.13.0", + "happy-dom": "^20.14.5", "rollup": "^4.55.1", "tslib": "^2.8.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "typescript-eslint": "^8.71.0", + "vitest": "^5.0.3" }, "dependencies": { - "custom-card-helpers": "^1.9.0", "lit": "^3.3.2" } } diff --git a/rollup.config.js b/rollup.config.js index c6e5f48..8cf40d7 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -9,7 +9,7 @@ export default { output: { file: "dist/homeshift-card.js", format: "es", - sourcemap: true, + sourcemap: false, }, plugins: [ resolve(), diff --git a/src/components/circular-slider.ts b/src/components/circular-slider.ts deleted file mode 100644 index 25f8cb2..0000000 --- a/src/components/circular-slider.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { LitElement, html, css, svg } from "lit"; -import { property, query } from "lit/decorators.js"; - -/* ======================= - CONFIGURATION -======================= */ - -const STROKE_WIDTH = 25; -const CLICK_AREA_PADDING = 10; - -// 180° arc (opening at the bottom) -const ARC_PATH = "M 30 150 A 85 85 0 1 1 170 150"; - -// Angles used for click calculations -const START_ANGLE = 34.2; -const TOTAL_ARC_DEGREES = 180; -const ARC_END_ANGLE = START_ANGLE - TOTAL_ARC_DEGREES; -const ARC_END_ANGLE_POSITIVE = ARC_END_ANGLE + 360; - -/* ======================= - COMPONENT -======================= */ - -// Segment colours, cycled by index so the component is not coupled to specific mode names. -const SEGMENT_COLORS = [ - "var(--disabled-text-color, #9e9e9e)", // Gray (first active mode, typically off) - "#e74c3c", // Red (second, typically heating) - "var(--primary-color, #3b82f6)", // Blue (third, typically cooling) - "#d4a574", // Beige (fourth, typically ventilation) -]; - -export class HomeShiftCircularSlider extends LitElement { - @property({ attribute: false }) public hass!: any; - @property() public entityId!: string; - @property() public currentValue!: string; - @property({ type: Array }) public options: string[] = []; - /** Optional display labels shown on the arc. Falls back to options[i] when not provided. */ - @property({ type: Array }) public labels: string[] = []; - @property({ type: Number }) private selectedIndex = -1; - @query("svg") private _svg?: SVGSVGElement; - - /* ======================= - UTILS - ======================= */ - - private _getColorForIndex(index: number): string { - return ( - SEGMENT_COLORS[index % SEGMENT_COLORS.length] ?? "var(--primary-color)" - ); - } - - private _valueToPercentage(index: number): number { - // Divide by total length to get equal segments - // Keep left-to-right mapping (0 -> 1) - return index / this.options.length; - } - - // Build dasharray for a total length of 1 (via pathLength="1") - private _strokeDashArc(fromIndex: number, toIndex: number): [string, string] { - const start = this._valueToPercentage(fromIndex); // e.g. 0.33 - const end = this._valueToPercentage(toIndex); // e.g. 0.66 - - const length = end - start; // Segment length (e.g. 0.33) - - // Dasharray: "segment_length large_gap" - // Dashoffset: "-start_point" (negative shifts the stroke to the right) - return [`${length} 10`, `-${start}`]; - } - - private _getPercentageFromEvent(e: MouseEvent): number { - if (!this._svg) return -1; - - const rect = this._svg.getBoundingClientRect(); - const x = (2 * (e.clientX - rect.left - rect.width / 2)) / rect.width; - const y = (2 * (e.clientY - rect.top - rect.height / 2)) / rect.height; - - const angle = (Math.atan2(y, x) * 180) / Math.PI; - - if (angle >= ARC_END_ANGLE && angle <= START_ANGLE) { - return (START_ANGLE - angle) / TOTAL_ARC_DEGREES; - } - - if (angle >= ARC_END_ANGLE_POSITIVE) { - return (START_ANGLE + (360 - angle)) / TOTAL_ARC_DEGREES; - } - - return -1; - } - - private _onSvgClick(e: MouseEvent) { - const p = this._getPercentageFromEvent(e); - if (p < 0 || this.options.length === 0) return; - - // Map percentage (0 to 1) directly to option index - const index = Math.floor(p * this.options.length); - - const selectedIdx = Math.max(0, Math.min(index, this.options.length - 1)); - - this.dispatchEvent( - new CustomEvent("option-selected", { - detail: { option: this.options[selectedIdx] }, - bubbles: true, - composed: true, - }), - ); - } - - /* ======================= - RENDER - ======================= */ - - protected render() { - // Guard: nothing to render if no options are provided yet - if (!this.options || this.options.length === 0) - return html`
`; - - // Sync selectedIndex with currentValue. - // When the thermostat is in its "off" state (e.g. "Eteint", "Off"), the off option - // is NOT included in this.options (filtered out by the parent card), so indexOf - // returns -1 → selectedIndex = -1 → no arc segment is highlighted. This replaces - // the previous explicit check against hardcoded strings like "Eteint" or "off". - const currentIndex = this.currentValue - ? this.options.indexOf(this.currentValue) - : -1; - if (currentIndex !== -1) { - this.selectedIndex = currentIndex; - } else { - this.selectedIndex = -1; - } - - return html` -
- - - - - - ${svg` - - `} ${this.selectedIndex !== -1 - ? (() => { - const [dasharray, dashoffset] = this._strokeDashArc( - this.selectedIndex, - this.selectedIndex + 1, - ); - - const strokeColor = this._getColorForIndex(this.selectedIndex); - - return svg` - - `; - })() - : null} - ${(() => { - const N = this.options.length; - if (N < 2) return null; - const eps = 0.002; // half-width of each separator mark in pathLength units - const seg = 1 / N; - // dasharray starts as "drawn", so prepend 0 to make the first value a gap. - // Pattern: 0 (first_gap) mark (gap) mark ... mark (large trailing gap) - const parts: number[] = [0, seg - eps]; - for (let s = 0; s < N - 2; s++) { - parts.push(2 * eps); // mark - parts.push(seg - 2 * eps); // gap - } - parts.push(2 * eps); // last mark - parts.push(100); // large trailing value to prevent pattern repetition - return svg` - - `; - })()} - ${this.options.map((_mode, i) => { - const [dasharray, dashoffset] = this._strokeDashArc(i, i + 1); - - return svg` - { - e.stopPropagation(); - this.dispatchEvent( - new CustomEvent("option-selected", { - detail: { option: this.options[i] }, - bubbles: true, - composed: true, - }), - ); - }} - /> - `; - })} - ${this.options.map((_mode, i) => { - // Compute the center position of the segment - const segmentStart = this._valueToPercentage(i); - const segmentEnd = this._valueToPercentage(i + 1); - const segmentCenter = (segmentStart + segmentEnd) / 2; - const startOffset = segmentCenter * 100; - // Prefer the translated label when provided, fall back to the raw option value - const displayLabel = this.labels[i] ?? this.options[i]; - - return svg` - { - e.stopPropagation(); - this.dispatchEvent( - new CustomEvent("option-selected", { - detail: { option: this.options[i] }, - bubbles: true, - composed: true, - }), - ); - }} - > - - ${displayLabel} - - - `; - })} - -
- `; - } - - static styles = css` - :host { - display: block; - } - - .slider-container { - display: flex; - justify-content: center; - padding: 8px 0; - } - - svg { - width: 100%; - max-width: 240px; - aspect-ratio: 1; - } - `; -} - -customElements.define("homeshift-circular-slider", HomeShiftCircularSlider); diff --git a/src/components/homeshift-card-editor.ts b/src/components/homeshift-card-editor.ts index 1c5069c..6c3f29f 100644 --- a/src/components/homeshift-card-editor.ts +++ b/src/components/homeshift-card-editor.ts @@ -1,17 +1,7 @@ import { LitElement, html, css } from "lit"; import { property, state } from "lit/decorators.js"; import { localize } from "../localize/localize"; - -interface HomeShiftCardConfig { - name?: string; - day_mode_entity?: string; - hermostat_mode_tentity?: string; - cover_open_time_entity?: string; - cover_close_time_entity?: string; - cover_entity?: string; - covers_left_open_entity?: string; - show_title?: boolean; -} +import { ENTITY_FIELDS, EntityConfigKey, HomeShiftCardConfig } from "../types"; class HomeShiftCardEditor extends LitElement { @property({ attribute: false }) public hass!: any; @@ -34,24 +24,24 @@ class HomeShiftCardEditor extends LitElement { private _onEntityChanged( ev: CustomEvent, - fieldName: keyof HomeShiftCardConfig, + fieldName: EntityConfigKey, ) { if (!this._config || !this.hass) return; const newValue = ev.detail.value; - if ((this._config as any)[fieldName] === newValue) return; + if (this._config[fieldName] === newValue) return; const newConfig = { ...this._config, [fieldName]: newValue }; this._config = newConfig; this._dispatchConfigChanged(newConfig); } - private _onShowTitleChanged(ev: Event) { + /** Schedules options: `show_schedules` and `schedule_entities`. */ + private _onScheduleChanged( + ev: CustomEvent, + key: "show_schedules" | "schedule_entities", + ) { if (!this._config || !this.hass) return; - const target = ev.target as any; - const newValue = target.checked; - if (this._config.show_title === newValue) return; - - const newConfig = { ...this._config, show_title: newValue }; + const newConfig = { ...this._config, [key]: ev.detail.value }; this._config = newConfig; this._dispatchConfigChanged(newConfig); } @@ -78,68 +68,34 @@ class HomeShiftCardEditor extends LitElement { @input=${this._onNameChanged} > - - this._onEntityChanged(e, "day_mode_entity")} - allow-custom-entity - > - - - this._onEntityChanged(e, "hermostat_mode_tentity")} - allow-custom-entity - > - - - this._onEntityChanged(e, "cover_open_time_entity")} - allow-custom-entity - > - - html` + this._onEntityChanged(e, field.key)} + allow-custom-entity + >`, + )} + + - this._onEntityChanged(e, "cover_close_time_entity")} - allow-custom-entity - > - - + - this._onEntityChanged(e, "cover_entity")} - allow-custom-entity - > - - - this._onEntityChanged(e, "covers_left_open_entity")} - allow-custom-entity - > - - - - + this._onScheduleChanged(e, "schedule_entities")} + >
`; } @@ -153,15 +109,10 @@ class HomeShiftCardEditor extends LitElement { } ha-textfield, - ha-entity-picker { + ha-entity-picker, + ha-selector { width: 100%; } - - ha-formfield { - display: flex; - align-items: center; - padding: 8px 0; - } `; } diff --git a/src/components/homeshift-card.ts b/src/components/homeshift-card.ts index b692805..753be55 100644 --- a/src/components/homeshift-card.ts +++ b/src/components/homeshift-card.ts @@ -1,70 +1,85 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { localize } from "../localize/localize"; -import "./circular-slider"; import "./homeshift-card-editor"; - -interface HomeShiftCardConfig { - name?: string; - day_mode_entity?: string; - thermostat_mode_entity?: string; - override_duration_entity?: string; - early_switch_entity?: string; - next_mode_entity?: string; - next_mode_at_entity?: string; - heat_protection_entity?: string; - cover_open_time_entity?: string; - cover_close_time_entity?: string; - cover_entity?: string; - covers_left_open_entity?: string; - show_title?: boolean; +import { ENTITY_FIELDS, HomeShiftCardConfig } from "../types"; +import { formatMinutes, isUsable, numericState } from "../state"; +import { formatShortDate, formatTime } from "../time"; +import "../schedule/schedule-panel"; +import { Schedule } from "../schedule/timeline"; + +type CoverAction = "open" | "close"; +type SettingName = "override" | "early" | "covers"; + +/** Covers HomeShift drives, and the end of each running inhibition (null: until resumed). */ +interface CoverInhibitions { + managed: string[]; + inhibited: Map; } class HomeShiftCard extends LitElement { - // Predefined override duration values in minutes (0 = disabled). - private static readonly OVERRIDE_PRESETS: { label: string; value: number }[] = - [ - { label: "--", value: 0 }, - { label: "15min", value: 15 }, - { label: "30min", value: 30 }, - { label: "1h", value: 60 }, - { label: "2h", value: 120 }, - { label: "4h", value: 240 }, - { label: "8h", value: 480 }, - { label: "12h", value: 720 }, - ]; - - // Predefined early-switch anticipation values in minutes (0 = disabled). - private static readonly EARLY_SWITCH_PRESETS: { - label: string; - value: number; - }[] = [ - { label: "--", value: 0 }, - { label: "15min", value: 15 }, - { label: "30min", value: 30 }, - { label: "45min", value: 45 }, - { label: "1h", value: 60 }, - { label: "1h30", value: 90 }, - { label: "2h", value: 120 }, - { label: "3h", value: 180 }, - { label: "4h", value: 240 }, + /** Minimum time the "sending" state of a cover button stays visible. */ + static COVER_FEEDBACK_MS = 600; + + // Override duration steps in minutes (0 = disabled). + private static readonly OVERRIDE_PRESETS = [0, 15, 30, 60, 120, 240, 480, 720]; + + // Early-switch anticipation steps in minutes (0 = disabled). + private static readonly EARLY_SWITCH_PRESETS = [ + 0, 15, 30, 45, 60, 90, 120, 180, 240, ]; + // How long a cover is paused for, in minutes (0 = until resumed). + private static readonly INHIBIT_PRESETS = [240, 1440, 2880, 4320, 10080, 0]; + private static readonly INHIBIT_DEFAULT_INDEX = 1; + @property({ attribute: false }) public hass!: any; + /** Set by Home Assistant in the card picker and the editor preview. */ + @property({ type: Boolean }) public preview = false; @state() private _config!: HomeShiftCardConfig; - @state() private _tick = 0; - private _refreshInterval?: ReturnType; + @state() private _openSetting: SettingName | null = null; + /** Index in INHIBIT_PRESETS of the pause duration picked in the covers row. */ + @state() private _inhibitIndex = HomeShiftCard.INHIBIT_DEFAULT_INDEX; + /** Cover button whose service call is in flight. */ + @state() private _coverPending?: CoverAction; + /** Whether the full list behind a "Not closed: N covers" chip is shown. */ + @state() private _coversExpanded = false; + /** Bumped at midnight so the "Today" / "Tomorrow" prefixes roll over. */ + @state() private _day = 0; + /** Every schedule of the Scheduler integration, once read (schedules panel). */ + @state() private _schedules?: Schedule[]; + /** Whether the schedules panel covers the card. */ + @state() private _schedulesOpen = false; + /** Fingerprint of the schedule switches, to read the schedules again when they change. */ + private _schedulesKey?: string; + private _midnightTimer?: ReturnType; + private _settingTimeout?: ReturnType; connectedCallback() { super.connectedCallback(); - this._refreshInterval = setInterval(() => { - this._tick++; - }, 30000); + this._scheduleMidnightRefresh(); } disconnectedCallback() { super.disconnectedCallback(); - clearInterval(this._refreshInterval); + clearTimeout(this._midnightTimer); + clearTimeout(this._settingTimeout); + } + + /** + * Entity changes already trigger renders, and so does opening a setting + * (its "until {time}" text is computed then); the only output that goes + * stale on its own is the day prefix of the next-mode time, at midnight. + */ + private _scheduleMidnightRefresh() { + clearTimeout(this._midnightTimer); + const now = new Date(); + const nextMidnight = new Date(now); + nextMidnight.setHours(24, 0, 1, 0); + this._midnightTimer = setTimeout(() => { + this._day++; + this._scheduleMidnightRefresh(); + }, nextMidnight.getTime() - now.getTime()); } public static getStubConfig(): HomeShiftCardConfig { @@ -80,9 +95,8 @@ class HomeShiftCard extends LitElement { "binary_sensor.homeshift_cover_heat_active", cover_open_time_entity: "sensor.homeshift_cover_open_time", cover_close_time_entity: "sensor.homeshift_cover_close_time", - cover_entity: "cover.homeshift_daily_covers", covers_left_open_entity: "binary_sensor.homeshift_covers_left_open", - show_title: true, + covers_inhibited_entity: "sensor.homeshift_covers_inhibited", }; } @@ -94,31 +108,17 @@ class HomeShiftCard extends LitElement { if (!config) { throw new Error("Missing configuration"); } - this._config = { + const normalized: HomeShiftCardConfig = { name: config.name ?? "Thermostat", - day_mode_entity: config.day_mode_entity ?? "select.homeshift_day_mode", - thermostat_mode_entity: - config.thermostat_mode_entity ?? "select.homeshift_thermostat_mode", - override_duration_entity: - config.override_duration_entity ?? "number.homeshift_override_duration", - early_switch_entity: - config.early_switch_entity ?? "number.homeshift_early_switch", - next_mode_entity: config.next_mode_entity ?? "sensor.homeshift_next_mode", - next_mode_at_entity: - config.next_mode_at_entity ?? "sensor.homeshift_next_mode_at", - heat_protection_entity: - config.heat_protection_entity ?? - "binary_sensor.homeshift_cover_heat_active", - cover_open_time_entity: - config.cover_open_time_entity ?? "sensor.homeshift_cover_open_time", - cover_close_time_entity: - config.cover_close_time_entity ?? "sensor.homeshift_cover_close_time", - cover_entity: config.cover_entity ?? "", - covers_left_open_entity: - config.covers_left_open_entity ?? - "binary_sensor.homeshift_covers_left_open", - show_title: config.show_title !== false, }; + for (const field of ENTITY_FIELDS) { + normalized[field.key] = config[field.key] ?? field.default; + } + normalized.show_schedules = config.show_schedules !== false; + normalized.schedule_entities = Array.isArray(config.schedule_entities) + ? config.schedule_entities + : []; + this._config = normalized; } public getCardSize(): number { @@ -126,24 +126,27 @@ class HomeShiftCard extends LitElement { } protected shouldUpdate(changedProps: Map): boolean { - if (changedProps.has("_config")) return true; - if (changedProps.has("_tick")) return true; + // Here rather than in willUpdate: most hass updates stop at this method. + if (changedProps.has("hass")) this._refreshSchedules(); + if (changedProps.has("_config") || changedProps.has("preview")) return true; + if ( + changedProps.has("_day") || + changedProps.has("_openSetting") || + changedProps.has("_inhibitIndex") || + changedProps.has("_coversExpanded") || + changedProps.has("_coverPending") || + changedProps.has("_schedules") || + changedProps.has("_schedulesOpen") + ) + return true; if (changedProps.has("hass")) { const oldHass = changedProps.get("hass") as any; if (!oldHass) return true; - const watchedEntities = [ - this._config?.day_mode_entity, - this._config?.thermostat_mode_entity, - this._config?.override_duration_entity, - this._config?.early_switch_entity, - this._config?.next_mode_entity, - this._config?.next_mode_at_entity, - this._config?.heat_protection_entity, - this._config?.cover_open_time_entity, - this._config?.cover_close_time_entity, - this._config?.cover_entity, - this._config?.covers_left_open_entity, - ].filter(Boolean) as string[]; + // The open panel shows the schedule switches and the room temperatures. + if (this._schedulesOpen) return true; + const watchedEntities = ENTITY_FIELDS.map( + (field) => this._config?.[field.key], + ).filter(Boolean) as string[]; return watchedEntities.some( (id) => oldHass.states[id] !== this.hass.states[id], ); @@ -151,6 +154,41 @@ class HomeShiftCard extends LitElement { return false; } + /** Colour class for a preset: its key, or the display name it was given. + + * A select exposing option_map hands over stable keys (off, heating, …). + * Without it, the only thing left is the display value, which is the + * user's own wording — so known labels are mapped back to a key, and + * anything else falls through to the neutral colour rather than breaking + * the class name. + */ + private _presetClass(key: string, display: string): string { + const slug = (text: string) => + text + .toLowerCase() + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-"); + const known = ["off", "heating", "cooling", "ventilation"]; + if (known.includes(slug(key))) return slug(key); + const byLabel: Record = { + eteint: "off", + arret: "off", + off: "off", + chauffage: "heating", + heat: "heating", + heating: "heating", + climatisation: "cooling", + clim: "cooling", + cool: "cooling", + cooling: "cooling", + ventilation: "ventilation", + ventilateur: "ventilation", + fan: "ventilation", + }; + return byLabel[slug(display)] ?? slug(key); + } + private getEntityState(entityId?: string) { if (!entityId) return undefined; return this.hass?.states?.[entityId]; @@ -167,6 +205,287 @@ class HomeShiftCard extends LitElement { ); } + private _friendlyName(entityId: string): string { + return this.hass?.states?.[entityId]?.attributes?.friendly_name ?? entityId; + } + + /** + * Every cover HomeShift drives, and the running inhibitions. An end + * already past is dropped here rather than waiting for the integration's + * next poll to forget it. + */ + private getCoverInhibitions(): CoverInhibitions { + const attributes = + this.getEntityState(this._config?.covers_inhibited_entity)?.attributes ?? + {}; + const managed: string[] = Array.isArray(attributes.managed_covers) + ? attributes.managed_covers + : []; + const inhibited = new Map(); + const now = Date.now(); + for (const [cover, until] of Object.entries(attributes.covers ?? {})) { + if (typeof until === "string" && new Date(until).getTime() <= now) { + continue; + } + inhibited.set(cover, typeof until === "string" ? until : null); + } + return { managed, inhibited }; + } + + /** Call a HomeShift cover service; show a refusal as Home Assistant's toast. */ + private async _callInhibitService(service: string, data: object) { + this._armSettingTimeout(); + try { + await this.hass.callService("homeshift", service, data); + } catch (err: any) { + this.dispatchEvent( + new CustomEvent("hass-notification", { + detail: { + message: localize(this.hass, "card.inhibit_failed", { + error: err?.message ?? String(err), + }), + }, + bubbles: true, + composed: true, + }), + ); + } + } + + private _inhibitCover(entityId: string) { + const minutes = HomeShiftCard.INHIBIT_PRESETS[this._inhibitIndex]; + this._callInhibitService("inhibit_covers", { + entity_id: [entityId], + ...(minutes ? { duration: { minutes } } : {}), + }); + } + + private _resumeCovers(entityIds?: string[]) { + this._callInhibitService( + "resume_covers", + entityIds ? { entity_id: entityIds } : {}, + ); + } + + private _stepInhibit(direction: 1 | -1) { + const last = HomeShiftCard.INHIBIT_PRESETS.length - 1; + this._inhibitIndex = Math.min( + last, + Math.max(0, this._inhibitIndex + direction), + ); + this._armSettingTimeout(); + } + + /** "4h", "3 d" / "3 j", or "Until resumed". */ + private _inhibitLabel(minutes: number): string { + if (!minutes) return localize(this.hass, "card.inhibit_forever"); + if (minutes < 1440) return formatMinutes(minutes); + return `${minutes / 1440} ${localize(this.hass, "card.days_short")}`; + } + + private _inhibitEnd(until: string | null): string { + return until + ? localize(this.hass, "card.inhibited_until", { + time: this._formatAbsoluteTime(until), + }) + : localize(this.hass, "card.inhibited_forever"); + } + + /** Collapsible row pausing the automation of chosen covers. */ + private _renderCoverInhibition({ managed, inhibited }: CoverInhibitions) { + const open = this._openSetting === "covers"; + const presets = HomeShiftCard.INHIBIT_PRESETS; + const minutes = presets[this._inhibitIndex]; + const label = localize(this.hass, "card.inhibit_label"); + const effect = minutes + ? localize(this.hass, "card.inhibit_effect", { + duration: this._inhibitLabel(minutes), + time: this._formatAbsoluteTime( + new Date(Date.now() + minutes * 60000).toISOString(), + ), + }) + : localize(this.hass, "card.inhibit_effect_forever"); + return html`
+ + ${open + ? html`
+
+ + ${this._inhibitLabel(minutes)} + +
+

${effect}

+
    + ${managed.map((cover) => { + const paused = inhibited.has(cover); + const name = this._friendlyName(cover); + const action = localize( + this.hass, + paused ? "card.resume" : "card.pause", + ); + return html`
  • + + ${name} + ${paused + ? this._inhibitEnd(inhibited.get(cover) ?? null) + : localize(this.hass, "card.inhibit_none")} + + +
  • `; + })} +
+ ${inhibited.size > 1 + ? html`` + : nothing} +
` + : nothing} +
`; + } + + /** + * Reads the schedules again when a schedule switch changes: the Scheduler + * integration updates them whenever a schedule is edited, toggled or run. + * Without the integration the call fails and the panel stays hidden. + */ + private _refreshSchedules() { + if (!this._config?.show_schedules || this.preview || !this.hass?.callWS) { + return; + } + const key = Object.values(this.hass.states ?? {}) + .filter((s: any) => s.entity_id.startsWith("switch.schedule")) + .map((s: any) => `${s.entity_id}:${s.last_updated}`) + .join("|"); + if (key === this._schedulesKey) return; + this._schedulesKey = key; + this.hass + .callWS({ type: "scheduler" }) + .then((schedules: Schedule[]) => { + this._schedules = Array.isArray(schedules) ? schedules : undefined; + }) + .catch(() => { + this._schedules = undefined; + }); + } + + /** Whether a schedule is tagged with one of the day modes. */ + private _hasDaySchedules(dayModes: string[]): boolean { + return (this._schedules ?? []).some((schedule) => + schedule.tags?.some((tag) => dayModes.includes(tag)), + ); + } + + private _toggleSchedules(open: boolean) { + this._schedulesOpen = open; + // Keep the focus on the control that opens or closes the panel. + this.updateComplete.then(() => + this.shadowRoot + ?.querySelector(open ? ".schedules-close" : ".schedules-open") + ?.focus(), + ); + } + + /** + * Bar at the foot of the card, and the panel it slides up over the card: + * a tab per day mode with the 24 h timelines of its schedules, and the + * scheduler card to edit them. The card keeps its height, so the + * dashboard around it does not move. + */ + private _renderSchedules(dayMode: string, dayModes: string[]) { + const title = localize(this.hass, "schedule.title"); + const bar = (open: boolean) => html``; + return html`${bar(false)} + ${this._schedulesOpen + ? html`
{ + if (ev.key !== "Escape") return; + ev.stopPropagation(); + this._toggleSchedules(false); + }} + > + ${bar(true)} +
+ +
+
` + : nothing}`; + } + private onSelect(entityId: string, ev: Event) { const target = ev.target as HTMLSelectElement; const option = target?.value; @@ -177,79 +496,251 @@ class HomeShiftCard extends LitElement { }); } - private onCircularSliderSelect(entityId: string, option: string) { + private onThermostatSelect(entityId: string, option: string) { this.hass.callService("select", "select_option", { entity_id: entityId, option, }); } - private onOffButtonClick(entityId: string, offOption: string) { - if (!offOption) return; - this.hass.callService("select", "select_option", { - entity_id: entityId, - option: offOption, - }); - } - - private onCoverAction(action: "open_cover" | "close_cover") { - const entityId = this._config.cover_entity; - if (!entityId) return; - this.hass.callService("cover", action, { entity_id: entityId }); - } - - private onOverrideDurationSelect(ev: Event) { - const entityId = this._config.override_duration_entity; - if (!entityId) return; - const value = Number((ev.target as HTMLSelectElement).value); - this.hass.callService("number", "set_value", { - entity_id: entityId, - value, - }); + /** + * Service call behind the "open now" / "close now" buttons, or undefined + * when the cover time must stay a plain label. + * + * Priority: a legacy `cover_entity`, when set, wins (cover.open_cover / + * close_cover on that cover, kept for existing dashboards); otherwise the + * integration's buttons (`open_covers_entity` / `close_covers_entity`) + * are pressed. A button's state is the time of its last press ("unknown" + * if never pressed), so only a missing or unavailable button disables it. + */ + private _coverCall( + action: CoverAction, + ): { domain: string; service: string; entity_id: string } | undefined { + const coverId = this._config.cover_entity; + if (coverId) { + return isUsable(this.getEntityState(coverId)) + ? { domain: "cover", service: `${action}_cover`, entity_id: coverId } + : undefined; + } + const buttonId = + action === "open" + ? this._config.open_covers_entity + : this._config.close_covers_entity; + const button = this.getEntityState(buttonId); + return button && button.state !== "unavailable" + ? { domain: "button", service: "press", entity_id: buttonId! } + : undefined; } - private onEarlySwitchSelect(ev: Event) { - const entityId = this._config.early_switch_entity; - if (!entityId) return; - const value = Number((ev.target as HTMLSelectElement).value); - this.hass.callService("number", "set_value", { - entity_id: entityId, - value, - }); + private async onCoverAction(action: CoverAction) { + const call = this._coverCall(action); + if (!call || this._coverPending) return; + this._coverPending = action; + try { + // Keep the "sending" state visible briefly even when HA answers at + // once, so the tap gets a feedback. + await Promise.all([ + this.hass.callService(call.domain, call.service, { + entity_id: call.entity_id, + }), + new Promise((resolve) => + setTimeout(resolve, HomeShiftCard.COVER_FEEDBACK_MS), + ), + ]); + } catch (err: any) { + // Same toast Home Assistant uses for its own failed actions. + this.dispatchEvent( + new CustomEvent("hass-notification", { + detail: { + message: localize(this.hass, `card.cover_${action}_failed`, { + error: err?.message ?? String(err), + }), + }, + bubbles: true, + composed: true, + }), + ); + } finally { + this._coverPending = undefined; + } } - private _formatRelativeTime(isoString?: string): string { - if (!isoString) return ""; - const dt = new Date(isoString); - if (isNaN(dt.getTime())) return isoString; - const diffMs = dt.getTime() - Date.now(); - const diffMin = Math.round(diffMs / 60000); - if (diffMin <= 0) return localize(this.hass, "card.past") || "passé"; - if (diffMin < 60) - return `${localize(this.hass, "card.in") || "dans"} ${diffMin} min`; - const h = Math.floor(diffMin / 60); - const m = diffMin % 60; - return `${localize(this.hass, "card.in") || "dans"} ${h}h${m > 0 ? m.toString().padStart(2, "0") : ""}`; + private _renderCoverTime(action: CoverAction, time: string) { + const icon = + action === "open" ? "mdi:roller-shade" : "mdi:roller-shade-closed"; + if (!this._coverCall(action)) { + return html` + ${time} + `; + } + const pending = this._coverPending === action; + const label = localize(this.hass, `card.cover_${action}_action`, { time }); + return html``; } private _formatAbsoluteTime(isoString?: string): string { if (!isoString) return ""; const dt = new Date(isoString); - if (isNaN(dt.getTime())) return isoString; + if (isNaN(dt.getTime())) return ""; const now = new Date(); const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); - const timeStr = dt.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); + const timeStr = formatTime(dt, this.hass?.locale); if (dt.toDateString() === now.toDateString()) { - return `${localize(this.hass, "card.today") || "Auj."} ${timeStr}`; + return `${localize(this.hass, "card.today")} ${timeStr}`; } if (dt.toDateString() === tomorrow.toDateString()) { - return `${localize(this.hass, "card.tomorrow") || "Dem."} ${timeStr}`; + return `${localize(this.hass, "card.tomorrow")} ${timeStr}`; } - return `${dt.toLocaleDateString([], { month: "short", day: "numeric" })} ${timeStr}`; + return `${formatShortDate(dt, this.hass?.locale)} ${timeStr}`; + } + + private _settingLabel(minutes: number | undefined): string { + if (minutes === undefined) return localize(this.hass, "card.unavailable"); + if (!minutes) return localize(this.hass, "card.duration_off"); + return formatMinutes(minutes); + } + + private _clockIn(minutes: number): string { + return formatTime( + new Date(Date.now() + minutes * 60000), + this.hass?.locale, + ); + } + + private _clockAt(isoString: string, shiftMinutes = 0): string { + const at = new Date(new Date(isoString).getTime() + shiftMinutes * 60000); + return formatTime(at, this.hass?.locale); + } + + /** Open one setting at a time, and fold it back once it is left alone. */ + private _toggleSetting(name: SettingName) { + this._openSetting = this._openSetting === name ? null : name; + this._armSettingTimeout(); + } + + private _armSettingTimeout() { + clearTimeout(this._settingTimeout); + if (!this._openSetting) return; + // Picking covers one by one takes longer than nudging a stepper. + const delay = this._openSetting === "covers" ? 20000 : 6000; + this._settingTimeout = setTimeout(() => { + this._openSetting = null; + }, delay); + } + + private _stepSetting( + name: "override" | "early", + presets: number[], + current: number, + direction: 1 | -1, + ) { + const entityId = + name === "override" + ? this._config.override_duration_entity + : this._config.early_switch_entity; + if (!entityId) return; + // The stored value may sit between two presets (25 min set from the + // entity page): land on the nearest preset in the direction asked for, + // 30 going up and 15 going down. + const next = + direction > 0 + ? presets.find((value) => value > current) + : [...presets].reverse().find((value) => value < current); + if (next === undefined) return; + this._armSettingTimeout(); + this.hass.callService("number", "set_value", { + entity_id: entityId, + value: next, + }); + } + + /** + * Collapsible minutes setting. `current` is undefined while the number + * entity is unknown or unavailable: the value then reads "Unavailable", + * is not highlighted and the setting cannot be opened (no NaN, no + * stepping from a value that does not exist). + */ + private _renderSetting( + name: "override" | "early", + icon: string, + label: string, + effect: string, + presets: number[], + current: number | undefined, + ) { + const open = current !== undefined && this._openSetting === name; + return html`
+ + ${open + ? html`
+
+ + ${this._settingLabel(current)} + +
+

${effect}

+
` + : nothing} +
`; + } + + /** + * Minutes held by a number entity, as `_renderSetting` expects them, or + * null when the setting gets no row at all: not configured, or configured + * but missing outside the card picker preview. + */ + private _minutes(entityId?: string): number | undefined | null { + if (!entityId) return null; + const stateObj = this.getEntityState(entityId); + if (!stateObj) return this.preview ? 0 : null; + return numericState(stateObj); } private _renderMain( @@ -257,11 +748,11 @@ class HomeShiftCard extends LitElement { day: any, heatProtectionActive: boolean, coversLeftOpen: string[], + coverInhibitions: CoverInhibitions, coverOpenTime?: string, coverCloseTime?: string, ) { // Day mode options from option_map attribute (HomeShift integration ≥ 1.1.0). - // Falls back to options list if option_map is not yet available. const dayModeMap: Record = day.attributes?.option_map ?? {}; const dayEntries: [string, string][] = Object.keys(dayModeMap).length > 0 @@ -270,214 +761,223 @@ class HomeShiftCard extends LitElement { (o: string) => [o, o] as [string, string], ); - // Thermostat mode options from option_map attribute (HomeShift integration ≥ 1.1.0). - // Falls back to attributes.options (first entry = "off" mode, rest = active modes). + // Thermostat presets, keyed so each can carry its own colour and its own + // translation regardless of the display values set in the integration. const thermoModeMap: Record = thermo.attributes?.option_map ?? {}; - const hasThermoMap = Object.keys(thermoModeMap).length > 0; - - // Display value for the "off" key — sent to select.select_option when off is pressed. - // The internal key for the off mode is always "off" (THERMOSTAT_OFF_KEY in the integration). - const offDisplay: string = hasThermoMap - ? (thermoModeMap["off"] ?? thermo.attributes?.options?.[0] ?? "") - : (thermo.attributes?.options?.[0] ?? ""); - - // Options for the circular slider: off mode first, then active modes. - const thermoSliderOptions: string[] = hasThermoMap - ? [ - offDisplay, - ...Object.entries(thermoModeMap) - .filter(([key]) => key !== "off") - .map(([, display]) => display), - ] - : (thermo.attributes?.options ?? []); - - // Localized labels for the circular slider arc (translated in the card, regardless - // of the display values configured in the integration). - // Standard internal keys from HomeShift: heating, cooling, ventilation. - const THERMOSTAT_ACTIVE_KEYS = ["heating", "cooling", "ventilation"]; - const thermoLocalizedLabels: string[] = hasThermoMap - ? [ - localize(this.hass, "card.off") || offDisplay, - ...Object.keys(thermoModeMap) - .filter((key) => key !== "off") - .map((key) => localize(this.hass, `thermostat.${key}`) || key), - ] - : [ - localize(this.hass, "card.off") || - (thermo.attributes?.options?.[0] ?? ""), - ...THERMOSTAT_ACTIVE_KEYS.map( - (key) => localize(this.hass, `thermostat.${key}`) || key, - ), - ]; + const thermoEntries: [string, string][] = + Object.keys(thermoModeMap).length > 0 + ? (Object.entries(thermoModeMap) as [string, string][]) + : (thermo.attributes?.options ?? []).map( + (o: string) => [o, o] as [string, string], + ); const nextMode = this.getEntityState(this._config.next_mode_entity); const nextModeAt = this.getEntityState(this._config.next_mode_at_entity); - const currentEarlySwitch = Number( - this.getEntityState(this._config.early_switch_entity)?.state ?? 0, + const earlySwitch = this._minutes(this._config.early_switch_entity); + const override = this._minutes(this._config.override_duration_entity); + + // The next-mode sensors are unknown when no switch is scheduled. + const hasNextMode = isUsable(nextMode); + const nextModeName = hasNextMode ? nextMode!.state : ""; + const nextModeAtText = isUsable(nextModeAt) + ? this._formatAbsoluteTime(nextModeAt!.state) + : ""; + const hasNextAt = nextModeAtText !== ""; + + const overrideEffect = override + ? localize(this.hass, "card.override_effect", { + duration: this._settingLabel(override), + time: this._clockIn(override), + }) + : localize(this.hass, "card.override_none"); + const earlyEffect = !hasNextAt + ? localize(this.hass, "card.early_no_event") + : earlySwitch + ? localize(this.hass, "card.early_effect", { + mode: nextModeName, + time: this._clockAt(nextModeAt!.state), + scheduled: this._clockAt(nextModeAt!.state, earlySwitch), + }) + : localize(this.hass, "card.early_none", { + mode: nextModeName, + time: this._clockAt(nextModeAt!.state), + }); + + const hasCoverOpenTime = isUsable(coverOpenTime); + const hasCoverCloseTime = isUsable(coverCloseTime); + const coversFull = localize(this.hass, "card.covers_left_open", { + covers: coversLeftOpen.join(", "), + }); + // Past three, the names stop fitting on one line: the chip shows a count + // and becomes a button that reveals the names (touch has no hover). + const coversTruncated = coversLeftOpen.length > 3; + const coversSummary = coversTruncated + ? localize(this.hass, "card.covers_left_open_count", { + count: String(coversLeftOpen.length), + }) + : coversFull; + + const pausedCovers = [...coverInhibitions.inhibited.keys()].map((id) => + this._friendlyName(id), ); + const pausedSummary = + pausedCovers.length > 2 + ? localize(this.hass, "card.covers_paused_count", { + count: String(pausedCovers.length), + }) + : localize(this.hass, "card.covers_paused", { + covers: pausedCovers.join(", "), + }); + const pausedDetail = [...coverInhibitions.inhibited.entries()] + .map(([id, until]) => `${this._friendlyName(id)} — ${this._inhibitEnd(until)}`) + .join("\n"); + + const activeKey: string | undefined = thermo.attributes?.current_key; + const thermoUsable = isUsable(thermo); - const hasNextMode = - nextMode?.state && nextMode.state !== "unknown" && nextMode.state !== ""; + return html` +
+
+ ${thermoEntries.map(([key, display]) => { + const active = + thermoUsable && + (activeKey ? key === activeKey : display === thermo.state); + return html``; + })} +
- const hasCoverOpenTime = - coverOpenTime && coverOpenTime !== "unknown" && coverOpenTime !== "unavailable"; - const hasCoverCloseTime = - coverCloseTime && coverCloseTime !== "unknown" && coverCloseTime !== "unavailable"; +
+ ${localize(this.hass, "card.day_mode")} + +
- const canControlCover = Boolean(this._config.cover_entity); + ${hasNextMode || hasNextAt + ? html`
+ ${localize(this.hass, "card.next")} + ${nextModeName}${hasNextMode && hasNextAt + ? " · " + : ""}${nextModeAtText} +
` + : nothing} + + ${override !== null + ? this._renderSetting( + "override", + "mdi:hand-back-left", + localize(this.hass, "card.override_label"), + overrideEffect, + HomeShiftCard.OVERRIDE_PRESETS, + override, + ) + : nothing} + ${earlySwitch !== null + ? this._renderSetting( + "early", + "mdi:clock-fast", + localize(this.hass, "card.early_label"), + earlyEffect, + HomeShiftCard.EARLY_SWITCH_PRESETS, + earlySwitch, + ) + : nothing} - return html` -
${hasCoverOpenTime || hasCoverCloseTime - ? html`
- ${hasCoverOpenTime - ? html`
this.onCoverAction("open_cover")} - > - - ${coverOpenTime} -
` - : nothing} - ${hasCoverCloseTime - ? html`
this.onCoverAction("close_cover")} - > - - ${coverCloseTime} -
` - : nothing} + ? html`
+ ${localize(this.hass, "card.covers")} + + ${hasCoverOpenTime + ? this._renderCoverTime("open", coverOpenTime!) + : nothing} + ${hasCoverCloseTime + ? this._renderCoverTime("close", coverCloseTime!) + : nothing} +
` : nothing} - ${heatProtectionActive || coversLeftOpen.length > 0 - ? html`
+ + ${coverInhibitions.managed.length > 0 + ? this._renderCoverInhibition(coverInhibitions) + : nothing} + + ${heatProtectionActive || + coversLeftOpen.length > 0 || + pausedCovers.length > 0 + ? html`
${heatProtectionActive - ? html`
+ ? html` -
` + ${localize(this.hass, "card.heat_protection_active")} + ` + : nothing} + ${coversLeftOpen.length > 0 && !coversTruncated + ? html` + + ${coversSummary} + ` : nothing} - ${coversLeftOpen.length > 0 - ? html`
+ (this._coversExpanded = !this._coversExpanded)} > -
` + ${coversSummary} + ` : nothing} -
` - : nothing} - - this.onCircularSliderSelect(thermo.entity_id, e.detail.option)} - > - -
- ${hasNextMode || nextModeAt?.state - ? html`
- ${hasNextMode - ? html`${nextMode!.state}` - : nothing} - ${nextModeAt?.state - ? html`${this._formatAbsoluteTime(nextModeAt.state)}` - : nothing} -
` - : nothing} - ${this._config.early_switch_entity - ? html`` - : nothing} -
- -
-
-
- -
- - ${(() => { - const currentOverride = Number( - this.getEntityState(this._config.override_duration_entity) - ?.state ?? 0, - ); - return html``; - })()} -
-
+ + ${pausedSummary} + ` + : nothing} +
+ ${coversTruncated && this._coversExpanded + ? html`

${coversFull}

` + : nothing}` + : nothing}
`; } @@ -485,39 +985,57 @@ class HomeShiftCard extends LitElement { protected render() { if (!this.hass || !this._config) return nothing; - const title = this._config.name ?? localize(this.hass, "card.title"); - - // Use real entities when available, fall back to stub data for the picker preview const dayRaw = this.getEntityState(this._config.day_mode_entity); const thermoRaw = this.getEntityState(this._config.thermostat_mode_entity); + // Stub data is only for the card picker / editor preview: on a real + // dashboard a missing entity (typo, integration not loaded) must say so + // rather than show a card that looks functional. + if (!this.preview) { + const missing = [ + [this._config.day_mode_entity, dayRaw], + [this._config.thermostat_mode_entity, thermoRaw], + ] + .filter(([, stateObj]) => !stateObj) + .map(([entityId]) => entityId || "?"); + if (missing.length > 0) { + return html` + + ${missing.map( + (entity) => + html`
+ ${localize(this.hass, "card.entity_not_found", { entity })} +
`, + )} +
+ `; + } + } + const day = dayRaw ?? { - entity_id: this._config.day_mode_entity ?? "select.homeshift_day_mode", - state: localize(this.hass, "preview.day_mode_state") || "Travail", + entity_id: this._config.day_mode_entity, + state: localize(this.hass, "preview.work"), attributes: { - options: ["Maison", "Travail", "Télétravail", "Absence"], - option_map: { - home: "Maison", - work: "Travail", - remote: "Télétravail", - away: "Absence", - }, + option_map: Object.fromEntries( + ["home", "work", "remote", "away"].map((key) => [ + key, + localize(this.hass, `preview.${key}`), + ]), + ), }, }; const thermo = thermoRaw ?? { - entity_id: - this._config.thermostat_mode_entity ?? - "select.homeshift_thermostat_mode", - state: localize(this.hass, "preview.thermostat_state") || "Chauffage", + entity_id: this._config.thermostat_mode_entity, + state: localize(this.hass, "thermostat.heating"), attributes: { - options: ["Eteint", "Chauffage", "Climatisation", "Ventilation"], - option_map: { - off: "Eteint", - heating: "Chauffage", - cooling: "Climatisation", - ventilation: "Ventilation", - }, + option_map: Object.fromEntries( + ["off", "heating", "cooling", "ventilation"].map((key) => [ + key, + localize(this.hass, `thermostat.${key}`), + ]), + ), + current_key: "heating", }, }; @@ -530,296 +1048,427 @@ class HomeShiftCard extends LitElement { this._config.cover_close_time_entity, )?.state; + const dayModes: string[] = day.attributes?.options ?? []; + const showSchedules = isUsable(day) && this._hasDaySchedules(dayModes); + return html` - -
+ +
${this._renderMain( thermo, day, heatProtectionActive, this.getCoversLeftOpen(), + this.getCoverInhibitions(), coverOpenTime, coverCloseTime, )}
+ ${showSchedules ? this._renderSchedules(day.state, dayModes) : nothing}
`; } static styles = css` ha-card { - padding: 8px; + padding: 12px; position: relative; - min-height: 240px; - text-align: center; + /* Clips the schedules panel while it slides up. */ + overflow: hidden; } .container { - display: flex; - justify-content: center; width: 100%; } - /* SLIDER VIEW */ - .thermo-section { - position: relative; - width: 100%; - max-width: 240px; + .rows { display: flex; flex-direction: column; - align-items: center; } - homeshift-circular-slider { - width: 100%; + /* Thermostat presets — one segmented control, one colour per mode. */ + .presets { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + border: 1px solid var(--divider-color, #ccc); + border-radius: 8px; + overflow: hidden; + /* Same gap above the first divider as the card's padding above. */ + margin-bottom: 12px; } - .thermo-bottom { - position: absolute; - bottom: 16px; - left: 50%; - transform: translateX(-50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - z-index: 2; - width: 90%; + .preset { + border: 0; + border-left: 1px solid var(--divider-color, #ccc); + background: transparent; + color: var(--secondary-text-color, #666); + font-size: 13px; + font-family: inherit; + padding: 8px 4px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: + background 0.2s ease, + color 0.2s ease; } - .off-row { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; + .preset:first-child { + border-left: 0; } - .bottom-controls { - flex: 1; + .preset:hover { + background: var(--secondary-background-color, rgba(127, 127, 127, 0.12)); + } + + .preset:disabled { + cursor: default; + opacity: 0.6; + } + + .preset:focus-visible, + .setting-head:focus-visible, + .stepper button:focus-visible, + .cover-time.actionable:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + + .preset.on { + background: var(--secondary-text-color, #666); + color: var(--text-primary-color, #fff); + } + + .preset--heating.on { + background: var(--state-climate-heat-color, #e24b4a); + } + + .preset--cooling.on { + background: var(--state-climate-cool-color, #378add); + } + + .preset--ventilation.on { + background: var(--state-fan-active-color, #1d9e75); + } + + .preset--off.on { + background: var(--divider-color, #9e9e9e); + color: var(--primary-text-color); + } + + .row { display: flex; align-items: center; + justify-content: space-between; gap: 8px; + padding: 8px 0; + border-top: 1px solid var(--divider-color, #e0e0e0); + font-size: 13px; } - .day-section { - flex: 1; - min-width: 0; + .row-key { + color: var(--secondary-text-color, #666); } - .day-section select { - width: 100%; + /* Collapsed by default: the value is readable without opening anything. */ + .setting { + border-top: 1px solid var(--divider-color, #e0e0e0); } - .center-button { + .setting-head { display: flex; align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border-radius: 50%; - border: 1px solid var(--divider-color, #ccc); - background: var(--card-background-color, #ffffff); + gap: 8px; + width: 100%; + padding: 8px 0; + border: 0; + background: transparent; + color: var(--primary-text-color); + font-size: 13px; + font-family: inherit; + text-align: left; cursor: pointer; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - transition: - background 0.2s, - border-color 0.2s; } - .center-button ha-icon { - color: var(--secondary-text-color, #666); + .setting-head:disabled { + cursor: default; } - .center-button.active { - background: var(--primary-color); - border-color: var(--primary-color); + .setting-head:disabled .chevron { + visibility: hidden; } - .center-button.active ha-icon { - color: var(--text-primary-color, #fff); + .setting-head ha-icon { + --mdc-icon-size: 18px; + color: var(--secondary-text-color, #666); + flex-shrink: 0; } - hui-card { - --ha-card-box-shadow: none; - --ha-card-border-width: 0; + .setting-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - @keyframes fadeIn { - from { - opacity: 0; - transform: translateY(5px); - } - to { - opacity: 1; - transform: translateY(0); - } + .setting-value { + color: var(--secondary-text-color, #666); } - /* UI ELEMENTS */ - select { - padding: 8px; - border-radius: 6px; - border: 1px solid var(--divider-color, #ccc); - background: var(--card-background-color); + .setting-value.set { color: var(--primary-text-color); + font-weight: 500; } - .list-select { - flex-shrink: 0; - align-self: stretch; - padding: 0 8px; - border-radius: 6px; + .setting .chevron { + transition: transform 0.15s ease; + } + + .setting.open .chevron { + transform: rotate(180deg); + } + + .setting-body { + padding: 0 0 10px 26px; + animation: fadeIn 0.15s ease; + } + + .stepper { + display: flex; + align-items: center; + gap: 10px; + } + + .stepper button { + width: 34px; + height: 34px; + border-radius: 8px; border: 1px solid var(--divider-color, #ccc); background: var(--card-background-color); color: var(--primary-text-color); - font-size: 13px; + font-size: 18px; + line-height: 1; cursor: pointer; - transition: - background 0.2s ease, - border-color 0.2s ease, - color 0.2s ease; } - .list-select--override.active { - border-color: var(--primary-color); - background: var(--primary-color); - color: var(--text-primary-color, #fff); + .stepper button:hover:not(:disabled) { + background: var(--secondary-background-color, rgba(127, 127, 127, 0.12)); } - .list-select--early.active { - border-color: var(--accent-color, var(--primary-color)); - background: var(--accent-color, var(--primary-color)); - color: var(--text-primary-color, #fff); + .stepper button:disabled { + opacity: 0.4; + cursor: default; } - .list-select--early { - padding: 8px; + .stepper-value { + min-width: 64px; + text-align: center; + font-size: 15px; + font-weight: 500; } - .next-info-group { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + .setting-effect { + margin: 8px 0 0; + font-size: 12px; + line-height: 1.4; + color: var(--secondary-text-color, #666); + } + + .cover-times { display: flex; - flex-direction: column; - align-items: center; gap: 6px; - z-index: 2; - text-align: center; - background: rgba(128, 128, 128, 0.12); - border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1)); - border-radius: 10px; - padding: 6px 12px; } - .next-info { - display: flex; - flex-direction: column; + .cover-time { + display: inline-flex; align-items: center; - gap: 2px; - pointer-events: none; + gap: 4px; + padding: 4px 8px; + border-radius: 8px; + border: 1px solid var(--divider-color, #ccc); + background: var(--card-background-color); + color: var(--primary-text-color); + font-size: 13px; + font-family: inherit; } - .next-mode-state { - font-size: 13px; - font-weight: 600; - color: var(--primary-text-color); + .cover-time ha-icon { + --mdc-icon-size: 16px; + color: var(--secondary-text-color, #666); } - .next-mode-at-state { - font-size: 11px; - color: var(--secondary-text-color); + .cover-time.actionable { + cursor: pointer; + } + + .cover-time.actionable:hover:not(:disabled) { + border-color: var(--primary-color); + } + + .cover-time.actionable:disabled { + cursor: progress; + } + + .cover-time.pending { + border-color: var(--primary-color); + color: var(--primary-color); + animation: cover-pending 0.8s ease-in-out infinite alternate; + } + + @keyframes cover-pending { + from { + opacity: 1; + } + to { + opacity: 0.5; + } + } + + @media (prefers-reduced-motion: reduce) { + .cover-time.pending { + animation: none; + } } .error { color: var(--error-color); - padding: 16px; + padding: 4px; } - .cover-times { - position: absolute; - right: 100%; - margin-right: 8px; - top: 4%; + /* Alerts only exist while something is wrong; nothing is shown otherwise. */ + .alerts { display: flex; - flex-direction: column; - gap: 4px; - z-index: 2; - animation: fadeIn 0.3s ease; + flex-wrap: wrap; + gap: 6px; + padding-top: 10px; + border-top: 1px solid var(--divider-color, #e0e0e0); } - .cover-time-row { - display: flex; + .chip { + display: inline-flex; align-items: center; gap: 4px; - padding: 2px 6px; - border-radius: 10px; - background: rgba(128, 128, 128, 0.12); - border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.1)); - font-size: 11px; - color: var(--secondary-text-color); - white-space: nowrap; + padding: 4px 8px; + border-radius: 8px; + font-size: 12px; + animation: fadeIn 0.3s ease; + } + + .chip ha-icon { + --mdc-icon-size: 16px; } - .cover-time-row.actionable { + button.chip { + border: 0; + font-family: inherit; cursor: pointer; - transition: - border-color 0.2s ease, - background 0.2s ease; } - .cover-time-row.actionable:hover { - border-color: var(--primary-color); - background: rgba(128, 128, 128, 0.22); + button.chip:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; } - .cover-time-row ha-icon { - color: var(--secondary-text-color); - --mdi-icon-size: 14px; + .covers-detail { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.4; + color: var(--primary-text-color); } - /* Badges sit beside the dial and stack downwards, so heat protection - and covers left open can be raised at the same time without one - covering the other. */ - .badge-stack { - position: absolute; - left: 100%; - margin-left: 8px; - top: 4%; + .chip--heat { + background: var(--error-color, #e7973c); + color: var(--text-primary-color, #fff); + } + + /* A paused cover is a choice, not a fault: calm colour, no animation. */ + .chip--paused { + border: 0; + font-family: inherit; + background: var(--secondary-background-color, rgba(127, 127, 127, 0.12)); + color: var(--primary-text-color); + cursor: pointer; + } + + .chip--paused ha-icon { + color: var(--secondary-text-color, #666); + } + + .cover-list { + list-style: none; + margin: 8px 0 0; + padding: 0; display: flex; flex-direction: column; + gap: 6px; + } + + .cover-item { + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; - z-index: 3; + font-size: 13px; } - .badge { + .cover-name { display: flex; + flex-direction: column; + min-width: 0; + } + + .cover-status { + font-size: 12px; + color: var(--secondary-text-color, #666); + } + + .cover-item.paused .cover-status { + color: var(--primary-color); + } + + .cover-toggle, + .resume-all { + display: inline-flex; align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: 50%; - color: var(--text-primary-color, #fff); - animation: fadeIn 0.3s ease; + gap: 4px; + flex-shrink: 0; + padding: 4px 10px; + border-radius: 8px; + border: 1px solid var(--divider-color, #ccc); + background: var(--card-background-color); + color: var(--primary-text-color); + font-size: 13px; + font-family: inherit; + cursor: pointer; } - .badge ha-icon { - color: var(--text-primary-color, #fff); - --mdi-icon-size: 20px; + .cover-toggle ha-icon { + --mdc-icon-size: 16px; } - .heat-protection-badge { - background: var(--error-color, #e7973c); - pointer-events: none; + .cover-toggle:hover, + .resume-all:hover { + border-color: var(--primary-color); + } + + .cover-item.paused .cover-toggle { + border-color: var(--primary-color); + color: var(--primary-color); + } + + .resume-all { + align-self: flex-start; + margin-top: 8px; } /* Pulses between orange and yellow: a cover left up is something to act - on tonight, not a steady status. Hoverable, so the tooltip can name - the covers. */ - .covers-left-open-badge { + on tonight, not a steady status. */ + .chip--covers { background: var(--warning-color, #ff9800); - cursor: help; + color: #412402; animation: fadeIn 0.3s ease, covers-left-open-blink 1.2s ease-in-out infinite; @@ -837,13 +1486,131 @@ class HomeShiftCard extends LitElement { } } - /* A blinking badge is exactly what reduced-motion asks us not to do; + /* A blinking chip is exactly what reduced-motion asks us not to do; the colour alone still reads as a warning. */ @media (prefers-reduced-motion: reduce) { - .covers-left-open-badge { + .chip--covers { animation: fadeIn 0.3s ease; } } + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* Schedules: a bar at the foot of the card, and the panel it slides up. */ + .schedules-bar { + display: flex; + align-items: center; + gap: 8px; + width: calc(100% + 24px); + margin: 10px -12px -12px; + padding: 10px 12px; + border: 0; + border-top: 1px solid var(--divider-color, #e0e0e0); + background: transparent; + color: var(--secondary-text-color, #666); + font-family: inherit; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.05em; + text-transform: uppercase; + text-align: left; + cursor: pointer; + } + + .schedules-bar:hover { + background: var(--secondary-background-color, rgba(127, 127, 127, 0.08)); + } + + .schedules-bar:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + + .schedules-bar ha-icon { + --mdc-icon-size: 18px; + flex-shrink: 0; + } + + .schedules-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .schedules-mode { + color: var(--primary-text-color); + text-transform: none; + letter-spacing: normal; + } + + /* Anchored to the foot of the card and as tall as its content: the top + of the card stays in sight while the schedules are shorter than it; + longer ones scroll inside the panel. */ + .schedules-panel { + position: absolute; + left: 0; + right: 0; + bottom: 0; + max-height: 100%; + z-index: 1; + border-top: 1px solid var(--divider-color, #e0e0e0); + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + background: var(--ha-card-background, var(--card-background-color, #fff)); + animation: schedules-slide-up 0.2s ease; + } + + .schedules-panel .schedules-bar { + width: 100%; + margin: 0; + border-top: 0; + border-bottom: 1px solid var(--divider-color, #e0e0e0); + } + + .schedules-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 10px 12px 12px; + } + + @keyframes schedules-slide-up { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } + } + + @media (prefers-reduced-motion: reduce) { + .schedules-panel { + animation: none; + } + } + + select { + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--divider-color, #ccc); + background: var(--card-background-color); + color: var(--primary-text-color); + font-size: 13px; + font-family: inherit; + } `; } diff --git a/src/localize/en.json b/src/localize/en.json index d7a666d..0bf4214 100644 --- a/src/localize/en.json +++ b/src/localize/en.json @@ -1,33 +1,89 @@ { "card": { - "title": "Thermostat", "entity_not_found": "Entity not found: {entity}", - "off": "Off", - "next_scan": "Next scan", - "in": "in", + "unavailable": "Unavailable", "today": "Today", "tomorrow": "Tomorrow", - "past": "past", "heat_protection_active": "Heat protection active", "cover_open_time": "Cover opening time", "cover_close_time": "Cover closing time", - "cover_open_action": "Open now", - "cover_close_action": "Close now", - "covers_left_open": "Covers left open: {covers}" + "cover_open_action": "Open the covers now (scheduled {time})", + "cover_close_action": "Close the covers now (scheduled {time})", + "cover_sending": "Sending…", + "cover_open_failed": "Could not open the covers: {error}", + "cover_close_failed": "Could not close the covers: {error}", + "covers_left_open": "Not closed: {covers}", + "thermostat_mode": "Thermostat mode", + "day_mode": "Day mode", + "next": "Next", + "covers": "Covers", + "duration_off": "No", + "decrease": "Decrease: {setting}", + "increase": "Increase: {setting}", + "override_label": "Keep my manual choice", + "override_none": "A manual change is replaced at the calendar's next pass.", + "override_effect": "After a change by hand, the calendar waits {duration} — until {time} if you change it now.", + "early_label": "Start events early", + "early_none": "{mode} will start at its scheduled time, {time}.", + "early_effect": "{mode} will start at {time} instead of {scheduled}.", + "early_no_event": "No event scheduled.", + "covers_left_open_count": "Not closed: {count} covers", + "inhibit_label": "Cover automation", + "inhibit_none": "Auto", + "inhibited_count": "{count} paused", + "inhibit_forever": "Until resumed", + "days_short": "d", + "inhibit_effect": "Pause for {duration} — until {time} if you start it now.", + "inhibit_effect_forever": "The cover stays paused until you resume it.", + "inhibited_until": "Paused until {time}", + "inhibited_forever": "Paused until resumed", + "pause": "Pause", + "resume": "Resume", + "resume_all": "Resume all", + "covers_paused": "Paused: {covers}", + "covers_paused_count": "{count} covers paused", + "inhibit_failed": "Could not pause or resume the cover: {error}" }, "thermostat": { + "off": "Off", "heating": "Heating", "cooling": "Cooling", "ventilation": "Ventilation" }, + "schedule": { + "title": "Schedules", + "empty": "No schedule for {mode}", + "on": "On", + "off": "Off", + "edit": "Edit schedules", + "back": "Back to timelines", + "active_mode": "Current day mode", + "next_run": "Next run", + "days": "Days" + }, "editor": { "name": "Name", "day_mode_entity": "Day Mode Entity", - "hermostat_mode_tentity": "Thermostat Mode Entity", + "thermostat_mode_entity": "Thermostat Mode Entity", + "override_duration_entity": "Override Duration Entity", + "early_switch_entity": "Early Switch Entity", + "next_mode_entity": "Next Mode Entity", + "next_mode_at_entity": "Next Mode Time Entity", + "heat_protection_entity": "Heat Protection Entity", "cover_open_time_entity": "Cover Open Time Entity", "cover_close_time_entity": "Cover Close Time Entity", - "cover_entity": "Cover Entity (manual open/close)", + "open_covers_entity": "Open Covers Button", + "close_covers_entity": "Close Covers Button", + "cover_entity": "Cover Entity (legacy, overrides the buttons)", "covers_left_open_entity": "Covers Left Open Entity", - "show_title": "Show title" + "covers_inhibited_entity": "Paused covers entity", + "show_schedules": "Schedules panel (Scheduler integration)", + "schedule_entities": "Schedules panel: only these entities (all when empty)" + }, + "preview": { + "home": "Home", + "work": "Work", + "remote": "Remote", + "away": "Away" } } diff --git a/src/localize/fr.json b/src/localize/fr.json index 8505605..1aeb2a6 100644 --- a/src/localize/fr.json +++ b/src/localize/fr.json @@ -1,33 +1,89 @@ { "card": { - "title": "Thermostat", "entity_not_found": "Entité introuvable: {entity}", - "off": "Éteint", - "next_scan": "Prochain scan", - "in": "dans", + "unavailable": "Indisponible", "today": "Auj.", "tomorrow": "Dem.", - "past": "passé", "heat_protection_active": "Protection thermique active", "cover_open_time": "Heure d'ouverture des volets", "cover_close_time": "Heure de fermeture des volets", - "cover_open_action": "Ouvrir maintenant", - "cover_close_action": "Fermer maintenant", - "covers_left_open": "Volets non fermés : {covers}" + "cover_open_action": "Ouvrir les volets maintenant (prévu à {time})", + "cover_close_action": "Fermer les volets maintenant (prévu à {time})", + "cover_sending": "Envoi…", + "cover_open_failed": "Impossible d'ouvrir les volets : {error}", + "cover_close_failed": "Impossible de fermer les volets : {error}", + "covers_left_open": "Non fermés : {covers}", + "thermostat_mode": "Mode thermostat", + "day_mode": "Mode du jour", + "next": "Prochain", + "covers": "Volets", + "duration_off": "Non", + "decrease": "Diminuer : {setting}", + "increase": "Augmenter : {setting}", + "override_label": "Garder mon choix manuel", + "override_none": "Un changement manuel est remplacé au prochain passage du calendrier.", + "override_effect": "Après un changement à la main, le calendrier attend {duration} — jusqu'à {time} si tu changes maintenant.", + "early_label": "Anticiper les événements", + "early_none": "{mode} commencera à l'heure prévue, {time}.", + "early_effect": "{mode} commencera à {time} au lieu de {scheduled}.", + "early_no_event": "Aucun événement prévu.", + "covers_left_open_count": "Non fermés : {count} volets", + "inhibit_label": "Pilotage auto des volets", + "inhibit_none": "Auto", + "inhibited_count": "{count} en pause", + "inhibit_forever": "Jusqu'à reprise", + "days_short": "j", + "inhibit_effect": "Pause de {duration} — jusqu'à {time} si tu l'actives maintenant.", + "inhibit_effect_forever": "Le volet reste en pause jusqu'à ce que tu le reprennes.", + "inhibited_until": "En pause jusqu'à {time}", + "inhibited_forever": "En pause jusqu'à reprise", + "pause": "Pause", + "resume": "Reprendre", + "resume_all": "Tout reprendre", + "covers_paused": "En pause : {covers}", + "covers_paused_count": "{count} volets en pause", + "inhibit_failed": "Échec de la mise en pause : {error}" }, "thermostat": { + "off": "Éteint", "heating": "Chauffage", "cooling": "Climatisation", "ventilation": "Ventilation" }, + "schedule": { + "title": "Plannings", + "empty": "Aucune planification pour {mode}", + "on": "Allumé", + "off": "Éteint", + "edit": "Modifier les planifications", + "back": "Retour aux frises", + "active_mode": "Mode du jour actuel", + "next_run": "Prochain lancement", + "days": "Jours" + }, "editor": { "name": "Nom", "day_mode_entity": "Entité mode jour", - "hermostat_mode_tentity": "Entité mode thermostat", + "thermostat_mode_entity": "Entité mode thermostat", + "override_duration_entity": "Entité durée de dérogation", + "early_switch_entity": "Entité anticipation", + "next_mode_entity": "Entité prochain mode", + "next_mode_at_entity": "Entité heure du prochain mode", + "heat_protection_entity": "Entité protection thermique", "cover_open_time_entity": "Entité heure d'ouverture des volets", "cover_close_time_entity": "Entité heure de fermeture des volets", - "cover_entity": "Entité volet (ouverture/fermeture manuelle)", + "open_covers_entity": "Bouton d'ouverture des volets", + "close_covers_entity": "Bouton de fermeture des volets", + "cover_entity": "Entité volet (ancienne option, prioritaire sur les boutons)", "covers_left_open_entity": "Entité volets non fermés", - "show_title": "Afficher le titre" + "covers_inhibited_entity": "Entité volets en pause", + "show_schedules": "Panneau des plannings (intégration Scheduler)", + "schedule_entities": "Panneau des plannings : seulement ces entités (toutes si vide)" + }, + "preview": { + "home": "Maison", + "work": "Travail", + "remote": "Télétravail", + "away": "Absence" } } diff --git a/src/localize/localize.ts b/src/localize/localize.ts index b5c5a32..9ca4d35 100644 --- a/src/localize/localize.ts +++ b/src/localize/localize.ts @@ -18,11 +18,23 @@ function resolveKey(obj: Dict, path: string): any { return path.split('.').reduce((acc: any, k: string) => (acc && acc[k] != null ? acc[k] : undefined), obj); } -export function localize(hass: any, key: string, params?: Record): string { +/** + * Translates `key` into the Home Assistant user's language. + * + * Lookup order: user's language, then English, then `fallback`, then the + * key itself (so a missing translation stays visible during development). + */ +export function localize( + hass: any, + key: string, + params?: Record, + fallback?: string, +): string { const lang = get(hass); - const dict = languages[lang] || languages.en; - let text = resolveKey(dict, key) as string | undefined; - if (!text) return key; + let text = (resolveKey(languages[lang], key) ?? + resolveKey(languages.en, key) ?? + fallback) as string | undefined; + if (text == null) return key; if (params) { Object.entries(params).forEach(([k, v]) => (text = (text as string).replace(`{${k}}`, v))); } diff --git a/src/schedule/schedule-panel.ts b/src/schedule/schedule-panel.ts new file mode 100644 index 0000000..d113413 --- /dev/null +++ b/src/schedule/schedule-panel.ts @@ -0,0 +1,276 @@ +import { LitElement, html, css, nothing, PropertyValues } from "lit"; +import { property, state } from "lit/decorators.js"; +import { localize } from "../localize/localize"; +import { Schedule } from "./timeline"; +import "./schedule-timeline"; + +/** + * Content of the card's schedules panel: one tab per day mode, the 24 h + * timelines of the shown mode, and a pencil that swaps them for the + * scheduler-card list of that mode, to add and edit its schedules. + */ +export class HomeShiftSchedulePanel extends LitElement { + @property({ attribute: false }) public hass!: any; + @property({ attribute: false }) public schedules: Schedule[] = []; + /** Day modes, in the order of the day mode entity: one tab each. */ + @property({ attribute: false }) public modes: string[] = []; + /** Current day mode: marked with a dot, and shown whenever it changes. */ + @property() public activeMode = ""; + /** Only the schedules acting on these entities, when there are some. */ + @property({ attribute: false }) public entities: string[] = []; + /** Day mode whose tab is shown. */ + @state() private _selected?: string; + /** Whether the scheduler card replaces the timelines. */ + @state() private _editing = false; + /** Bumped when a scheduler card is built, to render it. */ + @state() private _cardsVersion = 0; + /** One scheduler card per mode, built once and kept while switching tabs. */ + private _cards = new Map(); + private _building = new Set(); + private _helpers?: Promise; + + protected willUpdate(changed: PropertyValues): void { + if (changed.has("activeMode") && this.modes.includes(this.activeMode)) { + this._selected = this.activeMode; + } else if (!this._selected || !this.modes.includes(this._selected)) { + this._selected = this.modes[0]; + } + if (changed.has("entities") && changed.get("entities") !== undefined) { + this._cards = new Map(); + this._building.clear(); + } + } + + protected updated(): void { + const card = this._selected ? this._cards.get(this._selected) : undefined; + if (card && (card as any).hass !== this.hass) (card as any).hass = this.hass; + } + + /** scheduler-card configuration of one tab: its tag and entities. */ + private _schedulerConfig(mode: string): Record { + return { + type: "custom:scheduler-card", + title: false, + discover_existing: false, + show_header_toggle: false, + time_step: 5, + sort_by: ["title"], + display_options: { + primary_info: ["{name}"], + secondary_info: [ + `${localize(this.hass, "schedule.next_run")} {relative-time}`, + `${localize(this.hass, "schedule.days")} {days}`, + "additional-tasks", + ], + icon: "entity", + }, + tags: [mode], + ...(this.entities.length ? { include: this.entities } : {}), + }; + } + + /** Scheduler card of a mode, built on first display. */ + private _card(mode: string): HTMLElement | undefined { + const card = this._cards.get(mode); + if (card) return card; + if (!this._building.has(mode)) { + this._building.add(mode); + this._buildCard(mode).finally(() => this._building.delete(mode)); + } + return undefined; + } + + private async _buildCard(mode: string): Promise { + const config = this._schedulerConfig(mode); + const generation = this._cards; + this._helpers ??= + (window as any).loadCardHelpers?.() ?? Promise.resolve(undefined); + const helpers = await this._helpers; + let card: HTMLElement; + if (helpers) { + card = helpers.createCardElement(config); + } else { + await customElements.whenDefined("scheduler-card"); + card = document.createElement("scheduler-card"); + (card as any).setConfig(config); + } + // The entities changed while the card was being built. + if (generation !== this._cards) return; + // Home Assistant asks for a rebuild once a card that loaded late is defined. + card.addEventListener("ll-rebuild", (ev) => { + ev.stopPropagation(); + this._cards.delete(mode); + this._cardsVersion++; + }); + (card as any).hass = this.hass; + this._cards.set(mode, card); + this._cardsVersion++; + } + + private _select(mode: string) { + this._selected = mode; + } + + private _onTabKeydown(ev: KeyboardEvent) { + const step = ({ ArrowRight: 1, ArrowLeft: -1 } as Record)[ + ev.key + ]; + if (!step) return; + const index = this.modes.indexOf(this._selected ?? ""); + const next = this.modes[(index + step + this.modes.length) % this.modes.length]; + this._select(next); + ev.preventDefault(); + this.updateComplete.then(() => + this.shadowRoot + ?.querySelector('[aria-selected="true"]') + ?.focus(), + ); + } + + protected render() { + if (!this.hass || !this._selected) return nothing; + const selected = this._selected; + const editLabel = localize( + this.hass, + this._editing ? "schedule.back" : "schedule.edit", + ); + return html` +
+
+ ${this.modes.map((mode) => { + const isSelected = mode === selected; + return html``; + })} +
+ +
+
+ ${this._editing + ? (this._card(selected) ?? nothing) + : html``} +
+ `; + } + + static styles = css` + :host { + display: flex; + flex-direction: column; + gap: 10px; + } + .header { + display: flex; + align-items: center; + gap: 6px; + } + /* Segmented control: one button per mode, the shown one filled. */ + .tabs { + flex: 1; + min-width: 0; + display: flex; + gap: 2px; + padding: 3px; + border-radius: 10px; + background: color-mix(in srgb, var(--primary-text-color) 7%, transparent); + overflow-x: auto; + scrollbar-width: none; + } + .tab { + flex: 1 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px 8px; + border: none; + border-radius: 8px; + background: none; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + color: var(--secondary-text-color); + } + .tab:hover:not(.selected) { + background: color-mix(in srgb, var(--primary-text-color) 6%, transparent); + } + .tab:focus-visible, + .edit:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .tab.selected { + background: var(--primary-color); + color: var(--text-primary-color, #fff); + } + .active-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--state-active-color, var(--success-color, #43a047)); + flex: none; + } + .edit { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--primary-text-color) 7%, transparent); + color: var(--primary-color); + cursor: pointer; + --mdc-icon-size: 18px; + } + .edit:hover { + background: color-mix(in srgb, var(--primary-text-color) 12%, transparent); + } + .edit[aria-pressed="true"] { + background: var(--primary-color); + color: var(--text-primary-color, #fff); + } + /* The scheduler card blends into the panel. */ + .content { + --ha-card-border-width: 0; + --ha-card-box-shadow: none; + --ha-card-background: transparent; + } + `; +} + +if (!customElements.get("homeshift-schedule-panel")) { + customElements.define("homeshift-schedule-panel", HomeShiftSchedulePanel); +} diff --git a/src/schedule/schedule-timeline.ts b/src/schedule/schedule-timeline.ts new file mode 100644 index 0000000..71758c6 --- /dev/null +++ b/src/schedule/schedule-timeline.ts @@ -0,0 +1,401 @@ +import { LitElement, html, css, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import { localize } from "../localize/localize"; +import { + DAY_MINUTES, + Schedule, + Segment, + SlotValue, + daySegments, + formatMinutes, + scheduleEntities, + schedulesFor, + segmentColors, +} from "./timeline"; + +const KIND_ICONS: Record = { + heat: "mdi:radiator", + cool: "mdi:snowflake", + // A preset alone does not say heating or cooling: heating, as for the exclusion. + preset: "mdi:radiator", + on: "mdi:toggle-switch-outline", + off: "mdi:toggle-switch-off-outline", + other: "mdi:calendar-clock", +}; + +/** Schedules sharing a heading: those driving the same room's thermostat. */ +interface Room { + name: string; + /** Entity the schedules drive, when they drive a single one. */ + entity?: string; + schedules: Schedule[]; +} + +/** Whether a timeline heats or cools, for the heating / cooling exclusion. */ +function climateSide(kind: string): "heat" | "cool" | undefined { + if (kind === "cool") return "cool"; + if (kind === "heat" || kind === "preset") return "heat"; + return undefined; +} + +/** Order of a room's timelines: heating first, then cooling. */ +const KIND_ORDER = ["heat", "preset", "cool", "on", "off", "other"]; + +/** + * Kind of a timeline, for its icon: cooling wins, then heating, then + * presets; turning off alone does not tell what the schedule is for. + */ +function rowKind(segments: Segment[]): string { + const kinds = new Set(segments.map((s) => s.value.kind)); + return ( + ["cool", "heat", "preset", "on", "off"].find((kind) => kinds.has(kind as any)) ?? + "other" + ); +} + +/** + * 24 h timelines of the schedules tagged with one day mode, grouped by + * room, each with the switch that enables it. The schedules are handed + * over by the card, which fetches them once for the whole dashboard view. + */ +export class HomeShiftScheduleTimeline extends LitElement { + @property({ attribute: false }) public hass!: any; + @property({ attribute: false }) public schedules: Schedule[] = []; + /** Scheduler tag of the schedules shown: the day mode. */ + @property() public tag = ""; + /** Only the schedules acting on these entities, when there are some. */ + @property({ attribute: false }) public entities: string[] = []; + private _clock?: ReturnType; + + connectedCallback() { + super.connectedCallback(); + // Moves the "now" marker along the timelines. + this._clock = setInterval(() => this.requestUpdate(), 60_000); + } + + disconnectedCallback() { + super.disconnectedCallback(); + clearInterval(this._clock); + } + + protected render() { + if (!this.hass) return nothing; + const schedules = schedulesFor(this.schedules, this.tag, this.entities); + if (!schedules.length) { + return html`
+ ${localize(this.hass, "schedule.empty", { mode: this.tag })} +
`; + } + const now = new Date(); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + return html` + ${this._rooms(schedules).map((room) => this._renderRoom(room, nowMinutes))} + + `; + } + + /** + * Schedules grouped by room: those driving the same single entity (e.g. + * the heating and the cooling of the living room) share a heading; any + * other schedule is a room of its own, named after itself. + */ + private _rooms(schedules: Schedule[]): Room[] { + const rooms = new Map(); + for (const schedule of schedules) { + const entities = scheduleEntities(schedule, true); + const entity = entities.length === 1 ? entities[0] : undefined; + const key = entity ?? schedule.entity_id; + if (!rooms.has(key)) { + const name = entity + ? (this.hass.states[entity]?.attributes?.friendly_name ?? entity) + : schedule.name; + rooms.set(key, { name, entity, schedules: [] }); + } + rooms.get(key)!.schedules.push(schedule); + } + for (const room of rooms.values()) { + room.schedules.sort( + (a, b) => + KIND_ORDER.indexOf(rowKind(daySegments(a))) - + KIND_ORDER.indexOf(rowKind(daySegments(b))), + ); + } + return [...rooms.values()]; + } + + private _renderRoom(room: Room, nowMinutes: number) { + const measured = room.entity?.startsWith("climate.") + ? this.hass.states[room.entity]?.attributes?.current_temperature + : undefined; + const allDisabled = room.schedules.every((s) => !this._enabled(s)); + return html` +
+
+ ${room.name} + ${typeof measured === "number" + ? html`${this._number(measured)}°` + : nothing} +
+ ${room.schedules.map((s) => this._renderRow(s, room, nowMinutes))} +
+ `; + } + + private _enabled(schedule: Schedule): boolean { + const state = this.hass.states[schedule.entity_id]; + return state ? state.state !== "off" : schedule.enabled; + } + + /** One schedule: its kind, its timeline and its switch. */ + private _renderRow(schedule: Schedule, room: Room, nowMinutes: number) { + const segments = daySegments(schedule); + const kind = rowKind(segments); + const enabled = this._enabled(schedule); + + return html` +
+ +
+ ${segments.map((s) => this._renderSegment(s))} +
+
+ +
+ `; + } + + private _renderSegment(segment: Segment) { + const { background, color } = segmentColors(segment.value); + const width = ((segment.end - segment.start) / DAY_MINUTES) * 100; + const range = `${formatMinutes(segment.start)} – ${formatMinutes(segment.end)}`; + const label = this._label(segment.value); + // The card is narrow: a label is written only where it fits, about 2 % + // of the bar per character ("18°" fits in 10 %, "Confort" needs 16 %). + const fits = width >= Math.max(10, label.length * 2.2); + return html`
+ ${fits ? label : nothing} +
`; + } + + /** A number in the user's format (20.5 / 20,5). */ + private _number(value: number): string { + const language = this.hass.locale?.language ?? this.hass.language ?? "en"; + return value.toLocaleString(language, { maximumFractionDigits: 1 }); + } + + /** Text of a value, with the temperature in the user's number format (20.5° / 20,5°). */ + private _label(value: SlotValue): string { + const stateObj = value.entity ? this.hass.states[value.entity] : undefined; + if (value.temperature != null) return `${this._number(value.temperature)}°`; + // Presets and HVAC modes as Home Assistant names them ("Confort", "Éteint"...). + if (value.preset != null) { + return stateObj && this.hass.formatEntityAttributeValue + ? this.hass.formatEntityAttributeValue(stateObj, "preset_mode", value.preset) + : value.preset; + } + if (value.hvacMode != null) { + return stateObj && this.hass.formatEntityState + ? this.hass.formatEntityState(stateObj, value.hvacMode) + : value.hvacMode; + } + if (value.kind === "on" || value.kind === "off") { + return localize(this.hass, `schedule.${value.kind}`); + } + return value.label; + } + + /** + * Turns a schedule on or off. Heating and cooling the same room at once + * makes no sense: turning one on first turns off the room's enabled + * schedules of the other side. + */ + private async _toggle(schedule: Schedule, room: Room, enabled: boolean) { + if (enabled) { + await this.hass.callService("switch", "turn_off", { + entity_id: schedule.entity_id, + }); + return; + } + const side = climateSide(rowKind(daySegments(schedule))); + const opposite = side + ? room.schedules.filter((s) => { + const other = climateSide(rowKind(daySegments(s))); + return s !== schedule && other && other !== side && this._enabled(s); + }) + : []; + if (opposite.length) { + await this.hass.callService("switch", "turn_off", { + entity_id: opposite.map((s) => s.entity_id), + }); + } + await this.hass.callService("switch", "turn_on", { + entity_id: schedule.entity_id, + }); + } + + static styles = css` + :host { + display: flex; + flex-direction: column; + gap: 10px; + } + .room { + display: flex; + flex-direction: column; + gap: 4px; + } + .room-head { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + } + .room-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + } + .room-name.disabled { + color: var(--secondary-text-color); + } + .row { + display: flex; + align-items: center; + gap: 8px; + } + .row .bar { + flex: 1; + } + /* A disabled schedule keeps its colors, hatched, so it stays readable. */ + .row.disabled .segment { + background-image: repeating-linear-gradient( + 135deg, + rgba(0, 0, 0, 0.3) 0 4px, + transparent 4px 8px + ); + } + .kind { + flex: none; + --mdc-icon-size: 18px; + color: var(--secondary-text-color); + } + .kind.heat, + .kind.preset { + color: #ef9f27; + } + .kind.cool { + color: #378add; + } + .measured { + flex: none; + padding: 1px 8px; + border-radius: 10px; + background: color-mix(in srgb, var(--primary-text-color) 10%, transparent); + font-size: 12px; + } + .switch { + position: relative; + flex: none; + width: 34px; + height: 18px; + border: none; + border-radius: 9px; + background: var(--disabled-color, #888); + cursor: pointer; + padding: 0; + } + .switch::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 12px; + height: 12px; + border-radius: 50%; + background: #fff; + transition: left 0.15s; + } + .switch.on { + background: var(--switch-checked-track-color, var(--primary-color)); + } + .switch.on::after { + left: 19px; + } + .switch:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .bar { + position: relative; + height: 20px; + border-radius: 6px; + background: color-mix(in srgb, var(--primary-text-color) 6%, transparent); + overflow: hidden; + } + .segment { + position: absolute; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + overflow: hidden; + white-space: nowrap; + box-shadow: inset -1px 0 0 var(--ha-card-background, var(--card-background-color)); + } + .now { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + margin-left: -1px; + background: var(--primary-text-color); + } + .axis { + position: relative; + height: 14px; + /* Under the bars only: past the kind icon (18 + 8 px) and before the switch (34 + 8 px). */ + margin: -4px 42px 0 26px; + font-size: 11px; + color: var(--secondary-text-color); + } + .axis span { + position: absolute; + transform: translateX(-50%); + } + .axis span:first-child { + transform: none; + } + .axis span:last-child { + transform: translateX(-100%); + } + .empty { + padding: 16px 0; + text-align: center; + font-size: 13px; + color: var(--secondary-text-color); + } + `; +} + +if (!customElements.get("homeshift-schedule-timeline")) { + customElements.define("homeshift-schedule-timeline", HomeShiftScheduleTimeline); +} diff --git a/src/schedule/timeline.ts b/src/schedule/timeline.ts new file mode 100644 index 0000000..422890f --- /dev/null +++ b/src/schedule/timeline.ts @@ -0,0 +1,211 @@ +/** A schedule as returned by the scheduler component's `scheduler` websocket command. */ +export interface Schedule { + schedule_id: string; + entity_id: string; + name: string; + enabled: boolean; + weekdays: string[]; + tags?: string[]; + timeslots: Timeslot[]; +} + +export interface Timeslot { + start: string; + stop: string | null; + actions: { service: string; entity_id?: string; service_data?: Record }[]; +} + +export type ValueKind = "heat" | "cool" | "preset" | "off" | "on" | "other"; + +/** What a timeslot sets its entity to. */ +export interface SlotValue { + kind: ValueKind; + /** Entity of the action the value comes from. */ + entity?: string; + temperature?: number; + /** Preset set by `climate.set_preset_mode` ("comfort", "eco"...). */ + preset?: string; + /** HVAC mode set without a temperature ("off", "heat"...). */ + hvacMode?: string; + /** + * Raw text of the value ("20°", "comfort", "off"...); the card shows + * temperatures, presets and modes in the user's language instead. + */ + label: string; +} + +/** Part of the day, in minutes since midnight, during which a value applies. */ +export interface Segment { + start: number; + end: number; + value: SlotValue; +} + +export const DAY_MINUTES = 24 * 60; + +/** "07:30:00" → 450. */ +export function parseTime(time: string): number { + const [h, m] = time.split(":").map(Number); + return h * 60 + (m || 0); +} + +/** "HH:MM" of a number of minutes since midnight. */ +export function formatMinutes(minutes: number): string { + const m = minutes % DAY_MINUTES; + return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; +} + +const isClimate = (action: Timeslot["actions"][number]) => action.service.startsWith("climate."); + +/** Whether a schedule drives a thermostat: its timeline then shows only the thermostat. */ +function drivesClimate(schedule: Schedule): boolean { + return schedule.timeslots.some((slot) => slot.actions.some(isClimate)); +} + +/** Value set by a timeslot, from its first thermostat action, else its first action. */ +export function slotValue(slot: Timeslot): SlotValue { + const action = slot.actions?.find(isClimate) ?? slot.actions?.[0]; + if (!action) return { kind: "other", label: "" }; + const [, service] = action.service.split("."); + const data = action.service_data ?? {}; + const entity = action.entity_id; + const mode = data.hvac_mode; + if (mode === "off") return { kind: "off", entity, hvacMode: mode, label: mode }; + if (service === "turn_off") return { kind: "off", entity, label: "off" }; + if (typeof data.temperature === "number") { + return { + kind: mode === "cool" ? "cool" : "heat", + entity, + temperature: data.temperature, + label: `${data.temperature}°`, + }; + } + if (data.preset_mode != null) { + const preset = String(data.preset_mode); + return { kind: "preset", entity, preset, label: preset }; + } + if (mode) return { kind: mode === "cool" ? "cool" : "heat", entity, hvacMode: mode, label: mode }; + if (service === "turn_on") return { kind: "on", entity, label: "on" }; + return { kind: "other", entity, label: service ?? action.service }; +} + +/** + * Segments of the day a schedule covers. A timeslot with a stop time spans + * start → stop (across midnight if stop is earlier); a timeslot without one + * is a single action whose value holds until the next timeslot, wrapping + * around midnight. + */ +export function daySegments(schedule: Schedule): Segment[] { + const climate = drivesClimate(schedule); + const slots = schedule.timeslots + .filter((slot) => !climate || slot.actions.some(isClimate)) + .map((slot) => ({ slot, start: parseTime(slot.start) })) + .sort((a, b) => a.start - b.start); + const segments: Segment[] = []; + + slots.forEach(({ slot, start }, i) => { + const value = slotValue(slot); + let end: number; + if (slot.stop != null) { + end = parseTime(slot.stop); + if (end <= start) end += DAY_MINUTES; + } else { + const next = slots[(i + 1) % slots.length].start; + end = next > start ? next : next + DAY_MINUTES; + } + if (end > DAY_MINUTES) { + segments.push({ start, end: DAY_MINUTES, value }); + if (end - DAY_MINUTES > 0) segments.push({ start: 0, end: end - DAY_MINUTES, value }); + } else { + segments.push({ start, end, value }); + } + }); + + return mergeSegments( + segments.filter((s) => s.end > s.start).sort((a, b) => a.start - b.start), + ); +} + +/** Whether two values set the same thing. */ +export function sameValue(a: SlotValue, b: SlotValue): boolean { + return ( + a.kind === b.kind && + a.temperature === b.temperature && + a.preset === b.preset && + a.hvacMode === b.hvacMode && + a.label === b.label + ); +} + +/** Joins back-to-back segments of the same value ("Eco | Eco" → "Eco"). */ +function mergeSegments(segments: Segment[]): Segment[] { + const merged: Segment[] = []; + for (const segment of segments) { + const last = merged[merged.length - 1]; + if (last && last.end === segment.start && sameValue(last.value, segment.value)) { + last.end = segment.end; + } else { + merged.push({ ...segment }); + } + } + return merged; +} + +/** + * Entities a schedule acts on. With `shown`, only those of its timeline: + * its thermostats when it drives some. + */ +export function scheduleEntities(schedule: Schedule, shown = false): string[] { + const climate = shown && drivesClimate(schedule); + const ids = schedule.timeslots.flatMap((slot) => + slot.actions + .filter((a) => !climate || isClimate(a)) + .map((a) => a.entity_id) + .filter((id): id is string => !!id), + ); + return [...new Set(ids)]; +} + +/** Schedules of a tab: tagged with its tag, and acting on its entities when it has some. */ +export function schedulesFor(schedules: Schedule[], tag: string, entities: string[]): Schedule[] { + return schedules + .filter((s) => s.tags?.includes(tag)) + .filter((s) => !entities.length || scheduleEntities(s).some((e) => entities.includes(e))) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Segment background and text colors: warmer for higher setpoints. */ +export function segmentColors(value: SlotValue): { background: string; color: string } { + switch (value.kind) { + case "cool": + return { background: "#378ADD", color: "#fff" }; + case "off": + return { background: "var(--divider-color, #5F5E5A)", color: "var(--secondary-text-color)" }; + case "on": + return { background: "#1D9E75", color: "#fff" }; + case "other": + return { background: "#888780", color: "#fff" }; + case "preset": + return presetColors(value.preset ?? ""); + } + const t = value.temperature ?? 19; + if (t < 15) return { background: "#85B7EB", color: "#042C53" }; + if (t < 17.5) return { background: "#FAC775", color: "#412402" }; + if (t < 19.5) return { background: "#EF9F27", color: "#412402" }; + if (t < 21) return { background: "#D85A30", color: "#fff" }; + return { background: "#A32D2D", color: "#fff" }; +} + +/** + * Preset colors on the same scale as the setpoints: frost and away are + * cold, eco mild, comfort warm, boost hot (Versatile Thermostat and the + * presets Home Assistant defines). + */ +function presetColors(preset: string): { background: string; color: string } { + const p = preset.toLowerCase(); + if (/frost|hors|away|absen|vacation/.test(p)) return { background: "#85B7EB", color: "#042C53" }; + if (/eco|sleep|night|nuit/.test(p)) return { background: "#FAC775", color: "#412402" }; + if (/boost/.test(p)) return { background: "#A32D2D", color: "#fff" }; + if (/comfort|confort|home|activity/.test(p)) return { background: "#EF9F27", color: "#412402" }; + return { background: "#B4B2A9", color: "#2C2C2A" }; +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..fc361e7 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,26 @@ +/** States Home Assistant uses when an entity has no meaningful value. */ +const UNUSABLE_STATES = new Set(["unknown", "unavailable", ""]); + +/** + * True when the entity exists and carries a real value. Accepts a state + * object or a raw state string. + */ +export function isUsable(stateObj: { state?: unknown } | string | undefined | null): boolean { + const state = typeof stateObj === "string" ? stateObj : stateObj?.state; + return typeof state === "string" && !UNUSABLE_STATES.has(state); +} + +/** Numeric value of a number entity, or undefined when it has none. */ +export function numericState(stateObj: { state?: unknown } | undefined): number | undefined { + if (!isUsable(stateObj)) return undefined; + const value = Number(stateObj!.state); + return Number.isFinite(value) ? value : undefined; +} + +/** Duration label of the card's settings: "25 min", "1h", "1h30". */ +export function formatMinutes(minutes: number): string { + if (minutes < 60) return `${minutes} min`; + const h = Math.floor(minutes / 60); + const m = Math.round(minutes % 60); + return m > 0 ? `${h}h${String(m).padStart(2, "0")}` : `${h}h`; +} diff --git a/src/time.ts b/src/time.ts new file mode 100644 index 0000000..7d42108 --- /dev/null +++ b/src/time.ts @@ -0,0 +1,32 @@ +/** + * Locale and 12/24 h choice for Intl, following the user's Home Assistant + * profile (hass.locale) rather than the browser: + * - time_format "12" / "24" forces the hour cycle, + * - "system" uses the browser locale, + * - "language" (default) uses the HA language's own convention. + */ +export function timeFormat(hassLocale: any): { + locale: string | undefined; + hour12: boolean | undefined; +} { + const format = hassLocale?.time_format; + const locale = format === "system" ? undefined : hassLocale?.language; + const hour12 = format === "12" ? true : format === "24" ? false : undefined; + return { locale, hour12 }; +} + +/** "14:05" / "2:05 PM" according to the HA profile. */ +export function formatTime(date: Date, hassLocale: any): string { + const { locale, hour12 } = timeFormat(hassLocale); + return date.toLocaleTimeString(locale, { + hour: "2-digit", + minute: "2-digit", + hour12, + }); +} + +/** "12 Jan" / "Jan 12" according to the HA language. */ +export function formatShortDate(date: Date, hassLocale: any): string { + const { locale } = timeFormat(hassLocale); + return date.toLocaleDateString(locale, { month: "short", day: "numeric" }); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..4107d2e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,116 @@ +/** Card configuration, shared by the card and its visual editor. */ +export interface HomeShiftCardConfig { + name?: string; + day_mode_entity?: string; + thermostat_mode_entity?: string; + override_duration_entity?: string; + early_switch_entity?: string; + next_mode_entity?: string; + next_mode_at_entity?: string; + heat_protection_entity?: string; + cover_open_time_entity?: string; + cover_close_time_entity?: string; + /** Button pressed by the "open now" row (HomeShift >= button platform). */ + open_covers_entity?: string; + /** Button pressed by the "close now" row. */ + close_covers_entity?: string; + /** Legacy: when set, the rows call cover.open/close_cover on it instead. */ + cover_entity?: string; + covers_left_open_entity?: string; + /** HomeShift's covers_inhibited sensor: the cover automation pause row. */ + covers_inhibited_entity?: string; + /** + * Schedules panel: false hides it. Otherwise it appears on its own once + * the Scheduler integration holds schedules tagged with a day mode. + */ + show_schedules?: boolean; + /** Only the schedules acting on these entities, in the schedules panel. */ + schedule_entities?: string[]; +} + +export type EntityConfigKey = { + [K in keyof HomeShiftCardConfig]-?: K extends `${string}_entity` ? K : never; +}[keyof HomeShiftCardConfig]; + +/** + * Every entity option, in editor order, with the domains its picker offers + * and the entity the HomeShift integration creates by default ("" = none). + * The editor is generated from this table so it cannot drift from the + * config keys the card reads. + */ +export const ENTITY_FIELDS: readonly { + key: EntityConfigKey; + domains: string[]; + default: string; +}[] = [ + { + key: "day_mode_entity", + domains: ["select", "input_select"], + default: "select.homeshift_day_mode", + }, + { + key: "thermostat_mode_entity", + domains: ["select", "input_select"], + default: "select.homeshift_thermostat_mode", + }, + { + key: "override_duration_entity", + domains: ["number", "input_number"], + default: "number.homeshift_override_duration", + }, + { + key: "early_switch_entity", + domains: ["number", "input_number"], + default: "number.homeshift_early_switch", + }, + { + key: "next_mode_entity", + domains: ["sensor"], + default: "sensor.homeshift_next_mode", + }, + { + key: "next_mode_at_entity", + domains: ["sensor"], + default: "sensor.homeshift_next_mode_at", + }, + { + key: "heat_protection_entity", + domains: ["binary_sensor"], + default: "binary_sensor.homeshift_cover_heat_active", + }, + { + key: "cover_open_time_entity", + domains: ["sensor"], + default: "sensor.homeshift_cover_open_time", + }, + { + key: "cover_close_time_entity", + domains: ["sensor"], + default: "sensor.homeshift_cover_close_time", + }, + { + key: "open_covers_entity", + domains: ["button"], + default: "button.homeshift_open_covers", + }, + { + key: "close_covers_entity", + domains: ["button"], + default: "button.homeshift_close_covers", + }, + { + key: "cover_entity", + domains: ["cover"], + default: "", + }, + { + key: "covers_left_open_entity", + domains: ["binary_sensor"], + default: "binary_sensor.homeshift_covers_left_open", + }, + { + key: "covers_inhibited_entity", + domains: ["sensor"], + default: "sensor.homeshift_covers_inhibited", + }, +]; diff --git a/test/a11y.test.ts b/test/a11y.test.ts new file mode 100644 index 0000000..ae05104 --- /dev/null +++ b/test/a11y.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { $, $$, entity, homeshiftStates, makeHass, renderCard } from "./helpers"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("thermostat presets keyboard access (A-2)", () => { + it("renders one native button per mode, focusable and activatable", async () => { + const hass = makeHass(homeshiftStates()); + const card = await renderCard(hass); + const presets = $$(card, ".presets button.preset") as HTMLButtonElement[]; + expect(presets.map((b) => b.textContent!.trim())).toEqual([ + "Off", + "Heating", + "Cooling", + ]); + expect(presets.every((b) => !b.disabled && b.tabIndex === 0)).toBe(true); + expect($(card, ".presets")?.getAttribute("aria-label")).toBe( + "Thermostat mode", + ); + expect(presets.map((b) => b.getAttribute("aria-pressed"))).toEqual([ + "false", + "true", + "false", + ]); + presets[2].click(); + expect(hass.callService).toHaveBeenCalledWith("select", "select_option", { + entity_id: "select.homeshift_thermostat_mode", + option: "Climatisation", + }); + }); + + it("disables the presets and highlights none while unavailable", async () => { + const states = homeshiftStates(); + states["select.homeshift_thermostat_mode"].state = "unavailable"; + const card = await renderCard(makeHass(states)); + const presets = $$(card, "button.preset") as HTMLButtonElement[]; + expect(presets.every((b) => b.disabled)).toBe(true); + expect($(card, "button.preset.on")).toBeNull(); + }); +}); + +describe("covers left open chip (A-2, B-6)", () => { + function withCoversLeftOpen(names: string[]) { + const states = homeshiftStates(); + const ids = names.map((n) => `cover.${n.toLowerCase()}`); + states["binary_sensor.homeshift_covers_left_open"] = entity( + "binary_sensor.homeshift_covers_left_open", + "on", + { covers: ids }, + ); + names.forEach((n, i) => { + states[ids[i]] = entity(ids[i], "open", { friendly_name: n }); + }); + return states; + } + + it("names up to three covers in plain text", async () => { + const card = await renderCard(makeHass(withCoversLeftOpen(["Salon"]))); + const chip = $(card, ".chip--covers")!; + expect(chip.tagName).toBe("SPAN"); + expect(chip.textContent).toContain("Not closed: Salon"); + }); + + it("reveals the names behind a count on tap, not only on hover", async () => { + const names = ["Salon", "Cuisine", "Bureau", "Chambre"]; + const card = await renderCard(makeHass(withCoversLeftOpen(names))); + const chip = $(card, "button.chip--covers")!; + expect(chip.textContent).toContain("Not closed: 4 covers"); + expect(chip.getAttribute("aria-label")).toBe( + "Not closed: Salon, Cuisine, Bureau, Chambre", + ); + expect($(card, ".covers-detail")).toBeNull(); + chip.click(); + await card.updateComplete; + expect($(card, ".covers-detail")?.textContent).toContain("Chambre"); + expect(chip.getAttribute("aria-expanded")).toBe("true"); + }); + + it("shows the heat protection text without hover", async () => { + const states = homeshiftStates(); + states["binary_sensor.homeshift_cover_heat_active"].state = "on"; + const card = await renderCard(makeHass(states)); + expect($(card, ".chip--heat")?.textContent).toContain( + "Heat protection active", + ); + const css = (card.constructor as any).styles.cssText as string; + expect(css).not.toMatch(/chip--heat\s*\{[^}]*pointer-events:\s*none/); + }); +}); + +describe("labels", () => { + it("labels the stepper buttons in French", async () => { + const card = await renderCard(makeHass(homeshiftStates(), "fr")); + $$(card, "button.setting-head")[1].click(); + await card.updateComplete; + expect($(card, ".stepper button")?.getAttribute("aria-label")).toBe( + "Diminuer : Anticiper les événements", + ); + }); + + it("labels the day mode dropdown", async () => { + const card = await renderCard(makeHass(homeshiftStates(), "fr")); + expect($(card, "select")?.getAttribute("aria-label")).toBe("Mode du jour"); + }); + + it("exposes the collapsible settings as expandable buttons", async () => { + const card = await renderCard(makeHass(homeshiftStates())); + const heads = $$(card, "button.setting-head"); + expect(heads).toHaveLength(2); + expect(heads[0].getAttribute("aria-expanded")).toBe("false"); + heads[0].click(); + await card.updateComplete; + expect(heads[0].getAttribute("aria-expanded")).toBe("true"); + const [down, up] = $$(card, ".stepper button"); + expect(down.getAttribute("aria-label")).toBe( + "Decrease: Keep my manual choice", + ); + expect(up.getAttribute("aria-label")).toBe( + "Increase: Keep my manual choice", + ); + }); +}); diff --git a/test/covers.test.ts b/test/covers.test.ts new file mode 100644 index 0000000..eeb5427 --- /dev/null +++ b/test/covers.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { HomeShiftCard } from "../src/components/homeshift-card"; +import { $, $$, entity, homeshiftStates, makeHass, renderCard } from "./helpers"; + +beforeAll(() => { + HomeShiftCard.COVER_FEEDBACK_MS = 0; +}); + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function withButtons(state = "unknown") { + const states = homeshiftStates(); + for (const id of ["button.homeshift_open_covers", "button.homeshift_close_covers"]) { + states[id] = entity(id, state); + } + return states; +} + +describe("manual cover open / close", () => { + it("renders labelled buttons that press the integration buttons", async () => { + const hass = makeHass(withButtons()); + const card = await renderCard(hass); + const [open, close] = $$(card, "button.cover-time"); + expect(open.getAttribute("aria-label")).toBe( + "Open the covers now (scheduled 08:30)", + ); + expect(close.getAttribute("aria-label")).toBe( + "Close the covers now (scheduled 21:15)", + ); + + close.click(); + expect(hass.callService).toHaveBeenCalledWith("button", "press", { + entity_id: "button.homeshift_close_covers", + }); + }); + + it("uses the French labels", async () => { + const card = await renderCard(makeHass(withButtons(), "fr")); + expect($(card, "button.cover-time")?.getAttribute("aria-label")).toBe( + "Ouvrir les volets maintenant (prévu à 08:30)", + ); + }); + + it("presses configured button entities", async () => { + const states = withButtons(); + states["button.my_open"] = entity("button.my_open", "2026-01-01T08:00:00+00:00"); + const hass = makeHass(states); + const card = await renderCard(hass, { open_covers_entity: "button.my_open" }); + $(card, "button.cover-time")!.click(); + expect(hass.callService).toHaveBeenCalledWith("button", "press", { + entity_id: "button.my_open", + }); + }); + + it.each([ + ["missing", undefined], + ["unavailable", "unavailable"], + ])("keeps plain labels when the buttons are %s", async (_name, state) => { + const states = state ? withButtons(state) : homeshiftStates(); + const hass = makeHass(states); + const card = await renderCard(hass); + expect($$(card, "button.cover-time")).toHaveLength(0); + $(card, ".cover-time")!.click(); + expect(hass.callService).not.toHaveBeenCalled(); + }); + + it("gives the legacy cover_entity priority over the buttons", async () => { + const states = withButtons(); + states["cover.living"] = entity("cover.living", "closed"); + const hass = makeHass(states); + const card = await renderCard(hass, { cover_entity: "cover.living" }); + $(card, "button.cover-time")!.click(); + expect(hass.callService).toHaveBeenCalledWith("cover", "open_cover", { + entity_id: "cover.living", + }); + }); + + it("shows a sending state while the call is in flight", async () => { + const hass = makeHass(withButtons()); + let resolve!: () => void; + hass.callService.mockReturnValue(new Promise((r) => (resolve = r))); + const card = await renderCard(hass); + $(card, "button.cover-time")!.click(); + await card.updateComplete; + const [open, close] = $$(card, "button.cover-time") as HTMLButtonElement[]; + expect(open.classList.contains("pending")).toBe(true); + expect(open.getAttribute("aria-busy")).toBe("true"); + expect(open.textContent).toContain("Sending"); + expect(close.disabled).toBe(true); + + resolve(); + await vi.waitFor(() => + expect($(card, "button.cover-time")!.classList.contains("pending")).toBe(false), + ); + }); + + it("reports a failed call through hass-notification", async () => { + const hass = makeHass(withButtons()); + hass.callService.mockRejectedValue(new Error("Entity is unavailable")); + const card = await renderCard(hass); + const toast = vi.fn(); + card.addEventListener("hass-notification", (e) => + toast((e as CustomEvent).detail.message), + ); + $(card, "button.cover-time")!.click(); + await vi.waitFor(() => + expect(toast).toHaveBeenCalledWith( + "Could not open the covers: Entity is unavailable", + ), + ); + expect($(card, "button.cover-time")!.classList.contains("pending")).toBe(false); + }); +}); diff --git a/test/editor.test.ts b/test/editor.test.ts new file mode 100644 index 0000000..ffc2593 --- /dev/null +++ b/test/editor.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { HomeShiftCard } from "../src/components/homeshift-card"; +import { HomeShiftCardEditor } from "../src/components/homeshift-card-editor"; +import en from "../src/localize/en.json"; +import fr from "../src/localize/fr.json"; + +const hass = { states: {}, locale: { language: "en" } }; + +async function renderEditor(): Promise { + const editor = document.createElement( + "homeshift-card-editor", + ) as HomeShiftCardEditor; + (editor as any).hass = hass; + editor.setConfig(HomeShiftCard.getStubConfig()); + document.body.appendChild(editor); + await editor.updateComplete; + return editor; +} + +function cardConfigKeys(): string[] { + const card = new HomeShiftCard(); + card.setConfig({}); + return Object.keys((card as any)._config); +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("homeshift-card-editor", () => { + it("only writes keys the card reads", async () => { + const editor = await renderEditor(); + const written = new Set(); + editor.addEventListener("config-changed", (e: Event) => { + const config = (e as CustomEvent).detail.config; + Object.keys(config).forEach((k) => written.add(k)); + }); + + const pickers = editor.shadowRoot!.querySelectorAll("ha-entity-picker"); + pickers.forEach((picker, i) => + picker.dispatchEvent( + new CustomEvent("value-changed", { detail: { value: `sensor.x${i}` } }), + ), + ); + + expect(written.size).toBeGreaterThan(0); + const cardKeys = cardConfigKeys(); + for (const key of written) expect(cardKeys).toContain(key); + }); + + it("exposes every entity option of the card", async () => { + const editor = await renderEditor(); + const pickers = editor.shadowRoot!.querySelectorAll("ha-entity-picker"); + const entityKeys = cardConfigKeys().filter((k) => k.endsWith("_entity")); + expect(pickers.length).toBe(entityKeys.length); + }); + + it("shows the thermostat entity of an existing YAML config", async () => { + const editor = await renderEditor(); + const values = [ + ...editor.shadowRoot!.querySelectorAll("ha-entity-picker"), + ].map((p) => (p as any).value); + expect(values).toContain("select.homeshift_thermostat_mode"); + }); + + it("has a label in every language for every option", () => { + for (const dict of [en, fr]) { + for (const key of cardConfigKeys()) { + expect(dict.editor, key).toHaveProperty(key); + } + } + }); + + it("has no label for an option the card does not have", () => { + for (const dict of [en, fr]) { + expect(Object.keys(dict.editor).sort()).toEqual(cardConfigKeys().sort()); + } + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..c204c19 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,61 @@ +import { vi } from "vitest"; +import { HomeShiftCard } from "../src/components/homeshift-card"; +import "../src/components/homeshift-card"; + +export function entity(entity_id: string, state: string, attributes = {}) { + return { entity_id, state, attributes }; +} + +/** States of a fully configured HomeShift integration. */ +export function homeshiftStates(): Record { + const list = [ + entity("select.homeshift_day_mode", "Travail", { + options: ["Maison", "Travail"], + option_map: { home: "Maison", work: "Travail" }, + }), + entity("select.homeshift_thermostat_mode", "Chauffage", { + options: ["Eteint", "Chauffage", "Climatisation"], + option_map: { off: "Eteint", heating: "Chauffage", cooling: "Climatisation" }, + }), + entity("number.homeshift_override_duration", "0"), + entity("number.homeshift_early_switch", "0"), + entity("sensor.homeshift_next_mode", "Maison"), + entity("sensor.homeshift_next_mode_at", new Date().toISOString()), + entity("binary_sensor.homeshift_cover_heat_active", "off"), + entity("sensor.homeshift_cover_open_time", "08:30"), + entity("sensor.homeshift_cover_close_time", "21:15"), + entity("binary_sensor.homeshift_covers_left_open", "off", { covers: [] }), + ]; + return Object.fromEntries(list.map((e) => [e.entity_id, e])); +} + +export function makeHass(states: Record, language = "en") { + return { + states, + locale: { language, time_format: "24" }, + language, + callService: vi.fn().mockResolvedValue(undefined), + }; +} + +export async function renderCard( + hass: any, + config: Record = {}, + preview = false, +): Promise { + const card = document.createElement("homeshift-card") as HomeShiftCard; + card.setConfig(config); + card.preview = preview; + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +export function $(card: HomeShiftCard, selector: string) { + return card.shadowRoot!.querySelector(selector) as HTMLElement | null; +} + +export function $$(card: HomeShiftCard, selector: string) { + return [...card.shadowRoot!.querySelectorAll(selector)] as HTMLElement[]; +} diff --git a/test/inhibition.test.ts b/test/inhibition.test.ts new file mode 100644 index 0000000..58f47a9 --- /dev/null +++ b/test/inhibition.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { $, $$, entity, homeshiftStates, makeHass, renderCard } from "./helpers"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +const IN_THREE_DAYS = new Date(Date.now() + 3 * 86400e3).toISOString(); + +function withInhibitions(covers: Record = {}) { + const states = homeshiftStates(); + states["sensor.homeshift_covers_inhibited"] = entity( + "sensor.homeshift_covers_inhibited", + String(Object.keys(covers).length), + { covers, managed_covers: ["cover.living", "cover.bedroom"] }, + ); + states["cover.living"] = entity("cover.living", "open", { friendly_name: "Living" }); + states["cover.bedroom"] = entity("cover.bedroom", "closed", { friendly_name: "Bedroom" }); + return states; +} + +async function openPanel(card: any) { + const head = $$(card, ".setting-head").find((el) => + el.textContent?.includes("Cover automation"), + )!; + head.click(); + await card.updateComplete; +} + +describe("cover automation pause", () => { + it("is hidden without the covers_inhibited sensor", async () => { + const card = await renderCard(makeHass(homeshiftStates())); + expect(card.shadowRoot!.textContent).not.toContain("Cover automation"); + }); + + it("reads Auto while nothing is paused", async () => { + const card = await renderCard(makeHass(withInhibitions())); + const head = $$(card, ".setting-head").find((el) => + el.textContent?.includes("Cover automation"), + )!; + expect(head.querySelector(".setting-value")!.textContent).toBe("Auto"); + expect($(card, ".chip--paused")).toBeNull(); + }); + + it("pauses one cover for the picked duration", async () => { + const hass = makeHass(withInhibitions()); + const card = await renderCard(hass); + await openPanel(card); + + // Default is one day; one step up gives two. + $$(card, ".stepper button")[1].click(); + await card.updateComplete; + expect($(card, ".stepper-value")!.textContent).toBe("2 d"); + + $$(card, ".cover-toggle")[1].click(); + expect(hass.callService).toHaveBeenCalledWith("homeshift", "inhibit_covers", { + entity_id: ["cover.bedroom"], + duration: { minutes: 2880 }, + }); + }); + + it("pauses until resumed at the last step", async () => { + const hass = makeHass(withInhibitions()); + const card = await renderCard(hass); + await openPanel(card); + for (let i = 0; i < 6; i++) { + $$(card, ".stepper button")[1].click(); + await card.updateComplete; + } + expect($(card, ".stepper-value")!.textContent).toBe("Until resumed"); + + $$(card, ".cover-toggle")[0].click(); + expect(hass.callService).toHaveBeenCalledWith("homeshift", "inhibit_covers", { + entity_id: ["cover.living"], + }); + }); + + it("lists paused covers and resumes them", async () => { + const hass = makeHass( + withInhibitions({ "cover.bedroom": IN_THREE_DAYS, "cover.living": null }), + ); + const card = await renderCard(hass); + expect($(card, ".chip--paused")!.textContent).toContain("Paused: Bedroom, Living"); + + await openPanel(card); + const statuses = $$(card, ".cover-status").map((el) => el.textContent); + expect(statuses[0]).toBe("Paused until resumed"); + expect(statuses[1]).toMatch(/^Paused until /); + + $$(card, ".cover-toggle")[1].click(); + expect(hass.callService).toHaveBeenCalledWith("homeshift", "resume_covers", { + entity_id: ["cover.bedroom"], + }); + + $(card, ".resume-all")!.click(); + expect(hass.callService).toHaveBeenCalledWith("homeshift", "resume_covers", {}); + }); + + it("ignores an inhibition already over", async () => { + const past = new Date(Date.now() - 60000).toISOString(); + const card = await renderCard(makeHass(withInhibitions({ "cover.bedroom": past }))); + expect($(card, ".chip--paused")).toBeNull(); + }); + + it("shows a refused call as a notification", async () => { + const hass = makeHass(withInhibitions()); + hass.callService.mockRejectedValueOnce(new Error("nope")); + const card = await renderCard(hass); + const messages: string[] = []; + card.addEventListener("hass-notification", (ev: any) => + messages.push(ev.detail.message), + ); + await openPanel(card); + $$(card, ".cover-toggle")[0].click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messages).toEqual(["Could not pause or resume the cover: nope"]); + }); +}); diff --git a/test/localize.test.ts b/test/localize.test.ts new file mode 100644 index 0000000..392fbb8 --- /dev/null +++ b/test/localize.test.ts @@ -0,0 +1,75 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { localize } from "../src/localize/localize"; +import en from "../src/localize/en.json"; +import fr from "../src/localize/fr.json"; + +const FR = { locale: { language: "fr" } }; + +function leafKeys(obj: Record, prefix = ""): string[] { + return Object.entries(obj).flatMap(([k, v]) => + typeof v === "object" ? leafKeys(v, `${prefix}${k}.`) : [`${prefix}${k}`], + ); +} + +describe("localize", () => { + it("resolves a key in the user's language", () => { + expect(localize(FR, "thermostat.off")).toBe("Éteint"); + }); + + it("falls back to English for an unknown language", () => { + expect(localize({ locale: { language: "de" } }, "thermostat.off")).toBe("Off"); + }); + + it("uses the region-less language code", () => { + expect(localize({ locale: { language: "fr-CA" } }, "thermostat.off")).toBe( + "Éteint", + ); + }); + + it("returns the caller's fallback for a missing key", () => { + expect(localize(FR, "thermostat.dehumidify", undefined, "Déshu")).toBe( + "Déshu", + ); + }); + + it("returns the key when nothing else is available", () => { + expect(localize(FR, "card.does_not_exist")).toBe("card.does_not_exist"); + }); + + it("substitutes parameters", () => { + expect(localize(FR, "card.covers_left_open", { covers: "Salon" })).toBe( + "Non fermés : Salon", + ); + }); + + it("has the same keys in every language", () => { + expect(leafKeys(fr).sort()).toEqual(leafKeys(en).sort()); + }); + + it("has no key the sources never use", () => { + // Vitest runs from the repository root. + const source = ["src/components", "src/schedule"] + .map((sub) => join(process.cwd(), sub)) + .flatMap((dir) => + readdirSync(dir).map((f) => readFileSync(join(dir, f), "utf-8")), + ) + .join("\n"); + // Keys built at runtime: `thermostat.${key}`, `preview.${key}`, + // `editor.${field.key}`, `card.cover_${action}_...` and + // `schedule.${value.kind}`. + const dynamic = [ + /^thermostat\./, + /^preview\./, + /^editor\./, + /^card\.cover_(open|close)_(time|action|failed)$/, + /^schedule\.(on|off)$/, + ]; + const unused = leafKeys(en).filter( + (key) => + !source.includes(`"${key}"`) && !dynamic.some((re) => re.test(key)), + ); + expect(unused).toEqual([]); + }); +}); diff --git a/test/schedules.test.ts b/test/schedules.test.ts new file mode 100644 index 0000000..896cb9b --- /dev/null +++ b/test/schedules.test.ts @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HomeShiftCard } from "../src/components/homeshift-card"; +import { Schedule } from "../src/schedule/timeline"; +import { $, entity, homeshiftStates, makeHass, renderCard } from "./helpers"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +const heat = (entity_id: string, temperature: number) => ({ + service: "climate.set_temperature", + entity_id, + service_data: { temperature, hvac_mode: "heat" }, +}); + +const cool = (entity_id: string, temperature: number) => ({ + service: "climate.set_temperature", + entity_id, + service_data: { temperature, hvac_mode: "cool" }, +}); + +function schedules(): Schedule[] { + return [ + { + schedule_id: "a1", + entity_id: "switch.schedule_travail_salon", + name: "Travail salon", + enabled: true, + weekdays: ["daily"], + tags: ["Travail"], + timeslots: [ + { start: "00:00:00", stop: "07:00:00", actions: [heat("climate.salon", 18)] }, + { start: "07:00:00", stop: "00:00:00", actions: [heat("climate.salon", 20.5)] }, + ], + }, + { + schedule_id: "a2", + entity_id: "switch.schedule_travail_salon_clim", + name: "Travail salon clim", + enabled: false, + weekdays: ["daily"], + tags: ["Travail"], + timeslots: [{ start: "14:00:00", stop: "18:00:00", actions: [cool("climate.salon", 26)] }], + }, + { + schedule_id: "b1", + entity_id: "switch.schedule_maison_bureau", + name: "Maison bureau", + enabled: true, + weekdays: ["daily"], + tags: ["Maison"], + timeslots: [{ start: "08:00:00", stop: "18:00:00", actions: [heat("climate.bureau", 20)] }], + }, + ]; +} + +function schedulerHass(list: Schedule[] = schedules()) { + const states = homeshiftStates(); + states["climate.salon"] = entity("climate.salon", "heat", { + friendly_name: "Salon", + current_temperature: 19.2, + }); + states["switch.schedule_travail_salon"] = entity("switch.schedule_travail_salon", "on"); + states["switch.schedule_travail_salon_clim"] = entity( + "switch.schedule_travail_salon_clim", + "off", + ); + return { ...makeHass(states), callWS: vi.fn().mockResolvedValue(list) }; +} + +async function settle(card: HomeShiftCard) { + for (let i = 0; i < 3; i++) { + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + } +} + +async function render(hass: any, config: Record = {}) { + const card = await renderCard(hass, config); + await settle(card); + return card; +} + +type Lit = HTMLElement & { updateComplete: Promise }; + +/** Stands in for scheduler-card, recording the configuration it receives. */ +class FakeScheduler extends HTMLElement { + config: any; + hass: any; + setConfig(config: any) { + this.config = config; + } +} +if (!customElements.get("scheduler-card")) customElements.define("scheduler-card", FakeScheduler); + +/** Shadow root of the panel: the tabs, the edit button, the content. */ +async function panel(card: HomeShiftCard) { + await settle(card); + const element = $(card, "homeshift-schedule-panel") as Lit; + await element.updateComplete; + return element.shadowRoot!; +} + +/** Shadow root of the timelines of the shown tab. */ +async function timelines(card: HomeShiftCard) { + const element = (await panel(card)).querySelector("homeshift-schedule-timeline") as Lit; + await element.updateComplete; + return element.shadowRoot!; +} + +async function open(card: HomeShiftCard) { + $(card, ".schedules-open")!.click(); + return timelines(card); +} + +describe("schedules panel", () => { + it("shows a bar naming the day mode, panel closed", async () => { + const card = await render(schedulerHass()); + const bar = $(card, ".schedules-open")!; + expect(bar.textContent).toContain("Schedules"); + expect(bar.textContent).toContain("Travail"); + expect(bar.getAttribute("aria-expanded")).toBe("false"); + expect($(card, ".schedules-panel")).toBeNull(); + }); + + it("stays hidden without the Scheduler integration", async () => { + const hass = { ...makeHass(homeshiftStates()), callWS: vi.fn().mockRejectedValue(new Error("unknown command")) }; + const card = await render(hass); + expect($(card, ".schedules-bar")).toBeNull(); + }); + + it("stays hidden when no schedule is tagged with a day mode", async () => { + const list = schedules().map((s) => ({ ...s, tags: ["Other"] })); + const card = await render(schedulerHass(list)); + expect($(card, ".schedules-bar")).toBeNull(); + }); + + it("stays hidden and reads nothing with show_schedules: false", async () => { + const hass = schedulerHass(); + const card = await render(hass, { show_schedules: false }); + expect($(card, ".schedules-bar")).toBeNull(); + expect(hass.callWS).not.toHaveBeenCalled(); + }); + + it("opens over the card with the day mode's rooms", async () => { + const card = await render(schedulerHass()); + const root = await open(card); + expect($(card, ".schedules-close")!.getAttribute("aria-expanded")).toBe("true"); + expect($(card, ".container")!.hasAttribute("inert")).toBe(true); + const rooms = [...root.querySelectorAll(".room-name")].map((r) => r.textContent); + expect(rooms).toEqual(["Salon"]); + expect(root.querySelector(".measured")!.textContent).toBe("19.2°"); + // Heating first, then cooling; the disabled one hatched. + expect([...root.querySelectorAll(".kind")].map((k) => k.getAttribute("icon"))).toEqual([ + "mdi:radiator", + "mdi:snowflake", + ]); + expect(root.querySelectorAll(".row.disabled")).toHaveLength(1); + }); + + it("follows the day mode", async () => { + const hass = schedulerHass(); + hass.states["select.homeshift_day_mode"] = entity("select.homeshift_day_mode", "Maison", { + options: ["Maison", "Travail"], + }); + const card = await render(hass); + const root = await open(card); + expect([...root.querySelectorAll(".row")].map((r) => r.getAttribute("title"))).toEqual([ + "Maison bureau", + ]); + }); + + it("has a tab per day mode, the current one marked and shown", async () => { + const card = await render(schedulerHass()); + await open(card); + const root = await panel(card); + const tabs = [...root.querySelectorAll(".tab")]; + expect(tabs.map((t) => t.textContent!.trim())).toEqual(["Maison", "Travail"]); + expect(tabs[1].getAttribute("aria-selected")).toBe("true"); + expect(tabs[1].querySelector(".active-dot")).not.toBeNull(); + expect(tabs[0].querySelector(".active-dot")).toBeNull(); + + tabs[0].click(); + const rows = [...(await timelines(card)).querySelectorAll(".row")]; + expect(rows.map((r) => r.getAttribute("title"))).toEqual(["Maison bureau"]); + }); + + it("swaps the timelines for the scheduler card of the tab", async () => { + const card = await render(schedulerHass(), { schedule_entities: ["climate.salon"] }); + await open(card); + const root = await panel(card); + const edit = root.querySelector(".edit")!; + expect(edit.getAttribute("aria-label")).toBe("Edit schedules"); + edit.click(); + await panel(card); + await panel(card); + const scheduler = root.querySelector("scheduler-card") as FakeScheduler; + expect(scheduler.config).toMatchObject({ + type: "custom:scheduler-card", + tags: ["Travail"], + include: ["climate.salon"], + }); + expect(root.querySelector("homeshift-schedule-timeline")).toBeNull(); + + edit.click(); + await panel(card); + expect(root.querySelector("scheduler-card")).toBeNull(); + expect(root.querySelector("homeshift-schedule-timeline")).not.toBeNull(); + }); + + it("keeps only the schedules of schedule_entities", async () => { + const card = await render(schedulerHass(), { schedule_entities: ["climate.bureau"] }); + const root = await open(card); + expect(root.querySelector(".empty")!.textContent).toContain("No schedule for Travail"); + }); + + it("closes with the bar or Escape, focus back on the bar", async () => { + const card = await render(schedulerHass()); + await open(card); + $(card, ".schedules-panel")!.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + await settle(card); + expect($(card, ".schedules-panel")).toBeNull(); + expect(card.shadowRoot!.activeElement).toBe($(card, ".schedules-open")); + + await open(card); + $(card, ".schedules-close")!.click(); + await settle(card); + expect($(card, ".schedules-panel")).toBeNull(); + }); + + it("turns the cooling on after turning the heating of the room off", async () => { + const hass = schedulerHass(); + const card = await render(hass); + const root = await open(card); + const [, coolSwitch] = root.querySelectorAll(".switch"); + coolSwitch.click(); + await settle(card); + expect(hass.callService.mock.calls).toEqual([ + ["switch", "turn_off", { entity_id: ["switch.schedule_travail_salon"] }], + ["switch", "turn_on", { entity_id: "switch.schedule_travail_salon_clim" }], + ]); + }); + + it("reads the schedules again when a schedule switch changes", async () => { + const hass = schedulerHass(); + const card = await render(hass); + expect(hass.callWS).toHaveBeenCalledTimes(1); + const states = { ...hass.states }; + states["switch.schedule_travail_salon"] = { + ...states["switch.schedule_travail_salon"], + state: "off", + last_updated: "2026-10-10T12:00:00Z", + }; + card.hass = { ...hass, states }; + await settle(card); + expect(hass.callWS).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/states.test.ts b/test/states.test.ts new file mode 100644 index 0000000..96b5d31 --- /dev/null +++ b/test/states.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { formatMinutes, isUsable, numericState } from "../src/state"; +import { $, $$, entity, homeshiftStates, makeHass, renderCard } from "./helpers"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("state helpers", () => { + it("rejects unknown, unavailable, empty and missing states", () => { + expect(isUsable(undefined)).toBe(false); + expect(isUsable("unknown")).toBe(false); + expect(isUsable({ state: "unavailable" })).toBe(false); + expect(isUsable({ state: "" })).toBe(false); + expect(isUsable({ state: "08:30" })).toBe(true); + }); + + it("reads numbers only from usable finite states", () => { + expect(numericState({ state: "unavailable" })).toBeUndefined(); + expect(numericState({ state: "abc" })).toBeUndefined(); + expect(numericState({ state: "25" })).toBe(25); + }); + + it("formats minutes like the settings labels", () => { + expect(formatMinutes(25)).toBe("25 min"); + expect(formatMinutes(60)).toBe("1h"); + expect(formatMinutes(90)).toBe("1h30"); + }); +}); + +describe("missing entities (B-3)", () => { + it("reports a missing entity instead of showing stub data", async () => { + const states = homeshiftStates(); + delete states["select.homeshift_day_mode"]; + const card = await renderCard(makeHass(states)); + expect($(card, ".error")?.textContent).toContain( + "Entity not found: select.homeshift_day_mode", + ); + expect($(card, ".presets")).toBeNull(); + }); + + it("uses localized stub data in the card picker preview", async () => { + const card = await renderCard(makeHass({}, "fr"), {}, true); + expect($(card, ".error")).toBeNull(); + const labels = $$(card, "select option").map((o) => o.textContent!.trim()); + expect(labels).toEqual(["Maison", "Travail", "Télétravail", "Absence"]); + const selected = $(card, "select option[selected]"); + expect(selected?.textContent?.trim()).toBe("Travail"); + expect($$(card, "button.preset").map((b) => b.textContent!.trim())).toEqual( + ["Éteint", "Chauffage", "Climatisation", "Ventilation"], + ); + expect($(card, "button.preset.on")?.textContent?.trim()).toBe("Chauffage"); + expect(card.shadowRoot!.textContent).not.toContain("preview."); + }); + + it("hides a setting whose number entity is missing", async () => { + const states = homeshiftStates(); + delete states["number.homeshift_early_switch"]; + const card = await renderCard(makeHass(states)); + expect($$(card, ".setting")).toHaveLength(1); + }); +}); + +describe("unknown / unavailable sensors (B-4)", () => { + it.each(["unknown", "unavailable"])("hides next mode when %s", async (s) => { + const states = homeshiftStates(); + states["sensor.homeshift_next_mode"] = entity("sensor.homeshift_next_mode", s); + states["sensor.homeshift_next_mode_at"] = entity("sensor.homeshift_next_mode_at", s); + const card = await renderCard(makeHass(states)); + expect($(card, ".next-row")).toBeNull(); + expect(card.shadowRoot!.textContent).not.toContain(s); + }); + + it("hides cover times when unavailable", async () => { + const states = homeshiftStates(); + states["sensor.homeshift_cover_open_time"].state = "unavailable"; + states["sensor.homeshift_cover_close_time"].state = "unknown"; + const card = await renderCard(makeHass(states)); + expect($(card, ".cover-times")).toBeNull(); + }); +}); + +describe("duration settings (B-5)", () => { + it("is neither highlighted nor NaN when unavailable", async () => { + const states = homeshiftStates(); + states["number.homeshift_override_duration"].state = "unavailable"; + const card = await renderCard(makeHass(states)); + const head = $$(card, "button.setting-head")[0] as HTMLButtonElement; + expect(head.disabled).toBe(true); + expect($(card, ".setting-value")?.classList.contains("set")).toBe(false); + expect($(card, ".setting-value")?.textContent).toBe("Unavailable"); + expect(card.shadowRoot!.innerHTML).not.toContain("NaN"); + }); + + it("shows a value set outside the presets", async () => { + const states = homeshiftStates(); + states["number.homeshift_early_switch"].state = "25"; + const card = await renderCard(makeHass(states)); + const value = $$(card, ".setting-value")[1]; + expect(value.textContent).toBe("25 min"); + expect(value.classList.contains("set")).toBe(true); + }); + + it("calls number.set_value with the next preset", async () => { + const hass = makeHass(homeshiftStates()); + const card = await renderCard(hass); + $$(card, "button.setting-head")[0].click(); + await card.updateComplete; + $$(card, ".stepper button")[1].click(); + expect(hass.callService).toHaveBeenCalledWith("number", "set_value", { + entity_id: "number.homeshift_override_duration", + value: 15, + }); + }); +}); + +describe("cover entity (B-2)", () => { + it("is not part of the stub config", async () => { + const { HomeShiftCard } = await import("../src/components/homeshift-card"); + expect(HomeShiftCard.getStubConfig()).not.toHaveProperty("cover_entity"); + }); + + it("does not act on a configured but missing cover", async () => { + const hass = makeHass(homeshiftStates()); + const card = await renderCard(hass, { cover_entity: "cover.nope" }); + const row = $(card, ".cover-time")!; + expect(row.tagName).toBe("SPAN"); + row.click(); + expect(hass.callService).not.toHaveBeenCalled(); + }); +}); + +describe("duration stepper", () => { + async function stepFrom(state: string, button: 0 | 1) { + const states = homeshiftStates(); + states["number.homeshift_early_switch"].state = state; + const hass = makeHass(states); + const card = await renderCard(hass); + $$(card, "button.setting-head")[1].click(); + await card.updateComplete; + $$(card, ".stepper button")[button].click(); + return hass.callService; + } + + it.each([ + ["25", 1, 30], + ["25", 0, 15], + ["45", 1, 60], + ["45", 0, 30], + ] as const)("steps from %s min to the adjacent preset", async (state, button, value) => { + expect(await stepFrom(state, button)).toHaveBeenCalledWith( + "number", + "set_value", + { entity_id: "number.homeshift_early_switch", value }, + ); + }); + + it("does not step past the last preset", async () => { + const states = homeshiftStates(); + states["number.homeshift_early_switch"].state = "300"; + const card = await renderCard(makeHass(states)); + $$(card, "button.setting-head")[1].click(); + await card.updateComplete; + const [down, up] = $$(card, ".stepper button") as HTMLButtonElement[]; + expect(up.disabled).toBe(true); + expect(down.disabled).toBe(false); + }); +}); diff --git a/test/time.test.ts b/test/time.test.ts new file mode 100644 index 0000000..566ee65 --- /dev/null +++ b/test/time.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { formatTime, timeFormat } from "../src/time"; +import { $, entity, homeshiftStates, makeHass, renderCard } from "./helpers"; + +afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ""; +}); + +describe("next mode time", () => { + it("rolls Tomorrow over to Today at midnight, without polling", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "Date"] }); + vi.setSystemTime(new Date(2026, 0, 10, 23, 58)); + const states = homeshiftStates(); + states["sensor.homeshift_next_mode_at"] = entity( + "sensor.homeshift_next_mode_at", + new Date(2026, 0, 11, 8, 0).toISOString(), + ); + const card = await renderCard(makeHass(states)); + expect($(card, ".next-row")?.textContent).toContain("Tomorrow "); + expect(vi.getTimerCount()).toBe(1); + + await vi.advanceTimersByTimeAsync(3 * 60 * 1000); + await card.updateComplete; + expect($(card, ".next-row")?.textContent).toContain("Today "); + + card.remove(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("time format (A-4)", () => { + const afternoon = new Date(2026, 0, 10, 14, 5); + + it("follows a forced 24 h profile", () => { + expect(formatTime(afternoon, { language: "en", time_format: "24" })).toBe( + "14:05", + ); + }); + + it("follows a forced 12 h profile", () => { + expect( + formatTime(afternoon, { language: "fr", time_format: "12" }), + ).toMatch(/02:05\s?PM/i); + }); + + it("uses the HA language, not the browser, by default", () => { + expect(timeFormat({ language: "fr", time_format: "language" })).toEqual({ + locale: "fr", + hour12: undefined, + }); + expect(timeFormat({ language: "fr", time_format: "system" }).locale).toBe( + undefined, + ); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..9698150 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".." + }, + "include": ["../src/**/*", "./**/*"] +} diff --git a/tsconfig.json b/tsconfig.json index 8ec0c27..a9f0fe5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,10 +4,12 @@ "module": "ES2020", "lib": ["ES2020", "DOM", "DOM.Iterable"], "declaration": false, - "sourceMap": true, + "sourceMap": false, "outDir": "dist", "rootDir": "src", "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5b69960 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "happy-dom", + include: ["test/**/*.test.ts"], + }, +});