diff --git a/.gitignore b/.gitignore index 3f4c3a6..f3e7eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ deploy/watchdog/logs/* # Journal du chien de garde (churn permanent, aucune valeur en historique) logs/ + +# Secrets de la memoire de mesures (mots de passe, jeton InfluxDB) +deploy/historisation/historisation.env diff --git a/deploy/historisation/README.md b/deploy/historisation/README.md new file mode 100644 index 0000000..d8158af --- /dev/null +++ b/deploy/historisation/README.md @@ -0,0 +1,70 @@ +# Mémoire de mesures EVCS + +Deux bases sur **10.10.10.22**, décidées le 04/09/2026 : on met les deux en +service et on décide à l'usage s'il faut garder les deux. + +| | rôle | source | résolution | +|---|---|---|---| +| **PostgreSQL** | ce que n8n **calcule** — bilan, décisions, prévisions publiées | workflows n8n | cadence des workflows | +| **InfluxDB** | ce que Home Assistant **observe** — tous les états du périmètre énergie | intégration HA | chaque changement d'état | + +Le raisonnement complet et le comparatif : `HANDOFF.md` §42 et suivantes. + +## Pourquoi pas sur 10.10.10.17 + +Cette machine porte déjà Home Assistant, n8n, openclaw et open-webui. Une +mémoire hébergée par la machine dont elle documente les pannes ne survit pas à +celles-ci. Le volume n'est pas l'argument : les deux Synology ont des téraoctets +libres. + +## Mise en service + +### 1. Les deux bases (à faire sur 10.10.10.22) + +```sh +cd /volume2/projects/EVCS/EVCS/deploy/historisation +cp historisation.env.exemple historisation.env +# renseigner les trois secrets ; pour le jeton : openssl rand -hex 32 +docker compose up -d +docker compose logs -f +``` + +Vérifications : + +```sh +docker compose exec postgres psql -U evcs -d evcs -c '\dt' +curl -s http://10.10.10.22:8086/health +``` + +Le schéma (`schema.sql`) est appliqué **une seule fois**, à la création de la +base. Pour le faire évoluer ensuite, écrire une migration et la passer à `psql` : +recréer le volume effacerait l'historique, ce qui est précisément ce qu'on +cherche à ne plus faire. + +### 2. Brancher Home Assistant sur InfluxDB + +⚠️ **Demande un redémarrage de Home Assistant**, pendant lequel le contrôleur +EVCS ne décide plus rien. **Jamais entre 01:24 et 07:24**, ni pendant une charge. +Le bon créneau est le milieu de matinée. + +1. mettre le jeton dans `secrets.yaml` sous la clé `influxdb_token` ; +2. coller le bloc de `ha-influxdb.yaml` dans `configuration.yaml` ; +3. `homeassistant.check_config`, puis redémarrer. + +### 3. Alimenter PostgreSQL depuis n8n + +À faire après l'étape 1 — n8n a besoin que la base existe pour qu'on y crée +l'identifiant de connexion. + +## Ce qu'aucune des deux ne fait + +Elles ne remontent pas dans le passé. La mémoire commence le jour où on les +branche : c'est un argument pour les mettre en service vite, pas pour les +mettre en service parfaitement. + +## Sauvegarde + +Les deux bases vivent dans des volumes Docker nommés, donc **hors du dépôt et +hors des sauvegardes Home Assistant**. Une mémoire non sauvegardée est une +mémoire qu'on perdra une fois — voir `HANDOFF.md` §34, qui raconte la même +histoire pour Home Assistant. diff --git a/deploy/historisation/compose.yaml b/deploy/historisation/compose.yaml new file mode 100644 index 0000000..cb6c99f --- /dev/null +++ b/deploy/historisation/compose.yaml @@ -0,0 +1,124 @@ +# Mémoire de mesures EVCS — à déployer sur le SECOND Synology (10.10.10.22). +# +# Deux bases, deux rôles distincts, décidés le 04/09/2026 : +# +# postgres (proposition A) — ce que n8n CALCULE : sorties du bilan, +# décisions du contrôleur, prévision telle qu'elle a été publiée. +# Relationnel, donc capable de stocker un vecteur de 24 valeurs, +# une raison en texte, un événement daté — et de les JOINDRE. +# +# influxdb (proposition B) — ce que Home Assistant OBSERVE : chaque +# changement d'état, à la résolution native, y compris les entités +# auxquelles personne n'a encore pensé. C'est ce qui a manqué le +# 03/09, quand les modes des chargeurs MPPT se sont révélés être le +# bon signal alors que personne ne les enregistrait. +# +# ⚠️ NE PAS héberger sur 10.10.10.17 : cette machine porte déjà Home Assistant, +# n8n, openclaw et open-webui. Une mémoire qui meurt avec la machine qu'elle +# documente ne sert à rien (cf. HANDOFF §32). +# +# Mise en service : +# cd /volume2/projects/EVCS/EVCS/deploy/historisation +# cp historisation.env.exemple historisation.env puis y mettre les mots de passe +# docker compose up -d +# docker compose logs -f +# +# Vérification : +# docker compose exec postgres psql -U evcs -d evcs -c '\dt' +# curl -s http://10.10.10.22:8086/health +# +# Volumes NOMMÉS et non bind-mounts : le démon Docker de DSM refuse un +# bind-mount dont le dossier hôte est absent, et une base de données sur un +# partage réseau est une mauvaise idée (verrouillage, latence, fsync). + +services: + + postgres: + image: postgres:16-alpine + container_name: evcs-postgres + restart: always + network_mode: bridge + ports: + # Ouvert au réseau local : n8n tourne sur 10.10.10.17 et doit écrire ici. + - "5432:5432" + env_file: + - historisation.env + environment: + - TZ=Europe/Paris + - POSTGRES_USER=evcs + - POSTGRES_DB=evcs + # POSTGRES_PASSWORD vient de historisation.env + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - evcs-pgdata:/var/lib/postgresql/data + # Appliqué UNE SEULE FOIS, à la création de la base. Pour faire évoluer + # le schéma ensuite, écrire une migration et la passer à psql. + - ./schema.sql:/docker-entrypoint-initdb.d/10-schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U evcs -d evcs"] + interval: 1m + timeout: 10s + retries: 5 + + influxdb: + image: influxdb:2.7-alpine + container_name: evcs-influxdb + restart: always + network_mode: bridge + ports: + # Home Assistant, sur 10.10.10.17, écrit ici. + - "8086:8086" + env_file: + - historisation.env + environment: + - TZ=Europe/Paris + - DOCKER_INFLUXDB_INIT_MODE=setup + - DOCKER_INFLUXDB_INIT_ORG=airtria + - DOCKER_INFLUXDB_INIT_BUCKET=evcs + # Rétention 0 = illimitée sur le seau brut. Le sous-échantillonnage se + # fait par tâches (voir README), pas en jetant la donnée d'entrée. + - DOCKER_INFLUXDB_INIT_RETENTION=0 + # DOCKER_INFLUXDB_INIT_USERNAME / _PASSWORD / _ADMIN_TOKEN : env file + volumes: + - evcs-influx-data:/var/lib/influxdb2 + - evcs-influx-config:/etc/influxdb2 + healthcheck: + test: ["CMD", "influx", "ping"] + interval: 1m + timeout: 10s + retries: 5 + + # Alimentation de PostgreSQL depuis Home Assistant. Volontairement PAS + # depuis n8n : tout ce que n8n calcule, il le publie deja dans des helpers HA + # — les lire ici donne la meme information sans toucher aux trois workflows + # de production ni creer d'identifiant dans n8n. + historisation: + image: node:22-alpine + container_name: evcs-historisation + restart: always + working_dir: /app + network_mode: bridge + depends_on: + postgres: + condition: service_healthy + volumes: + # Depot en LECTURE SEULE : ce service n'ecrit que dans la base. + - /volume2/projects/EVCS/EVCS:/app:ro + env_file: + - historisation.env + environment: + - TZ=Europe/Paris + - HA_URL=http://10.10.10.17:8123 + - PGHOST=10.10.10.22 + - PGPORT=5432 + - PGUSER=evcs + - PGDATABASE=evcs + # PGPASSWORD doit valoir POSTGRES_PASSWORD : les deux sont dans le + # fichier d'environnement, tenus egaux a la main. + - HISTORISE_INTERVAL_S=60 + command: ["node", "tools/historise-evcs.mjs"] + +volumes: + evcs-pgdata: + evcs-influx-data: + evcs-influx-config: diff --git a/deploy/historisation/ha-influxdb.yaml b/deploy/historisation/ha-influxdb.yaml new file mode 100644 index 0000000..2281eaf --- /dev/null +++ b/deploy/historisation/ha-influxdb.yaml @@ -0,0 +1,53 @@ +# Bloc à ajouter à /mnt/ARCH3/docker/HA/configuration.yaml pour brancher +# Home Assistant sur InfluxDB (proposition B). +# +# ⚠️ NE PAS APPLIQUER N'IMPORTE QUAND. L'intégration influxdb n'est pas +# rechargeable : elle demande un REDÉMARRAGE de Home Assistant, pendant lequel +# le contrôleur EVCS ne décide plus rien. À faire hors des fenêtres qui +# comptent — donc jamais entre 01:24 et 07:24, ni pendant une charge en cours. +# Le bon créneau est le milieu de matinée, une fois la session réseau de nuit +# terminée et avant que le solaire ne devienne intéressant. +# +# Le jeton est celui de historisation.env (DOCKER_INFLUXDB_INIT_ADMIN_TOKEN). +# Le mettre dans secrets.yaml, PAS ici : configuration.yaml est lisible par +# tout ce qui monte le partage. +# # dans secrets.yaml +# influxdb_token: "…" + +influxdb: + api_version: 2 + ssl: false + host: 10.10.10.22 + port: 8086 + token: !secret influxdb_token + organization: airtria + bucket: evcs + # Pourquoi une liste plutôt que « tout » : la maison publie ~1,14 M états par + # jour, dont l'essentiel n'a aucun rapport avec l'énergie. On garde large sur + # le périmètre énergie — c'est justement là qu'on ne sait pas d'avance ce + # dont on aura besoin, comme l'ont montré les modes MPPT le 03/09 — et on + # laisse le reste de côté. + include: + entity_globs: + - sensor.victron_* + - sensor.mppt_* + - sensor.airtria_* + - sensor.gx_device_* + - sensor.sun_solar_elevation + - binary_sensor.victron_* + - switch.edf_* + - switch.gx_device_* + - number.gx_device_* + - input_boolean.victron_evcs_* + - input_boolean.evcs_* + - input_number.victron_evcs_* + - input_number.evcs_* + - input_select.victron_evcs_* + - input_text.victron_evcs_* + - input_text.evcs_* + exclude: + entities: + # Une clé d'API n'a rien à faire dans une base de séries temporelles. + - input_text.evcs_balance_forecast_api_key + tags: + installation: airtria diff --git a/deploy/historisation/historisation.env.exemple b/deploy/historisation/historisation.env.exemple new file mode 100644 index 0000000..308ce33 --- /dev/null +++ b/deploy/historisation/historisation.env.exemple @@ -0,0 +1,17 @@ +# Copier en historisation.env et renseigner. Ce fichier-ci est un exemple +# versionné ; historisation.env, lui, est ignoré par git (secrets). + +# --- PostgreSQL (proposition A) --- +POSTGRES_PASSWORD=a_remplacer +# Doit valoir EXACTEMENT le meme mot de passe : c'est celui qu'utilise le +# service d'historisation pour se connecter. +PGPASSWORD=a_remplacer +# Le jeton HA du service d'historisation (meme que celui du depot). +HA_TOKEN=a_remplacer + +# --- InfluxDB (proposition B) --- +DOCKER_INFLUXDB_INIT_USERNAME=evcs +DOCKER_INFLUXDB_INIT_PASSWORD=a_remplacer_16_caracteres_minimum +# Ce jeton est celui que Home Assistant utilisera pour écrire. +# Le générer plutôt que l'inventer : openssl rand -hex 32 +DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=a_remplacer diff --git a/deploy/historisation/schema.sql b/deploy/historisation/schema.sql new file mode 100644 index 0000000..1187744 --- /dev/null +++ b/deploy/historisation/schema.sql @@ -0,0 +1,149 @@ +-- ===================================================================== +-- Mémoire de mesures EVCS — schéma PostgreSQL (proposition A) +-- Appliqué automatiquement à la création de la base par le compose. +-- ===================================================================== +-- +-- Ce que cette base garde, et que ni l'historique de Home Assistant ni les +-- statistiques long terme ne savent garder : +-- +-- 1. ce que n8n CALCULE — SOC requis, heure de croisement, décisions et +-- leurs motifs. Ce ne sont pas des capteurs, donc aucune statistique. +-- 2. des VECTEURS — une prévision est 24 valeurs pour une journée. +-- 3. du TEXTE — un motif de censure, une raison de décision. +-- 4. des JOINTURES — « la prévision publiée à 04:00 contre la +-- production réelle de 13 h » est ici une requête, ailleurs un exercice. +-- +-- Ce qu'elle ne cherche PAS à faire : le flux brut de tous les états. C'est le +-- rôle d'InfluxDB, à côté (voir compose.yaml). +-- +-- Convention : tout horodatage est en timestamptz, écrit en UTC, lu en +-- Europe/Paris. Ne jamais stocker d'heure locale nue — le passage à l'heure +-- d'hiver du 25/10 rendrait deux heures indiscernables. + +SET timezone = 'Europe/Paris'; + +-- --------------------------------------------------------------------- +-- 1. MESURES — séries échantillonnées, format long +-- --------------------------------------------------------------------- +-- Format long (une ligne par couple clé/instant) et non large : les grandeurs +-- suivies changent au fil des sessions, et une table large impose une +-- migration à chaque ajout. L'index couvre l'accès réel : « telle clé, sur +-- telle plage de temps ». +CREATE TABLE mesure ( + ts timestamptz NOT NULL, + cle text NOT NULL, + valeur double precision NOT NULL, + PRIMARY KEY (cle, ts) +); +COMMENT ON TABLE mesure IS + 'Grandeurs numériques échantillonnées par n8n. Cadence = celle du workflow.'; + +-- --------------------------------------------------------------------- +-- 2. BILAN — une ligne par exécution du bilan énergétique +-- --------------------------------------------------------------------- +CREATE TABLE bilan ( + ts timestamptz PRIMARY KEY, + statut text, + soc_pct double precision, + energie_a_couvrir_kwh double precision, + soc_requis_pct double precision, + soc_requis_prudent_pct double precision, + heure_croisement text, + reseau_necessaire boolean, + reseau_energie_kwh double precision, + manque_stockage_kwh double precision, + pv_censure boolean, + motif_censure text, + orage_niveau text, + orage_reserve_kwh double precision, + -- Le reste du bloc tel que publié, pour ne pas avoir à migrer la table + -- chaque fois que le bilan gagne un champ. + brut jsonb +); +COMMENT ON COLUMN bilan.soc_requis_pct IS + 'NON écrêté au-delà de 100 : une valeur supérieure est le signal de sous-dimensionnement du stockage (HANDOFF §26).'; + +-- --------------------------------------------------------------------- +-- 3. DECISION — une ligne par changement de décision du contrôleur +-- --------------------------------------------------------------------- +-- Pas une ligne toutes les 30 s : seulement quand la signature change. Sinon +-- 2 880 lignes par jour dont 2 800 identiques, et la table devient illisible. +CREATE TABLE decision ( + ts timestamptz PRIMARY KEY, + profil text, + source_mode text, + etat text, + decision text, + charge_desiree boolean, + courant_a double precision, + soc_pct double precision, + cible_matin_pct double precision, + takeover_reseau boolean, + session_engagee boolean, + raison text, + brut jsonb +); + +-- --------------------------------------------------------------------- +-- 4. PREVISION — une ligne par publication, avec le vecteur horaire +-- --------------------------------------------------------------------- +-- `publie_a` est l'instant de publication, `jour` le jour prévu. Les deux sont +-- nécessaires : comparer une prévision à la réalité n'a de sens que si l'on +-- sait QUAND elle a été faite. La clé primaire les porte donc toutes les deux — +-- plusieurs prévisions du même jour, faites à des heures différentes, doivent +-- coexister. +CREATE TABLE prevision ( + publie_a timestamptz NOT NULL, + jour date NOT NULL, + source text NOT NULL DEFAULT 'forecast.solar', + total_kwh double precision, + horaire double precision[] NOT NULL, -- 24 valeurs, index 0 = 00 h + PRIMARY KEY (publie_a, jour) +); +COMMENT ON TABLE prevision IS + 'Prévision TELLE QU''ELLE A ÉTÉ PUBLIÉE. Sans cet horodatage de publication, on ne peut pas mesurer le biais de la prévision.'; + +-- --------------------------------------------------------------------- +-- 5. EVENEMENT — ce qu''on veut pouvoir retrouver un an après +-- --------------------------------------------------------------------- +-- Déploiements, incidents, interventions manuelles, changements de matériel. +-- Une courbe sans ses événements ne s'interprète pas : « le rendement a changé +-- le 12 novembre » n'a de sens qu'avec la ligne qui dit qu'un pack a été +-- rebranché ce jour-là. +CREATE TABLE evenement ( + ts timestamptz NOT NULL DEFAULT now(), + genre text NOT NULL, -- deploiement | incident | intervention | materiel + source text, -- qui l'a écrit + resume text NOT NULL, + detail jsonb, + id bigserial PRIMARY KEY +); +CREATE INDEX evenement_ts_idx ON evenement (ts DESC); + +-- --------------------------------------------------------------------- +-- Index de lecture +-- --------------------------------------------------------------------- +CREATE INDEX mesure_ts_idx ON mesure (ts DESC); +CREATE INDEX bilan_ts_idx ON bilan (ts DESC); +CREATE INDEX decision_ts_idx ON decision (ts DESC); +CREATE INDEX prevision_jour_idx ON prevision (jour, publie_a DESC); + +-- --------------------------------------------------------------------- +-- Vue de confort : la question qui a bloqué l'analyse du 03/09 +-- --------------------------------------------------------------------- +-- « Ce que le bilan exigeait, contre le SOC réellement atteint, heure par +-- heure. » Trois lignes de SQL ici ; impossible sans cette base. +CREATE VIEW bilan_contre_reel AS +SELECT date_trunc('hour', ts) AS heure, + round(avg(soc_requis_pct)::numeric, 1) AS soc_requis, + round(avg(soc_pct)::numeric, 1) AS soc_reel, + round(avg(soc_pct - soc_requis_pct)::numeric, 1) AS marge, + round(avg(energie_a_couvrir_kwh)::numeric, 2) AS a_couvrir_kwh +FROM bilan +GROUP BY 1 +ORDER BY 1 DESC; + +INSERT INTO evenement (genre, source, resume, detail) VALUES + ('deploiement', 'claude', + 'Création de la mémoire de mesures : PostgreSQL sur 10.10.10.22', + '{"motif": "historique HA limité à ~20 j et sans valeurs calculées", "handoff": "§42"}'); diff --git a/package-lock.json b/package-lock.json index 45a6aeb..14815b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "evcs-tools", "dependencies": { - "mqtt": "^5.10.1" + "mqtt": "^5.10.1", + "pg": "^8.23.0" } }, "node_modules/@babel/runtime": { @@ -311,6 +312,122 @@ "js-sdsl": "4.3.0" } }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -483,6 +600,14 @@ "optional": true } } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } } } } diff --git a/package.json b/package.json index 7238ee7..8a006b0 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "description": "Outillage EVCS. Seul le chien de garde a une dependance externe (client MQTT vers le GX Venus) ; tout le reste tourne sur le Node de base.", "dependencies": { - "mqtt": "^5.10.1" + "mqtt": "^5.10.1", + "pg": "^8.23.0" } } diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs new file mode 100644 index 0000000..af27c5d --- /dev/null +++ b/tools/historise-evcs.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env node +// ===================================================================== +// Historisation EVCS — alimente PostgreSQL depuis Home Assistant. +// ===================================================================== +// +// POURQUOI PAS DEPUIS n8n. C'était l'idée de départ (« n8n calcule déjà ce qui +// compte, l'écrire est une ligne de plus »). Mais tout ce que n8n calcule, il +// le PUBLIE déjà dans des helpers Home Assistant : SOC requis, heure de +// croisement, décisions, prévisions. Les lire ici donne la même information +// sans toucher aux trois workflows de production qu'on vient de stabiliser, et +// sans créer d'identifiant de connexion dans n8n. +// +// Ce service tourne sur 10.10.10.22, à côté de la base : si 10.10.10.17 tombe, +// il n'y a de toute façon plus rien à enregistrer, mais la mémoire déjà +// constituée, elle, survit. +// +// L'HORODATAGE EST CELUI DE HOME ASSISTANT, pas celui du sondage. Une valeur +// inchangée depuis le cycle précédent porte donc la même clé primaire et n'est +// pas réécrite (ON CONFLICT DO NOTHING) : on enregistre quand la grandeur a +// bougé, pas quand on a regardé. +// +// Usage : +// node tools/historise-evcs.mjs # boucle +// node tools/historise-evcs.mjs --once # un cycle puis sortie +// node tools/historise-evcs.mjs --once --dry-run # sans écrire +// ===================================================================== + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ONCE = process.argv.includes('--once'); +const DRY = process.argv.includes('--dry-run'); + +const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123'; +const INTERVAL_S = Number(process.env.HISTORISE_INTERVAL_S || 60); + +function fromEnvFile(cle) { + const p = path.join(ROOT, '.env'); + if (!fs.existsSync(p)) return null; + for (const l of fs.readFileSync(p, 'utf8').split('\n')) { + const m = l.match(new RegExp(`^\\s*${cle}\\s*=\\s*(.+?)\\s*$`)); + if (m) return m[1].replace(/^["']|["']$/g, ''); + } + return null; +} + +const HA_TOKEN = process.env.HA_TOKEN || fromEnvFile('HA_TOKEN'); +if (!HA_TOKEN) { + console.error('HA_TOKEN absent (ni variable d\'environnement, ni .env).'); + process.exit(1); +} + +// --------------------------------------------------------------------- +// Grandeurs suivies. Élargir ici est sans risque : une clé nouvelle +// commence simplement à s'enregistrer. Retirer une clé n'efface rien. +// --------------------------------------------------------------------- +const MESURES = [ + 'sensor.victron_battery_soc', + 'sensor.victron_battery_current', + 'sensor.victron_battery_power', + 'sensor.victron_battery_voltage', + 'sensor.victron_pv_power_system', + 'sensor.victron_consumption_power_l1', + 'sensor.victron_grid_power_l1', + 'sensor.victron_evcs_ac_power', + 'sensor.victron_evcs_current', + 'sensor.victron_vebus_dc_power_system', + 'sensor.sun_solar_elevation', + 'sensor.victron_max_charge_current_setting', + 'number.gx_device_ac_power_setpoint', + 'input_number.evcs_balance_required_soc', + 'input_number.evcs_balance_energy_to_cover_kwh', + 'input_number.victron_evcs_night_target_soc', +]; + +const num = (v) => { + const n = Number.parseFloat(v); + return Number.isFinite(n) ? n : null; +}; +const txt = (s) => (s ?? '').toString().trim(); + +async function etats() { + const r = await fetch(`${HA_URL}/api/states`, { + headers: { Authorization: `Bearer ${HA_TOKEN}` }, + }); + if (!r.ok) throw new Error(`HA ${r.status}`); + const tout = await r.json(); + const m = new Map(); + for (const s of tout) m.set(s.entity_id, s); + return m; +} + +// --------------------------------------------------------------------- +// Écritures. Chacune est idempotente : rejouer un cycle n'invente rien. +// --------------------------------------------------------------------- +async function ecrireMesures(sql, st) { + let n = 0; + for (const id of MESURES) { + const e = st.get(id); + if (!e) continue; + const v = num(e.state); + if (v === null) continue; // 'unknown' / 'unavailable' : on ne devine pas + const { rowCount } = await sql( + `INSERT INTO mesure (ts, cle, valeur) VALUES ($1, $2, $3) + ON CONFLICT (cle, ts) DO NOTHING`, + [e.last_updated, id, v] + ); + n += rowCount; + } + return n; +} + +async function ecrireBilan(sql, st) { + const marqueur = st.get('input_text.evcs_balance_last_run'); + if (!marqueur || !txt(marqueur.state)) return 0; + const g = (id) => st.get(id); + const { rowCount } = await sql( + `INSERT INTO bilan (ts, statut, soc_pct, energie_a_couvrir_kwh, soc_requis_pct, + soc_requis_prudent_pct, heure_croisement, reseau_necessaire, + reseau_energie_kwh, manque_stockage_kwh, pv_censure, + motif_censure, orage_niveau, orage_reserve_kwh, brut) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (ts) DO NOTHING`, + [ + marqueur.last_updated, + txt(g('input_text.evcs_balance_status')?.state), + num(g('sensor.victron_battery_soc')?.state), + num(g('input_number.evcs_balance_energy_to_cover_kwh')?.state), + num(g('input_number.evcs_balance_required_soc')?.state), + num(g('input_number.evcs_balance_required_soc_prudent')?.state), + txt(g('input_text.evcs_balance_crossing_hour')?.state), + txt(g('input_boolean.evcs_balance_grid_needed')?.state) === 'on', + num(g('input_number.evcs_balance_grid_energy_needed_kwh')?.state), + num(g('input_number.evcs_balance_storage_shortfall_kwh')?.state), + txt(g('input_boolean.evcs_balance_pv_censored')?.state) === 'on', + txt(g('input_text.evcs_balance_censor_reason')?.state), + txt(g('input_text.evcs_balance_storm_level')?.state), + num(g('input_number.evcs_balance_storm_reserve_kwh')?.state), + JSON.stringify({ + raison: txt(g('input_text.evcs_balance_reason')?.state), + fenetre_prochain_deficit: txt(g('input_text.evcs_balance_next_deficit_window')?.state), + soc_requis_prochain_deficit: num(g('input_number.evcs_balance_next_deficit_required_soc')?.state), + }), + ] + ); + return rowCount; +} + +async function ecrireDecision(sql, st) { + const etat = st.get('input_text.victron_evcs_surplus_state'); + if (!etat) return 0; + const g = (id) => st.get(id); + const { rowCount } = await sql( + `INSERT INTO decision (ts, profil, source_mode, etat, decision, charge_desiree, + courant_a, soc_pct, cible_matin_pct, takeover_reseau, + session_engagee, raison, brut) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + ON CONFLICT (ts) DO NOTHING`, + [ + etat.last_updated, + txt(g('input_text.victron_evcs_operating_mode')?.state), + txt(g('input_text.victron_evcs_source_mode')?.state), + txt(etat.state), + txt(g('input_text.victron_evcs_surplus_current_decision')?.state), + num(g('sensor.victron_evcs_ac_power')?.state) > 0, + num(g('number.victron_evcs_set_current_command')?.state), + num(g('sensor.victron_battery_soc')?.state), + num(g('input_number.victron_evcs_night_target_soc')?.state), + txt(g('input_boolean.victron_evcs_night_grid_takeover_needed')?.state) === 'on', + txt(g('input_boolean.victron_evcs_night_grid_session_committed')?.state) === 'on', + txt(g('input_text.victron_evcs_surplus_reason')?.state), + JSON.stringify({ + session_nuit: txt(g('input_boolean.victron_evcs_night_session_active')?.state), + edf: txt(g('switch.edf_general_ok_switch')?.state), + strategie: txt(g('input_select.victron_evcs_strategy')?.state), + vehicule: txt(g('input_select.victron_evcs_vehicle')?.state), + }), + ] + ); + return rowCount; +} + +// La prévision n'a de valeur que datée de sa PUBLICATION : sans cela on ne peut +// pas mesurer son biais, qui est précisément la question ouverte (§42). +async function ecrirePrevision(sql, st) { + const marqueur = st.get('input_text.evcs_balance_forecast_updated'); + if (!marqueur || !txt(marqueur.state)) return 0; + const publieA = marqueur.last_updated; + const auj = new Date(publieA); + let n = 0; + for (const [id, decalageJours] of [ + ['input_text.evcs_balance_forecast_today', 0], + ['input_text.evcs_balance_forecast_tomorrow', 1], + ]) { + const e = st.get(id); + if (!e) continue; + const v = txt(e.state).split(',').map(Number); + if (v.length !== 24 || v.some((x) => !Number.isFinite(x))) continue; + const jour = new Date(auj.getTime() + decalageJours * 86400000) + .toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' }); // AAAA-MM-JJ + const { rowCount } = await sql( + `INSERT INTO prevision (publie_a, jour, total_kwh, horaire) + VALUES ($1,$2,$3,$4) ON CONFLICT (publie_a, jour) DO NOTHING`, + [publieA, jour, v.reduce((a, b) => a + b, 0), v] + ); + n += rowCount; + } + return n; +} + +// --------------------------------------------------------------------- +async function cycle(client) { + // En blanc, on compte ce qu'on ECRIRAIT. Un essai a blanc qui renvoie + // toujours zero ne verifie rien — c'est le piege classique du test qui ne + // regarde que le chemin heureux. + const sql = DRY + ? async (q, p) => { + if (process.env.HISTORISE_VERBOSE) { + console.log(' ' + q.split('\n')[0].trim() + ' <- ' + JSON.stringify(p).slice(0, 120)); + } + return { rowCount: 1 }; + } + : (q, p) => client.query(q, p); + const st = await etats(); + const r = { + mesures: await ecrireMesures(sql, st), + bilan: await ecrireBilan(sql, st), + decision: await ecrireDecision(sql, st), + prevision: await ecrirePrevision(sql, st), + }; + const total = r.mesures + r.bilan + r.decision + r.prevision; + if (total > 0 || ONCE) { + console.log( + `${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` + + `${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` + + `decision=${r.decision} prevision=${r.prevision}` + ); + } +} + +const client = new pg.Client({ + host: process.env.PGHOST || '10.10.10.22', + port: Number(process.env.PGPORT || 5432), + user: process.env.PGUSER || 'evcs', + database: process.env.PGDATABASE || 'evcs', + password: process.env.PGPASSWORD, +}); + +if (!DRY) await client.connect(); +console.log( + `Historisation EVCS — HA=${HA_URL} base=${DRY ? '(blanc)' : client.host + ':' + client.port} ` + + `intervalle=${INTERVAL_S}s` +); + +if (ONCE) { + await cycle(client); + if (!DRY) await client.end(); + process.exit(0); +} + +// Une panne d'un cycle ne doit pas tuer la boucle : Home Assistant redémarre, +// le réseau hoquette, la base se recharge. On journalise et on retente. +for (;;) { + try { + await cycle(client); + } catch (e) { + console.error(`${new Date().toISOString()} cycle en echec : ${e.message}`); + } + await new Promise((r) => setTimeout(r, INTERVAL_S * 1000)); +}