// Kilometrage et capacite des vehicules (04/10/2026, demande du proprietaire). // // Deux mesures distinctes, decidees avec lui : // 1. km par kWh : entre deux RELEVES DU COMPTEUR kilometrique d'un vehicule, // kWh consommes = kWh livres par la borne a ce vehicule entre les deux // relevés + (niveau au 1er releve - niveau au 2nd) x capacite. // Cela mesure surtout la conduite et la saison (chauffage, froid). // 2. capacite estimee : a chaque branchement ou le niveau a ete SAISI et la // charge menee jusqu'au plein, capacite = kWh livres depuis la saisie / // (part manquante). C'est elle qui montrera l'usure de la batterie. // // ⚠️ La JEEP et la VW sont des HYBRIDES : batterie vide, elles roulent a // l'essence et ces kilometres gonfleraient le rendement. Un intervalle n'est // donc retenu que si le niveau au retour est > 0. La ZERO est tout electrique. // // Contrat d'entree (meme modele que l'etiquette du concierge) : // `input_text.evcs_releve_km` = tableau JSON des derniers releves, chacun // [vehicule, km, niveau_pct|null, epoch_s, source] // ecrit par le concierge WhatsApp ou par le script HA du tableau EVCS Master. // Le service les ingere chaque cycle, sans doublon (vehicule, horodatage). export const VEHICULES_TOUT_ELECTRIQUES = ['ZERO']; export const RELEVE_HELPER = 'input_text.evcs_releve_km'; export const NIVEAU_ENTITY = 'input_number.victron_evcs_vehicle_level_pct'; export const NIVEAU_AT_ENTITY = 'input_datetime.victron_evcs_vehicle_level_at'; export const NIVEAU_REF_ENTITY = 'input_number.victron_evcs_vehicle_level_ref_kwh'; // Etats du controleur qui disent « le vehicule n'accepte plus rien ». export const ETATS_PLEIN = ['day_vehicle_full_idle', 'night_recovery_done_idle', 'night_session_done_idle', 'departure_done_idle']; // Lit le helper. Renvoie { releves: [...], rejets: [motif...] }. Pure. export function lireReleves(brut) { const s = (brut ?? '').toString().trim(); if (!s || s === 'unknown' || s === 'unavailable') return { releves: [], rejets: [] }; let tab; try { tab = JSON.parse(s); } catch { return { releves: [], rejets: ['JSON illisible'] }; } if (!Array.isArray(tab)) return { releves: [], rejets: ['pas un tableau'] }; const releves = [], rejets = []; for (const e of tab) { if (!Array.isArray(e) || e.length < 4) { rejets.push('entree mal formee'); continue; } const [v, km, niveau, t, source] = e; const vehicule = (v ?? '').toString().trim().toUpperCase(); const kmN = Number(km), tN = Number(t); const nivN = niveau === null || niveau === undefined || niveau === '' ? null : Number(niveau); if (!vehicule) { rejets.push('vehicule absent'); continue; } if (!Number.isInteger(kmN) || kmN <= 0 || kmN > 2000000) { rejets.push(`${vehicule} : km invalide (${km})`); continue; } if (nivN !== null && !(Number.isInteger(nivN) && nivN >= 0 && nivN <= 100)) { rejets.push(`${vehicule} : niveau invalide (${niveau})`); continue; } if (!Number.isFinite(tN) || tN < 1.7e9) { rejets.push(`${vehicule} : horodatage invalide`); continue; } releves.push({ vehicule, km: kmN, niveau: nivN, ts: new Date(tN * 1000), source: (source ?? '').toString() || 'inconnue' }); } return { releves, rejets }; } // Ajoute un releve au tableau existant en gardant les 6 derniers (le helper est // plafonne a 255 caracteres par HA). Pure — utilisee par le concierge. export function ajouterReleve(brut, releve) { let tab = []; try { const t = JSON.parse(brut || '[]'); if (Array.isArray(t)) tab = t; } catch { /* repart a vide */ } tab.push([releve.vehicule, releve.km, releve.niveau ?? null, Math.round(releve.ts.getTime() / 1000), releve.source]); let out = tab.slice(-6); while (JSON.stringify(out).length > 255 && out.length > 1) out = out.slice(1); return JSON.stringify(out); } export async function creerSchemaKm(client) { await client.query(` CREATE TABLE IF NOT EXISTS releve_km ( vehicule text NOT NULL, ts timestamptz NOT NULL, km integer NOT NULL, niveau_pct integer, source text, capacite_kwh double precision, autonomie_ref_km double precision, tout_electrique boolean NOT NULL DEFAULT false, pose_a timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (vehicule, ts) )`); // Chaque saisie du niveau au branchement (concierge ou tableau de bord), // avec le compteur de la borne a cet instant. Le vehicule se deduit de // l'etiquette du branchement, jamais du selecteur. await client.query(` CREATE TABLE IF NOT EXISTS niveau_saisi ( saisi_a timestamptz PRIMARY KEY, niveau_pct integer NOT NULL, ref_kwh double precision NOT NULL, pose_a timestamptz NOT NULL DEFAULT now() )`); // Saisie ANCIENNE ajoutee a la main (04/10 : VW a 5 % le 15/09) : le compteur // de la borne n'etait pas encore historise, la reference peut manquer. await client.query('ALTER TABLE niveau_saisi ALTER COLUMN ref_kwh DROP NOT NULL'); await client.query('ALTER TABLE niveau_saisi ADD COLUMN IF NOT EXISTS source text'); await client.query(SQL_VUE_INTERVALLES); await client.query(SQL_VUE_CAPACITE); } // Un intervalle = deux releves consecutifs du meme vehicule. export const SQL_VUE_INTERVALLES = ` DROP VIEW IF EXISTS km_intervalle; CREATE VIEW km_intervalle AS WITH r AS ( SELECT vehicule, ts, km, niveau_pct, capacite_kwh, autonomie_ref_km, tout_electrique, lag(ts) OVER w AS ts0, lag(km) OVER w AS km0, lag(niveau_pct) OVER w AS n0 FROM releve_km WINDOW w AS (PARTITION BY vehicule ORDER BY ts) ), e AS ( SELECT r.*, km - km0 AS km_parcourus, (SELECT coalesce(sum(coalesce(s.energie_station_kwh, s.energie_kwh)), 0) FROM session_charge s WHERE s.vehicule = r.vehicule AND s.debut >= r.ts0 AND s.debut < r.ts) AS kwh_charges FROM r WHERE ts0 IS NOT NULL ), c AS ( SELECT e.*, CASE WHEN n0 IS NOT NULL AND niveau_pct IS NOT NULL AND capacite_kwh > 0 THEN kwh_charges + (n0 - niveau_pct) / 100.0 * capacite_kwh END AS kwh_consommes FROM e ) SELECT vehicule, ts0 AS debut, ts AS fin, km_parcourus, kwh_charges, n0 AS niveau_debut, niveau_pct AS niveau_fin, capacite_kwh, kwh_consommes, CASE WHEN kwh_consommes > 0 THEN km_parcourus / kwh_consommes END AS km_par_kwh, CASE WHEN capacite_kwh > 0 AND autonomie_ref_km > 0 THEN autonomie_ref_km / capacite_kwh END AS km_par_kwh_ref, CASE WHEN km_parcourus < 0 THEN 'compteur en baisse' WHEN km_parcourus = 0 THEN 'aucun km' WHEN n0 IS NULL OR niveau_pct IS NULL THEN 'niveau manquant' WHEN NOT tout_electrique AND niveau_pct <= 0 THEN 'batterie vide au retour : km essence possibles' WHEN kwh_consommes IS NULL OR kwh_consommes <= 0 THEN 'energie nulle' ELSE 'valide' END AS statut FROM c`; // Capacite estimee : derniere saisie de chaque branchement termine. // - Branchement = derniere transition 'on' au plus 1 h apres la saisie (la // saisie vaut si elle date de branchement - 1 h ou apres). // - Le niveau saisi est celui du BRANCHEMENT (le proprietaire le lit sur la // voiture avant de brancher, et le dit parfois une heure apres : « c'etait // 53 % a 13h10 ») : l'energie se compte donc du compteur de la borne au // branchement jusqu'au debranchement. // - Niveau de fin = 100 si le controleur a vu le vehicule plein, sinon le // niveau d'un releve km du meme vehicule dans l'heure qui suit le // debranchement (« debranche a 78 % »). // - Vehicule = etiquette CONFIRMEE des sessions du branchement (celle que le // proprietaire corrige), a defaut celle du concierge. export const SQL_VUE_CAPACITE = ` DROP VIEW IF EXISTS capacite_estimee; CREATE VIEW capacite_estimee AS WITH tr AS ( SELECT ts, detail->>'apres' AS apres FROM evenement WHERE genre = 'vehicule' AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' ), s AS ( SELECT n.*, (SELECT max(ts) FROM tr WHERE apres = 'on' AND tr.ts <= n.saisi_a + interval '1 hour') AS branche FROM niveau_saisi n ), s1 AS ( SELECT DISTINCT ON (branche) * FROM s WHERE branche IS NOT NULL ORDER BY branche, saisi_a DESC ), b AS ( SELECT s1.*, (SELECT min(ts) FROM tr WHERE apres = 'off' AND tr.ts > s1.branche) AS debranche FROM s1 WHERE saisi_a >= branche - interval '1 hour' ), v AS ( SELECT b.*, coalesce( (SELECT sc.vehicule FROM session_charge sc WHERE sc.debut >= b.branche AND sc.debut < b.debranche AND sc.vehicule_sur GROUP BY sc.vehicule ORDER BY sum(coalesce(sc.energie_station_kwh, sc.energie_kwh)) DESC LIMIT 1), (SELECT label FROM etiquette_branchement eb WHERE eb.branchement BETWEEN b.branche - interval '2 seconds' AND b.branche + interval '2 seconds' LIMIT 1)) AS vehicule FROM b WHERE debranche IS NOT NULL ), k AS ( SELECT v.*, -- Compteur de la borne (historise depuis le 30/09), a defaut la somme -- des sessions du branchement (compteur de la borne par session). coalesce( (SELECT valeur FROM mesure m WHERE m.cle = 'sensor.evcs_hq2239dnt7p_total_energy' AND m.ts <= v.debranche AND m.ts >= v.branche - interval '1 day' ORDER BY m.ts DESC LIMIT 1) - coalesce((SELECT valeur FROM mesure m WHERE m.cle = 'sensor.evcs_hq2239dnt7p_total_energy' AND m.ts <= v.branche AND m.ts >= v.branche - interval '1 day' ORDER BY m.ts DESC LIMIT 1), ref_kwh), (SELECT sum(coalesce(sc.energie_station_kwh, sc.energie_kwh)) FROM session_charge sc WHERE sc.debut >= v.branche AND sc.debut < v.debranche)) AS kwh_livres, EXISTS (SELECT 1 FROM decision d WHERE d.ts > v.branche AND d.ts <= v.debranche AND d.etat = ANY (ARRAY['${ETATS_PLEIN.join("','")}'])) AS plein, (SELECT r.niveau_pct FROM releve_km r WHERE r.vehicule = v.vehicule AND r.niveau_pct IS NOT NULL AND r.ts >= v.debranche AND r.ts <= v.debranche + interval '1 hour' ORDER BY r.ts LIMIT 1) AS niveau_releve_apres FROM v ), f AS ( SELECT k.*, CASE WHEN plein THEN 100 ELSE niveau_releve_apres END AS niveau_fin FROM k ) SELECT branche, debranche, coalesce(vehicule, '?') AS vehicule, niveau_pct, niveau_fin, kwh_livres, plein, CASE WHEN vehicule IS NOT NULL AND niveau_fin - niveau_pct >= 10 AND kwh_livres > 0 THEN kwh_livres / ((niveau_fin - niveau_pct) / 100.0) END AS capacite_kwh FROM f`; // Ingestion des releves du helper. Refuse un compteur qui baisse. export async function ingererReleves(sql, lire, st, journaliser) { const { releves, rejets } = lireReleves(st.get(RELEVE_HELPER)?.state); let n = 0; for (const r of releves) { const v = r.vehicule.toLowerCase(); const cap = Number(st.get(`input_number.victron_evcs_capacity_${v}_kwh`)?.state); const auto = Number(st.get(`input_number.victron_evcs_autonomie_${v}_km`)?.state); const { rows: prec } = await lire( 'SELECT km FROM releve_km WHERE vehicule = $1 AND ts < $2 ORDER BY ts DESC LIMIT 1', [r.vehicule, r.ts]); const { rows: deja } = await lire( 'SELECT 1 FROM releve_km WHERE vehicule = $1 AND ts = $2', [r.vehicule, r.ts]); if (deja.length) continue; if (prec.length && r.km < Number(prec[0].km)) { const cle = `${r.vehicule}|${r.ts.toISOString()}`; if (!rejetsJournalises.has(cle)) { rejetsJournalises.add(cle); await journaliser(sql, 'vehicule', `Releve km ${r.vehicule} refuse : ${r.km} km < ${prec[0].km} km du releve precedent`, { vehicule: r.vehicule, km: r.km, precedent: Number(prec[0].km), source: r.source }); } continue; } await sql( `INSERT INTO releve_km (vehicule, ts, km, niveau_pct, source, capacite_kwh, autonomie_ref_km, tout_electrique) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`, [r.vehicule, r.ts, r.km, r.niveau, r.source, cap > 0 ? cap : null, auto > 0 ? auto : null, VEHICULES_TOUT_ELECTRIQUES.includes(r.vehicule)]); await journaliser(sql, 'vehicule', `Releve km ${r.vehicule} : ${r.km} km` + (r.niveau === null ? ', niveau non donne' : `, niveau ${r.niveau} %`) + ` (${r.source})`, { vehicule: r.vehicule, km: r.km, niveau: r.niveau, ts: r.ts.toISOString(), source: r.source }); n++; } for (const m of rejets) { if (rejetsJournalises.has(m)) continue; rejetsJournalises.add(m); await journaliser(sql, 'vehicule', `Releve km illisible : ${m}`, { brut: (st.get(RELEVE_HELPER)?.state || '').slice(0, 255) }); } return n; } const rejetsJournalises = new Set(); // Saisie du niveau : une ligne par horodatage de saisie. export async function ingererNiveauSaisi(sql, st) { // L'attribut `timestamp` d'un input_datetime est en secondes epoch : pas de // fuseau a deviner (l'etat, lui, est une heure locale sans decalage). // A la SECONDE : un redemarrage de HA restaure l'horodatage sans ses // millisecondes, et la meme saisie revenait en double (04/10). const at = Math.floor(Number(st.get(NIVEAU_AT_ENTITY)?.attributes?.timestamp)) * 1000; const niveau = Number(st.get(NIVEAU_ENTITY)?.state); const ref = Number(st.get(NIVEAU_REF_ENTITY)?.state); if (!Number.isFinite(at) || !Number.isInteger(niveau) || !Number.isFinite(ref) || ref <= 0) return 0; const r = await sql( 'INSERT INTO niveau_saisi (saisi_a, niveau_pct, ref_kwh) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING', [new Date(at), niveau, ref]); return r?.rowCount || 0; } // --------------------------------------------------------------------- // JEEP LUE DIRECTEMENT (04/10/2026) : integration HACS hass-uconnect (API // Stellantis non officielle, compte du proprietaire, intervalle 15 min, sans // PIN ni entites de commande). La voiture publie un etat a chaque evenement // (arret, branchement) : `last_info_update_at` date les valeurs. // - RELEVE : un par changement d'odometre (fin de trajet), date de la mise a // jour, avec le niveau HV a cet instant. Le compteur immobile ne produit rien. // - NIVEAU AU BRANCHEMENT : la premiere mise a jour dans les 30 min apres le // branchement de la borne, si la Jeep se dit branchee -> niveau_saisi // (source uconnect). Une saisie humaine posterieure reste prioritaire (la // vue prend la derniere saisie du branchement). // --------------------------------------------------------------------- export const JEEP = { odometre: 'sensor.jeep_renegade_phev_odometer', niveau: 'sensor.jeep_renegade_phev_hvbattery_charge', majA: 'sensor.jeep_renegade_phev_last_info_update_at', branchee: 'binary_sensor.jeep_renegade_phev_ev_charger', }; // `etiqueter(branche, niveau)` (fourni par le service) : pose l'etiquette JEEP // du branchement, le selecteur et le niveau « saisi » — appelee UNE fois par // branchement, a la premiere mise a jour Uconnect qui le confirme. export async function ingererJeep(sql, lire, st, journaliser, etiqueter = null) { const km = Number(st.get(JEEP.odometre)?.state); const niv = Number(st.get(JEEP.niveau)?.state); const t = Date.parse(st.get(JEEP.majA)?.state ?? ''); if (!Number.isFinite(t) || !Number.isInteger(km) || km <= 0) return 0; const niveau = Number.isFinite(niv) && niv >= 0 && niv <= 100 ? Math.round(niv) : null; const ts = new Date(t); let n = 0; const { rows: der } = await lire( "SELECT km, ts FROM releve_km WHERE vehicule = 'JEEP' ORDER BY ts DESC LIMIT 1", []); if (!der.length || (Number(der[0].km) !== km && ts > der[0].ts)) { if (der.length && km < Number(der[0].km)) return 0; // compteur en baisse : on ne croit pas const cap = Number(st.get('input_number.victron_evcs_capacity_jeep_kwh')?.state); const auto = Number(st.get('input_number.victron_evcs_autonomie_jeep_km')?.state); const r = await sql( `INSERT INTO releve_km (vehicule, ts, km, niveau_pct, source, capacite_kwh, autonomie_ref_km, tout_electrique) VALUES ('JEEP',$1,$2,$3,'uconnect',$4,$5,false) ON CONFLICT DO NOTHING`, [ts, km, niveau, cap > 0 ? cap : null, auto > 0 ? auto : null]); if (r?.rowCount) { n++; await journaliser(sql, 'vehicule', `Releve km JEEP ${km} km` + (niveau === null ? '' : `, niveau ${niveau} %`) + ' (lu par Uconnect)', { vehicule: 'JEEP', km, niveau, ts: ts.toISOString(), source: 'uconnect' }); } } if (niveau !== null && st.get(JEEP.branchee)?.state === 'on' && st.get('binary_sensor.victron_evcs_vehicle_connected')?.state === 'on') { const { rows: b } = await lire( `SELECT max(ts) AS t FROM evenement WHERE genre = 'vehicule' AND detail->>'entite' = 'binary_sensor.victron_evcs_vehicle_connected' AND detail->>'apres' = 'on'`, []); const branche = b[0]?.t; if (branche && ts >= new Date(branche.getTime() - 5 * 60000) && ts <= new Date(branche.getTime() + 30 * 60000)) { const { rows: deja } = await lire( "SELECT 1 FROM niveau_saisi WHERE source = 'uconnect' AND saisi_a >= $1", [new Date(branche.getTime() - 5 * 60000)]); if (!deja.length) { const r = await sql( "INSERT INTO niveau_saisi (saisi_a, niveau_pct, ref_kwh, source) VALUES ($1,$2,NULL,'uconnect') ON CONFLICT DO NOTHING", [ts, niveau]); if (r?.rowCount) { n++; await journaliser(sql, 'vehicule', `Niveau JEEP au branchement : ${niveau} % (lu par Uconnect)`, { niveau, ts: ts.toISOString(), branchement: branche.toISOString() }); if (etiqueter) n += await etiqueter(branche, niveau); } } } } return n; }