// concierge-write.js — Concierge véhicule Airtria, ÉTAGE ÉCRITURE (canal HA restreint) // ----------------------------------------------------------------------------- // SEULES écritures HA d'Airtria. La restriction à ces entités est CONVENTIONNELLE // (le token HA peut techniquement tout écrire) : elle ne tient que par ce code. // Donc, invariants tenus ici et vérifiés par concierge-write.test.js : // - les entités écrites sont DEUX CONSTANTES EN DUR, jamais interpolées ; // - deux services seulement : input_text.set_value (étiquette) et // input_select.select_option (sélecteur, ajouté le 19/09/2026 à la demande // du propriétaire relayée par EVCS) ; // - le sélecteur n'accepte que les véhicules du FOYER (liste en dur) et une // source HUMAINE — jamais la déduction règle-puissance ; // - relecture obligatoire après chaque écriture (rejet silencieux HA : // set_value tronque au-delà de max, select_option IGNORE une option hors liste). // Voir airtria-concierge-vehicule-SPEC.md. // ----------------------------------------------------------------------------- 'use strict'; const fs = require('fs'); const path = require('path'); // ⚠️ Deux constantes, pas une de plus. Ne JAMAIS les rendre dynamiques. const HELPER_ENTITY = 'input_text.airtria_vehicle_label'; const SELECTOR_ENTITY = 'input_select.victron_evcs_vehicle'; // Liste EN DUR, volontairement PAS lue depuis HA : le sélecteur ne pilote que // les deux véhicules du foyer (départs VW 08:50 / JEEP 06:30). Si une option // est ajoutée côté HA un jour (ZERO ?), l'ajouter ici est une DÉCISION. const SELECTOR_LABELS = ['VW', 'JEEP']; // Le sélecteur ne s'écrit QUE sur confirmation humaine. La déduction // règle-puissance ne conclut jamais VW vs JEEP (indiscernables électriquement), // donc ne passe jamais ici — et ce garde le garantit même si ça changeait. const HUMAN_SOURCES = ['whatsapp']; const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123'; const MAX_LEN = 255; // le helper DOIT être déclaré max:255 côté HA (défaut = 100) function haToken() { return fs.readFileSync( process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim(); } // Publie le tuple {branchement, label, confirmed_at, source}. // branchement = ts::text VERBATIM de la transition 'on' (evenement), espace et +02 // compris. Le service EVCS joint dessus (±1 s). C'est ce que PostgreSQL a rendu. async function publishLabel(branchement, label, source = 'whatsapp') { if (!branchement || typeof branchement !== 'string') { throw new Error('branchement (ts::text verbatim) requis'); } if (!label || typeof label !== 'string') throw new Error('label invalide'); const payload = JSON.stringify({ branchement, label, confirmed_at: new Date().toISOString(), source, }); if (payload.length > MAX_LEN) { throw new Error(`payload ${payload.length} > ${MAX_LEN} : scinder le groupe ` + `en deux publications espacées de > 90 s`); } const token = haToken(); const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; // Écriture : service input_text.set_value sur la CONSTANTE en dur. const res = await fetch(`${HA_BASE}/api/services/input_text/set_value`, { method: 'POST', headers, body: JSON.stringify({ entity_id: HELPER_ENTITY, value: payload }), }); if (!res.ok) throw new Error(`set_value HTTP ${res.status}`); // Relecture OBLIGATOIRE : seul moyen de voir un rejet silencieux (max trop court). const back = await fetch(`${HA_BASE}/api/states/${HELPER_ENTITY}`, { headers }); if (!back.ok) throw new Error(`relecture HTTP ${back.status}`); const state = (await back.json()).state; if (state !== payload) { throw new Error('relecture != écrit — rejet silencieux probable ' + '(vérifier max:255 sur le helper)'); } return { entity: HELPER_ENTITY, payload }; } // Aligne le sélecteur temps réel sur la confirmation HUMAINE d'un véhicule du // FOYER. Pour MEGANE / INVITE / ZERO : ne pas appeler — le sélecteur reste tel // quel, seule l'étiquette est publiée. La relecture est le SEUL moyen de voir // qu'HA a refusé l'option : select_option ne renvoie pas d'erreur, il ignore. async function setVehicleSelector(label, source) { if (!HUMAN_SOURCES.includes(source)) { throw new Error(`sélecteur : source '${source}' non humaine — écriture interdite`); } if (!SELECTOR_LABELS.includes(label)) { throw new Error(`sélecteur : '${label}' hors foyer — on n'écrit pas (VW/JEEP seulement)`); } const token = haToken(); const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; const res = await fetch(`${HA_BASE}/api/services/input_select/select_option`, { method: 'POST', headers, body: JSON.stringify({ entity_id: SELECTOR_ENTITY, option: label }), }); if (!res.ok) throw new Error(`select_option HTTP ${res.status}`); const back = await fetch(`${HA_BASE}/api/states/${SELECTOR_ENTITY}`, { headers }); if (!back.ok) throw new Error(`relecture HTTP ${back.status}`); const state = (await back.json()).state; if (state !== label) { throw new Error(`relecture '${state}' != '${label}' — option refusée en silence par HA`); } return { entity: SELECTOR_ENTITY, option: label }; } module.exports = { publishLabel, setVehicleSelector, HELPER_ENTITY, SELECTOR_ENTITY, SELECTOR_LABELS, MAX_LEN, };