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
This commit is contained in:
52
src/energy-balance/BUILD_FORECAST_URL.js
Normal file
52
src/energy-balance/BUILD_FORECAST_URL.js
Normal file
@@ -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}`,
|
||||||
|
},
|
||||||
|
}];
|
||||||
51
src/energy-balance/BUILD_HA_CALLS_FORECAST.js
Normal file
51
src/energy-balance/BUILD_HA_CALLS_FORECAST.js
Normal file
@@ -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;
|
||||||
8
src/energy-balance/HA_SNAPSHOT_FORECAST.json.tpl
Normal file
8
src/energy-balance/HA_SNAPSHOT_FORECAST.json.tpl
Normal file
@@ -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 }}
|
||||||
64
src/energy-balance/PARSE_FORECAST.js
Normal file
64
src/energy-balance/PARSE_FORECAST.js
Normal file
@@ -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": <wattheures de la période>, ... }
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}];
|
||||||
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { scenarios as censorshipScenarios } from './scenarios.mjs';
|
import { scenarios as censorshipScenarios } from './scenarios.mjs';
|
||||||
import { profileScenarios, resourceScenarios } from './scenarios-profile.mjs';
|
import { profileScenarios, resourceScenarios } from './scenarios-profile.mjs';
|
||||||
import { balanceScenarios } from './scenarios-balance.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)), '../..');
|
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_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 = [
|
const all = [
|
||||||
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
|
...censorshipScenarios.map(s => ({ node: 'CENSORSHIP', ...s })),
|
||||||
@@ -103,6 +114,7 @@ const all = [
|
|||||||
...balanceScenarios,
|
...balanceScenarios,
|
||||||
...shadowScenarios,
|
...shadowScenarios,
|
||||||
...profilesShadowScenarios,
|
...profilesShadowScenarios,
|
||||||
|
...forecastScenarios,
|
||||||
];
|
];
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|||||||
@@ -108,3 +108,60 @@ export const profilesShadowScenarios = [
|
|||||||
expect: { outOfScope: '', wroteProfiles: false },
|
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 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
134
tools/gen-solar-forecast-workflow.mjs
Normal file
134
tools/gen-solar-forecast-workflow.mjs
Normal file
@@ -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)`);
|
||||||
@@ -50,6 +50,17 @@ export const WORKFLOWS = [
|
|||||||
{ file: 'BUILD_HA_CALLS_PROFILES.js', node: 'BUILD_HA_CALLS_PROFILES', kind: 'jsCode' },
|
{ 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)
|
// Rétro-compatibilité (outils historiques mono-workflow)
|
||||||
|
|||||||
@@ -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 isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json';
|
||||||
const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json';
|
const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json';
|
||||||
const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-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 skipGolden = process.argv.includes('--skip-golden');
|
||||||
const testSuite =
|
const testSuite =
|
||||||
isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' :
|
isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' :
|
||||||
|
|||||||
244
workflows/evcs-solar-forecast-v1.json
Normal file
244
workflows/evcs-solar-forecast-v1.json
Normal file
@@ -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\": <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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user