Files
EVCS/workflows/evcs-solar-forecast-v1.json
Tallagrand Thierry 26fb9f72ea Chantier : recuperation de la courbe horaire Forecast.Solar
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-08-25 02:30:28 +02:00

245 lines
12 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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\": <wattheures de la période>, ... }\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"
}