From 26fb9f72eacd282d2700a144f7efd776a1ecdfca Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Tue, 25 Aug 2026 02:30:28 +0200 Subject: [PATCH] Chantier : recuperation de la courbe horaire Forecast.Solar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow "EVCS Solar Forecast V1" (07P8I349ugna9FH4), actif, cadence 15 min — la frequence de rafraichissement de la prevision elle-meme, l'API precisant qu'interroger plus souvent n'a aucun interet. 96 appels par jour pour un quota de 300 par heure. Rien n'est code en dur : la cle d'API, les plans et les coordonnees viennent tous de Home Assistant. La cle vit dans un input_text en mode password, exclue du recorder, et n'apparait ni dans le depot ni dans le JSON du workflow — l'URL est assemblee a l'execution par BUILD_FORECAST_URL, qui refuse explicitement de construire une URL incomplete plutot que de gaspiller le quota sur un appel malforme. BUILD_HA_CALLS_FORECAST ne publie rien en cas d'echec plutot que d'ecraser une prevision valide par du vide. Suite portee a 33 scenarios, dont le refus sur cle absente, sur plans mal formes, sur kill-switch, et la preservation de la prevision precedente en cas d'echec. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- src/energy-balance/BUILD_FORECAST_URL.js | 52 ++++ src/energy-balance/BUILD_HA_CALLS_FORECAST.js | 51 ++++ .../HA_SNAPSHOT_FORECAST.json.tpl | 8 + src/energy-balance/PARSE_FORECAST.js | 64 +++++ tests/energy-balance/run.mjs | 14 +- tests/energy-balance/scenarios-shadow.mjs | 57 ++++ tools/gen-solar-forecast-workflow.mjs | 134 ++++++++++ tools/lib-node-map.mjs | 11 + tools/n8n-push.mjs | 3 +- workflows/evcs-solar-forecast-v1.json | 244 ++++++++++++++++++ 10 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 src/energy-balance/BUILD_FORECAST_URL.js create mode 100644 src/energy-balance/BUILD_HA_CALLS_FORECAST.js create mode 100644 src/energy-balance/HA_SNAPSHOT_FORECAST.json.tpl create mode 100644 src/energy-balance/PARSE_FORECAST.js create mode 100644 tools/gen-solar-forecast-workflow.mjs create mode 100644 workflows/evcs-solar-forecast-v1.json diff --git a/src/energy-balance/BUILD_FORECAST_URL.js b/src/energy-balance/BUILD_FORECAST_URL.js new file mode 100644 index 0000000..721ff90 --- /dev/null +++ b/src/energy-balance/BUILD_FORECAST_URL.js @@ -0,0 +1,52 @@ +// EVCS Energy Balance — BUILD_FORECAST_URL +// ===================================================================== +// Construit l'URL d'appel à Forecast.Solar. Rien n'est codé en dur : la clé, +// les plans et les coordonnées viennent tous de Home Assistant. +// +// ⚠️ CONVENTION D'AZIMUT : l'API utilise 0 = sud, −90 = est, +90 = ouest, +// alors que Home Assistant utilise 0 = nord. Le helper +// `evcs_balance_forecast_planes` contient DÉJÀ les valeurs converties +// (azimut_API = azimut_HA − 180), au format attendu : des triplets +// dec/azimut/kWc successifs séparés par des barres obliques. +// Les listes séparées par des virgules sont refusées par l'API (erreur 422). +// +// ⚠️ La clé apparaît dans l'URL, donc dans les données d'exécution n8n si +// l'enregistrement des erreurs est actif. Compromis assumé : l'API n'accepte +// la clé que dans le chemin, et l'instance n8n est privée. +// ===================================================================== + +function unwrapSnapshot(raw) { + if (raw && typeof raw.data === 'string') { + try { return JSON.parse(raw.data); } catch { return raw; } + } + return (raw && raw.data) || raw; +} + +const input = unwrapSnapshot($input.first().json); + +const enabled = (input.enabled ?? 'on').toString().toLowerCase() === 'on'; +const key = (input.api_key || '').toString().trim(); +const planes = (input.planes || '').toString().trim(); +const lat = Number.parseFloat(input.latitude); +const lon = Number.parseFloat(input.longitude); + +// Refus explicite plutôt qu'un appel malformé : une URL incomplète +// consommerait le quota pour rien et renverrait une erreur opaque. +const problems = []; +if (!enabled) problems.push('bilan désactivé (kill-switch)'); +if (!key || ['unknown', 'unavailable'].includes(key.toLowerCase())) problems.push('clé API absente'); +if (!/^[\d.\-\/]+$/.test(planes) || planes.split('/').length % 3 !== 0) problems.push('plans invalides'); +if (!Number.isFinite(lat) || !Number.isFinite(lon)) problems.push('coordonnées absentes'); + +if (problems.length) { + return [{ json: { ok: false, problems, url: null } }]; +} + +return [{ + json: { + ok: true, + nowMs: Number.parseFloat(input.nowMs), + planeCount: planes.split('/').length / 3, + url: `https://api.forecast.solar/${key}/estimate/watthours/period/${lat}/${lon}/${planes}`, + }, +}]; diff --git a/src/energy-balance/BUILD_HA_CALLS_FORECAST.js b/src/energy-balance/BUILD_HA_CALLS_FORECAST.js new file mode 100644 index 0000000..22bac70 --- /dev/null +++ b/src/energy-balance/BUILD_HA_CALLS_FORECAST.js @@ -0,0 +1,51 @@ +// EVCS Energy Balance — BUILD_HA_CALLS_FORECAST +// ===================================================================== +// Publie la prévision horaire dans ses helpers. +// MÊME GARANTIE DE PÉRIMÈTRE : uniquement des entités `evcs_balance_*`. +// En cas d'échec, on ne publie rien plutôt que d'écraser une prévision valide +// par du vide — une prévision d'il y a quinze minutes vaut mieux que rien. +// ===================================================================== + +const parsed = $input.first().json; + +function stampParis() { + const p = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }).formatToParts(new Date()); + const g = t => p.find(x => x.type === t)?.value || ''; + return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`; +} + +const calls = []; +function setText(entity, value) { + calls.push({ json: { + domain: 'input_text', service: 'set_value', + data: { entity_id: entity, value: (value ?? '').toString().slice(0, 250) }, + } }); +} + +if (!parsed.ok) { + setText('input_text.evcs_balance_forecast_updated', + `échec : ${(parsed.problems || []).join(', ')}`); + return calls; +} + +setText('input_text.evcs_balance_forecast_today', parsed.today.csv); +if (parsed.tomorrow) { + setText('input_text.evcs_balance_forecast_tomorrow', parsed.tomorrow.csv); +} +setText('input_text.evcs_balance_forecast_updated', stampParis()); + +calls.push({ json: { + domain: 'logbook', service: 'log', + data: { + name: 'EVCS Bilan — prévision solaire', + message: `${parsed.today.date} : ${parsed.today.totalKwh} kWh attendus` + + (parsed.tomorrow ? `, lendemain ${parsed.tomorrow.totalKwh} kWh` : '') + + ` (${parsed.days} jours reçus, quota restant ${parsed.rateLimitRemaining ?? '?'})`, + entity_id: 'input_text.evcs_balance_forecast_today', + }, +} }); + +return calls; diff --git a/src/energy-balance/HA_SNAPSHOT_FORECAST.json.tpl b/src/energy-balance/HA_SNAPSHOT_FORECAST.json.tpl new file mode 100644 index 0000000..5d981b8 --- /dev/null +++ b/src/energy-balance/HA_SNAPSHOT_FORECAST.json.tpl @@ -0,0 +1,8 @@ +{{ { + "nowMs": as_timestamp(now()) * 1000, + "enabled": states("input_boolean.evcs_balance_enabled"), + "api_key": states("input_text.evcs_balance_forecast_api_key"), + "planes": states("input_text.evcs_balance_forecast_planes"), + "latitude": state_attr("zone.home", "latitude"), + "longitude": state_attr("zone.home", "longitude") +} | tojson }} diff --git a/src/energy-balance/PARSE_FORECAST.js b/src/energy-balance/PARSE_FORECAST.js new file mode 100644 index 0000000..5e6aec5 --- /dev/null +++ b/src/energy-balance/PARSE_FORECAST.js @@ -0,0 +1,64 @@ +// EVCS Energy Balance — PARSE_FORECAST +// ===================================================================== +// Transforme la réponse de Forecast.Solar en deux profils horaires (jour et +// lendemain), en kWh par heure, prêts à être publiés en CSV compact. +// +// Cette courbe REMPLACE l'enveloppe mesurée comme source de forme dans +// `RESOURCE_ESTIMATE` : elle apporte forme ET magnitude, sur 6 jours, et elle +// n'est pas censurée par l'état de notre batterie. L'enveloppe conservait un +// biais connu — étant un maximum horaire pris sur des jours différents, elle +// surpondère le matin, dont la variabilité jour à jour est plus forte qu'à +// midi (constaté le 25/08 : deux modèles indépendants la contredisent de la +// même façon). +// ===================================================================== + +function round2(v) { + return Number.isFinite(v) ? Math.round(v * 100) / 100 : 0; +} + +const raw = $input.first().json; + +// L'étape amont a pu refuser de construire l'URL. +if (raw && raw.ok === false) { + return [{ json: { ok: false, problems: raw.problems || ['url non construite'] } }]; +} + +let body = raw; +if (body && typeof body.data === 'string') { + try { body = JSON.parse(body.data); } catch { body = null; } +} else if (body && body.data) { + body = body.data; +} + +if (!body || !body.result) { + return [{ json: { ok: false, problems: ['réponse illisible ou sans résultat'] } }]; +} + +// result : { "2026-08-25 07:00:00": , ... } +const byDay = new Map(); +for (const [stamp, wh] of Object.entries(body.result)) { + const day = stamp.slice(0, 10); + const hour = Number.parseInt(stamp.slice(11, 13), 10); + if (!Number.isFinite(hour)) continue; + if (!byDay.has(day)) byDay.set(day, Array.from({ length: 24 }, () => 0)); + byDay.get(day)[hour] += Number.parseFloat(wh) / 1000; +} + +const days = [...byDay.keys()].sort(); +const csv = (arr) => arr.map(v => round2(v).toFixed(2)).join(','); +const sum = (arr) => round2(arr.reduce((a, b) => a + b, 0)); + +const today = days[0] ? byDay.get(days[0]) : null; +const tomorrow = days[1] ? byDay.get(days[1]) : null; + +return [{ + json: { + ok: !!today, + days: days.length, + today: today ? { date: days[0], csv: csv(today), totalKwh: sum(today) } : null, + tomorrow: tomorrow ? { date: days[1], csv: csv(tomorrow), totalKwh: sum(tomorrow) } : null, + // Reste du quota, utile pour surveiller qu'on n'appelle pas trop souvent. + rateLimitRemaining: body.message?.ratelimit?.remaining ?? null, + place: body.message?.info?.place ?? null, + }, +}]; diff --git a/tests/energy-balance/run.mjs b/tests/energy-balance/run.mjs index a4a8df5..635bf75 100644 --- a/tests/energy-balance/run.mjs +++ b/tests/energy-balance/run.mjs @@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'; import { scenarios as censorshipScenarios } from './scenarios.mjs'; import { profileScenarios, resourceScenarios } from './scenarios-profile.mjs'; import { balanceScenarios } from './scenarios-balance.mjs'; -import { shadowScenarios, profilesShadowScenarios } from './scenarios-shadow.mjs'; +import { shadowScenarios, profilesShadowScenarios, forecastScenarios } from './scenarios-shadow.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -95,6 +95,17 @@ SUMMARIZE.BUILD_HA_CALLS = (calls) => { }; }; SUMMARIZE.BUILD_HA_CALLS_PROFILES = SUMMARIZE.BUILD_HA_CALLS; +SUMMARIZE.BUILD_HA_CALLS_FORECAST = (calls) => { + const base = SUMMARIZE.BUILD_HA_CALLS(calls); + const entities = calls.filter(c => c.json.domain !== 'logbook').map(c => c.json.data.entity_id || ''); + return { ...base, wroteForecast: entities.includes('input_text.evcs_balance_forecast_today') }; +}; +SUMMARIZE.BUILD_FORECAST_URL = (o) => ({ + urlOk: o.ok, + problems: (o.problems || []).join(','), + planeCount: o.planeCount ?? null, + urlTail: o.url ? o.url.split('/period/')[1].split('/').slice(2).join('/') : null, +}); const all = [ ...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })), @@ -103,6 +114,7 @@ const all = [ ...balanceScenarios, ...shadowScenarios, ...profilesShadowScenarios, + ...forecastScenarios, ]; let failed = 0; diff --git a/tests/energy-balance/scenarios-shadow.mjs b/tests/energy-balance/scenarios-shadow.mjs index 3502303..2b2240c 100644 --- a/tests/energy-balance/scenarios-shadow.mjs +++ b/tests/energy-balance/scenarios-shadow.mjs @@ -108,3 +108,60 @@ export const profilesShadowScenarios = [ expect: { outOfScope: '', wroteProfiles: false }, }, ]; + +// --- Prévision solaire (workflow 15 min) --------------------------------- +const FS_OK = { + ok: true, days: 6, + today: { date: '2026-08-25', csv: '0.00,'.repeat(23) + '1.00', totalKwh: 43.5 }, + tomorrow: { date: '2026-08-26', csv: '0.00,'.repeat(23) + '2.00', totalKwh: 30.0 }, + rateLimitRemaining: 293, +}; + +export const forecastScenarios = [ + { + node: 'BUILD_FORECAST_URL', + id: 'url-refus-cle-absente', + label: 'Clé absente : refus explicite, aucun appel gaspillé sur le quota', + snapshot: { enabled: 'on', api_key: 'unknown', planes: '40/-45/4.03', latitude: 48.7, longitude: -3.5 }, + expect: { urlOk: false, problems: 'clé API absente' }, + }, + { + node: 'BUILD_FORECAST_URL', + id: 'url-refus-plans-invalides', + label: 'Plans mal formés (nombre de valeurs non multiple de 3) : refus', + snapshot: { enabled: 'on', api_key: 'ABCD1234', planes: '40/-45', latitude: 48.7, longitude: -3.5 }, + expect: { urlOk: false, problems: 'plans invalides' }, + }, + { + node: 'BUILD_FORECAST_URL', + id: 'url-kill-switch', + label: 'Kill-switch à OFF : aucune URL construite', + snapshot: { enabled: 'off', api_key: 'ABCD1234', planes: '40/-45/4.03', latitude: 48.7, longitude: -3.5 }, + expect: { urlOk: false }, + }, + { + node: 'BUILD_FORECAST_URL', + id: 'url-trois-plans', + label: 'Trois plans valides : URL construite, triplets préservés', + snapshot: { + enabled: 'on', api_key: 'ABCD1234', + planes: '40/-45/4.03/35/0/9.3/45/90/2.79', + latitude: 48.7732657, longitude: -3.5187179, + }, + expect: { urlOk: true, planeCount: 3, urlTail: '40/-45/4.03/35/0/9.3/45/90/2.79' }, + }, + { + node: 'BUILD_HA_CALLS_FORECAST', + id: 'forecast-perimetre-respecte', + label: 'Publication de la prévision : périmètre evcs_balance_* respecté', + snapshot: FS_OK, + expect: { outOfScope: '', callCount: 4, hasLogbook: true }, + }, + { + node: 'BUILD_HA_CALLS_FORECAST', + id: 'forecast-echec-ne-detruit-rien', + label: 'Échec de récupération : la prévision précédente n\'est pas écrasée', + snapshot: { ok: false, problems: ['réponse illisible ou sans résultat'] }, + expect: { outOfScope: '', wroteForecast: false }, + }, +]; diff --git a/tools/gen-solar-forecast-workflow.mjs b/tools/gen-solar-forecast-workflow.mjs new file mode 100644 index 0000000..97b1061 --- /dev/null +++ b/tools/gen-solar-forecast-workflow.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +// Génère le squelette du workflow "EVCS Solar Forecast V1" : récupération de la +// courbe horaire Forecast.Solar (abonnement Professional) et publication dans +// les helpers du bilan. +// +// Cadence : 15 min. C'est la fréquence de rafraîchissement de la prévision +// elle-même — l'API précise qu'interroger plus souvent n'a aucun intérêt. +// 96 appels par jour pour un quota de 300 par heure. +// +// La clé d'API n'apparaît NULLE PART ici : elle est lue dans un helper Home +// Assistant par le snapshot, et l'URL est assemblée par BUILD_FORECAST_URL. + +import fs from 'node:fs'; + +const HA_CRED = { id: 'uv3sknYYigxBkaVN', name: 'Bearer Auth account 2' }; +const HA_URL = 'http://10.10.10.17:8123'; + +const nodes = [ + { + name: 'Schedule Forecast', + type: 'n8n-nodes-base.scheduleTrigger', + typeVersion: 1.2, + position: [0, 0], + parameters: { rule: { interval: [{ field: 'minutes', minutesInterval: 15 }] } }, + }, + { + name: 'HA Snapshot Forecast', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 0], + parameters: { + method: 'POST', + url: `${HA_URL}/api/template`, + authentication: 'genericCredentialType', + genericAuthType: 'httpBearerAuth', + sendHeaders: true, + headerParameters: { + parameters: [ + { name: 'Content-Type', value: 'application/json' }, + { name: 'Accept', value: 'text/plain' }, + ], + }, + sendBody: true, + bodyParameters: { parameters: [{ name: 'template', value: '(injecté depuis src/energy-balance)' }] }, + options: { timeout: 15000 }, + }, + credentials: { httpBearerAuth: HA_CRED }, + }, + { + name: 'BUILD_FORECAST_URL', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [440, 0], + parameters: { jsCode: '// injecté depuis src/energy-balance/BUILD_FORECAST_URL.js' }, + }, + { + name: 'Forecast.Solar', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [660, 0], + parameters: { + method: 'GET', + // URL assemblée par le nœud précédent : la clé ne transite pas par ce fichier. + url: '={{ $json.url }}', + options: { + timeout: 30000, + response: { response: { responseFormat: 'text', neverError: true } }, + }, + }, + // Si l'URL est nulle (clé absente, kill-switch), l'appel échoue sans casser + // la chaîne : PARSE_FORECAST le détectera et rien ne sera publié. + onError: 'continueRegularOutput', + }, + { + name: 'PARSE_FORECAST', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [880, 0], + parameters: { jsCode: '// injecté depuis src/energy-balance/PARSE_FORECAST.js' }, + }, + { + name: 'BUILD_HA_CALLS_FORECAST', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [1100, 0], + parameters: { jsCode: '// injecté depuis src/energy-balance/BUILD_HA_CALLS_FORECAST.js' }, + }, + { + name: 'Execute HA Service Forecast', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [1320, 0], + parameters: { + method: 'POST', + url: `={{ '${HA_URL}/api/services/' + $json.domain + '/' + $json.service }}`, + authentication: 'genericCredentialType', + genericAuthType: 'httpBearerAuth', + sendHeaders: true, + headerParameters: { + parameters: [ + { name: 'Content-Type', value: 'application/json' }, + { name: 'accept', value: 'application/json' }, + ], + }, + sendBody: true, + specifyBody: 'json', + jsonBody: '={{ $json.data }}', + options: { timeout: 15000 }, + }, + credentials: { httpBearerAuth: HA_CRED }, + onError: 'continueRegularOutput', + }, +]; + +const connections = { + 'Schedule Forecast': { main: [[{ node: 'HA Snapshot Forecast', type: 'main', index: 0 }]] }, + 'HA Snapshot Forecast': { main: [[{ node: 'BUILD_FORECAST_URL', type: 'main', index: 0 }]] }, + BUILD_FORECAST_URL: { main: [[{ node: 'Forecast.Solar', type: 'main', index: 0 }]] }, + 'Forecast.Solar': { main: [[{ node: 'PARSE_FORECAST', type: 'main', index: 0 }]] }, + PARSE_FORECAST: { main: [[{ node: 'BUILD_HA_CALLS_FORECAST', type: 'main', index: 0 }]] }, + BUILD_HA_CALLS_FORECAST: { main: [[{ node: 'Execute HA Service Forecast', type: 'main', index: 0 }]] }, +}; + +const workflow = { + id: '07P8I349ugna9FH4', + name: 'EVCS Solar Forecast V1', + nodes, + connections, + settings: { executionOrder: 'v1', callerPolicy: 'workflowsFromSameOwner' }, + staticData: null, +}; + +fs.writeFileSync('workflows/evcs-solar-forecast-v1.json', JSON.stringify(workflow, null, 2) + '\n'); +console.log(`Squelette écrit : workflows/evcs-solar-forecast-v1.json (${nodes.length} nodes)`); diff --git a/tools/lib-node-map.mjs b/tools/lib-node-map.mjs index 2a6fa27..af839dd 100644 --- a/tools/lib-node-map.mjs +++ b/tools/lib-node-map.mjs @@ -50,6 +50,17 @@ export const WORKFLOWS = [ { file: 'BUILD_HA_CALLS_PROFILES.js', node: 'BUILD_HA_CALLS_PROFILES', kind: 'jsCode' }, ], }, + { + id: 'solar-forecast', + workflowFile: 'workflows/evcs-solar-forecast-v1.json', + srcDir: 'src/energy-balance', + nodes: [ + { file: 'BUILD_FORECAST_URL.js', node: 'BUILD_FORECAST_URL', kind: 'jsCode' }, + { file: 'PARSE_FORECAST.js', node: 'PARSE_FORECAST', kind: 'jsCode' }, + { file: 'BUILD_HA_CALLS_FORECAST.js', node: 'BUILD_HA_CALLS_FORECAST', kind: 'jsCode' }, + { file: 'HA_SNAPSHOT_FORECAST.json.tpl', node: 'HA Snapshot Forecast', kind: 'template' }, + ], + }, ]; // Rétro-compatibilité (outils historiques mono-workflow) diff --git a/tools/n8n-push.mjs b/tools/n8n-push.mjs index 22927fc..1fa32c2 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -110,7 +110,8 @@ const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.js const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json'; const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json' - || path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json'; + || path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json' + || path.basename(INPUT) === 'evcs-solar-forecast-v1.json'; const skipGolden = process.argv.includes('--skip-golden'); const testSuite = isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' : diff --git a/workflows/evcs-solar-forecast-v1.json b/workflows/evcs-solar-forecast-v1.json new file mode 100644 index 0000000..01c775e --- /dev/null +++ b/workflows/evcs-solar-forecast-v1.json @@ -0,0 +1,244 @@ +{ + "name": "EVCS Solar Forecast V1", + "nodes": [ + { + "name": "Schedule Forecast", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 0 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 15 + } + ] + } + } + }, + { + "name": "HA Snapshot Forecast", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 220, + 0 + ], + "parameters": { + "method": "POST", + "url": "http://10.10.10.17:8123/api/template", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "text/plain" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "template", + "value": "{{ {\n \"nowMs\": as_timestamp(now()) * 1000,\n \"enabled\": states(\"input_boolean.evcs_balance_enabled\"),\n \"api_key\": states(\"input_text.evcs_balance_forecast_api_key\"),\n \"planes\": states(\"input_text.evcs_balance_forecast_planes\"),\n \"latitude\": state_attr(\"zone.home\", \"latitude\"),\n \"longitude\": state_attr(\"zone.home\", \"longitude\")\n} | tojson }}\n" + } + ] + }, + "options": { + "timeout": 15000 + } + }, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "name": "BUILD_FORECAST_URL", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 440, + 0 + ], + "parameters": { + "jsCode": "// EVCS Energy Balance — BUILD_FORECAST_URL\n// =====================================================================\n// Construit l'URL d'appel à Forecast.Solar. Rien n'est codé en dur : la clé,\n// les plans et les coordonnées viennent tous de Home Assistant.\n//\n// ⚠️ CONVENTION D'AZIMUT : l'API utilise 0 = sud, −90 = est, +90 = ouest,\n// alors que Home Assistant utilise 0 = nord. Le helper\n// `evcs_balance_forecast_planes` contient DÉJÀ les valeurs converties\n// (azimut_API = azimut_HA − 180), au format attendu : des triplets\n// dec/azimut/kWc successifs séparés par des barres obliques.\n// Les listes séparées par des virgules sont refusées par l'API (erreur 422).\n//\n// ⚠️ La clé apparaît dans l'URL, donc dans les données d'exécution n8n si\n// l'enregistrement des erreurs est actif. Compromis assumé : l'API n'accepte\n// la clé que dans le chemin, et l'instance n8n est privée.\n// =====================================================================\n\nfunction unwrapSnapshot(raw) {\n if (raw && typeof raw.data === 'string') {\n try { return JSON.parse(raw.data); } catch { return raw; }\n }\n return (raw && raw.data) || raw;\n}\n\nconst input = unwrapSnapshot($input.first().json);\n\nconst enabled = (input.enabled ?? 'on').toString().toLowerCase() === 'on';\nconst key = (input.api_key || '').toString().trim();\nconst planes = (input.planes || '').toString().trim();\nconst lat = Number.parseFloat(input.latitude);\nconst lon = Number.parseFloat(input.longitude);\n\n// Refus explicite plutôt qu'un appel malformé : une URL incomplète\n// consommerait le quota pour rien et renverrait une erreur opaque.\nconst problems = [];\nif (!enabled) problems.push('bilan désactivé (kill-switch)');\nif (!key || ['unknown', 'unavailable'].includes(key.toLowerCase())) problems.push('clé API absente');\nif (!/^[\\d.\\-\\/]+$/.test(planes) || planes.split('/').length % 3 !== 0) problems.push('plans invalides');\nif (!Number.isFinite(lat) || !Number.isFinite(lon)) problems.push('coordonnées absentes');\n\nif (problems.length) {\n return [{ json: { ok: false, problems, url: null } }];\n}\n\nreturn [{\n json: {\n ok: true,\n nowMs: Number.parseFloat(input.nowMs),\n planeCount: planes.split('/').length / 3,\n url: `https://api.forecast.solar/${key}/estimate/watthours/period/${lat}/${lon}/${planes}`,\n },\n}];\n" + } + }, + { + "name": "Forecast.Solar", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 660, + 0 + ], + "parameters": { + "method": "GET", + "url": "={{ $json.url }}", + "options": { + "timeout": 30000, + "response": { + "response": { + "responseFormat": "text", + "neverError": true + } + } + } + }, + "onError": "continueRegularOutput" + }, + { + "name": "PARSE_FORECAST", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 880, + 0 + ], + "parameters": { + "jsCode": "// EVCS Energy Balance — PARSE_FORECAST\n// =====================================================================\n// Transforme la réponse de Forecast.Solar en deux profils horaires (jour et\n// lendemain), en kWh par heure, prêts à être publiés en CSV compact.\n//\n// Cette courbe REMPLACE l'enveloppe mesurée comme source de forme dans\n// `RESOURCE_ESTIMATE` : elle apporte forme ET magnitude, sur 6 jours, et elle\n// n'est pas censurée par l'état de notre batterie. L'enveloppe conservait un\n// biais connu — étant un maximum horaire pris sur des jours différents, elle\n// surpondère le matin, dont la variabilité jour à jour est plus forte qu'à\n// midi (constaté le 25/08 : deux modèles indépendants la contredisent de la\n// même façon).\n// =====================================================================\n\nfunction round2(v) {\n return Number.isFinite(v) ? Math.round(v * 100) / 100 : 0;\n}\n\nconst raw = $input.first().json;\n\n// L'étape amont a pu refuser de construire l'URL.\nif (raw && raw.ok === false) {\n return [{ json: { ok: false, problems: raw.problems || ['url non construite'] } }];\n}\n\nlet body = raw;\nif (body && typeof body.data === 'string') {\n try { body = JSON.parse(body.data); } catch { body = null; }\n} else if (body && body.data) {\n body = body.data;\n}\n\nif (!body || !body.result) {\n return [{ json: { ok: false, problems: ['réponse illisible ou sans résultat'] } }];\n}\n\n// result : { \"2026-08-25 07:00:00\": , ... }\nconst byDay = new Map();\nfor (const [stamp, wh] of Object.entries(body.result)) {\n const day = stamp.slice(0, 10);\n const hour = Number.parseInt(stamp.slice(11, 13), 10);\n if (!Number.isFinite(hour)) continue;\n if (!byDay.has(day)) byDay.set(day, Array.from({ length: 24 }, () => 0));\n byDay.get(day)[hour] += Number.parseFloat(wh) / 1000;\n}\n\nconst days = [...byDay.keys()].sort();\nconst csv = (arr) => arr.map(v => round2(v).toFixed(2)).join(',');\nconst sum = (arr) => round2(arr.reduce((a, b) => a + b, 0));\n\nconst today = days[0] ? byDay.get(days[0]) : null;\nconst tomorrow = days[1] ? byDay.get(days[1]) : null;\n\nreturn [{\n json: {\n ok: !!today,\n days: days.length,\n today: today ? { date: days[0], csv: csv(today), totalKwh: sum(today) } : null,\n tomorrow: tomorrow ? { date: days[1], csv: csv(tomorrow), totalKwh: sum(tomorrow) } : null,\n // Reste du quota, utile pour surveiller qu'on n'appelle pas trop souvent.\n rateLimitRemaining: body.message?.ratelimit?.remaining ?? null,\n place: body.message?.info?.place ?? null,\n },\n}];\n" + } + }, + { + "name": "BUILD_HA_CALLS_FORECAST", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1100, + 0 + ], + "parameters": { + "jsCode": "// EVCS Energy Balance — BUILD_HA_CALLS_FORECAST\n// =====================================================================\n// Publie la prévision horaire dans ses helpers.\n// MÊME GARANTIE DE PÉRIMÈTRE : uniquement des entités `evcs_balance_*`.\n// En cas d'échec, on ne publie rien plutôt que d'écraser une prévision valide\n// par du vide — une prévision d'il y a quinze minutes vaut mieux que rien.\n// =====================================================================\n\nconst parsed = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nfunction setText(entity, value) {\n calls.push({ json: {\n domain: 'input_text', service: 'set_value',\n data: { entity_id: entity, value: (value ?? '').toString().slice(0, 250) },\n } });\n}\n\nif (!parsed.ok) {\n setText('input_text.evcs_balance_forecast_updated',\n `échec : ${(parsed.problems || []).join(', ')}`);\n return calls;\n}\n\nsetText('input_text.evcs_balance_forecast_today', parsed.today.csv);\nif (parsed.tomorrow) {\n setText('input_text.evcs_balance_forecast_tomorrow', parsed.tomorrow.csv);\n}\nsetText('input_text.evcs_balance_forecast_updated', stampParis());\n\ncalls.push({ json: {\n domain: 'logbook', service: 'log',\n data: {\n name: 'EVCS Bilan — prévision solaire',\n message: `${parsed.today.date} : ${parsed.today.totalKwh} kWh attendus`\n + (parsed.tomorrow ? `, lendemain ${parsed.tomorrow.totalKwh} kWh` : '')\n + ` (${parsed.days} jours reçus, quota restant ${parsed.rateLimitRemaining ?? '?'})`,\n entity_id: 'input_text.evcs_balance_forecast_today',\n },\n} });\n\nreturn calls;\n" + } + }, + { + "name": "Execute HA Service Forecast", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1320, + 0 + ], + "parameters": { + "method": "POST", + "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.data }}", + "options": { + "timeout": 15000 + } + }, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + }, + "onError": "continueRegularOutput" + } + ], + "connections": { + "Schedule Forecast": { + "main": [ + [ + { + "node": "HA Snapshot Forecast", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot Forecast": { + "main": [ + [ + { + "node": "BUILD_FORECAST_URL", + "type": "main", + "index": 0 + } + ] + ] + }, + "BUILD_FORECAST_URL": { + "main": [ + [ + { + "node": "Forecast.Solar", + "type": "main", + "index": 0 + } + ] + ] + }, + "Forecast.Solar": { + "main": [ + [ + { + "node": "PARSE_FORECAST", + "type": "main", + "index": 0 + } + ] + ] + }, + "PARSE_FORECAST": { + "main": [ + [ + { + "node": "BUILD_HA_CALLS_FORECAST", + "type": "main", + "index": 0 + } + ] + ] + }, + "BUILD_HA_CALLS_FORECAST": { + "main": [ + [ + { + "node": "Execute HA Service Forecast", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner" + }, + "staticData": null, + "id": "07P8I349ugna9FH4" +}