#!/usr/bin/env node // rapport-solaire.js — RAPPORT SOLAIRE type, imprimé tout formaté pour l'agent. // ----------------------------------------------------------------------------- // Composition validée par Thierry le 26/09/2026, calquée sur la page VRM : // 1. en-tête (date, heure, météo) 2. production (réel du jour, détail par chargeur, // PV maintenant, pic par chargeur) 6. consommation du jour (EVCS) 4. réseau EDF // 5. batterie + conclusion. // Hors rapport pour l'instant : prévision (Forecast.Solar sous-estime de 63 % sur 7 j, // EVCS cherche un accès VRM), véhicule. On les ajoutera quand EVCS les fournit. // // Doctrine : le script LIT et MET EN FORME, il ne calcule aucun verdict. L'agent // relaie la sortie telle quelle. Une entité absente s'affiche « indisponible », // jamais une valeur inventée ni une valeur d'hier. // // Usage : node rapport-solaire.js → version complète (mail / écrit) // node rapport-solaire.js --vocal → version courte (6 phrases, à lire) // ----------------------------------------------------------------------------- const fs = require('node:fs'); const HA = process.env.HA_BASE || 'http://10.10.10.17:8123'; const TOKEN = fs.readFileSync(`${__dirname}/.ha-token`, 'utf8').trim(); const VOCAL = process.argv.includes('--vocal'); const E = { prod: 'sensor.evcs_production_jour_kwh', conso: 'sensor.evcs_consommation_jour_kwh', diag: 'sensor.evcs_pv_diagnostic', reseau: 'sensor.evcs_reseau', importJour: 'sensor.evcs_import_jour_kwh', gridOn: 'input_datetime.victron_grid_last_on_at', gridOff: 'input_datetime.victron_grid_last_off_at', soc: 'sensor.victron_battery_soc', battP: 'sensor.victron_battery_power', battState: 'sensor.gx_device_dc_battery_state', battV: 'sensor.victron_battery_voltage', battT: 'sensor.victron_battery_temperature', cibleNuit: 'input_number.victron_evcs_night_target_soc', nuit: 'sensor.evcs_bilan_nuit', cellules: 'sensor.evcs_cellules', meteo: 'weather.forecast_maison', pluie: 'sensor.meteo_france_forecast_for_city_pleumeur_bodou_bretagne_22_fr_pleumeur_bodou_rain_chance', vigilance: 'sensor.meteo_france_alert_for_department_22_22_weather_alert', picMppt: 'sensor.mppt_450_200_hq2449yr9pt_max_power_today', picAirtria: 'sensor.airtria_mppt1_max_power_today', picV1: 'sensor.v1_max_power_today', }; const NOMS = { mppt_450_200: 'MPPT 450/200', airtria_mppt1: 'AIRTRIA MPPT1', v1: 'V1' }; const METEO_FR = { sunny: 'ensoleillé', clearnight: 'ciel dégagé', partlycloudy: 'partiellement nuageux', cloudy: 'couvert', rainy: 'pluie', pouring: 'fortes pluies', fog: 'brouillard', windy: 'venteux', lightning: 'orage', snowy: 'neige', hail: 'grêle' }; const ETAT_BATT = { charging: 'en charge', discharging: 'en décharge', idle: 'au repos' }; const ETAT_RESEAU = { non_raccorde: 'non raccordé', raccorde: 'raccordé', etablissement: 'en cours de raccordement', mesure_muette: 'mesure muette', incoherent: 'incohérent', indetermine: 'indéterminé' }; async function get(id) { try { const r = await fetch(`${HA}/api/states/${id}`, { headers: { Authorization: `Bearer ${TOKEN}` } }); if (!r.ok) return null; const j = await r.json(); if (['unavailable', 'unknown', ''].includes(String(j.state))) return null; return j; } catch { return null; } } const n = (v, d = 1) => (v == null || isNaN(Number(v))) ? 'indisponible' : Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: d }); const kwh = (v, d = 1) => v == null ? 'indisponible' : `${n(v, d)} kWh`; const w = v => v == null ? 'indisponible' : `${n(Math.round(v), 0)} W`; const hhmm = iso => { if (!iso) return 'indisponible'; const d = new Date(iso.replace(' ', 'T')); if (isNaN(d)) return String(iso); return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); }; (async () => { const s = {}; await Promise.all(Object.entries(E).map(async ([k, id]) => { s[k] = await get(id); })); const st = k => s[k] ? s[k].state : null; const at = (k, a) => s[k] ? s[k].attributes[a] : undefined; const now = new Date(); const dateFr = now.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Paris' }); const heure = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }).replace(':', 'h'); // 1. météo const cond = METEO_FR[st('meteo')] || st('meteo') || 'indisponible'; const temp = at('meteo', 'temperature'); const nuages = at('meteo', 'cloud_coverage'); // 2. production const prod = st('prod'); const statut = at('prod', 'statut'); const muets = at('prod', 'chargeurs_muets') || []; let detail = at('prod', 'detail_kwh'); if (typeof detail === 'string') { try { detail = JSON.parse(detail); } catch { detail = null; } } const prodTxt = prod == null ? 'indisponible' : statut === 'partiel' ? `au moins ${kwh(prod)} (partiel : ${muets.join(', ') || 'chargeur muet'})` : `${kwh(prod)} (${statut || 'statut inconnu'})`; const detailTxt = detail ? Object.entries(detail).map(([k, v]) => `${NOMS[k] || k} ${n(v, 1)}`).join(' · ') : 'indisponible'; const pvW = at('diag', 'pv_w'); const diagEtat = st('diag'); const diagExpl = at('diag', 'explication'); const pics = [['MPPT 450/200', st('picMppt')], ['AIRTRIA MPPT1', st('picAirtria')], ['V1', st('picV1')]] .filter(([, v]) => v != null).map(([k, v]) => `${k} ${w(v)}`).join(' · ') || 'indisponible'; // diagnostic en une phrase courte pour l'oral (les phrases longues sont coupées par la synthèse vocale du téléphone) const DIAG_COURT = { bridage_normal: 'bridage normal, du solaire reste disponible pour une charge de plus' }; const diagCourt = diagEtat == null ? 'indisponible' : DIAG_COURT[diagEtat] || (diagExpl ? String(diagExpl).split(/(?<=\.)\s/)[0].replace(/\.$/, '') : String(diagEtat).replace(/_/g, ' ')); // 6. consommation (capteur EVCS, maison + borne, depuis minuit) const conso = st('conso'); const consoExpl = at('conso', 'explication'); let cdet = at('conso', 'detail_kwh'); if (typeof cdet === 'string') { try { cdet = JSON.parse(cdet); } catch { cdet = null; } } const consoTxt = conso == null ? `indisponible${consoExpl ? ` — ${consoExpl}` : ''}` : at('conso', 'statut') === 'partiel' ? `au moins ${kwh(conso)} (partiel)` : `${kwh(conso)}${cdet ? ` (réseau ${n(cdet.depuis_reseau, 1)}, onduleur ${n(cdet.depuis_onduleur, 1)})` : ''}`; const consoVocal = conso == null ? 'Consommation du jour indisponible.' : `Consommation du jour : ${at('conso', 'statut') === 'partiel' ? 'au moins ' : ''}${kwh(conso)}${cdet ? `, dont ${kwh(cdet.depuis_reseau)} depuis le réseau` : ''}.`; // 4. réseau const rEtat = ETAT_RESEAU[st('reseau')] || st('reseau') || 'indisponible'; const rDepuis = at('reseau', 'depuis_heure'); const rExpl = at('reseau', 'explication'); const imp = st('importJour'); // 5. batterie const soc = st('soc'); const bp = st('battP'); const bEtat = ETAT_BATT[st('battState')] || st('battState') || ''; const bTxt = soc == null ? 'indisponible' : `${n(soc, 0)} %${bEtat ? `, ${bEtat}` : ''}${bp != null ? ` à ${w(Math.abs(bp))}` : ''}`; const nuitDep = at('nuit', 'batterie_maison_depart_pct'); const nuitBas = at('nuit', 'batterie_maison_plus_bas_pct'); const nuitH = at('nuit', 'batterie_maison_heure_plus_bas'); const cellOk = at('cellules', 'statut') === 'frais'; const cellTxt = !s.cellules ? 'indisponible' : cellOk ? `module ${at('cellules', 'repere_le_plus_sensible')} (${at('cellules', 'module_le_plus_sensible')}), écart ${n(st('cellules'), 0)} mV, relevé frais` : `relevé ${at('cellules', 'statut') || 'périmé'} : ne pas annoncer de module`; if (VOCAL) { const phrases = [ `Rapport solaire de ${heure}, temps ${cond}${temp != null ? `, ${n(temp, 0)} degrés` : ''}.`, `Production du jour : ${prodTxt}${pvW != null ? `, ${w(pvW)} en ce moment` : ''}.`, consoVocal, `Réseau EDF ${rEtat}${rDepuis ? ` depuis ${rDepuis}` : ''}, ${imp != null ? `${kwh(imp)} importés aujourd'hui` : 'import indisponible'}.`, `Batterie à ${bTxt}${s.battT ? `, ${n(st('battT'), 0)} degrés` : ''}.`, `Diagnostic : ${diagCourt}.`, ]; console.log(phrases.join('\n')); return; } const L = []; L.push(`RAPPORT SOLAIRE — ${dateFr}, ${heure}`); L.push(`Météo : ${cond}${temp != null ? `, ${n(temp, 0)} °C` : ''}${nuages != null ? `, nuages ${n(nuages, 0)} %` : ''}${st('pluie') != null ? `, pluie ${n(st('pluie'), 0)} %` : ''}${st('vigilance') ? `, vigilance ${st('vigilance')}` : ''}`); L.push(''); L.push('PRODUCTION'); L.push(`Réalisé du jour : ${prodTxt}`); L.push(`Par chargeur (kWh) : ${detailTxt}`); L.push(`PV en ce moment : ${w(pvW)}${diagEtat ? ` — état ${diagEtat}` : ''}`); L.push(`Pic du jour : ${pics}`); L.push(''); L.push('CONSOMMATION'); L.push(`Depuis minuit (maison et borne) : ${consoTxt}`); L.push(''); L.push('RÉSEAU EDF'); L.push(`État : ${rEtat}${rDepuis ? ` depuis ${rDepuis}` : ''}${rExpl ? ` — ${rExpl}` : ''}`); L.push(`Importé aujourd'hui : ${kwh(imp)} — injecté : 0 kWh (installation sans injection)`); L.push(`Contacteur : dernière fermeture ${hhmm(st('gridOn'))}, dernière ouverture ${hhmm(st('gridOff'))}`); L.push(''); L.push('BATTERIE MAISON'); L.push(`SOC : ${bTxt}${s.battV ? ` — ${n(st('battV'), 1)} V` : ''}${s.battT ? `, ${n(st('battT'), 0)} °C` : ''}`); L.push(`Nuit passée : ${nuitDep != null ? `départ ${n(nuitDep, 0)} %, plus bas ${n(nuitBas, 0)} %${nuitH ? ` à ${nuitH}` : ''}` : 'indisponible'} — objectif SOC nuit : ${st('cibleNuit') != null ? `${n(st('cibleNuit'), 0)} %` : 'indisponible'}`); L.push(`Cellule la plus sensible : ${cellTxt}`); L.push(''); L.push('CONCLUSION'); L.push(diagExpl || 'Diagnostic indisponible.'); console.log(L.join('\n')); })();