191 lines
10 KiB
JavaScript
191 lines
10 KiB
JavaScript
// 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');
|
|
|
|
// ⚠️ Quatre constantes, pas une de plus. Ne JAMAIS les rendre dynamiques.
|
|
// La troisieme (niveau saisi) est une DECISION du proprietaire du 03/10/2026 :
|
|
// le niveau annonce dans un message (« JEEP reste 70 % ») etait perdu, la regle
|
|
// de fiche qui devait le faire ecrire n'etait appliquee qu'une fois sur trois,
|
|
// et le modele avait « corrige » 150 en 100 avant de l'ecrire.
|
|
const HELPER_ENTITY = 'input_text.airtria_vehicle_label';
|
|
const SELECTOR_ENTITY = 'input_select.victron_evcs_vehicle';
|
|
const LEVEL_ENTITY = 'input_number.victron_evcs_vehicle_level_pct';
|
|
// La quatrieme (04/10/2026, decision du proprietaire) : la file des RELEVES
|
|
// KILOMETRIQUES, que le service EVCS ingere (km par kWh, usure des batteries).
|
|
// Tableau JSON des derniers releves [vehicule, km, niveau|null, epoch_s, source].
|
|
const RELEVE_ENTITY = 'input_text.evcs_releve_km';
|
|
// 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.
|
|
// 03/10/2026 : ZERO ajoutee (moto du foyer, option du selecteur depuis le 28/09,
|
|
// et le concierge devient le seul chemin d'ecriture du vehicule).
|
|
const SELECTOR_LABELS = ['VW', 'JEEP', 'ZERO'];
|
|
// 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 };
|
|
}
|
|
|
|
// Niveau de la voiture branchee, ANNONCE par le proprietaire (aucun capteur ne
|
|
// le donne). Entier de 0 a 100 SEULEMENT : une valeur invalide est REFUSEE, jamais
|
|
// corrigee. Source humaine obligatoire. Relecture comme pour les deux autres.
|
|
// La reference du compteur et la date de saisie sont memorisees par une
|
|
// automatisation HA a chaque ecriture : on n'y touche pas.
|
|
async function setVehicleLevel(niveau, source) {
|
|
if (!HUMAN_SOURCES.includes(source)) {
|
|
throw new Error(`niveau : source '${source}' non humaine — écriture interdite`);
|
|
}
|
|
if (!Number.isInteger(niveau) || niveau < 0 || niveau > 100) {
|
|
throw new Error(`niveau : '${niveau}' invalide (entier de 0 à 100) — on n'écrit pas`);
|
|
}
|
|
const token = haToken();
|
|
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
|
const res = await fetch(`${HA_BASE}/api/services/input_number/set_value`, {
|
|
method: 'POST', headers,
|
|
body: JSON.stringify({ entity_id: LEVEL_ENTITY, value: niveau }),
|
|
});
|
|
if (!res.ok) throw new Error(`set_value niveau HTTP ${res.status}`);
|
|
const back = await fetch(`${HA_BASE}/api/states/${LEVEL_ENTITY}`, { headers });
|
|
if (!back.ok) throw new Error(`relecture HTTP ${back.status}`);
|
|
const state = Number((await back.json()).state);
|
|
if (Math.round(state) !== niveau) {
|
|
throw new Error(`relecture '${state}' != '${niveau}' — niveau refusé en silence par HA`);
|
|
}
|
|
return { entity: LEVEL_ENTITY, value: niveau };
|
|
}
|
|
|
|
// Ajoute des releves a la file (garde les derniers dans 255 caracteres). Pure.
|
|
function fileReleves(brut, releves) {
|
|
let tab = [];
|
|
try { const t = JSON.parse(brut || '[]'); if (Array.isArray(t)) tab = t; } catch { /* repart a vide */ }
|
|
for (const r of releves) tab.push([r.vehicule, r.km, r.niveau ?? null, r.ts, r.source]);
|
|
let out = tab.slice(-6);
|
|
while (JSON.stringify(out).length > MAX_LEN && out.length > 1) out = out.slice(1);
|
|
return JSON.stringify(out);
|
|
}
|
|
|
|
// Releves kilometriques ANNONCES par le proprietaire. Vehicule du foyer, km
|
|
// entier positif, niveau entier 0-100 ou absent : sinon REFUS, jamais de
|
|
// correction. Source humaine. Relecture comme pour les autres ecritures.
|
|
async function setReleveKm(releves, source) {
|
|
if (!HUMAN_SOURCES.includes(source)) {
|
|
throw new Error(`releve km : source '${source}' non humaine — écriture interdite`);
|
|
}
|
|
if (!Array.isArray(releves) || !releves.length) throw new Error('releve km : rien a ecrire');
|
|
for (const r of releves) {
|
|
if (!SELECTOR_LABELS.includes(r.vehicule)) throw new Error(`releve km : '${r.vehicule}' hors foyer`);
|
|
if (!Number.isInteger(r.km) || r.km <= 0 || r.km > 2000000) throw new Error(`releve km : '${r.km}' invalide`);
|
|
if (r.niveau !== null && !(Number.isInteger(r.niveau) && r.niveau >= 0 && r.niveau <= 100)) {
|
|
throw new Error(`releve km : niveau '${r.niveau}' invalide`);
|
|
}
|
|
if (!Number.isInteger(r.ts)) throw new Error('releve km : horodatage invalide');
|
|
}
|
|
const token = haToken();
|
|
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
|
const cur = await fetch(`${HA_BASE}/api/states/${RELEVE_ENTITY}`, { headers });
|
|
if (!cur.ok) throw new Error(`lecture file HTTP ${cur.status}`);
|
|
// 12/10/2026 (EVCS) : un niveau DÉDUIT de l'autonomie porte sa propre source « photo_autonomie:<km>/<réf> »
|
|
// (5e champ), pour que la capacité estimée puisse l'écarter. Toute autre valeur → la source humaine.
|
|
const payload = fileReleves((await cur.json()).state, releves.map(r => ({ ...r,
|
|
source: /^photo_autonomie:\d+\/\d+$/.test(r.source || '') ? r.source : source })));
|
|
const res = await fetch(`${HA_BASE}/api/services/input_text/set_value`, {
|
|
method: 'POST', headers,
|
|
body: JSON.stringify({ entity_id: RELEVE_ENTITY, value: payload }),
|
|
});
|
|
if (!res.ok) throw new Error(`set_value releve HTTP ${res.status}`);
|
|
const back = await fetch(`${HA_BASE}/api/states/${RELEVE_ENTITY}`, { headers });
|
|
if (!back.ok) throw new Error(`relecture HTTP ${back.status}`);
|
|
if ((await back.json()).state !== payload) throw new Error('relecture != écrit — releve refusé en silence par HA');
|
|
return { entity: RELEVE_ENTITY, payload };
|
|
}
|
|
|
|
module.exports = {
|
|
publishLabel, setVehicleSelector, setVehicleLevel, setReleveKm, fileReleves,
|
|
HELPER_ENTITY, SELECTOR_ENTITY, LEVEL_ENTITY, RELEVE_ENTITY, SELECTOR_LABELS, MAX_LEN,
|
|
};
|