#!/usr/bin/env node // airtria-ha-vehicule.js — SEULE écriture autorisée de l'agent maison vers Home Assistant : // quel véhicule est branché et à quel niveau (saisi par Thierry, aucun capteur ne le donne). // node airtria-ha-vehicule.js → lit et affiche l'état courant // node airtria-ha-vehicule.js --vehicule JEEP --niveau 12 // node airtria-ha-vehicule.js --niveau 40 → niveau seul (véhicule inchangé) // node airtria-ha-vehicule.js --depart 16:00 → départ ponctuel AUJOURD'HUI (VW, ZERO) ; --depart aucun l'efface // node airtria-ha-vehicule.js --mode-nuit complete|sans_edf → charge voiture de la nuit (remis à sans_edf chaque jour à 12:00 par HA) // Écrit UNIQUEMENT : input_select (véhicule, option lue dans HA), input_number (niveau, entier 0-100) // et input_datetime (départ ponctuel, toujours daté du JOUR, jamais une autre date), // et input_select.victron_evcs_night_charge_mode (option lue dans HA). JAMAIS le budget // (input_text.victron_evcs_night_budget, input_number.evcs_balance_night_vehicle_budget_kwh). // Ne touche JAMAIS victron_evcs_vehicle_level_ref_kwh ni victron_evcs_vehicle_level_at : une automatisation HA // les mémorise seule à chaque appel (une saisie identique compte aussi). 'use strict'; const fs = require('fs'); const path = require('path'); const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123'; const token = () => fs.readFileSync(process.env.HA_TOKEN_FILE || path.join(__dirname, '.ha-token'), 'utf8').trim(); const SEL = 'input_select.victron_evcs_vehicle'; // « Véhicule branché » (désigné par EVCS) const NIV = 'input_number.victron_evcs_vehicle_level_pct'; // niveau saisi, % const DEP = 'input_datetime.victron_evcs_departure_ponctuel'; // départ ponctuel du jour (00:00 = aucun) const MODE = 'input_select.victron_evcs_night_charge_mode'; // sans_edf | complete const args = process.argv.slice(2); const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; }; const vehicule = opt('--vehicule'); const niveauArg = opt('--niveau'); const departArg = opt('--depart'); const modeArg = opt('--mode-nuit'); async function ha(method, url, body) { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 10000); try { const r = await fetch(`${HA_BASE}${url}`, { method, signal: ac.signal, headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); if (!r.ok) throw new Error(`HA ${r.status} sur ${url}`); return r.json(); } finally { clearTimeout(t); } } const etat = async (e) => ha('GET', `/api/states/${e}`); // 03/10/2026 : fichier drapeau `.simulation` (pose avant un banc qui passe par // l'agent complet) -> AUCUNE ecriture, les lectures restent reelles. Incident // du 02/10 : niveau 100 % ecrit en reel pendant un banc. const SIMULATION = fs.existsSync(path.join(__dirname, '.simulation')); const ecrit = async (url, body) => { if (SIMULATION) { console.log(`[SIMULATION : rien écrit — ${url} ${JSON.stringify(body)}]`); return {}; } return ha('POST', url, body); }; (async () => { if (modeArg !== undefined) { const m = await etat(MODE); const opts = m.attributes.options || []; const o = opts.find((x) => x.toLowerCase() === String(modeArg).toLowerCase()); if (!o) { console.log(`refus : mode « ${modeArg} » inconnu de HA, options : ${opts.join(', ')}`); process.exit(2); } await ecrit('/api/services/input_select/select_option', { entity_id: MODE, option: o }); await new Promise((r) => setTimeout(r, 800)); const lu = (await etat(MODE)).state; if (lu !== o) { console.log(`erreur : écrit ${o} mais HA relit ${lu}`); process.exit(1); } console.log(lu === 'complete' ? "C'est noté : charge complète cette nuit (batterie d'abord, puis EDF en heures creuses pour le reste). Retour à « sans EDF » demain à midi." : "C'est noté : charge sans EDF cette nuit (la voiture s'arrête au budget batterie et attend le soleil)."); return; } const sel = await etat(SEL); const niv = await etat(NIV); const options = sel.attributes.options || []; const aujourdhui = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 10); const departTxt = (d) => (d.state.startsWith(aujourdhui) && !d.state.endsWith('00:00:00')) ? `départ ponctuel aujourd'hui ${d.state.slice(11, 16)}` : 'pas de départ ponctuel saisi'; let branche = 'état de branchement illisible'; try { branche = (await etat('binary_sensor.victron_evcs_vehicle_connected')).state === 'on' ? 'une voiture EST branchée' : "AUCUNE voiture n'est branchée"; } catch { /* lecture seule */ } const affiche = (s, n, d, prefix) => console.log(`${prefix}${branche} ; véhicule sélectionné = ${s.state}, dernier niveau saisi = ${Math.round(Number(n.state))} %, ${departTxt(d)} (options : ${options.join(', ')})`); if (vehicule === undefined && niveauArg === undefined && departArg === undefined) { affiche(sel, niv, await etat(DEP), 'HA : '); return; } // Tout est validé AVANT la première écriture : un refus n'écrit jamais rien (30/09). if (niveauArg !== undefined) { const n0 = Number(niveauArg); if (!Number.isInteger(n0) || n0 < 0 || n0 > 100) { console.log(`refus : niveau « ${niveauArg} » invalide, entier de 0 à 100 attendu`); process.exit(2); } } if (vehicule !== undefined && !options.some((x) => x.toLowerCase() === String(vehicule).toLowerCase())) { console.log(`refus : véhicule « ${vehicule} » inconnu de HA, options : ${options.join(', ')}`); process.exit(2); } if (departArg !== undefined && String(departArg).toLowerCase() !== 'aucun') { const m0 = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(String(departArg)); const now0 = new Date(); const nowHM0 = `${String(now0.getHours()).padStart(2, '0')}:${String(now0.getMinutes()).padStart(2, '0')}`; if (!m0) { console.log(`refus : départ « ${departArg} » invalide, heure HH:MM d'aujourd'hui attendue (ou « aucun »)`); process.exit(2); } if (`${m0[1].padStart(2, '0')}:${m0[2]}` <= nowHM0) { console.log(`refus : départ ${m0[1]}:${m0[2]} déjà passé (il est ${nowHM0}), le départ ponctuel ne vaut que pour la suite de la journée`); process.exit(2); } } if (vehicule !== undefined) { const o = options.find((x) => x.toLowerCase() === String(vehicule).toLowerCase()); if (!o) { console.log(`refus : véhicule « ${vehicule} » inconnu de HA, options : ${options.join(', ')}`); process.exit(2); } await ecrit('/api/services/input_select/select_option', { entity_id: SEL, option: o }); } if (niveauArg !== undefined) { const n = Number(niveauArg); if (!Number.isInteger(n) || n < 0 || n > 100) { console.log(`refus : niveau « ${niveauArg} » invalide, entier de 0 à 100 attendu`); process.exit(2); } await ecrit('/api/services/input_number/set_value', { entity_id: NIV, value: n }); } if (departArg !== undefined) { let heure = '00:00'; if (String(departArg).toLowerCase() !== 'aucun') { const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(String(departArg)); if (!m) { console.log(`refus : départ « ${departArg} » invalide, heure HH:MM d'aujourd'hui attendue (ou « aucun »)`); process.exit(2); } heure = `${m[1].padStart(2, '0')}:${m[2]}`; const now = new Date(); const nowHM = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; if (heure <= nowHM) { console.log(`refus : départ ${heure} déjà passé (il est ${nowHM}), le départ ponctuel ne vaut que pour la suite de la journée`); process.exit(2); } } await ecrit('/api/services/input_datetime/set_datetime', { entity_id: DEP, datetime: `${aujourdhui} ${heure}:00` }); } await new Promise((r) => setTimeout(r, 800)); affiche(await etat(SEL), await etat(NIV), await etat(DEP), 'écrit et relu — '); })().catch((e) => { console.log(`erreur : ${e.message}`); process.exit(1); });