diff --git a/dashboards/evcs_master.yaml b/dashboards/evcs_master.yaml index cf9f834..70b386b 100755 --- a/dashboards/evcs_master.yaml +++ b/dashboards/evcs_master.yaml @@ -205,6 +205,32 @@ views: icon: mdi:calendar-clock icon_color: cyan + - type: entities + title: Relevé kilométrique (km par kWh et usure des batteries) + show_header_toggle: false + entities: + - entity: input_select.evcs_releve_vehicule + name: Véhicule + - entity: input_number.evcs_releve_km_valeur + name: Compteur (km) + - entity: input_number.evcs_releve_niveau + name: Niveau batterie (%, -1 = inconnu) + - type: button + name: Enregistrer le relevé + icon: mdi:content-save + action_name: Enregistrer + tap_action: + action: perform-action + perform_action: script.evcs_enregistrer_releve_km + - type: section + label: "Ou par WhatsApp : « JEEP 112694 km 54 % » — toujours avec le niveau" + - entity: input_number.victron_evcs_autonomie_jeep_km + name: Autonomie de référence JEEP + - entity: input_number.victron_evcs_autonomie_vw_km + name: Autonomie de référence VW + - entity: input_number.victron_evcs_autonomie_zero_km + name: Autonomie de référence ZERO + - type: entities title: Paramètres utilisateur show_header_toggle: false diff --git a/deploy/historisation/grafana/dashboards/vehicules.json b/deploy/historisation/grafana/dashboards/vehicules.json index 41bbfab..ef0d7be 100644 --- a/deploy/historisation/grafana/dashboards/vehicules.json +++ b/deploy/historisation/grafana/dashboards/vehicules.json @@ -579,6 +579,557 @@ "show": false } } + }, + { + "type": "xychart", + "pluginVersion": "11.3.0", + "title": "Kilometres contre kWh : le nuage des trajets", + "description": "Un point = l'intervalle entre deux releves du compteur d'un vehicule. kWh consommes = kWh livres par la borne entre les deux releves + (niveau au 1er - niveau au 2nd) x capacite. Les droites sont les references donnees par le proprietaire (JEEP 50 km, VW 42 km, ZERO 200 km pour une charge pleine). Sous la droite : on a roule moins loin par kWh (conduite, froid, chauffage — ou batterie qui s'use). Les intervalles d'une hybride finis batterie vide sont ecartes (km essence possibles).", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "gridPos": { + "x": 0, + "y": 24, + "w": 14, + "h": 11 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "format": "table", + "rawQuery": true, + "editorMode": "code", + "rawSql": "WITH i AS (SELECT * FROM km_intervalle WHERE statut = 'valide'),\ncap AS (SELECT DISTINCT ON (vehicule) capacite_kwh AS c FROM releve_km\n WHERE capacite_kwh > 0 ORDER BY vehicule, ts DESC),\nx AS (SELECT round(kwh_consommes::numeric, 2)::float8 AS x, vehicule, km_parcourus::float8 AS km FROM i\n UNION ALL SELECT 0, NULL, NULL\n UNION ALL SELECT c::float8, NULL, NULL FROM cap)\nSELECT x AS \"kWh consommes\",\n CASE WHEN vehicule = 'JEEP' THEN km END AS \"JEEP\",\n CASE WHEN vehicule = 'VW' THEN km END AS \"VW\",\n CASE WHEN vehicule = 'ZERO' THEN km END AS \"ZERO\",\n round((x * (SELECT autonomie_ref_km / capacite_kwh FROM releve_km\n WHERE vehicule = 'JEEP' AND capacite_kwh > 0 AND autonomie_ref_km > 0 ORDER BY ts DESC LIMIT 1))::numeric, 1)::float8 AS \"Reference JEEP\",\n round((x * (SELECT autonomie_ref_km / capacite_kwh FROM releve_km\n WHERE vehicule = 'VW' AND capacite_kwh > 0 AND autonomie_ref_km > 0 ORDER BY ts DESC LIMIT 1))::numeric, 1)::float8 AS \"Reference VW\",\n round((x * (SELECT autonomie_ref_km / capacite_kwh FROM releve_km\n WHERE vehicule = 'ZERO' AND capacite_kwh > 0 AND autonomie_ref_km > 0 ORDER BY ts DESC LIMIT 1))::numeric, 1)::float8 AS \"Reference ZERO\"\nFROM x ORDER BY 1" + } + ], + "fieldConfig": { + "defaults": { + "unit": "lengthkm", + "color": { + "mode": "palette-classic" + }, + "custom": { + "show": "points", + "pointSize": { + "fixed": 10 + }, + "pointShape": "circle", + "lineWidth": 1, + "fillOpacity": 60 + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "JEEP" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "VW" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ZERO" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "purple" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Reference JEEP" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + }, + { + "id": "custom.show", + "value": "lines" + }, + { + "id": "custom.lineStyle", + "value": { + "fill": "dash", + "dash": [ + 10, + 10 + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Reference VW" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + }, + { + "id": "custom.show", + "value": "lines" + }, + { + "id": "custom.lineStyle", + "value": { + "fill": "dash", + "dash": [ + 10, + 10 + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Reference ZERO" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "purple" + } + }, + { + "id": "custom.show", + "value": "lines" + }, + { + "id": "custom.lineStyle", + "value": { + "fill": "dash", + "dash": [ + 10, + 10 + ] + } + } + ] + } + ] + }, + "options": { + "mapping": "manual", + "series": [ + { + "frame": { + "matcher": { + "id": "byIndex", + "options": 0 + } + }, + "x": { + "matcher": { + "id": "byName", + "options": "kWh consommes" + } + }, + "y": { + "matcher": { + "id": "byName", + "options": "JEEP" + } + } + }, + { + "frame": { + "matcher": { + "id": "byIndex", + "options": 0 + } + }, + "x": { + "matcher": { + "id": "byName", + "options": "kWh consommes" + } + }, + "y": { + "matcher": { + "id": "byName", + "options": "VW" + } + } + }, + { + "frame": { + "matcher": { + "id": "byIndex", + "options": 0 + } + }, + "x": { + "matcher": { + "id": "byName", + "options": "kWh consommes" + } + }, + "y": { + "matcher": { + "id": "byName", + "options": "ZERO" + } + } + }, + { + "frame": { + "matcher": { + "id": "byIndex", + "options": 0 + } + }, + "x": { + "matcher": { + "id": "byName", + "options": "kWh consommes" + } + }, + "y": { + "matcher": { + "id": "byName", + "options": "Reference JEEP" + } + } + }, + { + "frame": { + "matcher": { + "id": "byIndex", + "options": 0 + } + }, + "x": { + "matcher": { + "id": "byName", + "options": "kWh consommes" + } + }, + "y": { + "matcher": { + "id": "byName", + "options": "Reference VW" + } + } + }, + { + "frame": { + "matcher": { + "id": "byIndex", + "options": 0 + } + }, + "x": { + "matcher": { + "id": "byName", + "options": "kWh consommes" + } + }, + "y": { + "matcher": { + "id": "byName", + "options": "Reference ZERO" + } + } + } + ], + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + } + }, + { + "type": "table", + "title": "Les intervalles entre releves", + "description": "Tous les intervalles, y compris ceux ecartes du nuage, avec la raison. « niveau manquant » : redonner le releve avec le niveau (« JEEP 112694 km 54 % »).", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "format": "table", + "rawQuery": true, + "editorMode": "code", + "rawSql": "SELECT vehicule AS \"Vehicule\",\n to_char(debut AT TIME ZONE 'Europe/Paris', 'Dy DD/MM HH24:MI') AS \"Du\",\n to_char(fin AT TIME ZONE 'Europe/Paris', 'Dy DD/MM HH24:MI') AS \"Au\",\n km_parcourus AS \"km\",\n round(kwh_charges::numeric, 2) AS \"kWh borne\",\n niveau_debut || ' -> ' || niveau_fin || ' %' AS \"Niveau\",\n round(kwh_consommes::numeric, 2) AS \"kWh consommes\",\n round(km_par_kwh::numeric, 1) AS \"km/kWh\",\n round(km_par_kwh_ref::numeric, 1) AS \"Reference\",\n statut AS \"Statut\"\nFROM km_intervalle ORDER BY fin DESC" + } + ], + "gridPos": { + "x": 14, + "y": 24, + "w": 10, + "h": 11 + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + }, + "filterable": true + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "kWh borne" + }, + "properties": [ + { + "id": "unit", + "value": "kwatth" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "kWh consommes" + }, + "properties": [ + { + "id": "unit", + "value": "kwatth" + } + ] + } + ] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "footer": { + "show": false + } + } + }, + { + "type": "timeseries", + "title": "Capacite estimee des batteries (usure)", + "description": "Un point = un branchement ou le niveau a ete donne et la charge menee au plein (ou suivie d'un releve « debranche a NN % ») : capacite = kWh livres par la borne depuis le branchement / part manquante. Ce sont des kWh AU COMPTEUR (pertes du chargeur comprises). Une pente descendante sur des mois = usure de la batterie. Le niveau saisi est un entier : sur une petite charge, 1 point d'erreur pese lourd.", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "format": "time_series", + "rawQuery": true, + "editorMode": "code", + "rawSql": "SELECT debranche AS time,\n CASE WHEN vehicule = 'JEEP' THEN capacite_kwh END AS \"JEEP\",\n CASE WHEN vehicule = 'VW' THEN capacite_kwh END AS \"VW\",\n CASE WHEN vehicule = 'ZERO' THEN capacite_kwh END AS \"ZERO\"\nFROM capacite_estimee WHERE capacite_kwh IS NOT NULL AND $__timeFilter(debranche)\nORDER BY 1" + } + ], + "gridPos": { + "x": 0, + "y": 35, + "w": 14, + "h": 9 + }, + "fieldConfig": { + "defaults": { + "unit": "kwatth", + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 8, + "showPoints": "always", + "spanNulls": false, + "axisBorderShow": false, + "gradientMode": "opacity", + "drawStyle": "points", + "pointSize": 10 + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "JEEP" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "VW" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ZERO" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "purple" + } + } + ] + } + ] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "type": "table", + "title": "Les releves du compteur", + "description": "Releves recus par WhatsApp (concierge) ou par le tableau EVCS Master. Un releve fait au branchement est date du branchement : le compteur n'avance pas pendant la charge.", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "evcs-pg" + }, + "format": "table", + "rawQuery": true, + "editorMode": "code", + "rawSql": "SELECT to_char(ts AT TIME ZONE 'Europe/Paris', 'Dy DD/MM HH24:MI') AS \"Quand\",\n vehicule AS \"Vehicule\", km AS \"Compteur\",\n coalesce(niveau_pct || ' %', '?') AS \"Niveau\", source AS \"Source\"\nFROM releve_km ORDER BY ts DESC" + } + ], + "gridPos": { + "x": 14, + "y": 35, + "w": 10, + "h": 9 + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + }, + "filterable": true + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Compteur" + }, + "properties": [ + { + "id": "unit", + "value": "lengthkm" + } + ] + } + ] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "footer": { + "show": false + } + } } ] } diff --git a/packages/evcs_kilometrage.yaml b/packages/evcs_kilometrage.yaml new file mode 100644 index 0000000..094e7d4 --- /dev/null +++ b/packages/evcs_kilometrage.yaml @@ -0,0 +1,89 @@ +# ===================================================== +# EVCS — KILOMETRAGE DES VEHICULES (04/10/2026, demande du proprietaire) +# Releves du compteur kilometrique + niveau, pour mesurer les km par kWh et, +# avec les charges menees au plein, la capacite (usure) de chaque batterie. +# Calcul : service d'historisation (tools/kilometrage.mjs), Grafana « Les +# vehicules ». ⚠️ JEEP et VW hybrides : donner le NIVEAU avec le compteur, +# un intervalle fini batterie vide (essence possible) est ecarte. +# ===================================================== + +input_text: + # Tableau JSON des derniers releves [vehicule, km, niveau|null, epoch_s, source], + # ecrit par le concierge WhatsApp ou par le script ci-dessous. Ne pas editer. + evcs_releve_km: + name: EVCS - Relevés kilométriques (file) + max: 255 + icon: mdi:counter + +input_select: + evcs_releve_vehicule: + name: "EVCS - Relevé km : véhicule" + options: + - JEEP + - VW + - ZERO + icon: mdi:car-multiple + +input_number: + evcs_releve_km_valeur: + name: "EVCS - Relevé km : compteur" + min: 0 + max: 2000000 + step: 1 + unit_of_measurement: km + mode: box + icon: mdi:counter + # -1 = niveau inconnu (le releve compte, mais l'intervalle restera « niveau manquant ») + evcs_releve_niveau: + name: "EVCS - Relevé km : niveau batterie (-1 = inconnu)" + min: -1 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: box + icon: mdi:battery-50 + # Autonomie de reference d'une charge pleine, donnee par le proprietaire + # (04/10/2026) : droite de reference du nuage km / kWh. + victron_evcs_autonomie_jeep_km: + name: EVCS - Autonomie de référence JEEP + min: 0 + max: 1000 + step: 1 + unit_of_measurement: km + mode: box + victron_evcs_autonomie_vw_km: + name: EVCS - Autonomie de référence VW + min: 0 + max: 1000 + step: 1 + unit_of_measurement: km + mode: box + victron_evcs_autonomie_zero_km: + name: EVCS - Autonomie de référence ZERO + min: 0 + max: 1000 + step: 1 + unit_of_measurement: km + mode: box + +script: + evcs_enregistrer_releve_km: + alias: "EVCS — enregistrer le relevé kilométrique" + mode: queued + icon: mdi:content-save + sequence: + - condition: template + value_template: "{{ states('input_number.evcs_releve_km_valeur') | int(0) > 0 }}" + - service: input_text.set_value + target: + entity_id: input_text.evcs_releve_km + data: + value: > + {% set brut = states('input_text.evcs_releve_km') %} + {% set tab = brut | from_json if brut.startswith('[') else [] %} + {% set n = states('input_number.evcs_releve_niveau') | int(-1) %} + {% set e = [states('input_select.evcs_releve_vehicule'), + states('input_number.evcs_releve_km_valeur') | int, + n if n >= 0 else none, + now().timestamp() | int, 'ha'] %} + {{ (tab[-4:] + [e]) | to_json }} diff --git a/tests/kilometrage/run.mjs b/tests/kilometrage/run.mjs new file mode 100644 index 0000000..951c30e --- /dev/null +++ b/tests/kilometrage/run.mjs @@ -0,0 +1,91 @@ +// Tests du kilometrage (04/10/2026). +// node tests/kilometrage/run.mjs -> fonctions pures +// node tests/kilometrage/run.mjs --vues -> vues SQL, dans une transaction +// ANNULEE (a lancer dans le +// conteneur d'historisation) +import { lireReleves, ajouterReleve, creerSchemaKm } from '../../tools/kilometrage.mjs'; + +let ok = 0, ko = 0; +const eq = (nom, a, b) => { + const sa = JSON.stringify(a), sb = JSON.stringify(b); + if (sa === sb) { ok++; } else { ko++; console.log(`ECHEC ${nom}\n obtenu ${sa}\n attendu ${sb}`); } +}; +const proche = (nom, a, b, tol = 1e-6) => eq(nom, Math.abs(Number(a) - b) <= tol ? b : Number(a), b); + +// --- lireReleves +const T = 1759515246; +eq('vide', lireReleves('').releves, []); +eq('unknown', lireReleves('unknown').releves, []); +eq('json illisible', lireReleves('[x').rejets, ['JSON illisible']); +const r1 = lireReleves(JSON.stringify([['jeep', 112694, 54, T, 'w'], ['ZERO', 16469, null, T, 'w']])); +eq('deux releves', r1.releves.map(r => [r.vehicule, r.km, r.niveau, r.ts.getTime() / 1000]), + [['JEEP', 112694, 54, T], ['ZERO', 16469, null, T]]); +eq('km decimal refuse', lireReleves(JSON.stringify([['VW', 1.5, null, T, 'w']])).rejets.length, 1); +eq('niveau 150 refuse', lireReleves(JSON.stringify([['VW', 1000, 150, T, 'w']])).rejets.length, 1); +eq('horodatage absurde refuse', lireReleves(JSON.stringify([['VW', 1000, null, 12, 'w']])).rejets.length, 1); + +// --- ajouterReleve : garde les derniers, tient dans 255 caracteres +let b = ''; +for (let i = 0; i < 10; i++) b = ajouterReleve(b, { vehicule: 'JEEP', km: 112694 + i, niveau: 54, ts: new Date((T + i) * 1000), source: 'whatsapp' }); +eq('255 car max', b.length <= 255, true); +eq('dernier garde', JSON.parse(b).at(-1)[1], 112703); +eq('helper corrompu repart a vide', JSON.parse(ajouterReleve('pas du json', { vehicule: 'VW', km: 5, niveau: null, ts: new Date(T * 1000), source: 'ha' })).length, 1); + +if (process.argv.includes('--vues')) { + const pg = (await import('pg')).default; + const c = new pg.Client(); await c.connect(); + try { + await c.query('BEGIN'); + await creerSchemaKm(c); + // Vehicules fictifs, annee 2020 : rien ne se melange au reel, et tout est annule. + const ins = (v, ts, km, n, cap, auto, elec) => c.query( + 'INSERT INTO releve_km (vehicule, ts, km, niveau_pct, source, capacite_kwh, autonomie_ref_km, tout_electrique) VALUES ($1,$2,$3,$4,\'test\',$5,$6,$7)', + [v, ts, km, n, cap, auto, elec]); + await ins('TESTH', '2020-01-01T18:00Z', 1000, 54, 8.7, 50, false); + await ins('TESTH', '2020-01-02T18:00Z', 1030, 20, 8.7, 50, false); // pas de charge : 34 % x 8,7 = 2,958 kWh + await ins('TESTH', '2020-01-04T18:00Z', 1080, 0, 8.7, 50, false); // batterie vide : essence possible + await ins('TESTE', '2020-01-01T18:00Z', 500, 80, 15, 200, true); + await ins('TESTE', '2020-01-03T18:00Z', 600, 0, 15, 200, true); // electrique : 0 % reste valide + await c.query(`INSERT INTO session_charge (debut, fin, terminee, vehicule, vehicule_sur, energie_kwh, energie_station_kwh) + VALUES ('2020-01-03T01:00Z','2020-01-03T03:00Z',true,'TESTH',true,4.0,4.2)`); + const { rows } = await c.query("SELECT * FROM km_intervalle WHERE vehicule LIKE 'TEST%' ORDER BY vehicule, debut"); + const h1 = rows.find(r => r.vehicule === 'TESTH' && r.km_parcourus === 30); + proche('H1 kWh consommes = 0 + 0,34 x 8,7', h1.kwh_consommes, 2.958); + proche('H1 km/kWh', h1.km_par_kwh, 30 / 2.958); + proche('ref km/kWh = 50/8,7', h1.km_par_kwh_ref, 50 / 8.7); + eq('H1 valide', h1.statut, 'valide'); + const h2 = rows.find(r => r.vehicule === 'TESTH' && r.km_parcourus === 50); + proche('H2 charge de la borne comptee (4,2)', h2.kwh_charges, 4.2); + eq('H2 hybride vide au retour', h2.statut, 'batterie vide au retour : km essence possibles'); + const e1 = rows.find(r => r.vehicule === 'TESTE'); + proche('E1 kWh = 0,80 x 15', e1.kwh_consommes, 12); + eq('E1 electrique a 0 % valide', e1.statut, 'valide'); + + // Capacite : branchement 2020-02-01 10:00 -> 14:00, niveau 50 % saisi a + // 10:40 (compteur deja a 101,0 : la voiture chargeait), compteur 100,0 au + // branchement et 104,5 au debranchement, plein vu a 13:00. + const ev = (ts, apres) => c.query(`INSERT INTO evenement (ts, genre, source, resume, detail) VALUES ($1,'vehicule','test','test',$2)`, + [ts, JSON.stringify({ entite: 'binary_sensor.victron_evcs_vehicle_connected', apres })]); + const cpt = (ts, v) => c.query(`INSERT INTO mesure (ts, cle, valeur) VALUES ($1,'sensor.evcs_hq2239dnt7p_total_energy',$2)`, [ts, v]); + await ev('2020-02-01T10:00:00.123456Z', 'on'); await ev('2020-02-01T14:00Z', 'off'); + await c.query(`INSERT INTO etiquette_branchement (branchement, label, source_airtria) VALUES ('2020-02-01T10:00:00.123Z','TESTH','test')`); + await c.query(`INSERT INTO niveau_saisi (saisi_a, niveau_pct, ref_kwh) VALUES ('2020-02-01T10:40Z', 50, 101.0)`); + await cpt('2020-02-01T09:59Z', 100.0); await cpt('2020-02-01T13:59Z', 104.5); + await c.query(`INSERT INTO decision (ts, etat) VALUES ('2020-02-01T13:00Z','day_vehicle_full_idle')`); + const capa = async () => (await c.query("SELECT * FROM capacite_estimee WHERE branche >= '2020-02-01' AND branche < '2020-02-02'")).rows[0]; + let cap = await capa(); + proche('energie depuis le branchement, pas la saisie', cap.kwh_livres, 4.5); + proche('capacite = 4,5 / 0,5', cap.capacite_kwh, 9.0); + eq('vehicule de l etiquette (horodatage a la microseconde pres)', cap.vehicule, 'TESTH'); + await c.query(`INSERT INTO session_charge (debut, fin, terminee, vehicule, vehicule_sur, energie_kwh, energie_station_kwh) + VALUES ('2020-02-01T10:01Z','2020-02-01T13:00Z',true,'TESTX',true,4.4,4.5)`); + eq('la session confirmee prime sur l etiquette', (await capa()).vehicule, 'TESTX'); + await c.query(`DELETE FROM decision WHERE ts = '2020-02-01T13:00Z'`); + cap = await capa(); + eq('pas plein, pas de releve -> pas de capacite', cap.capacite_kwh, null); + await ins('TESTX', '2020-02-01T14:20Z', 777, 80, 15, 200, true); + proche('releve dans l heure : 4,5 / (80-50) %', (await capa()).capacite_kwh, 15.0); + } finally { await c.query('ROLLBACK'); await c.end(); } +} +console.log(`${ok}/${ok + ko}`); +process.exit(ko ? 1 : 0); diff --git a/tools/gen-grafana-dashboards.py b/tools/gen-grafana-dashboards.py index 82c7845..e88cb4b 100644 --- a/tools/gen-grafana-dashboards.py +++ b/tools/gen-grafana-dashboards.py @@ -1350,6 +1350,101 @@ ORDER BY date_trunc(pas.unite, debut)""" COULEURS_VEHICULES = {"VW": "blue", "JEEP": "green", "ZERO": "purple", "Invite": "yellow", "Non etiquete": "orange"} +# Kilometrage et capacite (04/10/2026) : vues km_intervalle et capacite_estimee +# du service d'historisation (tools/kilometrage.mjs). Aucune constante ici : les +# references (autonomie / capacite) viennent du releve, donc des helpers HA. +KM_REF = """(SELECT autonomie_ref_km / capacite_kwh FROM releve_km + WHERE vehicule = '{v}' AND capacite_kwh > 0 AND autonomie_ref_km > 0 ORDER BY ts DESC LIMIT 1)""" +KM_NUAGE = """WITH i AS (SELECT * FROM km_intervalle WHERE statut = 'valide'), +cap AS (SELECT DISTINCT ON (vehicule) capacite_kwh AS c FROM releve_km + WHERE capacite_kwh > 0 ORDER BY vehicule, ts DESC), +x AS (SELECT round(kwh_consommes::numeric, 2)::float8 AS x, vehicule, km_parcourus::float8 AS km FROM i + UNION ALL SELECT 0, NULL, NULL + UNION ALL SELECT c::float8, NULL, NULL FROM cap) +SELECT x AS "kWh consommes", + CASE WHEN vehicule = 'JEEP' THEN km END AS "JEEP", + CASE WHEN vehicule = 'VW' THEN km END AS "VW", + CASE WHEN vehicule = 'ZERO' THEN km END AS "ZERO", + round((x * """ + KM_REF.format(v="JEEP") + """)::numeric, 1)::float8 AS "Reference JEEP", + round((x * """ + KM_REF.format(v="VW") + """)::numeric, 1)::float8 AS "Reference VW", + round((x * """ + KM_REF.format(v="ZERO") + """)::numeric, 1)::float8 AS "Reference ZERO" +FROM x ORDER BY 1""" + +KM_XY = {"type": "xychart", "pluginVersion": "11.3.0", + "title": "Kilometres contre kWh : le nuage des trajets", + "description": ("Un point = l'intervalle entre deux releves du compteur d'un vehicule. " + "kWh consommes = kWh livres par la borne entre les deux releves + " + "(niveau au 1er - niveau au 2nd) x capacite. Les droites sont les " + "references donnees par le proprietaire (JEEP 50 km, VW 42 km, ZERO 200 km " + "pour une charge pleine). Sous la droite : on a roule moins loin par kWh " + "(conduite, froid, chauffage — ou batterie qui s'use). Les intervalles " + "d'une hybride finis batterie vide sont ecartes (km essence possibles)."), + "datasource": PG, "gridPos": {"x": 0, "y": 24, "w": 14, "h": 11}, + "targets": [sql("A", KM_NUAGE, fmt="table")], + "fieldConfig": {"defaults": {"unit": "lengthkm", "color": {"mode": "palette-classic"}, + "custom": {"show": "points", "pointSize": {"fixed": 10}, "pointShape": "circle", + "lineWidth": 1, "fillOpacity": 60}}, + "overrides": [sur(v, [{"id": "color", "value": {"mode": "fixed", "fixedColor": COULEURS_VEHICULES[v]}}]) + for v in ["JEEP", "VW", "ZERO"]] + + [sur("Reference " + v, [{"id": "color", "value": {"mode": "fixed", "fixedColor": COULEURS_VEHICULES[v]}}, + {"id": "custom.show", "value": "lines"}, + {"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [10, 10]}}]) + for v in ["JEEP", "VW", "ZERO"]]}, + "options": {"mapping": "manual", + "series": [{"frame": {"matcher": {"id": "byIndex", "options": 0}}, + "x": {"matcher": {"id": "byName", "options": "kWh consommes"}}, + "y": {"matcher": {"id": "byName", "options": n}}} + for n in ["JEEP", "VW", "ZERO", "Reference JEEP", "Reference VW", "Reference ZERO"]], + "legend": {"showLegend": True, "displayMode": "list", "placement": "bottom"}, + "tooltip": {"mode": "single", "sort": "none"}}} + +KM_INTERVALLES = table("Les intervalles entre releves", [sql("A", + "SELECT vehicule AS \"Vehicule\",\n" + " to_char(debut AT TIME ZONE 'Europe/Paris', 'Dy DD/MM HH24:MI') AS \"Du\",\n" + " to_char(fin AT TIME ZONE 'Europe/Paris', 'Dy DD/MM HH24:MI') AS \"Au\",\n" + " km_parcourus AS \"km\",\n" + " round(kwh_charges::numeric, 2) AS \"kWh borne\",\n" + " niveau_debut || ' -> ' || niveau_fin || ' %' AS \"Niveau\",\n" + " round(kwh_consommes::numeric, 2) AS \"kWh consommes\",\n" + " round(km_par_kwh::numeric, 1) AS \"km/kWh\",\n" + " round(km_par_kwh_ref::numeric, 1) AS \"Reference\",\n" + " statut AS \"Statut\"\n" + "FROM km_intervalle ORDER BY fin DESC", fmt="table")], + PG, 14, 24, 10, 11, + "Tous les intervalles, y compris ceux ecartes du nuage, avec la raison. " + "« niveau manquant » : redonner le releve avec le niveau (« JEEP 112694 km 54 % »).", + overrides=[sur("kWh borne", [{"id": "unit", "value": "kwatth"}]), + sur("kWh consommes", [{"id": "unit", "value": "kwatth"}])]) + +KM_CAPACITE = ts("Capacite estimee des batteries (usure)", [sql("A", + "SELECT debranche AS time,\n" + " CASE WHEN vehicule = 'JEEP' THEN capacite_kwh END AS \"JEEP\",\n" + " CASE WHEN vehicule = 'VW' THEN capacite_kwh END AS \"VW\",\n" + " CASE WHEN vehicule = 'ZERO' THEN capacite_kwh END AS \"ZERO\"\n" + "FROM capacite_estimee WHERE capacite_kwh IS NOT NULL AND $__timeFilter(debranche)\n" + "ORDER BY 1")], + PG, 0, 35, 14, 9, "kwatth", + "Un point = un branchement ou le niveau a ete donne et la charge menee au plein " + "(ou suivie d'un releve « debranche a NN % ») : capacite = kWh livres par la borne " + "depuis le branchement / part manquante. Ce sont des kWh AU COMPTEUR (pertes du " + "chargeur comprises). Une pente descendante sur des mois = usure de la batterie. " + "Le niveau saisi est un entier : sur une petite charge, 1 point d'erreur pese lourd.", + extra={"overrides": [sur(v, [{"id": "color", "value": {"mode": "fixed", "fixedColor": COULEURS_VEHICULES[v]}}]) + for v in ["JEEP", "VW", "ZERO"]]}) +KM_CAPACITE["fieldConfig"]["defaults"]["custom"].update( + {"drawStyle": "points", "pointSize": 10, "showPoints": "always", "spanNulls": False}) + +KM_RELEVES = table("Les releves du compteur", [sql("A", + "SELECT to_char(ts AT TIME ZONE 'Europe/Paris', 'Dy DD/MM HH24:MI') AS \"Quand\",\n" + " vehicule AS \"Vehicule\", km AS \"Compteur\",\n" + " coalesce(niveau_pct || ' %', '?') AS \"Niveau\", source AS \"Source\"\n" + "FROM releve_km ORDER BY ts DESC", fmt="table")], + PG, 14, 35, 10, 9, + "Releves recus par WhatsApp (concierge) ou par le tableau EVCS Master. Un " + "releve fait au branchement est date du branchement : le compteur n'avance pas " + "pendant la charge.", + overrides=[sur("Compteur", [{"id": "unit", "value": "lengthkm"}])]) + vehicules = dashboard("evcs-vehicules", "Les vehicules", ( "Ce que chaque voiture encaisse, session par session et sur la periode " "choisie. L'etiquette vient d'un selecteur manuel : elle peut etre fausse, " @@ -1430,6 +1525,7 @@ vehicules = dashboard("evcs-vehicules", "Les vehicules", ( {"id": "custom.width", "value": 110}]), sur("dont reseau", [{"id": "unit", "value": "kwatth"}]), sur("P max", [{"id": "unit", "value": "watt"}])]), + KM_XY, KM_INTERVALLES, KM_CAPACITE, KM_RELEVES, ]) # ===================================================================== @@ -1791,7 +1887,7 @@ journee = dashboard( # found ». Ici on veut les quatre colonnes, tout le temps. barchart("Energie heure par heure", [sql("A", energie_sql(prevision=True), fmt="table")], - PG, 0, 4, 24, 9, "kwatth", + PG, 0, 5, 24, 9, "kwatth", "La forme de la journee : quand le solaire a couvert la maison, quand il " "a fallu acheter — et, a la suite des barres mesurees, ce que la derniere " "prevision annonce pour les heures qui restent. L'heure EN COURS n'a pas " diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index af5e484..1c95f08 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -29,6 +29,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import pg from 'pg'; +import { creerSchemaKm, ingererReleves, ingererNiveauSaisi } from './kilometrage.mjs'; import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.mjs'; import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs'; @@ -710,6 +711,9 @@ async function migrer(client) { // « montre-moi tous les signalements graves de la semaine ». await client.query( 'CREATE INDEX IF NOT EXISTS observation_gravite_idx ON observation (gravite, ts DESC)'); + // Kilometrage et capacite des vehicules (04/10/2026) : tables releve_km et + // niveau_saisi, vues km_intervalle et capacite_estimee (tools/kilometrage.mjs). + await creerSchemaKm(client); // ------------------------------------------------------------------- // REPARATION UNIQUE (13/09/2026) — onze sessions etiquetees « VW » a tort @@ -2128,6 +2132,11 @@ async function cycle(client) { ordre: await appliquerOrdreAirtria(sql, client, st).catch((e) => { console.error(`ordre airtria en echec : ${e.message}`); return 0; }), + kilometrage: DRY ? 0 : await ingererReleves(sql, (q, v) => client.query(q, v), st, journaliser).catch((e) => { + console.error(`releves km en echec : ${e.message}`); return 0; + }) + await ingererNiveauSaisi(sql, st).catch((e) => { + console.error(`niveau saisi en echec : ${e.message}`); return 0; + }), pvDiagnostic: DRY ? 0 : await publierDiagnosticPv(st).catch((e) => { console.error(`diagnostic PV en echec : ${e.message}`); return 0; }), diff --git a/tools/kilometrage.mjs b/tools/kilometrage.mjs new file mode 100644 index 0000000..15ed53c --- /dev/null +++ b/tools/kilometrage.mjs @@ -0,0 +1,247 @@ +// 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() + )`); + 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.*, + (SELECT valeur FROM mesure m WHERE m.cle = 'sensor.evcs_hq2239dnt7p_total_energy' + AND m.ts <= v.debranche 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 ORDER BY m.ts DESC LIMIT 1), ref_kwh) 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). + const at = 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; +}