feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s
Tests / test (push) Successful in 1m1s
Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
This commit is contained in:
@@ -26,13 +26,13 @@ export function createPlannerV4Bridge({configuredPrognosisPlant,roleAllowed,body
|
||||
if(method!=='GET'&&!roleAllowed(access.session,['owner','admin','operator'])){json(res,403,{error:'Keine Aenderungsberechtigung.'});return true;}
|
||||
await forward(req,res,access.plant.installation_id,action?`/${action}`:'',method);return true;
|
||||
}
|
||||
const d=url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/prognosis\/planner-v4(?:\/(operation|ack|measurements))?$/i);
|
||||
const d=url.pathname.match(/^\/api\/v1\/installations\/([0-9a-f-]{36})\/prognosis\/planner-v4(?:\/(operation|ack|measurements|setup|workflow))?$/i);
|
||||
if(d){
|
||||
const action=d[2]||'';if(req.method!==(action?'POST':'GET')){json(res,405,{error:'Methode nicht erlaubt.'});return true;}
|
||||
if(!checkDeviceRate(req,d[1],'planner-v4')){json(res,429,{error:'Zu viele Anfragen.'});return true;}
|
||||
const activation=deviceActivation(req,res,d[1]);if(!activation)return true;
|
||||
if(Number(ownedLicenseState(activation.plant_id).quantities.grid_schedule||0)<1){json(res,403,{error:'Netzfahrplan nicht lizenziert.'});return true;}
|
||||
await forward(req,res,d[1],action==='operation'?'/inputs/operation':action==='measurements'?'/measurements':action?'/ack':'',req.method);return true;
|
||||
await forward(req,res,d[1],action==='operation'?'/inputs/operation':action?`/${action}`:'',req.method);return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -4,10 +4,12 @@ Legacy UTC-naive forecast indices are explicitly interpreted as UTC here.
|
||||
"""
|
||||
from datetime import datetime,timezone
|
||||
from hashlib import sha256
|
||||
from uuid import uuid4
|
||||
from uuid import UUID,uuid4
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request,urlopen
|
||||
import json,logging,math,os
|
||||
import json,logging,math,os,time
|
||||
|
||||
_enrollment_cache = {'key': None, 'checked': 0., 'plants': set()}
|
||||
|
||||
def timestamp(value):
|
||||
if hasattr(value,'to_pydatetime'):value=value.to_pydatetime()
|
||||
@@ -71,13 +73,42 @@ def ckw_payload(label,rows,publication_timestamp=None,at=None):
|
||||
result['periods']=periods
|
||||
return result
|
||||
|
||||
def enabled(plant):return bool(os.getenv('NETPLAN_V4_URL') and plant in {p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()})
|
||||
def planning_plants():
|
||||
"""Discover enrolled planning tenants; this registry has no actuator authority."""
|
||||
base=os.getenv('NETPLAN_V4_URL','').rstrip('/');token=os.getenv('PROGNOSIS_SERVICE_TOKEN','')
|
||||
if not base:return set()
|
||||
explicit={p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()}
|
||||
if not token:return explicit
|
||||
url=urlparse(base)
|
||||
if url.scheme not in ('http','https') or url.username or url.password:return explicit
|
||||
key=(base,token);now=time.monotonic()
|
||||
if _enrollment_cache['key']==key and now-_enrollment_cache['checked']<60:
|
||||
return explicit|_enrollment_cache['plants']
|
||||
plants=set()
|
||||
try:
|
||||
req=Request(base+'/internal/v2/planner/installations',headers={'X-Enelix-Service-Token':token})
|
||||
with urlopen(req,timeout=5) as response:
|
||||
raw=response.read(500001)
|
||||
if len(raw)>500000:raise ValueError('Oversized enrollment response')
|
||||
data=json.loads(raw)
|
||||
values=data['installationIds']
|
||||
if not isinstance(values,list) or len(values)>10000 or data.get('controlEnabled') is not False:
|
||||
raise ValueError('Invalid planning enrollment response')
|
||||
if any(not isinstance(p,str) or str(UUID(p))!=p for p in values):
|
||||
raise ValueError('Invalid planning installation ID')
|
||||
plants=set(values)
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).warning('V4 planning enrollment unavailable: %s',type(exc).__name__)
|
||||
_enrollment_cache.update(key=key,checked=now,plants=plants)
|
||||
return explicit|plants
|
||||
|
||||
def enabled(plant):return plant in planning_plants()
|
||||
|
||||
def send(plant,kind,payload):
|
||||
if not enabled(plant):return {'status':'disabled'}
|
||||
token=os.getenv('PROGNOSIS_SERVICE_TOKEN','');base=os.environ['NETPLAN_V4_URL'].rstrip('/');url=urlparse(base)
|
||||
if not token or url.scheme not in ('http','https') or url.username or url.password:raise ValueError('Private V4 transport not configured')
|
||||
UUID=__import__('uuid').UUID;UUID(plant)
|
||||
if str(UUID(plant))!=plant:raise ValueError('Canonical installation UUID required')
|
||||
if kind not in ('forecast','tariffs','prices'):raise ValueError('Unsupported publisher input kind')
|
||||
req=Request(base+'/internal/v2/prognosis/'+plant+'/planner/inputs/'+kind,
|
||||
json.dumps(payload,allow_nan=False).encode(),{'Content-Type':'application/json','X-Enelix-Service-Token':token},method='POST')
|
||||
@@ -99,7 +130,7 @@ def publish_tariffs(plant,config):
|
||||
except Exception as exc:logging.getLogger(__name__).warning('V4 shadow tariffs for %s: %s',plant,type(exc).__name__)
|
||||
|
||||
def publish_ckw(label,rows,publication_timestamp=None,tariff_type='integrated'):
|
||||
plants=[p.strip() for p in os.getenv('NETPLAN_V4_PLANTS','').split(',') if p.strip()]
|
||||
plants=sorted(planning_plants())
|
||||
if not os.getenv('NETPLAN_V4_URL') or not plants:return
|
||||
try:
|
||||
if tariff_type!='integrated':raise ValueError('Full integrated tariff required')
|
||||
|
||||
Reference in New Issue
Block a user