chore(docs): range specs/contrats/refs dans docs/, track l'autorité

- specs -> docs/ (DASHBOARD_SPEC compagnon du contrat)
- ref introspection nymea-jsonRPC -> docs/reference/
- mockups HTML -> docs/mockups/ ; briefs consommés -> docs/archive/
- track AGENTS.md + INTERFACE_etmvariableload.md (étaient untracked)
- INTERFACE_*: marqué miroir, canonique = repo plugin
This commit is contained in:
Patrick Schurig 2026-06-28 12:49:57 +02:00
parent be6694ce30
commit 0ebbde49da
11 changed files with 6133 additions and 0 deletions

32
AGENTS.md Normal file
View File

@ -0,0 +1,32 @@
# AGENTS.md — etm-powersync-app
Application mobile Flutter ETM : lecture live + écrans add & config.
- **Licence** : à confirmer · **Miroir public** : selon licence
- **Agent** : app · **Branche** : feature/beta-add-config · **Scope** : écrans add & config
## Invariants locaux
1. Lecture live + transport JSON-RPC DÉJÀ faits — ne pas y toucher.
2. Câbler sur l'API publiée (namespace `NymeaEnergy` actuel ; cible `Ems`).
3. Mode Optimiser / Manuel. Héos GRISÉ par défaut (capabilities `community/false/false`).
Objet capabilities à 4 champs : tier / optimizerExpected / optimizerAlive / activeStrategy.
4. Pas de libellé « Héos » dans la config GPL. Pas d'écran tier Auto/Predict en beta.
5. Distribution TestFlight (iOS) + APK/Play internal (Android) — pas de review store sur le chemin critique.
## Déploiement live-demo
Sur instruction explicite uniquement (`"déploie la demo"` / `"mets à jour app.etm-powersync.fr"`).
Ne pas déployer automatiquement, même après un build réussi.
```bash
flutter build web --release
rsync -avz --delete build/web/ etm@192.168.1.241:/var/www/html/app.etm-powersync.fr/
```
**Prérequis** : build `--release` sans erreur.
**Serveur** : DMZ Apache · `192.168.1.241` · user `etm`
**URL** : https://app.etm-powersync.fr
**Routing** : `FallbackResource /index.html` (go_router côté client)
## Références
- `CLAUDE.md` existant, `TODO.md`.
Carte globale et frontières : voir `../AGENTS.md`.

View File

@ -0,0 +1,171 @@
<!-- ⚠️ MIROIR — NE PAS ÉDITER ICI. Canonique : etm-powersync-energy-plugin-etm/INTERFACE_etmvariableload.md. Éditer le canonique, puis resync. -->
# INTERFACE `etmvariableload` — contrat charge pilotée · **rév. 2**
> **Source de vérité unique.** Déposé **à l'identique dans les deux repos**
> (`etm-powersync-app` et `etm-powersync-energy-plugin-etm`). Toute divergence se tranche **ici**,
> pas dans le code. Un agent qui a besoin d'un champ absent met à jour ce doc **d'abord**, dans
> les deux repos.
>
> **rév. 2 — corrige la rév. 1 sur un point structurant :** `etmvariableload` ne couvre **que les
> charges à puissance pilotable** (EV, ECS résistif, routeur PV). **La PAC SG-Ready en est exclue** :
> elle reste un modèle **à états**, interface séparée et conservée (voir §8). `LoadAction` garde
> donc **deux** kinds (`Setpoint` ET l'état SG-Ready), pas un seul.
---
## 1. Principe & frontière
Trois niveaux, trois responsables. **Aucune notion de matériel ne remonte au-dessus du thing.**
| Niveau | Où | Quoi |
|---|---|---|
| **Câblage** | thing / plugin device (wizard Things) | GPIO, registres Modbus, quelle résistance sur quelle phase |
| **Déclaration** | config charge pilotée (app) | mode, paliers atteignables **en W**, priorité, besoins |
| **Runtime** | interface `etmvariableload` | setpoint W envoyé ↔ puissance réelle remontée |
> **Invariant directeur.** L'energymanager raisonne **uniquement en watts**. Aucune résistance,
> aucun relais, aucun index de palier matériel ne franchit l'app ni n'atteint l'arbitre.
> **Le thing décide du matériel ; l'energymanager décide de la puissance.**
---
## 2. Portée de `etmvariableload`
**EST une `etmvariableload`** (charge à puissance pilotable) :
- **EV charger** (setpoint courant/puissance),
- **ECS résistif** — classique (on/off) ou multipalier,
- **Routeur PV / triac** (modulation continue).
**N'EST PAS une `etmvariableload`** :
- **PAC SG-Ready** → modèle à **états** (2 bits, hystérésis, `minStateHold`). Interface distincte,
conservée telle quelle. Voir §8. Un signal SG-Ready est une *suggestion d'état* à la PAC, pas
une consigne de puissance en watts — le forcer en setpoint W serait faux.
---
## 3. Interface device `etmvariableload`
Implémentée par le thing (ECS / EV / routeur). **Côté moteur, seul l'`EtmVariableLoadAdapter`
consommateur est livré** ; la déclaration de l'interface nymea + le driver (résistances/triac/
anti-rebond/phases) sont une **session device dédiée, hors repo moteur**.
| State | Accès | Type | Unité | Sémantique |
|---|---|---|---|---|
| `maxPowerW` | read | uint | W | Plafond physique de la charge. |
| `powerLevels` | read | list&lt;uint&gt; | W | Paliers atteignables, triés croissants, **`0` inclus**. Présent en *fixed*. Vide/absent ⇒ modulation continue. |
| `powerSetpoint` | **write** | uint | W | Consigne demandée par l'energymanager. |
| `currentPowerW` | read | uint | W | Puissance **réellement** appliquée. Ferme la boucle de quantification. |
**Le thing, en recevant un `powerSetpoint` :** choisit la combinaison matérielle concrète,
applique ses **verrous anti-rebond** (minOn/minOff) et son **équilibrage de phases**, puis publie
`currentPowerW`. L'energymanager n'a aucune visibilité là-dessus — c'est voulu.
---
## 4. `LoadDescriptor` — config app → energymanager
Émis par l'écran « Configurer charge pilotée » (sous-ensemble de `OPTIMIZER_PROTOCOL.md §5`,
noms identiques) :
```jsonc
{
"id": "uuid-thing-ecs",
"label": "Chauffe-eau",
"adapter": "etmvariableload",
"mode": "fixed", // "fixed" | "dynamic"
"powerLevels": [0, 600, 1200, 1800, 2400], // REQUIS si fixed — absent si dynamic
"maxPowerW": 3000, // REQUIS si dynamic ; plafond aussi en fixed
"priority": 2, // rang ; 1 = servi en premier
"enabled": true,
"needs": { "dailyDeadline": "06:00", "minEnergyWhPerDay": 4000 }
}
```
- `powerLevels` saisi par l'installateur — directement ou via l'**assistant résistances** (UI-only :
additionne les unités, pré-remplit, puis disparaît ; **aucune résistance n'est stockée ni envoyée**).
- Config **persistée côté plugin** (`/var/lib/nymea/energy-load-configuration.json`), jamais en
SharedPreferences. App = éditeur : `NymeaEnergy.GetLoadConfig` / `SetLoadConfig`.
---
## 5. Règle d'arrondi (energymanager)
À chaque cycle, pour une charge dont c'est le tour dans la priorité :
- **Fixed** : `level = max( l ∈ powerLevels | l ≤ budget )` puis `setPowerSetpoint(level)`.
L'arbitre connaît la **granularité déclarée** → pas de sur-allocation, **zéro cycle de retard**.
- **Dynamic** : `setPowerSetpoint( clamp(budget, 0, maxPowerW) )`.
- **Résidu** : lire le `currentPowerW` **de début de cycle** (télémétrie de l'adapter, comme la
Correction B — **PAS** une relecture post-setpoint : invariant 8, aucune boucle de feedback).
Le résidu `budget currentPowerW` repart vers la charge suivante de la priorité **du même cycle**.
**Déclaré vs réel :** la **déclaration** (`powerLevels`/`maxPowerW`) est la référence de
**planification**. `currentPowerW` est le **juge de paix runtime**. Si le réel plafonne durablement
sous le déclaré → erreur de déclaration, l'installateur corrige. L'energymanager **n'écrase jamais**
la déclaration.
---
## 6. `LoadAction`**deux** kinds
| Kind | Pour | Porte |
|---|---|---|
| `Setpoint` | EV / ECS / routeur (`etmvariableload`) | `powerW` |
| *état SG-Ready* | PAC (interface à états, §8) | `state` (14) |
| `Constraint` | batterie (déféré 3f) | conservé |
- **Supprimer `Stage`** (ECS → `Setpoint`). Retirer `stage/minStage/maxStage` de
`LoadContextTelemetry` (le verrou ECS redescend dans le thing).
- **Conserver le kind d'état SG-Ready** et `state/minState/maxState` pour la PAC — son
`minStateHold` (protection court-cycling compresseur) reste dans son chemin, **inchangé**.
- `reason` reste porté par chaque `LoadAction` (déjà le cas).
---
## 7. Expo du `reason` — tableau additif `loadActions[]`
Sérialiser dans `GetChargingSchedules` / `ChargingSchedulesChanged` un **champ additif**
(rétro-compatible, ne touche pas la structure EV `ChargingAction`) :
```jsonc
"loadActions": [
{ "loadId": "uuid-ecs", "setpointW": 1200, "currentPowerW": 1180, "reason": "Surplus PV 1.3 kW — ECS rang 2" },
{ "loadId": "uuid-pac", "state": 3, "reason": "Surplus PV — PAC en état 3" }
]
```
- Couvre **toutes** les charges, y compris la PAC. `setpointW`/`currentPowerW` **optionnels**
(absents pour une charge à états) ; `loadId` + `reason` **toujours** présents.
- Nécessite que l'arbitre **retienne le dernier plan** (aujourd'hui loggé puis jeté,
`energyarbitrator.cpp:140-153`) — petite addition d'état.
- C'est le canal que consomme la **decision card** de l'app : « pour chaque charge, le pourquoi ».
---
## 8. PAC SG-Ready — modèle à états (conservé)
Hors `etmvariableload`. Le `SgReadyAdapter` existant (états 14, combos K1/K2, hystérésis,
`minStateHold`) **n'est pas réécrit**. L'arbitre lui envoie un **état** (kind §6), pas un setpoint W.
Repli L2 : état 2 (normal, mains off), jamais état 1 (blocage) — comportement actuel conservé.
> Une PAC pilotée par Modbus qui exposerait une vraie puissance pilotable pourra, *plus tard*,
> implémenter `etmvariableload`. Tant qu'on pilote par signal SG-Ready, c'est un modèle à états.
---
## 9. Sécurité (déjà en place)
- **Watchdog L2** (`evaluateMeterFreshness`, 90 s, `applyDegradedMode`) existant. En dégradé,
repli charge pilotée = **`setPowerSetpoint(0)` `force=true`** (remplace l'ECS stage-0 actuel) ;
PAC → état 2. Conserver l'ordre `update()` (sécurité avant planif) intact.
- `enabled:false` → rôle déclaré mais **exclu** de l'arbitrage (aucune action envoyée).
---
## 10. Dépendances liées (à coder en même temps, côté plugin)
- Handler `nymeaenergyjsonhandler` : `Get/SetLoadConfig` + persistance.
- `loadActions[]` dans `GetChargingSchedules` / `ChargingSchedulesChanged`.
- Câbler l'arbitre pour construire ses adapters depuis `LoadConfig` au lieu du registre en dur
(`energypluginnymea.cpp:58-80` — le « 3g »).

252
docs/DASHBOARD_SPEC.md Normal file
View File

@ -0,0 +1,252 @@
# Dashboard ETM-PowerSync — Brief d'implémentation Flutter
> Spec pour Claude Code. Objectif : recréer l'écran **Dashboard** de l'app `app.etm-powersync.fr` (Flutter), tel que validé en maquette. Les fichiers HTML de référence (`etm-powersync-dashboard-v4.html`, `etm-powersync-flux-drilldown.html`) sont la **source de vérité visuelle** — ce document en est la transcription technique.
---
## 1. Contexte
ETM-PowerSync est un HEMS (Home Energy Management System). Le cœur intelligent s'appelle **Héos** (optimiseur prédictif MILP, horizon 2448 h). Le Dashboard doit faire deux choses : montrer l'état énergétique **en direct**, et rendre **lisible et explicable** ce que Héos décide — c'est le différenciateur produit. Public : clients résidentiels (langage clair, zéro jargon) + installateurs.
---
## 2. Principes directeurs (ne pas dévier)
1. **Chaque information à un seul endroit.** Pas de doublon entre les blocs. L'autoconsommation est en KPI (haut) → elle n'apparaît nulle part ailleurs sur le Dashboard.
2. **Lisibilité avant densité.** Lecture rapide en haut, détail optionnel en bas. Le client pressé s'arrête à la décision Héos ; le curieux scrolle.
3. **Expliquer = créer la confiance.** Toute action automatique de Héos est accompagnée d'un *pourquoi* en langage courant.
4. **Cohérence des flux = physique réelle.** Héos est au centre. Le solaire et le réseau alimentent (→ vers le centre) ; le centre répartit vers batterie et maison. Jamais « maison → batterie ».
5. **Passé plein, futur voilé.** Sur tout graphe temporel : réalisé en couleur pleine, prévu en hachuré + fond grisé, ligne « maintenant » qui traverse.
6. **Fluide = rien ne saute.** Chaque valeur live s'anime (interpolation), jamais de flash.
7. **Quality floor non négociable.** Responsive, dark mode, focus clavier, `prefers-reduced-motion` respecté, contraste AA.
---
## 3. Design tokens (`etm_tokens.dart`)
Compléter le fichier existant avec ces valeurs. Typo : **IBM Plex Sans** (UI) + **IBM Plex Mono** (toutes les valeurs chiffrées / données).
### Couleurs de marque (identiques light/dark sauf note)
| Token | Hex | Usage |
|---|---|---|
| `brand` | `#31A3DD` | bleu ETM, accents, navigation active |
| `solar` | `#FEC113` (dark `#FFCB33`) | production solaire |
| `eco` | `#28A06A` (dark `#33B87A`) | Héos, éco, autonomie, batterie |
| `heat` | `#EF8B2A` | chauffage / PAC |
| `water` | `#31A3DD` | eau chaude (ECS) |
| `grid` | `#8A98A3` | réseau |
| `import` | `#E8806F` | import réseau |
### Thème clair
| Token | Valeur |
|---|---|
| `bg` | `#EEF2F5` |
| `bg2` | `#E4EAEE` |
| `surface` | `#FFFFFF` |
| `ink` | `#0D2B3B` |
| `muted` | `#5D6F7B` |
| `faint` | `#8B9AA4` |
| `line` | `rgba(13,43,59,.10)` |
| `lineStrong` | `rgba(13,43,59,.16)` |
### Thème sombre
| Token | Valeur |
|---|---|
| `bg` | `#06141D` |
| `bg2` | `#0A1F2B` |
| `surface` | `#0D2B3B` (la couleur de marque devient la surface) |
| `ink` | `#EAF3F7` |
| `muted` | `#9BB0BC` |
| `faint` | `#6C8493` |
| `line` | `rgba(255,255,255,.09)` |
| `lineStrong` | `rgba(255,255,255,.16)` |
### Couleurs des flux (graphe « plan de la journée »)
`solToHouse #B8C34D` · `solToEcs #31A3DD` · `solToVe #28A06A` · `solToBatt #7FCF72` · `solToGrid #FEC113` · `battToHouse #2F6FB0` · `gridToHouse #E8806F`
### Formes & profondeur
- Rayons : cartes **20**, contrôles internes **1314**, pills/badges **30** (stadium).
- **Parti pris assumé** : bordure hairline 1 px (`line`) + rayon large. Ombres quasi nulles en clair (`0 1px 2px rgba(13,43,59,.04)`), aucune en sombre. **Ne pas** revenir aux cartes blanches à ombre portée Material par défaut.
- Espacement entre cartes : 14. Padding interne carte : 1416.
---
## 4. Architecture de l'écran
`DashboardScreen` = `Scaffold` + `SingleChildScrollView` (colonne), bottom nav fixe à 5 onglets. Ordre vertical des blocs :
```
[ TopBar : wordmark + toggle thème clair/sombre ]
[ 1. EnergyFlowCard ] ← flux en croix, temps réel, drill-down
[ 2. KpiRow ] ← 4 tuiles : autoconso, autonomie, vers/depuis réseau
[ 3. HeosDecisionCard ] ← décision en cours + pourquoi
[ 4. DayPlanCard ] ← météo + barres horaires (réalisé/prévu)
[ BottomNav : Dashboard·Énergie·Things·A/C·Favoris ]
```
Onglets : `Dashboard` actif (couleur `brand`), les autres en `faint`.
---
## 5. Composants
### 5.1 EnergyFlowCard — flux en croix + drill-down
**Layout** : carte carrée (aspect 1:1, max 320). `Stack` :
- Couche basse : `CustomPaint` (lignes hairline entre hub↔nœuds + **particules animées**).
- Couche haute : nœuds en `Positioned` (Stack), `GestureDetector` sur les nœuds tappables.
**Vue d'ensemble (`overview`)** — positions cardinales :
| Position | Nœud | Valeur (ex.) | Direction flux | Tap |
|---|---|---|---|---|
| centre (hub) | **Héos** (icône HEMS, anneau eco pulsant) | — | — | — |
| haut | Solaire | `3.8 kW` | entrant (nœud→hub) | → `prod` |
| droite | Batterie | `87 %` | sortant (hub→nœud) | — |
| gauche | Réseau | `0.2 kW` | entrant | — |
| bas | Maison | `2.4 kW` | sortant | → `conso` |
**Drill-down `conso`** (depuis Maison) — hub = Maison, autour :
PAC (haut, `heat`), Eau chaude (droite, `water`), Voiture (bas, `eco`), **Autres (estimé)** (gauche, bordure **dashed**, `grid`).
> Le nœud « Autres (estimé) » est **obligatoire** : il absorbe le non-mesuré pour que le bilan boucle (conso totale = somme des postes). Sans lui, l'addition ne tombe pas juste et le client se méfie.
**Drill-down `prod`** (depuis Solaire) — hub = Solaire, autour :
Onduleur 1 (haut), Onduleur 2 (bas), chacun avec **point d'état** (vert OK / orange défaut). Sens : onduleur → hub.
**Particules** : `CustomPainter` + `AnimationController` en boucle. N points par segment circulant de la source vers la destination. **Vitesse et densité ∝ puissance** (flux fort = plus de points, plus rapides ; flux faible = 1 point lent). Couleur = couleur de la source.
**Transitions drill-down** : sortie = fade + scale 0.8 (~170 ms) ; entrée = « éclatement radial », chaque nœud apparaît en `scale .25→1` avec overshoot (`Curves.easeOutBack`) et **stagger** (~60 ms entre nœuds). Détail intégral du sens/positions dans `etm-powersync-flux-drilldown.html`.
**Affordance tap** : badge `+` discret sur les nœuds tappables (Héos n'est pas tappable ; Solaire et Maison le sont). Profondeur **max 2 niveaux** — au-delà, écran dédié.
**Icône HEMS** : utiliser `HemsIcon` (déjà livré, `hems_icon.dart`), couleur `eco`.
### 5.2 KpiRow
Grille 2×2.
- **Tuile autoconsommation** : anneau de progression (`CustomPaint`, couleur `solar`) + `%` en mono. S'anime au chargement (anneau qui se remplit + count-up).
- **Tuile autonomie** : idem, couleur `eco`.
- **Tuile « Vers réseau »** : icône flèche ←, valeur `kWh` du jour, badge fond `eco` léger.
- **Tuile « Depuis réseau »** : icône flèche →, valeur `kWh`, badge fond `import` léger.
> Ces deux métriques (autoconso/autonomie) sont les seules occurrences sur le Dashboard. Ne pas les répéter ailleurs.
### 5.3 HeosDecisionCard
Carte bordée `eco` (liseré subtil). Contenu :
- **En-tête** : icône étincelle (mark Héos) + titre « Héos pilote en ce moment » + sous-titre mono « optimise sur 24 h · maj HH:MM » + point eco pulsant.
- **Résumé** : 1 phrase en langage client, segment clé surligné `eco` (ex. « Le surplus part dans l'eau chaude et la voiture plutôt que d'être revendu à bas prix »).
- **Liste d'actions** (1 par asset piloté) : icône + titre court + **le pourquoi** (1 ligne, `muted`) + puissance (`kW`, mono, `eco`), séparées par hairline.
- **Pied** : lien « Voir le plan de la journée » → ouvre l'écran plein **Plan de la journée** (séparé, voir `etm-powersync-plan-journee.html` ; c'est là que vivent les pistes appareils détaillées + la ligne gains €).
### 5.4 DayPlanCard — météo + barres horaires
`CustomPaint` unique (préférer à fl_chart : contrôle fin du voile futur + bande météo intégrée). **Une seule colonne temporelle, axe horaire partagé**, de haut en bas :
1. **Bande météo** : icône (soleil / soleil-nuage / lune) + température, à intervalles (~toutes les 4 h), posée sur l'axe.
2. **Barres empilées horaires** (kWh) : segments positifs vers le haut (`solToHouse`, `solToEcs`, `solToVe`, `solToBatt`, `solToGrid`), négatifs vers le bas (`battToHouse`, `gridToHouse`). Axe Y `0/2/4`.
3. **Axe X** : `0h 6h 12h 18h 24h`.
**Traitement passé/futur** : tout ce qui est à droite de la ligne « maintenant » = **fond grisé + voile hachuré** (lignes diagonales couleur surface, opacité ~.5) ; ligne « maintenant » verticale + tab `HH:MM`.
**Légende** sous le graphe : pastilles maison/eau/voiture/batterie/réseau/batt.→maison.
> **NE PAS mettre sur le Dashboard** (retirés volontairement, ils vivent dans l'écran plein « Plan de la journée ») :
> - les **pistes appareils** (timelines ECS / batterie charge-décharge / VE) ;
> - la **ligne gains** (autoconso prévue / réseau ce soir / +€).
### 5.5 Toggle thème
Switch clair/sombre dans la TopBar, knob animé (overshoot). Persister le choix (provider de thème).
---
## 6. Animations & widgets
| Effet | Approche Flutter |
|---|---|
| Valeurs live (kW, %) | `TweenAnimationBuilder` (count-up, easeOutCubic ~900 ms) |
| Particules de flux | `CustomPainter` + `AnimationController` répété ; position = `(t + offset) % 1` mappée sur le segment |
| Anneaux KPI | `CustomPaint`, `Tween` sur l'angle balayé |
| Toggles / switch | `AnimatedAlign` ou `AnimatedPositioned`, courbe overshoot (`Curves.easeOutBack`) |
| Segmented (pill qui glisse) | `Stack` + `AnimatedPositioned` du « glider » |
| Cartes expandables (A/C) | `AnimatedSize` |
| Drill-down croix | `AnimationController` + `Interval` staggered (scale + opacity) |
| Anneau Héos (hub) | pulsation lente en boucle (opacité 0.25↔0.6) |
**Reduced motion** : si `MediaQuery.of(context).disableAnimations`, court-circuiter les animations (valeurs finales directes, pas de particules en boucle).
---
## 7. Modèles de données (esquisse)
```dart
class LiveFlow { // alimente EnergyFlowCard (overview)
final double solarKw, gridKw, houseKw, batteryKw; // batteryKw signé
final int batteryPct;
}
class FlowNode { // générique, réutilisé à chaque niveau
final String label, valueText, iconKey;
final FlowDirection direction; // inbound | outbound
final bool tappable, estimated; // estimated → bordure dashed
final NodeState? state; // ok | warn (onduleurs)
final String? drillTo; // 'conso' | 'prod'
}
class HeosDecision {
final String summary; // langage client
final List<HeosAction> actions;
final DateTime updatedAt;
}
class HeosAction { final String assetIconKey, title, why; final double powerKw; }
class Kpi { final int autoconsumptionPct, autonomyPct; final double toGridKwh, fromGridKwh; }
class DayPlan {
final List<HourlyFlow> hours; // 0..23
final List<WeatherPoint> weather;
final double nowHour; // ex. 14.5
}
class HourlyFlow { // tous en kWh, signés selon sens
final int hour;
final double solToHouse, solToEcs, solToVe, solToBatt, solToGrid;
final double battToHouse, gridToHouse;
}
class WeatherPoint { final int hour; final WeatherKind kind; final int tempC; }
```
Toutes les valeurs affichées proviennent de ces modèles (les chiffres des maquettes sont des exemples). Prévoir des états **loading** (skeleton) et **vide** (« en attente de données du compteur… », jamais d'écran blanc).
---
## 8. Assets fournis
- `hems_icon.dart``HemsIcon(size, color)`, `CustomPainter` natif sans dépendance. À utiliser pour le hub central.
- `hems_icon.svg` — équivalent SVG (`currentColor`) si passage par `flutter_svg`.
---
## 9. Arborescence suggérée
```
lib/features/dashboard/
dashboard_screen.dart
widgets/energy_flow_card.dart + painters/flow_painter.dart
widgets/kpi_row.dart + painters/ring_painter.dart
widgets/heos_decision_card.dart
widgets/day_plan_card.dart + painters/day_plan_painter.dart
models/dashboard_models.dart
lib/shared/widgets/hems_icon.dart
lib/theme/etm_tokens.dart (compléter)
```
## 10. Ordre de construction recommandé
1. Compléter `etm_tokens.dart` (couleurs light/dark, typo, radius).
2. `KpiRow` + `RingPainter` (le plus simple, valide les tokens).
3. `HeosDecisionCard` (statique, valide la typo/hairlines).
4. `EnergyFlowCard` overview + `FlowPainter` (lignes + particules), **puis** drill-down.
5. `DayPlanCard` + `DayPlanPainter` (barres + voile futur + bande météo).
6. Assemblage `DashboardScreen` + bottom nav + toggle thème.
7. Passe quality floor : reduced motion, focus, contraste AA en clair **et** sombre.
---
*Réfs visuelles : `etm-powersync-dashboard-v4.html` (écran complet) et `etm-powersync-flux-drilldown.html` (interaction croix). En cas de doute sur un espacement ou une couleur, ces fichiers font foi.*

View File

@ -0,0 +1,68 @@
# BRIEF — Agent App (`etm-powersync-app`)
Écran **Rôles & appareils** + écran **Configurer une charge pilotée**.
## Fichiers de référence (joints)
1. **`INTERFACE_etmvariableload.md`** — le **contrat**. Source de vérité.
2. **`roles_appareils_mockup.html`** — écran 1 : liste, deux zones, drag-priorité, tri-état.
3. **`config_ecs_mockup.html`** — écran 2 : config Dynamique/Fixe + assistant résistances.
> **Règle d'or : le contrat (`.md`) PRIME sur les maquettes.** Les `.html` sont l'intention
> visuelle (look, structure, interactions). En cas de conflit entre une maquette et le `.md`,
> **le `.md` gagne.** Ne reproduis pas un pixel au détriment d'une décision du contrat.
## ⚠️ La maquette écran 1 est partiellement périmée
Elle a été produite avant plusieurs décisions. **Ne la suis pas aveuglément** sur ces 3 points —
applique la version ci-dessous :
1. **Paliers ≠ config matérielle.** L'écran 1 affiche les paliers comme une propriété « matériel »
de l'ECS. **Faux.** Ce sont une **déclaration** (`powerLevels`), éditée dans l'écran 2. Sur la
carte de liste, ne montre qu'un résumé (ex. « 5 paliers · max 2400 W »), pas un éditeur.
2. **Auto-détection solaire/batterie.** Les rôles **inférés** par interface (production solaire,
batterie) apparaissent **directement en zone définitive** avec un badge « détecté » — ils ne
passent **pas** par « À configurer ». La batterie garde sa carte (priorité + `needs`), mais
n'est jamais « à configurer » au sens bloquant.
3. **Compteur `X / 6`.** Comptage : inférés (solaire, batterie) = configurés d'office ;
pickés (réseau, EV, ECS, PAC) = comptés quand **assignés** *ou* actés **« Pas d'appareil »**.
« Complet » = aucun rôle en non-configuré bloquant.
## Décisions verrouillées (ne pas re-débattre)
- **Tri-état par rôle** : `present(thingId)` / `absent({status:"absent"})` / non-configuré
(clé absente). Jamais `thingId:null`.
- **Trois destinations** : Compteurs (réseau→rootMeter, solaire→détecté) · Charges pilotables
(EV/ECS/PAC/batterie, priorisables) · Absent.
- **Cycle de vie** : « À configurer » → assigné (remonte dans Compteurs *ou* Charges pilotables
selon le rôle) **ou** « Pas d'appareil » (acté absent). Trois états d'écran : vierge (tout à
configurer, zones du haut vides + invite) / en cours / complet.
- **Drag = priorité.** Réordonner les cartes de charges pilotables écrit `priority` (rang 1 =
servi en premier). Les cartes compteur ne sont **pas** draggables. L'ordre est **indicatif**
(le scheduler le bouscule sur Tempo rouge / deadline) — le signaler dans l'UI, pas le présenter
comme rigide.
- **Config charge pilotée** (écran 2) émet un `LoadDescriptor` au format du contrat §3 :
`mode` (dynamic|fixed), `powerLevels[]` **ou** `maxPowerW`, `priority`, `enabled`, `needs`.
- **Assistant résistances = UI-only.** Il additionne les unités et pré-remplit `powerLevels`,
puis disparaît. **Aucune résistance n'est stockée ni envoyée.** Ne calcule la combinatoire
**nulle part ailleurs**.
## Plomberie / état du repo
- Base de départ fournie : `energy_setup_provider.dart` (modèle tri-état + routage des sinks)
est une **intention**, à adapter aux signatures réelles du repo — pas à coller tel quel.
- **Persistance = côté plugin**, pas SharedPreferences. L'app appelle `NymeaEnergy.GetLoadConfig`
/ `SetLoadConfig`. **Ces méthodes n'existent pas encore****stub** : logger le JSON du
`LoadDescriptor` qui partira, ne rien casser. Le câblage réel arrive quand l'agent energymanager
les expose.
- `gridMeter``Energy.SetRootMeter` (natif, testable contre `hems` maintenant).
- **Bug à corriger au passage** : `nymea_service.dart` appelle `EnergyPlugin.SetChargingInfo`
doit être `NymeaEnergy.SetChargingInfo` ; et les champs `ChargingInfo` sont faux
(`mode``chargingMode`, `targetSoc``targetPercentage`, `endTime``endDateTime`,
`minCurrent` n'existe pas). Réf : introspect `jsonRpc.txt`.
## Périmètre de CE brief
Les écrans Rôles & appareils + Configurer charge pilotée **uniquement**. Pas le wizard Things
(autre lot), pas Système/Réseau/ModbusRTU (autre lot), pas la decision card. Lis `jsonRpc.txt`
(l'introspect) avant tout appel nymea — ne devine pas les signatures.

View File

@ -0,0 +1,80 @@
# BRIEF — Agent App · lot **Connexions multi-HEMS** (`etm-powersync-app`)
Gestionnaire d'installations : découverte mDNS, liste persistée, bascule, auth par box.
> **À lancer APRÈS le lot Rôles & appareils, pas en parallèle.** Ce lot retouche
> `nymea_service.dart`, la persistance des tokens et le chargement au switch — les fichiers que
> le lot rôles vient de toucher. Deux agents dessus en même temps = collision.
## Fichiers de référence (joints)
1. **`installations_mockup.html`** — écrans Installations + Connexion (intention visuelle).
2. `jsonRpc.txt` — introspect (namespaces `Authentication.*`, mDNS, `System.GetServerUuid`/About).
## Modèle (décisions verrouillées)
- **Identité stable d'une installation = Server UUID**, jamais l'IP (DHCP → l'IP change).
Une installation enregistrée = `{ uuid (clé), nom, host, port, token, lastSeen }`.
- **Une seule connexion active à la fois.** `nymea_service` reste mono-connexion ; un
**`ConnectionManager`** au-dessus gère la liste + le switch (déconnecte l'active, connecte la
cible). **Pas** de N sockets parallèles.
- **Token par UUID.** La persistance actuelle stocke *un* token → passer à un token **par UUID**
(clé = uuid de la box). En basculant, réutiliser le token de la box cible. C'est le piège
mono→multi : ne pas réutiliser le token de la box précédente.
- **Découverte mDNS** (`_nymea._tcp`) **+ ajout manuel** (host/port) en repli. Au scan, matcher
par **UUID** : box connue → réutiliser son token ; nouvelle → proposer de l'ajouter.
- **Auth à deux branches**, détectées au handshake : box neuve (pas d'admin) → **créer** un
utilisateur (`Authentication.CreateUser`) ; box initialisée → **login**
(`Authentication.Authenticate`). Le segmenteur UI n'est qu'un repli ; la détection est auto.
## Transport — attention
- En LAN c'est **`ws://IP:4444`** (clair). Le code actuel ne fait **pas** de TLS (`Socket.connect`
nu, `ws://`). Ne pas coder un champ `wss://` qui ne marche pas encore — le TLS (port 2222
`nymeas://`) est une dette séparée. Champ « port » par défaut **4444**.
## Routage / gate
- Condition du gate = **« pas de connexion active »** (≠ « liste vide »).
- Pas de connexion active au démarrage → **Installations en racine** (plein écran).
- Connexion active → **dashboard** ; Installations reste accessible.
- **Réentrée** : le **nom de la box active dans l'en-tête du drawer** (le « Site démo » déjà présent
sous le logo) devient **cliquable → ouvre Installations** pour basculer. Pas d'entrée de menu
dédiée à enfouir : l'en-tête du drawer EST l'accès.
## Trois états de l'écran Installations
1. **Vierge** (liste vide, 1ʳᵉ fois) → découverte mDNS + « Ajouter manuellement » au premier plan.
2. **Liste sans active** (box connues, aucune connectée) → liste en « hors-ligne », tap = reconnecter.
3. **Liste avec active** (ouvert depuis le drawer) → une box « actif », les autres pour basculer.
Mêmes composants, trois configurations.
## Niveaux d'accès (client vs installateur)
- **Client / général** : lister + **basculer** entre installations. Pas de déverrouillage requis.
- **Installateur** (gated) : **ajout manuel**, **créer l'admin**, **supprimer** une installation.
Mêmes écran ; ces actions grisées/masquées hors mode installateur.
## Bascule (switch) — invalidation
- Switcher = déconnecter l'active → connecter la cible (token par UUID) → **ré-déclencher le
chargement** (rôles, things, powerbalance) pour la nouvelle box.
- **Vider les caches** des écrans (dashboard, rôles, things) au switch : aucune donnée de la box
précédente ne doit subsister. C'est le vrai risque d'intégration.
- Transition courte avec le **nom de la box cible** (« Connexion à Client Kutzenhausen… ») avant
d'atterrir sur le dashboard, pour que l'installateur soit sûr d'être sur la bonne installation.
## Hors lot (séparé, plus tard)
- **Wizard de commissioning d'une box neuve** (réseau filaire/wifi de nymea:core, façon nymea-app
écrans 2-3). C'est l'installation d'un système, distinct de « connecter l'app à un HEMS
existant ». Pas dans ce lot.
## Avant de coder
Explore le repo (`nymea_service.dart`, `etm_tokens.dart`, `nymea_user.dart`, le drawer, le routeur),
`flutter analyze`, et **propose un plan ordonné** (modèle installation + persistance par UUID →
`ConnectionManager` + switch → mDNS → écran Installations 3 états → auth 2 branches → gate/routage →
en-tête drawer cliquable) **avant** d'écrire. Lis `jsonRpc.txt` pour les signatures `Authentication.*`.
Ne devine pas.

View File

@ -0,0 +1,249 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ETM PowerSync — Configurer charge pilotée (ECS)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#081f2b; --panel:#0d2b3b; --card:#12384a; --card2:#0f3242;
--line:rgba(255,255,255,.08); --line2:rgba(255,255,255,.14);
--blue:#31a3dd; --yellow:#fec113; --green:#35c98a; --red:#e07a7a;
--txt:#e8f1f6; --muted:#90a8b6; --muted2:#6f8b9a; --r:16px;
}
*{box-sizing:border-box}
body{margin:0;background:
radial-gradient(900px 500px at 15% -10%, #0e3145 0%, transparent 60%),
radial-gradient(900px 600px at 110% 10%, #0b2535 0%, transparent 55%),
var(--bg);
font-family:'Inter',system-ui,sans-serif;color:var(--txt);min-height:100vh;padding:34px 20px 60px}
.head{max-width:880px;margin:0 auto 26px}
.head h1{font-family:'Poppins';font-weight:700;font-size:20px;margin:0 0 6px}
.head p{margin:0;color:var(--muted);font-size:13.5px;line-height:1.55;max-width:700px}
.head code{color:#9fd4ee}
.stage{display:flex;gap:34px;justify-content:center;flex-wrap:wrap;align-items:flex-start}
.col{display:flex;flex-direction:column;align-items:center;gap:12px}
.cap{color:var(--muted2);font-size:12px;font-family:'Poppins';font-weight:500;letter-spacing:.4px;text-transform:uppercase}
.phone{width:392px;background:var(--panel);border:1px solid var(--line2);border-radius:30px;
overflow:hidden;box-shadow:0 30px 80px -30px rgba(0,0,0,.7);display:flex;flex-direction:column;height:812px}
.appbar{display:flex;align-items:center;gap:12px;padding:18px 16px 14px;border-bottom:1px solid var(--line);
background:linear-gradient(#102f40,#0d2b3b)}
.appbar .ic{width:26px;height:26px;display:grid;place-items:center;color:var(--muted);font-size:18px}
.appbar h2{font-family:'Poppins';font-weight:600;font-size:15.5px;margin:0;flex:1;text-align:center}
.scroll{overflow:auto;padding:16px 15px 26px}
.scroll::-webkit-scrollbar{width:0}
.field{margin-top:18px}
.field:first-child{margin-top:4px}
.field>label{display:block;font-family:'Poppins';font-weight:600;font-size:12px;color:var(--muted);
letter-spacing:.3px;margin-bottom:9px}
.field>label small{color:var(--muted2);font-weight:500;letter-spacing:0}
/* segmented Dynamique/Fixe */
.seg{display:flex;background:var(--card2);border:1px solid var(--line);border-radius:11px;padding:4px;gap:4px}
.seg button{flex:1;border:0;background:transparent;color:var(--muted);font-family:'Poppins';font-weight:600;
font-size:13px;padding:10px;border-radius:8px;cursor:pointer;transition:.15s}
.seg button.on{background:var(--blue);color:#04222f}
.seg .hint{}
.mode-hint{margin-top:9px;font-size:11.5px;color:var(--muted2);line-height:1.5}
.pick{display:flex;align-items:center;gap:10px;padding:11px;border-radius:11px;border:1px solid var(--line);
background:var(--card2);margin-bottom:8px}
.pick.sel{border-color:var(--blue);background:rgba(49,163,221,.08)}
.pick .radio{width:18px;height:18px;border-radius:50%;border:2px solid var(--muted2);flex:none}
.pick.sel .radio{border-color:var(--blue);background:radial-gradient(circle,var(--blue) 42%,transparent 46%)}
.pick .t{font-size:13.5px;font-weight:500}.pick .s{font-size:11.5px;color:var(--muted2);margin-top:1px}
.typeflag{font-size:11px;color:var(--muted);background:rgba(255,255,255,.05);border:1px solid var(--line);
border-radius:7px;padding:3px 9px;display:inline-block}
/* paliers chips (le champ réellement sauvé) */
.saved-flag{font-size:10.5px;color:var(--green);background:rgba(53,201,138,.12);border:1px solid rgba(53,201,138,.3);
border-radius:20px;padding:2px 8px;font-weight:600;float:right;margin-top:-2px}
.chips{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.pchip{display:flex;align-items:center;gap:8px;background:var(--card2);border:1px solid var(--line2);
border-radius:9px;padding:9px 11px;font-family:'Poppins';font-weight:600;font-size:13.5px}
.pchip i{font-style:normal;color:var(--muted2);font-weight:500;font-size:11px}
.pchip.off{opacity:.6}
.pchip .x{color:var(--muted2);cursor:pointer;font-size:13px;margin-left:2px}
.add{width:36px;height:36px;border-radius:9px;border:1px dashed var(--line2);color:var(--muted);
display:grid;place-items:center;font-size:19px;background:transparent;cursor:pointer}
/* assistant */
.assist-btn{margin-top:11px;display:flex;align-items:center;gap:8px;width:100%;justify-content:center;
background:transparent;border:1px dashed rgba(49,163,221,.4);color:var(--blue);font-family:'Poppins';
font-weight:600;font-size:12.5px;padding:10px;border-radius:10px;cursor:pointer}
.assist{margin-top:10px;border:1px solid rgba(49,163,221,.28);background:rgba(49,163,221,.06);
border-radius:12px;padding:13px;display:none}
.assist.open{display:block}
.assist h4{font-family:'Poppins';font-size:12px;margin:0 0 3px;color:var(--blue)}
.assist p{margin:0 0 11px;font-size:11px;color:var(--muted2);line-height:1.45}
.res-row{display:flex;gap:8px;margin-bottom:9px}
.res-row input{flex:1;background:var(--panel);border:1px solid var(--line2);border-radius:9px;color:var(--txt);
font-family:'Poppins';font-weight:600;font-size:13px;padding:9px 11px;width:100%;text-align:center}
.res-row input::placeholder{color:var(--muted2);font-weight:500}
.preview{font-size:11.5px;color:var(--muted);margin:4px 0 12px;line-height:1.5}
.preview b{color:var(--green);font-family:'Poppins'}
.assist .apply{width:100%;background:var(--blue);color:#04222f;border:0;border-radius:9px;padding:10px;
font-family:'Poppins';font-weight:600;font-size:12.5px;cursor:pointer}
.stepper{display:flex;align-items:center;justify-content:space-between;padding:11px 12px;border:1px solid var(--line);
border-radius:11px;background:var(--card2);margin-bottom:8px}
.stepper .lab{font-size:13px}.stepper .lab small{display:block;color:var(--muted2);font-size:11px;margin-top:1px}
.stepper .ctl{display:flex;align-items:center;gap:13px}
.stepper .ctl b{font-family:'Poppins';font-weight:600;font-size:14px;min-width:64px;text-align:right}
.pm{width:28px;height:28px;border-radius:8px;border:1px solid var(--line2);color:var(--blue);background:transparent;
font-size:17px;display:grid;place-items:center;cursor:pointer}
.prio{width:24px;height:24px;border-radius:7px;background:var(--blue);color:#04222f;font-family:'Poppins';
font-weight:700;font-size:12px;display:grid;place-items:center}
.sw{width:40px;height:23px;border-radius:20px;background:rgba(53,201,138,.85);position:relative;cursor:pointer}
.sw::after{content:"";position:absolute;top:2px;left:19px;width:19px;height:19px;border-radius:50%;background:#fff}
.save{display:flex;gap:10px;margin-top:24px}
.btn{text-align:center;font-family:'Poppins';font-weight:600;font-size:13px;padding:12px;border-radius:11px;cursor:pointer}
.btn.go{background:var(--blue);color:#04222f;flex:2;border:0}
.btn.test{flex:1.3;background:transparent;border:1px solid var(--line2);color:var(--txt)}
.note{max-width:392px;font-size:11.5px;color:var(--muted2);line-height:1.55;text-align:left}
.note b{color:var(--yellow)}
</style>
</head>
<body>
<div class="head">
<h1>Configurer une charge pilotée — ECS</h1>
<p>Mode <b>Dynamique</b> (consigne continue) ou <b>Fixe</b> (paliers). Le champ sauvé est <b>toujours la liste de paliers en W</b> — jamais les résistances. L'assistant n'est qu'un <i>générateur de saisie côté UI</i> : il additionne les résistances et pré-remplit les paliers, puis disparaît. L'energymanager ne reçoit que <code>powerLevels: [0,600,1200,1800,2400]</code> et envoie un setpoint W ; le thing ECS décide la combinaison.</p>
</div>
<div class="stage">
<!-- MODE FIXE -->
<div class="col">
<div class="phone">
<div class="appbar"><div class="ic"></div><h2>Configurer · Chauffe-eau</h2><div class="ic"></div></div>
<div class="scroll">
<div class="field">
<label>Appareil</label>
<div class="pick sel"><div class="radio"></div>
<div style="flex:1"><div class="t">Relay-ECS</div><div class="s">multipallier · 4 sorties</div></div>
<div class="typeflag">etmvariableload</div></div>
</div>
<div class="field">
<label>Mode de pilotage</label>
<div class="seg">
<button>Dynamique</button>
<button class="on">Fixe (paliers)</button>
</div>
<div class="mode-hint">Fixe : l'energymanager arrondit le surplus au palier atteignable ≤ surplus, puis l'envoie. Le thing choisit quelles résistances composent ce palier.</div>
</div>
<div class="field">
<label>Paliers de puissance <span class="saved-flag">enregistré · powerLevels</span><br><small>valeurs envoyées à l'energymanager</small></label>
<div class="chips" id="levels">
<div class="pchip off">0 W <i>off</i></div>
<div class="pchip">600 W<span class="x">×</span></div>
<div class="pchip">1200 W<span class="x">×</span></div>
<div class="pchip">1800 W<span class="x">×</span></div>
<div class="pchip">2400 W<span class="x">×</span></div>
<button class="add" title="Ajouter un palier">+</button>
</div>
<button class="assist-btn" onclick="document.getElementById('asst').classList.toggle('open')">⚡ Calculer depuis mes résistances</button>
<div class="assist" id="asst">
<h4>Assistant résistances</h4>
<p>Entrez la puissance de chaque résistance. L'UI calcule les combinaisons et remplit les paliers ci-dessus. Rien de tout ça n'est transmis — seuls les paliers le sont.</p>
<div class="res-row">
<input value="600"><input value="600"><input value="600"><input value="600">
</div>
<div class="preview">4 résistances → paliers atteignables : <b>0 · 600 · 1200 · 1800 · 2400 W</b></div>
<button class="apply" onclick="document.getElementById('asst').classList.remove('open')">Remplir les paliers</button>
</div>
</div>
<div class="field">
<label>Stratégie</label>
<div class="stepper"><div class="lab">Rang de service<small>défini au glisser-déposer</small></div><div class="ctl"><div class="prio">2</div></div></div>
<div class="stepper"><div class="lab">Inclure dans l'arbitrage</div><div class="ctl"><div class="sw"></div></div></div>
</div>
<div class="field">
<label>Besoins · needs</label>
<div class="stepper"><div class="lab">Eau chaude prête à<small>dailyDeadline</small></div><div class="ctl"><b>06:00</b></div></div>
<div class="stepper"><div class="lab">Énergie min. / jour<small>minEnergyWhPerDay</small></div><div class="ctl"><button class="pm"></button><b>4 kWh</b><button class="pm">+</button></div></div>
</div>
<div class="save"><div class="btn test">Tester</div><div class="btn go">Enregistrer</div></div>
</div>
</div>
<div class="cap">Mode Fixe · paliers + assistant</div>
</div>
<!-- MODE DYNAMIQUE -->
<div class="col">
<div class="phone">
<div class="appbar"><div class="ic"></div><h2>Configurer · Routeur solaire</h2><div class="ic"></div></div>
<div class="scroll">
<div class="field">
<label>Appareil</label>
<div class="pick sel"><div class="radio"></div>
<div style="flex:1"><div class="t">Routeur PV</div><div class="s">triac · modulation continue</div></div>
<div class="typeflag">etmvariableload</div></div>
</div>
<div class="field">
<label>Mode de pilotage</label>
<div class="seg">
<button class="on">Dynamique</button>
<button>Fixe (paliers)</button>
</div>
<div class="mode-hint">Dynamique : l'energymanager envoie n'importe quelle valeur entre 0 et la puissance nominale. Pas de paliers.</div>
</div>
<div class="field">
<label>Puissance nominale max <span class="saved-flag">enregistré · maxPowerW</span></label>
<div class="stepper"><div class="lab">Résistance ECS<small>plafond de modulation</small></div><div class="ctl"><button class="pm"></button><b>3000 W</b><button class="pm">+</button></div></div>
</div>
<div class="field">
<label>Stratégie</label>
<div class="stepper"><div class="lab">Rang de service<small>défini au glisser-déposer</small></div><div class="ctl"><div class="prio">2</div></div></div>
<div class="stepper"><div class="lab">Inclure dans l'arbitrage</div><div class="ctl"><div class="sw"></div></div></div>
</div>
<div class="field">
<label>Besoins · needs</label>
<div class="stepper"><div class="lab">Eau chaude prête à<small>dailyDeadline</small></div><div class="ctl"><b>06:00</b></div></div>
<div class="stepper"><div class="lab">Énergie min. / jour<small>minEnergyWhPerDay</small></div><div class="ctl"><button class="pm"></button><b>4 kWh</b><button class="pm">+</button></div></div>
</div>
<div class="save"><div class="btn test">Tester</div><div class="btn go">Enregistrer</div></div>
</div>
</div>
<div class="cap">Mode Dynamique · puissance max seule</div>
</div>
</div>
<div style="display:flex;justify-content:center;margin-top:24px">
<div class="note">
<b>Frontière.</b> Aucune notion de résistance ne franchit l'app. En Fixe, le contrat est <code style="color:#9fd4ee">powerLevels:[…]</code> ; en Dynamique, <code style="color:#9fd4ee">maxPowerW</code>. Dans les deux cas, l'energymanager raisonne en W et le thing décide du matériel.
</div>
</div>
<script>
document.querySelectorAll('.seg button').forEach(b=>b.addEventListener('click',()=>{
b.parentNode.querySelectorAll('button').forEach(x=>x.classList.remove('on'));b.classList.add('on');
}));
document.querySelectorAll('.sw').forEach(s=>s.addEventListener('click',()=>{
s.style.background=s.style.background.includes('53,201')?'rgba(255,255,255,.16)':'rgba(53,201,138,.85)';
s.querySelector?s.firstChild:0;
}));
document.querySelectorAll('.pchip .x').forEach(x=>x.addEventListener('click',e=>e.target.closest('.pchip').remove()));
</script>
</body>
</html>

View File

@ -0,0 +1,239 @@
<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ETM PowerSync — dashboard v4</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--sans:'IBM Plex Sans',system-ui,sans-serif; --mono:'IBM Plex Mono',ui-monospace,monospace;
--brand:#31a3dd; --solar:#fec113; --eco:#28a06a; --heat:#ef8b2a; --water:#31a3dd; --grid-c:#8a98a3; --import:#e8806f;
--c-house:#b8c34d; --c-ecs:#31a3dd; --c-ve:#28a06a; --c-bcharge:#7fcf72; --c-bdis:#2f6fb0; --c-imp:#e8806f; --c-exp:#fec113;
--radius:20px;
}
html[data-theme="light"]{--bg:#eef2f5;--bg2:#e4eaee;--surface:#fff;--ink:#0d2b3b;--muted:#5d6f7b;--faint:#8b9aa4;
--line:rgba(13,43,59,.10);--line-strong:rgba(13,43,59,.16);--heos-tint:rgba(40,160,106,.09);--track:#e7edf1;--futurebg:rgba(13,43,59,.035);--veil:#fff;--shadow:0 1px 2px rgba(13,43,59,.04)}
html[data-theme="dark"]{--bg:#06141d;--bg2:#0a1f2b;--surface:#0d2b3b;--ink:#eaf3f7;--muted:#9bb0bc;--faint:#6c8493;
--line:rgba(255,255,255,.09);--line-strong:rgba(255,255,255,.16);--heos-tint:rgba(40,160,106,.15);--track:rgba(255,255,255,.06);--futurebg:rgba(255,255,255,.04);--veil:#0d2b3b;--shadow:none;--eco:#33b87a;--solar:#ffcb33}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--sans);background:var(--bg2);color:var(--ink);min-height:100vh;display:flex;justify-content:center;align-items:flex-start;padding:24px 12px;-webkit-font-smoothing:antialiased}
body,.surface,.theme-toggle,.card,.kpi,.node,.tabbar{transition:background .35s,color .35s,border-color .35s}
.phone{width:100%;max-width:392px;background:var(--bg);border-radius:34px;border:1px solid var(--line);overflow:hidden;position:relative;padding-bottom:74px}
.topbar{display:flex;align-items:center;justify-content:space-between;padding:18px 20px 8px}
.wordmark b{font-size:17px;font-weight:700;letter-spacing:-.01em;display:block;line-height:1.1}
.wordmark span{font-family:var(--mono);font-size:10px;color:var(--faint)}
.theme-toggle{width:58px;height:30px;border-radius:30px;border:1px solid var(--line-strong);background:var(--surface);position:relative;cursor:pointer;display:flex;align-items:center;padding:0 6px;justify-content:space-between}
.theme-toggle svg{width:14px;height:14px;color:var(--faint);z-index:1}
.theme-toggle .knob{position:absolute;top:3px;left:3px;width:22px;height:22px;border-radius:50%;background:var(--brand);transition:transform .32s cubic-bezier(.34,1.56,.64,1)}
html[data-theme="dark"] .theme-toggle .knob{transform:translateX(28px);background:var(--solar)}
.scroll{padding:6px 16px 16px}
/* flux croix */
.flowcard{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:14px 16px 6px;margin-bottom:14px}
.flowhead{display:flex;align-items:center;justify-content:space-between;margin-bottom:2px}
.flowhead h2{font-size:13px;font-weight:600}
.flowhead .live{display:flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11px;color:var(--muted)}
.flowhead .dot{width:7px;height:7px;border-radius:50%;background:var(--eco);animation:pulse 2.4s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(40,160,106,.5)}70%{box-shadow:0 0 0 7px rgba(40,160,106,0)}100%{box-shadow:0 0 0 0 rgba(40,160,106,0)}}
.flow{position:relative;width:100%;aspect-ratio:1/1;max-width:320px;margin:0 auto}
.flow svg.lines{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.node{position:absolute;transform:translate(-50%,-50%);width:72px;height:72px;border-radius:50%;background:var(--surface);border:1.5px solid var(--line);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;text-align:center}
.node .ico{width:21px;height:21px} .node .val{font-family:var(--mono);font-size:11.5px;font-weight:600;line-height:1} .node .lab{font-size:8.5px;color:var(--muted)}
.node.hub{width:90px;height:90px;border-width:2px} .node.hub .val{font-size:14px}
.node.solar{left:50%;top:12%;border-color:rgba(254,193,19,.45)} .node.batt{left:88%;top:50%;border-color:rgba(40,160,106,.4)} .node.grid{left:12%;top:50%} .node.loads{left:50%;top:88%;border-color:rgba(49,163,221,.4)} .node.hub{left:50%;top:50%}
.ico.solar{color:var(--solar)}.ico.batt{color:var(--eco)}.ico.grid{color:var(--grid-c)}.ico.loads{color:var(--water)}.ico.hub{color:var(--ink)}
.heos-ring{position:absolute;inset:-5px;border-radius:50%;border:1.5px solid var(--eco);animation:ring 3s ease-in-out infinite}
@keyframes ring{0%,100%{opacity:.25}50%{opacity:.6}}
.flowline{fill:none;stroke:var(--line-strong);stroke-width:.7}
/* KPI */
.kpis{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px}
.kpi{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:13px 14px;display:flex;align-items:center;gap:12px}
.kpi .ring{width:50px;height:50px;flex:0 0 auto} .kpi .ring circle{fill:none;stroke-width:5;stroke-linecap:round} .kpi .ring .bg{stroke:var(--track)} .kpi .ring .fg{transition:stroke-dashoffset 1s cubic-bezier(.4,0,.2,1);transform:rotate(-90deg);transform-origin:50% 50%}
.kpi .tx .v{font-family:var(--mono);font-size:18px;font-weight:600;line-height:1} .kpi .tx .l{font-size:11px;color:var(--muted);margin-top:3px}
.kpi.small .ic{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;flex:0 0 auto} .kpi.small .ic svg{width:16px;height:16px}
.ic.exp{background:rgba(40,160,106,.12);color:var(--eco)} .ic.imp{background:rgba(232,128,111,.14);color:var(--import)}
.kpi.small .v{font-family:var(--mono);font-size:16px;font-weight:600;line-height:1} .kpi.small .l{font-size:10.5px;color:var(--muted);margin-top:3px}
/* carte Héos */
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:14px;overflow:hidden}
.card.heos{border-color:rgba(40,160,106,.3)}
.heos-hd{display:flex;align-items:center;gap:9px;padding:15px 16px 12px}
.heos-hd .mark{width:30px;height:30px;border-radius:9px;background:var(--heos-tint);display:flex;align-items:center;justify-content:center;color:var(--eco)} .heos-hd .mark svg{width:17px;height:17px}
.heos-hd .t{flex:1} .heos-hd .t b{font-size:14px;font-weight:600;display:block} .heos-hd .t small{font-size:11px;color:var(--muted);font-family:var(--mono)}
.heos-hd .glow{width:8px;height:8px;border-radius:50%;background:var(--eco);animation:pulse 2.4s infinite}
.summary{padding:0 16px 14px;font-size:14px;line-height:1.45} .summary .hl{color:var(--eco);font-weight:600}
.acts{padding:0 16px 8px}
.act{display:flex;align-items:flex-start;gap:12px;padding:11px 0;border-top:1px solid var(--line)}
.act .ai{width:34px;height:34px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex:0 0 auto} .act .ai svg{width:18px;height:18px}
.ai-car{background:rgba(40,160,106,.12);color:var(--eco)} .ai-water{background:rgba(49,163,221,.12);color:var(--water)} .ai-batt{background:rgba(40,160,106,.12);color:var(--eco)}
.act .ax{flex:1;min-width:0} .act .ax .top{display:flex;justify-content:space-between;align-items:baseline;gap:8px} .act .ax .top b{font-size:13.5px;font-weight:600} .act .ax .top .kw{font-family:var(--mono);font-size:12px;font-weight:600;color:var(--eco)} .act .ax .why{font-size:12px;color:var(--muted);margin-top:2px;line-height:1.35}
.seemore{display:flex;align-items:center;justify-content:center;gap:6px;padding:13px;border-top:1px solid var(--line);font-size:13px;font-weight:600;color:var(--brand);cursor:pointer} .seemore svg{width:15px;height:15px}
/* plan de la journée */
.card-hd{display:flex;align-items:center;justify-content:space-between;padding:15px 16px 2px} .card-hd b{font-size:14px;font-weight:600} .card-hd small{font-size:11px;font-family:var(--mono);color:var(--muted)}
.chartwrap{padding:4px 8px 0} svg.viz{width:100%;height:auto;display:block;overflow:visible}
.axlab{font-family:var(--mono);font-size:7.5px;fill:var(--faint)} .ylab{font-family:var(--mono);font-size:7.5px;fill:var(--faint);text-anchor:end}
.trk-name{font-size:9px;fill:var(--ink);font-weight:500} .trk-sub{font-size:7px;fill:var(--faint);font-family:var(--mono)}
.temp{font-family:var(--mono);font-size:8px;fill:var(--muted);text-anchor:middle}
.now-tab{font-family:var(--mono);font-size:8px;fill:#fff;font-weight:600}
.legend{display:flex;flex-wrap:wrap;gap:7px 12px;padding:8px 16px 4px} .lg{display:flex;align-items:center;gap:6px;font-size:10px;color:var(--muted)} .lg i{width:9px;height:9px;border-radius:2px;display:inline-block}
.gains{display:flex;align-items:center;gap:12px;padding:13px 16px} .gains .g{flex:1} .gains .g .v{font-family:var(--mono);font-size:15px;font-weight:600} .gains .g .l{font-size:10px;color:var(--muted);margin-top:2px} .gains .sep{width:1px;align-self:stretch;background:var(--line)} .gains .v.pos{color:var(--eco)}
.tabbar{position:absolute;left:0;right:0;bottom:0;height:66px;background:var(--surface);border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-around;padding-bottom:4px}
.tab{display:flex;flex-direction:column;align-items:center;gap:3px;font-size:10px;color:var(--faint);cursor:pointer;border:0;background:transparent} .tab svg{width:21px;height:21px} .tab.active{color:var(--brand)}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
</head>
<body>
<div class="phone">
<div class="topbar">
<div class="wordmark"><b>ETM PowerSync</b><span>Dashboard</span></div>
<button class="theme-toggle" id="themeBtn" aria-label="Basculer le thème">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.4 1.4M17.6 17.6L19 19M19 5l-1.4 1.4M6.4 17.6L5 19"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1111.2 3 7 7 0 0021 12.8z"/></svg>
<span class="knob"></span>
</button>
</div>
<div class="scroll">
<!-- FLUX CROIX -->
<div class="flowcard">
<div class="flowhead"><h2>Flux énergétique</h2><div class="live"><span class="dot"></span> en direct</div></div>
<div class="flow">
<svg class="lines" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
<path id="ls" class="flowline" d="M50 22 L50 39"/><path id="lb" class="flowline" d="M61 50 L78 50"/><path id="lg" class="flowline" d="M22 50 L39 50"/><path id="ll" class="flowline" d="M50 61 L50 78"/>
<g fill="#fec113"><circle r="1.5"><animateMotion dur="1.5s" repeatCount="indefinite"><mpath href="#ls"/></animateMotion></circle><circle r="1.5"><animateMotion dur="1.5s" begin="-.5s" repeatCount="indefinite"><mpath href="#ls"/></animateMotion></circle><circle r="1.5"><animateMotion dur="1.5s" begin="-1s" repeatCount="indefinite"><mpath href="#ls"/></animateMotion></circle></g>
<g fill="#28a06a"><circle r="1.4"><animateMotion dur="2.3s" repeatCount="indefinite"><mpath href="#lb"/></animateMotion></circle><circle r="1.4"><animateMotion dur="2.3s" begin="-1.15s" repeatCount="indefinite"><mpath href="#lb"/></animateMotion></circle></g>
<g fill="#8a98a3"><circle r="1.2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#lg"/></animateMotion></circle></g>
<g fill="#31a3dd"><circle r="1.4"><animateMotion dur="2s" repeatCount="indefinite"><mpath href="#ll"/></animateMotion></circle><circle r="1.4"><animateMotion dur="2s" begin="-1s" repeatCount="indefinite"><mpath href="#ll"/></animateMotion></circle></g>
</svg>
<div class="node solar"><svg class="ico solar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.4 1.4M17.6 17.6L19 19M19 5l-1.4 1.4M6.4 17.6L5 19"/></svg><div class="val" data-kw="3.8">0.0 kW</div><div class="lab">Solaire</div></div>
<div class="node batt"><svg class="ico batt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="8" width="16" height="11" rx="2"/><path d="M9 8V6h6v2M8 13l3-2v4l3-2"/></svg><div class="val">87 %</div><div class="lab">+0.4 kW</div></div>
<div class="node grid"><svg class="ico grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 21V9l7-6 7 6v12M9 21v-6h6v6"/></svg><div class="val" data-kw="0.2">0.0 kW</div><div class="lab">Réseau</div></div>
<div class="node loads"><svg class="ico loads" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 2v6M15 2v6M7 8h10v3a5 5 0 01-10 0zM12 16v6"/></svg><div class="val" data-kw="2.1">0.0 kW</div><div class="lab">Pilotés</div></div>
<div class="node hub"><div class="heos-ring"></div><svg class="ico hub" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 11l9-7 9 7M5 10v10h14V10M10 20v-6h4v6"/></svg><div class="val" data-kw="1.1">0.0 kW</div><div class="lab">Maison</div></div>
</div>
</div>
<!-- KPI -->
<div class="kpis">
<div class="kpi"><svg class="ring" viewBox="0 0 50 50"><circle class="bg" cx="25" cy="25" r="20"/><circle class="fg" cx="25" cy="25" r="20" stroke="var(--solar)" data-pct="87"/></svg><div class="tx"><div class="v" data-pct="87">0 %</div><div class="l">Autoconsommation</div></div></div>
<div class="kpi"><svg class="ring" viewBox="0 0 50 50"><circle class="bg" cx="25" cy="25" r="20"/><circle class="fg" cx="25" cy="25" r="20" stroke="var(--eco)" data-pct="94"/></svg><div class="tx"><div class="v" data-pct="94">0 %</div><div class="l">Autonomie</div></div></div>
<div class="kpi small"><div class="ic exp"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l-7 7 7 7"/></svg></div><div><div class="v">6.2 kWh</div><div class="l">Vers réseau · aujourd'hui</div></div></div>
<div class="kpi small"><div class="ic imp"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 5l7 7-7 7"/></svg></div><div><div class="v">1.1 kWh</div><div class="l">Depuis réseau · aujourd'hui</div></div></div>
</div>
<!-- HÉOS -->
<div class="card heos">
<div class="heos-hd"><div class="mark"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l1.8 5.4L19 9l-5.2 1.6L12 16l-1.8-5.4L5 9l5.2-1.6z"/></svg></div><div class="t"><b>Héos pilote en ce moment</b><small>optimise sur 24 h · maj 22:54</small></div><span class="glow"></span></div>
<div class="summary">Le soleil couvre toute la maison. <span class="hl">Le surplus part dans l'eau chaude et la voiture</span> plutôt que d'être revendu à bas prix.</div>
<div class="acts">
<div class="act"><div class="ai ai-water"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3s6 6.5 6 11a6 6 0 01-12 0c0-4.5 6-11 6-11z"/></svg></div><div class="ax"><div class="top"><b>Eau chaude — chauffe maintenant</b><span class="kw">1.2 kW</span></div><div class="why">Le surplus baisse après 17 h. Mieux vaut monter le ballon à 60 °C tant que c'est gratuit.</div></div></div>
<div class="act"><div class="ai ai-car"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 13l1.5-5h11L19 13M5 13h14v5H5zM7 18v2M17 18v2"/></svg></div><div class="ax"><div class="top"><b>Voiture — charge sur surplus</b><span class="kw">0.9 kW</span></div><div class="why">Énergie solaire gratuite. La charge s'ajuste seule si un nuage passe.</div></div></div>
<div class="act"><div class="ai ai-batt"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="8" width="16" height="11" rx="2"/><path d="M9 8V6h6v2"/></svg></div><div class="ax"><div class="top"><b>Batterie — se réserve pour ce soir</b><span class="kw">+0.4 kW</span></div><div class="why">Charge lente jusqu'à 87 %, pour alimenter la maison après 18 h sans toucher au réseau.</div></div></div>
</div>
<div class="seemore">Voir le plan de la journée <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6"/></svg></div>
</div>
<!-- ===== PLAN DE LA JOURNÉE (météo + flux + appareils) ===== -->
<div class="card">
<div class="card-hd"><b>Plan de la journée</b><small>météo · flux · appareils</small></div>
<div class="chartwrap"><svg class="viz" id="plansvg" viewBox="0 0 340 178" preserveAspectRatio="xMidYMid meet"></svg></div>
<div class="legend">
<div class="lg"><i style="background:var(--c-house)"></i>maison</div>
<div class="lg"><i style="background:var(--c-ecs)"></i>eau</div>
<div class="lg"><i style="background:var(--c-ve)"></i>voiture</div>
<div class="lg"><i style="background:var(--c-bcharge)"></i>batterie</div>
<div class="lg"><i style="background:var(--c-exp)"></i>réseau</div>
<div class="lg"><i style="background:var(--c-bdis)"></i>batt.→maison</div>
</div>
</div>
</div>
<nav class="tabbar">
<button class="tab active"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 11l9-7 9 7M5 10v10h14V10"/></svg>Dashboard</button>
<button class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 20V10M12 20V4M19 20v-7"/></svg>Énergie</button>
<button class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M6 8v3a3 3 0 003 3h6a3 3 0 003-3V8"/></svg>Things</button>
<button class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M5 9l7-7 7 7M5 15l7 7 7-7"/></svg>A/C</button>
<button class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l2.5 6 6.5.5-5 4.3 1.6 6.4L12 17l-5.6 3.2L8 13.8 3 9.5 9.5 9z"/></svg>Favoris</button>
</nav>
</div>
<script>
const reduce=window.matchMedia('(prefers-reduced-motion:reduce)').matches;
document.getElementById('themeBtn').addEventListener('click',()=>{const h=document.documentElement;h.dataset.theme=h.dataset.theme==='dark'?'light':'dark';});
function up(el,target,suffix,dur){const dec=suffix==' kW'?1:0;if(reduce){el.textContent=target.toFixed(dec)+suffix;return;}
let t0=null;function s(ts){t0=t0||ts;const p=Math.min((ts-t0)/dur,1),e=1-Math.pow(1-p,3);el.textContent=(target*e).toFixed(dec)+suffix;if(p<1)requestAnimationFrame(s);}requestAnimationFrame(s);}
document.querySelectorAll('[data-kw]').forEach(el=>up(el,parseFloat(el.dataset.kw),' kW',900));
const C=2*Math.PI*20;
document.querySelectorAll('.ring .fg').forEach(c=>{c.style.strokeDasharray=C;c.style.strokeDashoffset=reduce?C*(1-(+c.dataset.pct)/100):C;});
document.querySelectorAll('.tx .v').forEach(el=>up(el,+el.dataset.pct,' %',1100));
window.requestAnimationFrame(()=>setTimeout(()=>{document.querySelectorAll('.ring .fg').forEach(c=>{c.style.strokeDashoffset=C*(1-(+c.dataset.pct)/100);});},reduce?0:120));
/* ===== plan de la journée ===== */
const PAD_L=62,PAD_R=12,W=340,plotW=W-PAD_L-PAD_R,NOW=14.5;
const x=h=>PAD_L+(h/24)*plotW;
const NS="http://www.w3.org/2000/svg";
const E=(t,a,p)=>{const e=document.createElementNS(NS,t);for(const k in a)e.setAttribute(k,a[k]);p&&p.appendChild(e);return e;};
const svg=document.getElementById('plansvg');
const METEO_TOP=8, base=150, posK=17, negK=12, BOTTOM=172;
// [solH, solEcs, solVe, solBatt, solGrid, battH, gridH]
const D=[
[0,0,0,0,0,.35,.05],[0,0,0,0,0,.35,.05],[0,0,0,0,0,.3,0],[0,0,0,0,0,.3,0],[0,0,0,0,0,.3,0],[0,0,0,0,0,.35,.1],
[.3,0,0,0,0,0,.05],[.55,0,0,.35,0,0,0],[.7,0,0,1.1,0,0,0],[.6,0,0,2.2,0,0,0],[.6,0,0,2.0,.9,0,0],
[.6,1.2,0,1.5,.55,0,0],[.7,1.2,0,1.0,1.1,0,0],[.6,0,1.8,.8,.7,0,0],[.6,0,2.2,.5,.2,0,0],
[.6,0,2.0,.3,0,0,0],[.6,0,1.5,0,0,0,0],[.7,0,.6,0,0,0,0],[.6,0,0,0,0,.55,0],[.2,0,0,0,0,1.0,0],
[0,0,0,0,0,1.2,0],[0,0,0,0,0,1.0,0],[0,0,0,0,0,.8,0],[0,0,0,0,0,.5,0]
];
const POS=[[0,'var(--c-house)'],[1,'var(--c-ecs)'],[2,'var(--c-ve)'],[3,'var(--c-bcharge)'],[4,'var(--c-exp)']];
const NEG=[[5,'var(--c-bdis)'],[6,'var(--c-imp)']];
const bw=plotW/24*0.62;
// fond futur (toute hauteur)
E('rect',{x:x(NOW),y:METEO_TOP-4,width:x(24)-x(NOW),height:BOTTOM-(METEO_TOP-4),fill:'var(--futurebg)'},svg);
// gridlines verticales discrètes
[6,12,18].forEach(h=>E('line',{x1:x(h),y1:METEO_TOP-2,x2:x(h),y2:BOTTOM,stroke:'var(--line)','stroke-width':.5},svg));
// ===== météo =====
function sun(cx,cy,c){const g=E('g',{},svg);E('circle',{cx,cy,r:3.4,fill:c},g);for(let i=0;i<8;i++){const a=i*Math.PI/4;E('line',{x1:cx+Math.cos(a)*5,y1:cy+Math.sin(a)*5,x2:cx+Math.cos(a)*6.8,y2:cy+Math.sin(a)*6.8,stroke:c,'stroke-width':1.3,'stroke-linecap':'round'},g);}}
function moon(cx,cy,c){const g=E('g',{},svg);E('path',{d:`M${cx+3.5},${cy-4} a5,5 0 1 0 0,8 a4,4 0 1 1 0,-8 z`,fill:c},g);}
function cloudSun(cx,cy,c){sun(cx-2,cy-1,c);E('path',{d:`M${cx-1},${cy+4} a3,3 0 0 1 6,0 h1 a2.6,2.6 0 0 1 0,5 h-8 a2.6,2.6 0 0 1 1-5 z`,fill:'var(--faint)'},svg);}
const METEO=[[2,'moon',15],[6,'sun',17],[10,'sun',23],[14,'sun',28],[18,'cloudsun',25],[22,'moon',18]];
METEO.forEach(([h,t,temp])=>{const cx=x(h),cy=METEO_TOP+6;
if(t==='moon')moon(cx,cy,'var(--brand)');else if(t==='cloudsun')cloudSun(cx,cy,'var(--solar)');else sun(cx,cy,'var(--solar)');
const tx=E('text',{x:cx,y:METEO_TOP+22,class:'temp'},svg);tx.textContent=temp+'°';});
// ===== grille Y + barres =====
[0,2,4].forEach(v=>{const yy=base-v*posK;E('line',{x1:PAD_L,y1:yy,x2:W-PAD_R,y2:yy,stroke:'var(--line)','stroke-width':v===0?1:.5},svg);
const t=E('text',{x:PAD_L-6,y:yy+3,class:'ylab'},svg);t.textContent=v;});
const yl=E('text',{x:PAD_L-6,y:base-4*posK-4,class:'ylab'},svg);yl.textContent='kWh';
D.forEach((row,h)=>{const cx=x(h+.5),fut=(h+.5)>NOW;
let yt=base;POS.forEach(([i,c])=>{const v=row[i];if(v<=0)return;const hh=v*posK;yt-=hh;E('rect',{x:cx-bw/2,y:yt,width:bw,height:hh,fill:c,rx:1},svg);});
let yb=base;NEG.forEach(([i,c])=>{const v=row[i];if(v<=0)return;const hh=v*negK;E('rect',{x:cx-bw/2,y:yb,width:bw,height:hh,fill:c,rx:1},svg);yb+=hh;});});
// ===== pistes appareils : retirées du Dashboard (gardées dans le plein écran Plan) =====
// ===== voile hachuré futur (toute hauteur) =====
const defs=E('defs',{},svg);const pat=E('pattern',{id:'hatch',width:5,height:5,patternUnits:'userSpaceOnUse',patternTransform:'rotate(45)'},defs);
E('line',{x1:0,y1:0,x2:0,y2:5,stroke:'var(--veil)','stroke-width':2.4,'stroke-opacity':.5},pat);
E('rect',{x:x(NOW),y:METEO_TOP-4,width:x(24)-x(NOW),height:BOTTOM-(METEO_TOP-4),fill:'url(#hatch)'},svg);
// ===== axe X (partagé) =====
[0,6,12,18,24].forEach(h=>{const t=E('text',{x:x(h),y:base+18,class:'axlab','text-anchor':'middle'},svg);t.textContent=h+'h';});
// ===== ligne NOW (traverse tout) =====
E('rect',{x:x(NOW)-1,y:METEO_TOP-2,width:2,height:BOTTOM-(METEO_TOP-2),fill:'var(--ink)','fill-opacity':.2},svg);
E('rect',{x:x(NOW)-16,y:METEO_TOP-6,width:32,height:13,rx:6,fill:'var(--ink)'},svg);
const nt=E('text',{x:x(NOW),y:METEO_TOP+3.5,class:'now-tab','text-anchor':'middle'},svg);nt.textContent='14:30';
</script>
</body>
</html>

View File

@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="fr" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ETM PowerSync — flux drill-down</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--sans:'IBM Plex Sans',system-ui,sans-serif;--mono:'IBM Plex Mono',ui-monospace,monospace;
--brand:#31a3dd;--solar:#fec113;--eco:#28a06a;--heat:#ef8b2a;--water:#31a3dd;--grid-c:#8a98a3;--radius:20px}
html[data-theme="light"]{--bg:#eef2f5;--bg2:#e4eaee;--surface:#fff;--ink:#0d2b3b;--muted:#5d6f7b;--faint:#8b9aa4;--line:rgba(13,43,59,.10);--line-strong:rgba(13,43,59,.16);--shadow:0 1px 2px rgba(13,43,59,.04)}
html[data-theme="dark"]{--bg:#06141d;--bg2:#0a1f2b;--surface:#0d2b3b;--ink:#eaf3f7;--muted:#9bb0bc;--faint:#6c8493;--line:rgba(255,255,255,.09);--line-strong:rgba(255,255,255,.16);--shadow:none;--eco:#33b87a;--solar:#ffcb33}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--sans);background:var(--bg2);color:var(--ink);min-height:100vh;display:flex;justify-content:center;align-items:flex-start;padding:24px 12px;-webkit-font-smoothing:antialiased}
body,.surface,.theme-toggle,.flowcard,.node{transition:background .35s,color .35s,border-color .35s}
.phone{width:100%;max-width:392px;background:var(--bg);border-radius:34px;border:1px solid var(--line);overflow:hidden;position:relative}
.topbar{display:flex;align-items:center;justify-content:space-between;padding:18px 20px 10px}
.wordmark b{font-size:17px;font-weight:700;letter-spacing:-.01em;display:block;line-height:1.1}.wordmark span{font-family:var(--mono);font-size:10px;color:var(--faint)}
.theme-toggle{width:58px;height:30px;border-radius:30px;border:1px solid var(--line-strong);background:var(--surface);position:relative;cursor:pointer;display:flex;align-items:center;padding:0 6px;justify-content:space-between}
.theme-toggle svg{width:14px;height:14px;color:var(--faint);z-index:1}
.theme-toggle .knob{position:absolute;top:3px;left:3px;width:22px;height:22px;border-radius:50%;background:var(--brand);transition:transform .32s cubic-bezier(.34,1.56,.64,1)}
html[data-theme="dark"] .theme-toggle .knob{transform:translateX(28px);background:var(--solar)}
.scroll{padding:6px 16px 22px}
.flowcard{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:12px 16px 14px}
.flowhead{display:flex;align-items:center;gap:10px;min-height:34px;margin-bottom:2px}
.flowhead .back{width:32px;height:32px;border-radius:10px;border:1px solid var(--line);background:transparent;display:none;align-items:center;justify-content:center;cursor:pointer;color:var(--ink);flex:0 0 auto}
.flowhead .back svg{width:17px;height:17px}
.flowhead.detail .back{display:flex}
.flowhead h2{font-size:13px;font-weight:600;flex:1}
.flowhead .live{display:flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11px;color:var(--muted)}
.flowhead .dot{width:7px;height:7px;border-radius:50%;background:var(--eco)}
.flow{position:relative;width:100%;aspect-ratio:1/1;max-width:320px;margin:2px auto 0}
.flow svg.lines{position:absolute;inset:0;width:100%;height:100%;overflow:visible;transition:opacity .25s}
.node{position:absolute;transform:translate(-50%,-50%);width:74px;height:74px;border-radius:50%;background:var(--surface);border:1.5px solid var(--line);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;text-align:center}
.node .ico{width:21px;height:21px}.node .val{font-family:var(--mono);font-size:11px;font-weight:600;line-height:1}.node .lab{font-size:8.5px;color:var(--muted)}
.node.hub{width:92px;height:92px;border-width:2px}.node.hub .val{font-size:14px}.node.hub .lab{font-size:9px}
.node.tappable{cursor:pointer}
.node.tappable::after{content:"";position:absolute;right:5px;top:5px;width:15px;height:15px;border-radius:50%;background:var(--brand);background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3'><path d='M12 5v14M5 12h14'/></svg>");background-size:11px;background-repeat:no-repeat;background-position:center}
.node.estim{border-style:dashed;opacity:.85}
.node .state{position:absolute;bottom:6px;width:7px;height:7px;border-radius:50%}
.state.ok{background:var(--eco)} .state.warn{background:var(--heat)}
.ico.solar{color:var(--solar)}.ico.batt{color:var(--eco)}.ico.grid{color:var(--grid-c)}.ico.home{color:var(--ink)}.ico.heat{color:var(--heat)}.ico.water{color:var(--water)}.ico.car{color:var(--eco)}.ico.other{color:var(--faint)}.ico.inv{color:var(--solar)}.ico.hems{color:var(--eco)}
.heos-ring{position:absolute;inset:-5px;border-radius:50%;border:1.5px solid var(--eco);animation:ring 3s ease-in-out infinite}
@keyframes ring{0%,100%{opacity:.22}50%{opacity:.55}}
.flowline{fill:none;stroke:var(--line-strong);stroke-width:.7}
.enter{animation:pop .42s cubic-bezier(.34,1.5,.5,1) both}
@keyframes pop{from{transform:translate(-50%,-50%) scale(.25);opacity:0}to{transform:translate(-50%,-50%) scale(1);opacity:1}}
.leaving{animation:fade .16s ease forwards}
@keyframes fade{to{opacity:0;transform:translate(-50%,-50%) scale(.8)}}
.hint{text-align:center;font-size:11.5px;color:var(--faint);margin-top:12px}
.hint b{color:var(--brand);font-weight:600}
@media (prefers-reduced-motion:reduce){.enter,.leaving{animation:none!important}.heos-ring{animation:none}}
</style>
</head>
<body>
<div class="phone">
<div class="topbar">
<div class="wordmark"><b>ETM PowerSync</b><span>Flux énergétique</span></div>
<button class="theme-toggle" id="themeBtn" aria-label="Basculer le thème">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.4 1.4M17.6 17.6L19 19M19 5l-1.4 1.4M6.4 17.6L5 19"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1111.2 3 7 7 0 0021 12.8z"/></svg>
<span class="knob"></span>
</button>
</div>
<div class="scroll">
<div class="flowcard">
<div class="flowhead" id="head">
<button class="back" id="back" aria-label="Retour"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg></button>
<h2 id="title">Flux énergétique</h2>
<div class="live"><span class="dot"></span> en direct</div>
</div>
<div class="flow" id="flow"><svg class="lines" id="lines" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet"></svg></div>
<div class="hint" id="hint">Touchez <b>Maison</b> ou <b>Solaire</b> pour détailler</div>
</div>
</div>
</div>
<script>
const reduce=window.matchMedia('(prefers-reduced-motion:reduce)').matches;
document.getElementById('themeBtn').addEventListener('click',()=>{const h=document.documentElement;h.dataset.theme=h.dataset.theme==='dark'?'light':'dark';});
const NS="http://www.w3.org/2000/svg";
const SE=(t,a,p)=>{const e=document.createElementNS(NS,t);for(const k in a)e.setAttribute(k,a[k]);p&&p.appendChild(e);return e;};
const flow=document.getElementById('flow'), lines=document.getElementById('lines');
const title=document.getElementById('title'), head=document.getElementById('head'), hint=document.getElementById('hint');
const ICONS={
sun:'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.4 1.4M17.6 17.6L19 19M19 5l-1.4 1.4M6.4 17.6L5 19"/>',
batt:'<rect x="4" y="8" width="16" height="11" rx="2"/><path d="M9 8V6h6v2M8 13l3-2v4l3-2"/>',
grid:'<path d="M5 21V9l7-6 7 6v12M9 21v-6h6v6"/>',
home:'<path d="M3 11l9-7 9 7M5 10v10h14V10M10 20v-6h4v6"/>',
plug:'<path d="M9 2v6M15 2v6M7 8h10v3a5 5 0 01-10 0zM12 16v6"/>',
heat:'<path d="M12 3c2 3 1 5 0 6 3-1 5 2 5 5a5 5 0 01-10 0c0-4 3-6 5-11z"/>',
water:'<path d="M12 3s6 6.5 6 11a6 6 0 01-12 0c0-4.5 6-11 6-11z"/>',
car:'<path d="M5 13l1.5-5h11L19 13M5 13h14v5H5zM7 18v2M17 18v2"/>',
other:'<rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/>',
inv:'<rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 9l3 3-3 3M13 15h3"/>',
hems:'<rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/><circle cx="12" cy="12" r="2.4"/>'
};
const POS={top:[50,12],right:[88,50],bottom:[50,88],left:[12,50],hub:[50,50]};
const EDGE={ // [nodeEdge, hubEdge]
top:[[50,22],[50,39]], right:[[78,50],[61,50]], left:[[22,50],[39,50]], bottom:[[50,78],[50,61]]
};
const SCENES={
overview:{title:'Flux énergétique', hub:{lab:'Héos',val:'',ico:'hems',heos:true},
ring:[
{pos:'top',lab:'Solaire',val:'3.8 kW',ico:'sun',color:'#fec113',dir:'in',tap:'prod'},
{pos:'right',lab:'Batterie',val:'87 %',ico:'batt',color:'#28a06a',dir:'out'},
{pos:'left',lab:'Réseau',val:'0.2 kW',ico:'grid',color:'#8a98a3',dir:'in'},
{pos:'bottom',lab:'Maison',val:'2.4 kW',ico:'home',color:'#31a3dd',dir:'out',tap:'conso'}
]},
conso:{title:'Consommation — détail', hub:{lab:'Maison',val:'3.1 kW',ico:'home'},
ring:[
{pos:'top',lab:'PAC',val:'0.4 kW',ico:'heat',color:'#ef8b2a',dir:'out'},
{pos:'right',lab:'Eau chaude',val:'1.2 kW',ico:'water',color:'#31a3dd',dir:'out'},
{pos:'bottom',lab:'Voiture',val:'0.9 kW',ico:'car',color:'#28a06a',dir:'out'},
{pos:'left',lab:'Autres',val:'0.6 kW',ico:'other',color:'#8a98a3',dir:'out',estim:true}
]},
prod:{title:'Production — détail', hub:{lab:'Solaire',val:'3.8 kW',ico:'sun'},
ring:[
{pos:'top',lab:'Onduleur 1',val:'2.3 kW',ico:'inv',color:'#fec113',dir:'in',state:'ok'},
{pos:'bottom',lab:'Onduleur 2',val:'1.5 kW',ico:'inv',color:'#fec113',dir:'in',state:'ok'}
]}
};
function mkNode(cfg,cls,delay){
const [l,t]=POS[cfg.pos||'hub'];
const n=document.createElement('div');
n.className='node '+(cls||'')+(cfg.estim?' estim':'')+(cfg.tap?' tappable':'');
n.style.left=l+'%'; n.style.top=t+'%';
if(delay!=null)n.style.animationDelay=delay+'ms';
n.innerHTML=(cfg.heos?'<div class="heos-ring"></div>':'')
+`<svg class="ico ${cfg.ico}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${ICONS[cfg.ico]}</svg>`
+(cfg.val?`<div class="val">${cfg.val}</div>`:'')+`<div class="lab">${cfg.lab}</div>`
+(cfg.state?`<span class="state ${cfg.state}"></span>`:'');
if(cfg.tap)n.addEventListener('click',()=>go(cfg.tap));
return n;
}
function render(sceneKey){
const sc=SCENES[sceneKey];
title.textContent=sc.title;
head.classList.toggle('detail',sceneKey!=='overview');
hint.style.visibility = sceneKey==='overview'?'visible':'hidden';
// clear lines + old nodes
lines.innerHTML='';
[...flow.querySelectorAll('.node')].forEach(n=>n.remove());
// lines + particles
sc.ring.forEach(r=>{
const [ne,he]=EDGE[r.pos];
const from=r.dir==='in'?ne:he, to=r.dir==='in'?he:ne;
const id='ln_'+r.pos;
SE('path',{class:'flowline',d:`M${ne[0]} ${ne[1]} L${he[0]} ${he[1]}`},lines);
const mp=SE('path',{id,d:`M${from[0]} ${from[1]} L${to[0]} ${to[1]}`,fill:'none',stroke:'none'},lines);
const g=SE('g',{fill:r.color},lines);
for(let i=0;i<2;i++){const c=SE('circle',{r:1.4},g);
const am=SE('animateMotion',{dur:'2s',begin:(-i)+'s',repeatCount:'indefinite'},c);
SE('mpath',{href:'#'+id},am);}
});
// hub + ring nodes
flow.appendChild(mkNode(sc.hub,'hub enter',0));
sc.ring.forEach((r,i)=>flow.appendChild(mkNode(r,'enter',120+i*60)));
}
function go(key){
// anim out
[...flow.querySelectorAll('.node')].forEach(n=>n.classList.add('leaving'));
lines.style.opacity=0;
setTimeout(()=>{lines.style.opacity=1;render(key);},reduce?0:170);
}
document.getElementById('back').addEventListener('click',()=>go('overview'));
render('overview');
</script>
</body>
</html>

View File

@ -0,0 +1,232 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ETM PowerSync — Installations (multi-HEMS)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#081f2b; --panel:#0d2b3b; --card:#12384a; --card2:#0f3242;
--line:rgba(255,255,255,.08); --line2:rgba(255,255,255,.14);
--blue:#31a3dd; --yellow:#fec113; --green:#35c98a; --red:#e07a7a; --grey:#6f8b9a;
--txt:#e8f1f6; --muted:#90a8b6; --muted2:#6f8b9a; --r:16px;
}
*{box-sizing:border-box}
body{margin:0;background:
radial-gradient(900px 500px at 15% -10%, #0e3145 0%, transparent 60%),
radial-gradient(900px 600px at 110% 10%, #0b2535 0%, transparent 55%),
var(--bg);
font-family:'Inter',system-ui,sans-serif;color:var(--txt);min-height:100vh;padding:34px 20px 60px}
.head{max-width:880px;margin:0 auto 26px}
.head h1{font-family:'Poppins';font-weight:700;font-size:20px;margin:0 0 6px}
.head p{margin:0;color:var(--muted);font-size:13.5px;line-height:1.55;max-width:700px}
.head code{color:#9fd4ee}
.stage{display:flex;gap:34px;justify-content:center;flex-wrap:wrap;align-items:flex-start}
.col{display:flex;flex-direction:column;align-items:center;gap:12px}
.cap{color:var(--muted2);font-size:12px;font-family:'Poppins';font-weight:500;letter-spacing:.4px;text-transform:uppercase}
.phone{width:392px;background:var(--panel);border:1px solid var(--line2);border-radius:30px;overflow:hidden;
box-shadow:0 30px 80px -30px rgba(0,0,0,.7);display:flex;flex-direction:column;height:812px}
.appbar{display:flex;align-items:center;gap:12px;padding:18px 16px 14px;border-bottom:1px solid var(--line);
background:linear-gradient(#102f40,#0d2b3b)}
.appbar .ic{width:26px;height:26px;display:grid;place-items:center;color:var(--muted);font-size:18px}
.appbar h2{font-family:'Poppins';font-weight:600;font-size:16px;margin:0;flex:1;text-align:center}
.scroll{overflow:auto;padding:15px 15px 26px}
.scroll::-webkit-scrollbar{width:0}
.sec{display:flex;align-items:center;gap:8px;margin:16px 4px 10px}
.sec h3{font-family:'Poppins';font-weight:600;font-size:11.5px;letter-spacing:1.3px;text-transform:uppercase;
color:var(--blue);margin:0}
.sec .scan{margin-left:auto;display:flex;align-items:center;gap:6px;color:var(--muted2);font-size:11px}
.pulse{width:8px;height:8px;border-radius:50%;background:var(--blue);box-shadow:0 0 0 0 rgba(49,163,221,.5);
animation:p 1.6s infinite}
@keyframes p{0%{box-shadow:0 0 0 0 rgba(49,163,221,.5)}70%{box-shadow:0 0 0 7px rgba(49,163,221,0)}100%{box-shadow:0 0 0 0 rgba(49,163,221,0)}}
.inst{display:flex;align-items:center;gap:12px;background:var(--card);border:1px solid var(--line);
border-radius:var(--r);padding:14px;margin-bottom:11px;cursor:pointer;transition:border-color .15s}
.inst:hover{border-color:var(--line2)}
.inst.active{border-color:rgba(53,201,138,.4);background:linear-gradient(rgba(53,201,138,.05),transparent)}
.inst .dot{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;font-size:17px;flex:none;
background:rgba(255,255,255,.05)}
.inst .body{flex:1;min-width:0}
.inst .name{font-family:'Poppins';font-weight:600;font-size:14.5px;display:flex;align-items:center;gap:8px}
.inst .url{color:var(--muted);font-size:12px;margin-top:2px;font-family:'Inter'}
.inst .uuid{color:var(--muted2);font-size:10.5px;margin-top:3px;font-family:ui-monospace,monospace}
.state{font-size:10.5px;font-weight:700;padding:3px 9px;border-radius:20px;white-space:nowrap}
.state.on{background:rgba(53,201,138,.14);color:var(--green)}
.state.av{background:rgba(49,163,221,.12);color:var(--blue)}
.state.off{background:rgba(255,255,255,.05);color:var(--grey)}
.sdot{width:8px;height:8px;border-radius:50%;flex:none}
.sdot.on{background:var(--green)}.sdot.off{background:var(--grey)}
.chevron{color:var(--muted2);font-size:18px}
.discover{border-style:dashed;border-color:rgba(49,163,221,.35);background:rgba(49,163,221,.04)}
.addchip{font-size:11px;font-weight:600;color:var(--blue);background:rgba(49,163,221,.12);
border:1px solid rgba(49,163,221,.3);border-radius:20px;padding:5px 12px}
.addbtn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;margin-top:8px;
background:transparent;border:1px solid var(--line2);color:var(--txt);font-family:'Poppins';font-weight:600;
font-size:13px;padding:13px;border-radius:12px;cursor:pointer}
.demo{display:block;text-align:center;margin-top:16px;color:var(--muted2);font-size:12px;text-decoration:none}
.demo b{color:var(--yellow)}
/* auth */
.idhdr{display:flex;align-items:center;gap:12px;padding:14px;background:var(--card);border:1px solid var(--line);
border-radius:var(--r);margin-bottom:18px}
.idhdr .dot{width:38px;height:38px;border-radius:10px;background:rgba(49,163,221,.12);display:grid;place-items:center;
font-size:18px;color:var(--blue);flex:none}
.idhdr .name{font-family:'Poppins';font-weight:600;font-size:15px}
.idhdr .url{color:var(--muted);font-size:12px;margin-top:2px}
.idhdr .uuid{color:var(--muted2);font-size:10px;margin-top:2px;font-family:ui-monospace,monospace}
.seg{display:flex;background:var(--card2);border:1px solid var(--line);border-radius:11px;padding:4px;gap:4px;margin-bottom:6px}
.seg button{flex:1;border:0;background:transparent;color:var(--muted);font-family:'Poppins';font-weight:600;
font-size:12.5px;padding:10px;border-radius:8px;cursor:pointer}
.seg button.on{background:var(--blue);color:#04222f}
.seg-hint{font-size:11px;color:var(--muted2);margin:9px 2px 4px;line-height:1.45}
.inp{margin-top:13px}
.inp label{display:block;font-family:'Poppins';font-weight:600;font-size:12px;color:var(--muted);margin-bottom:7px}
.inp .box{display:flex;align-items:center;gap:10px;background:var(--card2);border:1px solid var(--line2);
border-radius:11px;padding:12px 13px}
.inp .box span{color:var(--muted2);font-size:15px}
.inp .box i{font-style:normal;color:var(--txt);font-size:13.5px;flex:1}
.inp .box i.ph{color:var(--muted2)}
.cta{width:100%;margin-top:20px;background:var(--blue);color:#04222f;border:0;border-radius:12px;padding:14px;
font-family:'Poppins';font-weight:600;font-size:14px;cursor:pointer}
.detect{margin-top:14px;padding:10px 12px;border-radius:11px;background:rgba(254,193,19,.06);
border:1px solid rgba(254,193,19,.18);color:#d8c79a;font-size:11px;line-height:1.5}
.detect b{color:var(--yellow)}
.hide{display:none}
</style>
</head>
<body>
<div class="head">
<h1>Installations — multi-HEMS</h1>
<p>Identité stable = <b>Server UUID</b> (pas l'IP, qui change en DHCP). Découverte <b>mDNS</b> en haut, <b>installations enregistrées</b> en dessous (token stocké par UUID), <b>ajout manuel</b> en repli. Une seule connexion <b>active</b> à la fois ; basculer = reconnecter. À droite, l'auth à deux branches : <b>créer</b> l'admin si la box est neuve, <b>se connecter</b> sinon.</p>
</div>
<div class="stage">
<!-- ÉCRAN 1 — INSTALLATIONS -->
<div class="col">
<div class="phone">
<div class="appbar"><div class="ic"></div><h2>Installations</h2><div class="ic"></div></div>
<div class="scroll">
<div class="sec"><h3>Détectées sur le réseau</h3><div class="scan"><span class="pulse"></span>recherche…</div></div>
<div class="inst discover">
<div class="dot">📡</div>
<div class="body">
<div class="name">nymea</div>
<div class="url">192.168.1.75 : 4444</div>
</div>
<div class="addchip">+ Ajouter</div>
</div>
<div class="sec"><h3>Mes installations</h3></div>
<div class="inst active">
<div class="dot"><span class="sdot on"></span></div>
<div class="body">
<div class="name">Site démo <span class="state on">actif</span></div>
<div class="url">nymea://192.168.1.75 : 4444</div>
<div class="uuid">3943203a-dc47-4d40-8a00-ff16cff1f46c</div>
</div>
</div>
<div class="inst">
<div class="dot"><span class="sdot on"></span></div>
<div class="body">
<div class="name">Client Kutzenhausen <span class="state av">disponible</span></div>
<div class="url">nymea://192.168.10.20 : 4444</div>
<div class="uuid">a1b2c3d4-…-9f8e</div>
</div>
<div class="chevron"></div>
</div>
<div class="inst">
<div class="dot"><span class="sdot off"></span></div>
<div class="body">
<div class="name">Atelier ETM <span class="state off">hors-ligne</span></div>
<div class="url">nymea://192.168.1.40 : 4444</div>
<div class="uuid">7c6b5a49-…-1d2e</div>
</div>
<div class="chevron"></div>
</div>
<button class="addbtn"> Ajouter manuellement</button>
<a href="#" class="demo">ou continuer en <b>mode démo</b></a>
</div>
</div>
<div class="cap">Liste · découverte · bascule</div>
</div>
<!-- ÉCRAN 2 — CONNEXION / AUTH -->
<div class="col">
<div class="phone">
<div class="appbar"><div class="ic"></div><h2>Connexion</h2><div class="ic"></div></div>
<div class="scroll">
<div class="idhdr">
<div class="dot">🔌</div>
<div style="flex:1">
<div class="name">nymea</div>
<div class="url">192.168.1.75 : 4444</div>
<div class="uuid">3943203a-dc47-4d40-8a00-ff16cff1f46c</div>
</div>
</div>
<div class="seg">
<button id="bNew" class="on" onclick="branch('new')">Nouvelle</button>
<button id="bExist" onclick="branch('exist')">Existante</button>
</div>
<div class="seg-hint" id="hint">Box neuve : aucun administrateur n'existe encore. Crée le compte qui pilotera cette installation.</div>
<!-- branche NOUVELLE -->
<div id="formNew">
<div class="inp"><label>Utilisateur (e-mail)</label>
<div class="box"><span></span><i class="ph">installateur@etm-schurig.eu</i></div></div>
<div class="inp"><label>Mot de passe</label>
<div class="box"><span>🔒</span><i class="ph">••••••••</i></div></div>
<div class="inp"><label>Confirmer le mot de passe</label>
<div class="box"><span>🔒</span><i class="ph">••••••••</i></div></div>
<button class="cta">Créer le compte administrateur</button>
</div>
<!-- branche EXISTANTE -->
<div id="formExist" class="hide">
<div class="inp"><label>Utilisateur</label>
<div class="box"><span></span><i class="ph">installateur@etm-schurig.eu</i></div></div>
<div class="inp"><label>Mot de passe</label>
<div class="box"><span>🔒</span><i class="ph">••••••••</i></div></div>
<button class="cta">Se connecter</button>
</div>
<div class="detect"><b>Détection auto.</b> L'app interroge la box au handshake : si un administrateur existe déjà, elle ouvre directement « Existante ». Le segmenteur ci-dessus n'est qu'un repli manuel. Le token obtenu est stocké <b>sous l'UUID</b> de cette box.</div>
</div>
</div>
<div class="cap">Auth · créer ou se connecter</div>
</div>
</div>
<script>
function branch(which){
const isNew = which==='new';
document.getElementById('bNew').classList.toggle('on', isNew);
document.getElementById('bExist').classList.toggle('on', !isNew);
document.getElementById('formNew').classList.toggle('hide', !isNew);
document.getElementById('formExist').classList.toggle('hide', isNew);
document.getElementById('hint').textContent = isNew
? "Box neuve : aucun administrateur n'existe encore. Crée le compte qui pilotera cette installation."
: "Box déjà initialisée : connecte-toi avec un compte existant.";
}
</script>
</body>
</html>

View File

@ -0,0 +1,400 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ETM PowerSync — Rôles & appareils · maquette</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#081f2b; --panel:#0d2b3b; --card:#12384a; --card2:#0f3242;
--line:rgba(255,255,255,.08); --line2:rgba(255,255,255,.14);
--blue:#31a3dd; --yellow:#fec113; --green:#35c98a; --red:#e07a7a;
--txt:#e8f1f6; --muted:#90a8b6; --muted2:#6f8b9a;
--r:16px;
}
*{box-sizing:border-box}
body{margin:0;background:
radial-gradient(900px 500px at 15% -10%, #0e3145 0%, transparent 60%),
radial-gradient(900px 600px at 110% 10%, #0b2535 0%, transparent 55%),
var(--bg);
font-family:'Inter',system-ui,sans-serif;color:var(--txt);
min-height:100vh;padding:34px 20px 60px;}
.head{max-width:880px;margin:0 auto 26px}
.head h1{font-family:'Poppins';font-weight:700;font-size:20px;letter-spacing:.2px;margin:0 0 6px}
.head p{margin:0;color:var(--muted);font-size:13.5px;line-height:1.5;max-width:680px}
.stage{display:flex;gap:30px;justify-content:center;flex-wrap:wrap;align-items:flex-start}
.col{display:flex;flex-direction:column;align-items:center;gap:12px}
.cap{color:var(--muted2);font-size:12px;font-family:'Poppins';font-weight:500;letter-spacing:.4px;text-transform:uppercase}
/* phone */
.phone{width:390px;background:var(--panel);border:1px solid var(--line2);
border-radius:30px;overflow:hidden;box-shadow:0 30px 80px -30px rgba(0,0,0,.7);
display:flex;flex-direction:column;height:780px}
.appbar{display:flex;align-items:center;gap:12px;padding:18px 16px 14px;
border-bottom:1px solid var(--line);background:linear-gradient(#102f40,#0d2b3b)}
.appbar .ic{width:26px;height:26px;display:grid;place-items:center;color:var(--muted);font-size:18px}
.appbar h2{font-family:'Poppins';font-weight:600;font-size:16px;margin:0;flex:1;text-align:center}
.scroll{overflow:auto;padding:14px 14px 24px}
.scroll::-webkit-scrollbar{width:0}
/* counter */
.counter{display:flex;align-items:center;gap:11px;background:rgba(49,163,221,.10);
border:1px solid rgba(49,163,221,.28);border-radius:12px;padding:11px 13px;margin-bottom:16px}
.counter .dot{width:30px;height:30px;border-radius:8px;background:rgba(49,163,221,.18);
display:grid;place-items:center;color:var(--blue);font-size:15px}
.counter b{font-family:'Poppins';font-weight:600;font-size:14px}
.counter span{color:var(--muted);font-size:12px;display:block;margin-top:1px}
.sec{display:flex;align-items:baseline;gap:8px;margin:18px 4px 9px}
.sec h3{font-family:'Poppins';font-weight:600;font-size:11.5px;letter-spacing:1.3px;
text-transform:uppercase;color:var(--blue);margin:0}
.sec small{color:var(--muted2);font-size:11px;margin-left:auto;text-align:right;max-width:185px;line-height:1.35}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--r);
padding:14px;margin-bottom:11px}
.row{display:flex;align-items:center;gap:11px}
.emoji{width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,.05);
display:grid;place-items:center;font-size:18px;flex:none}
.name{font-family:'Poppins';font-weight:600;font-size:14.5px}
.sub{color:var(--muted);font-size:12.5px;margin-top:2px}
.tag{font-size:10.5px;font-weight:600;padding:3px 8px;border-radius:20px;white-space:nowrap}
.tag.root{background:rgba(254,193,19,.14);color:var(--yellow)}
.tag.auto{background:rgba(255,255,255,.06);color:var(--muted)}
.val{font-family:'Poppins';font-weight:600;font-size:15px;color:var(--green)}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:11px}
.chip{font-size:11px;color:var(--muted);background:rgba(255,255,255,.04);
border:1px solid var(--line);border-radius:7px;padding:4px 8px}
.chip b{color:var(--txt);font-weight:600}
/* toggle */
.sw{width:40px;height:23px;border-radius:20px;background:rgba(53,201,138,.85);
position:relative;flex:none;cursor:pointer;transition:.15s}
.sw::after{content:"";position:absolute;top:2px;left:19px;width:19px;height:19px;
border-radius:50%;background:#fff;transition:.15s}
.sw.off{background:rgba(255,255,255,.16)}
.sw.off::after{left:2px}
/* draggable load card */
.load{background:var(--card);border:1px solid var(--line);border-radius:var(--r);
padding:13px 13px 13px 6px;margin-bottom:11px;display:flex;gap:8px;align-items:flex-start;
cursor:grab;transition:border-color .15s, transform .12s, box-shadow .15s}
.load:active{cursor:grabbing}
.load.drag{opacity:.4}
.load.over{border-color:var(--blue);box-shadow:0 0 0 1px var(--blue)}
.grip{display:flex;flex-direction:column;align-items:center;gap:7px;padding-top:2px;flex:none}
.grip .dots{color:var(--muted2);font-size:15px;line-height:.5;letter-spacing:0}
.prio{width:22px;height:22px;border-radius:7px;background:var(--blue);color:#04222f;
font-family:'Poppins';font-weight:700;font-size:12px;display:grid;place-items:center}
.load .body{flex:1;min-width:0}
.load .top{display:flex;align-items:center;gap:10px}
/* todo / tri-state */
.todo .sub{color:var(--muted2)}
.acts{display:flex;gap:8px;margin-top:12px}
.btn{flex:1;text-align:center;font-family:'Poppins';font-weight:600;font-size:12.5px;
padding:9px;border-radius:9px;border:1px solid transparent;cursor:pointer}
.btn.prim{background:rgba(49,163,221,.14);color:var(--blue);border-color:rgba(49,163,221,.3)}
.btn.ghost{background:transparent;color:var(--muted);border-color:var(--line2)}
.legend{margin-top:18px;padding:12px 13px;border-radius:12px;background:rgba(254,193,19,.06);
border:1px solid rgba(254,193,19,.18);color:#d8c79a;font-size:11.5px;line-height:1.55}
.legend b{color:var(--yellow)}
/* detail sheet */
.field{margin-top:16px}
.field>label{display:block;font-family:'Poppins';font-weight:600;font-size:12px;
color:var(--muted);letter-spacing:.3px;margin-bottom:8px}
.pick{display:flex;align-items:center;gap:10px;padding:11px;border-radius:11px;
border:1px solid var(--line);background:var(--card2);margin-bottom:8px}
.pick.sel{border-color:var(--blue);background:rgba(49,163,221,.08)}
.pick .radio{width:18px;height:18px;border-radius:50%;border:2px solid var(--muted2);flex:none}
.pick.sel .radio{border-color:var(--blue);background:radial-gradient(circle,var(--blue) 42%,transparent 46%)}
.pick .t{font-size:13.5px;font-weight:500}
.pick .s{font-size:11.5px;color:var(--muted2);margin-top:1px}
.typeflag{font-size:11px;color:var(--muted);background:rgba(255,255,255,.05);
border:1px solid var(--line);border-radius:7px;padding:3px 9px;display:inline-block}
.stages{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
.stage-chip{display:flex;align-items:center;gap:6px;background:var(--card2);
border:1px solid var(--line2);border-radius:9px;padding:8px 11px;font-family:'Poppins';
font-weight:600;font-size:13px}
.stage-chip i{font-style:normal;color:var(--muted2);font-weight:500;font-size:11px}
.stage-chip.off{opacity:.6}
.add{width:34px;height:34px;border-radius:9px;border:1px dashed var(--line2);
color:var(--muted);display:grid;place-items:center;font-size:18px;background:transparent}
.stepper{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;
border:1px solid var(--line);border-radius:11px;background:var(--card2);margin-bottom:8px}
.stepper .lab{font-size:13px}
.stepper .lab small{display:block;color:var(--muted2);font-size:11px;margin-top:1px}
.stepper .ctl{display:flex;align-items:center;gap:14px}
.stepper .ctl b{font-family:'Poppins';font-weight:600;font-size:14px;min-width:54px;text-align:right}
.pm{width:28px;height:28px;border-radius:8px;border:1px solid var(--line2);color:var(--blue);
background:transparent;font-size:17px;display:grid;place-items:center}
.save{display:flex;gap:10px;margin-top:22px}
.save .btn.go{background:var(--blue);color:#04222f;flex:2}
.save .btn.test{flex:1.4;background:transparent;border-color:var(--line2);color:var(--txt)}
@media(max-width:860px){ .stage{gap:40px} }
</style>
</head>
<body>
<div class="head">
<h1>Rôles &amp; appareils — maquette de validation</h1>
<p>Deux zones distinctes : les <b>compteurs</b> (mesure, jamais priorisés) et les <b>charges pilotables</b> (réordonnables par glisser-déposer → écrit <code style="color:#9fd4ee">LoadDescriptor.priority</code>). Tri-état présent / absent / non&nbsp;configuré. À droite, l'éditeur qu'ouvre « Configurer » : les champs <code style="color:#9fd4ee">declared / limits / needs</code> du protocole §5. <b style="color:#fec113">Essayez de glisser les cartes de la 2ᵉ zone.</b></p>
</div>
<div class="stage">
<!-- ░░░░░░░░░░ ÉCRAN 1 : LISTE ░░░░░░░░░░ -->
<div class="col">
<div class="phone">
<div class="appbar">
<div class="ic"></div>
<h2>Rôles &amp; appareils</h2>
<div class="ic">?</div>
</div>
<div class="scroll">
<div class="counter">
<div class="dot"></div>
<div>
<b>3 / 6 rôles assignés</b>
<span>1 détecté automatiquement · 1 reste à configurer</span>
</div>
</div>
<!-- COMPTEURS -->
<div class="sec"><h3>Compteurs</h3><small>Mesure — hors priorité</small></div>
<div class="card">
<div class="row">
<div class="emoji"></div>
<div style="flex:1">
<div class="name">Compteur réseau</div>
<div class="sub">GRID · Eastron SDM630</div>
</div>
<div class="tag root">rootMeter</div>
</div>
<div class="chips">
<span class="chip">Souscrit <b>12 kVA</b></span>
<span class="chip">Limite <b>63 A</b>/phase</span>
</div>
</div>
<div class="card">
<div class="row">
<div class="emoji">☀️</div>
<div style="flex:1">
<div class="name">Production solaire</div>
<div class="sub">Fronius Primo GEN24 10.0</div>
</div>
<div style="text-align:right">
<div class="val">3.3 kW</div>
<div class="tag auto" style="margin-top:4px">auto · lecture seule</div>
</div>
</div>
</div>
<!-- CHARGES PILOTABLES -->
<div class="sec"><h3>Charges pilotables</h3><small>Glissez pour l'ordre de service du surplus</small></div>
<div id="loadlist">
<div class="load" draggable="true">
<div class="grip"><div class="dots"></div><div class="prio">1</div></div>
<div class="body">
<div class="top">
<div class="emoji">🔋</div>
<div style="flex:1">
<div class="name">Batterie</div>
<div class="sub">BYD HVS 10.2 · détecté</div>
</div>
<div class="sw"></div>
</div>
<div class="chips">
<span class="chip"><b>10.2 kWh</b></span>
<span class="chip">réserve <b>10 %</b></span>
<span class="chip">charge <b>5 kW</b></span>
</div>
</div>
</div>
<div class="load" draggable="true">
<div class="grip"><div class="dots"></div><div class="prio">2</div></div>
<div class="body">
<div class="top">
<div class="emoji">🌡️</div>
<div style="flex:1">
<div class="name">Chauffe-eau</div>
<div class="sub">Relay3 · C3 — relay-stages</div>
</div>
<div class="sw"></div>
</div>
<div class="chips">
<span class="chip">paliers <b>0 / 1200 / 2400 W</b></span>
</div>
</div>
</div>
<div class="load" draggable="true">
<div class="grip"><div class="dots"></div><div class="prio">3</div></div>
<div class="body">
<div class="top">
<div class="emoji">🚗</div>
<div style="flex:1">
<div class="name">EV Charger</div>
<div class="sub">Terra AC (TCP)</div>
</div>
<div class="sw"></div>
</div>
<div class="chips">
<span class="chip"><b>632 A</b></span>
<span class="chip"><b></b></span>
<span class="chip">cible <b>80 %</b> @ 18:00</span>
</div>
</div>
</div>
</div>
<!-- À CONFIGURER (tri-état) -->
<div class="sec"><h3>À configurer</h3></div>
<div class="card todo">
<div class="row">
<div class="emoji">♨️</div>
<div style="flex:1">
<div class="name">Pompe à chaleur</div>
<div class="sub">Aucun appareil configuré</div>
</div>
</div>
<div class="acts">
<div class="btn prim">+ Configurer</div>
<div class="btn ghost">Pas d'appareil</div>
</div>
</div>
<div class="legend">
<b>Ordre indicatif.</b> Il fixe le service par défaut du surplus. Le pilote peut le bousculer automatiquement les jours <b>Tempo rouge</b> ou à l'approche d'une <b>échéance</b> de recharge.
</div>
</div>
</div>
<div class="cap">Écran 1 · liste + priorité</div>
</div>
<!-- ░░░░░░░░░░ ÉCRAN 2 : DÉTAIL ░░░░░░░░░░ -->
<div class="col">
<div class="phone">
<div class="appbar">
<div class="ic"></div>
<h2>Configurer · Chauffe-eau</h2>
<div class="ic"></div>
</div>
<div class="scroll">
<div class="field">
<label>Appareil</label>
<div class="pick sel">
<div class="radio"></div>
<div style="flex:1"><div class="t">Relay3</div><div class="s">C3 · GPIO 5</div></div>
<div class="typeflag">relay-stages</div>
</div>
<div class="pick">
<div class="radio"></div>
<div style="flex:1"><div class="t">Relay4</div><div class="s">C4 · GPIO 6</div></div>
</div>
</div>
<div class="field">
<label>Paliers de puissance · declared.stages</label>
<div class="stages">
<div class="stage-chip off">0 W <i>off</i></div>
<div class="stage-chip">1200 W</div>
<div class="stage-chip">2400 W</div>
<button class="add">+</button>
</div>
</div>
<div class="field">
<label>Priorité &amp; activation</label>
<div class="stepper">
<div class="lab">Rang de service<small>défini par glisser-déposer</small></div>
<div class="ctl"><div class="prio" style="width:24px;height:24px">2</div></div>
</div>
<div class="stepper">
<div class="lab">Inclure dans l'arbitrage</div>
<div class="ctl"><div class="sw"></div></div>
</div>
</div>
<div class="field">
<label>Anti-rebond · limits</label>
<div class="stepper">
<div class="lab">Durée minimale ON<small>minOnS</small></div>
<div class="ctl"><button class="pm"></button><b>120 s</b><button class="pm">+</button></div>
</div>
<div class="stepper">
<div class="lab">Durée minimale OFF<small>minOffS</small></div>
<div class="ctl"><button class="pm"></button><b>180 s</b><button class="pm">+</button></div>
</div>
</div>
<div class="field">
<label>Besoins · needs</label>
<div class="stepper">
<div class="lab">Échéance quotidienne<small>dailyDeadline · eau chaude prête</small></div>
<div class="ctl"><b>06:00</b></div>
</div>
<div class="stepper">
<div class="lab">Énergie min. / jour<small>minEnergyWhPerDay</small></div>
<div class="ctl"><button class="pm"></button><b>4 kWh</b><button class="pm">+</button></div>
</div>
</div>
<div class="save">
<div class="btn test">Tester</div>
<div class="btn go">Enregistrer</div>
</div>
</div>
</div>
<div class="cap">Écran 2 · « Configurer » (declared / limits / needs)</div>
</div>
</div>
<script>
// toggles
document.querySelectorAll('.sw').forEach(s=>s.addEventListener('click',()=>s.classList.toggle('off')));
// drag-reorder priority list
const list=document.getElementById('loadlist');
let dragged=null;
list.querySelectorAll('.load').forEach(card=>{
card.addEventListener('dragstart',()=>{dragged=card;card.classList.add('drag');});
card.addEventListener('dragend',()=>{card.classList.remove('drag');renumber();});
card.addEventListener('dragover',e=>{
e.preventDefault();
const t=e.currentTarget;
if(t===dragged)return;
t.classList.add('over');
const r=t.getBoundingClientRect();
const after=(e.clientY-r.top)/r.height>0.5;
list.insertBefore(dragged, after?t.nextSibling:t);
});
card.addEventListener('dragleave',e=>e.currentTarget.classList.remove('over'));
card.addEventListener('drop',e=>e.currentTarget.classList.remove('over'));
});
function renumber(){
list.querySelectorAll('.over').forEach(c=>c.classList.remove('over'));
list.querySelectorAll('.load .prio').forEach((p,i)=>p.textContent=i+1);
}
</script>
</body>
</html>

4228
docs/reference/jsonRPC.txt Normal file

File diff suppressed because it is too large Load Diff