{ "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\n// La DATE que decrit la courbe, et non l'heure a laquelle on l'a recue.\n// Sans elle, « today » veut dire « le premier jour renvoye par l'API au moment\n// de la publication » — ce qui devient faux des qu'on passe minuit sans\n// rafraichir. Nuit du 07 au 08/09 : plus aucune publication de 23:45 a 04:30,\n// et le bilan a lu comme « aujourd'hui » la courbe du 7, dont toutes les heures\n// etaient passees. Il en a conclu « aucun croisement, 47,6 kWh a couvrir, SOC\n// requis 100 % », s'est declare degrade, et le controleur est retombe sur le\n// palier de 35 % pendant quatre heures.\nsetText('input_text.evcs_balance_forecast_day0', parsed.today.date);\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" }