163 lines
8.4 KiB
Python
163 lines
8.4 KiB
Python
"""Explicit, time-limited commissioning authority; NEVER enables shadow execution.
|
|
|
|
Only a reviewed internal operator call can arm a trial, and only for a separate
|
|
allowlist (empty by default). The manager and battery must additionally consent
|
|
locally. Existing shadow plans/acknowledgements keep their original meaning.
|
|
"""
|
|
from copy import deepcopy
|
|
from datetime import datetime, timedelta, timezone
|
|
from uuid import UUID
|
|
import json
|
|
|
|
MAX_SECONDS = 1800
|
|
MAX_POWER_W = 5000
|
|
AUTHORITY_TTL_SECONDS = 90
|
|
|
|
|
|
def schema(con):
|
|
con.execute('''CREATE TABLE IF NOT EXISTS planner_controlled_trials(
|
|
plant TEXT PRIMARY KEY, session_id TEXT NOT NULL UNIQUE,
|
|
value TEXT NOT NULL, revoked_at TEXT)''')
|
|
|
|
|
|
def timestamp(v):
|
|
if not isinstance(v, str):
|
|
raise ValueError('Explicit timestamp required')
|
|
t = datetime.fromisoformat(v.replace('Z', '+00:00'))
|
|
if t.tzinfo is None:
|
|
raise ValueError('Timezone required')
|
|
return t.astimezone(timezone.utc)
|
|
|
|
|
|
def uuid(v):
|
|
if not isinstance(v, str) or str(UUID(v)) != v:
|
|
raise ValueError('Canonical UUID required')
|
|
return v
|
|
|
|
|
|
def power(v):
|
|
if type(v) not in (int, float) or not 0 < v <= MAX_POWER_W:
|
|
raise ValueError('Pilot limit must be explicit and at most 5000 W')
|
|
return float(v)
|
|
|
|
|
|
def accounting(plan):
|
|
# A human checkbox alone must not relabel aggregate house consumption.
|
|
quality = plan.get('inputQuality', {})
|
|
if quality.get('loadBasis') != 'base_load':
|
|
raise ValueError('Verified base-load/SDL adapter required before a control trial')
|
|
evidence = quality.get('accountingEvidenceId')
|
|
if not isinstance(evidence, str) or not 8 <= len(evidence) <= 160:
|
|
raise ValueError('Plan lacks traceable base-load accounting evidence')
|
|
return evidence
|
|
|
|
|
|
def eligibility(view, plant):
|
|
if view.get('installationId') != plant or view.get('liveEnabled') is not False:
|
|
raise ValueError('Wrong plant or service mode')
|
|
p = view.get('plan')
|
|
if view.get('fresh') is not True or not isinstance(p, dict):
|
|
raise ValueError('Fresh independently validated planning input required')
|
|
if (p.get('installationId') != plant or p.get('runMode') != 'shadow'
|
|
or p.get('liveEnabled') is not False or p.get('executable') is not True):
|
|
raise ValueError('Wrong source plan identity or mode')
|
|
settings = view.get('settings', {})
|
|
if settings.get('family') == 'auto' or p.get('sourceFamily') != settings.get('family'):
|
|
raise ValueError('First controlled trial requires a fixed model family')
|
|
if p.get('configRevision') != settings.get('revision'):
|
|
raise ValueError('Configuration revision changed')
|
|
if len(p.get('controlContext', {}).get('batteries', {})) != 1:
|
|
raise ValueError('First controlled trial supports exactly one battery')
|
|
accounting(p)
|
|
return p
|
|
|
|
|
|
def arm(store, plant, request, view, now, allowed_plants):
|
|
"""Caller is authenticated internal operator; not exposed via device proxy."""
|
|
if plant not in allowed_plants:
|
|
raise ValueError('Controlled trial disabled by server allowlist')
|
|
fields = {'sessionId', 'expectedPlanId', 'expectedRevision', 'durationSeconds',
|
|
'maxChargeW', 'maxDischargeW', 'assetId', 'managerId', 'batteryInstanceId',
|
|
'acceptEstimatedPeak', 'actuatorWatchdogEvidenceId', 'confirmation'}
|
|
if set(request) != fields or request['confirmation'] != 'ARM_BOUNDED_CONTROL_TRIAL':
|
|
raise ValueError('Explicit reviewed commissioning request required')
|
|
session = uuid(request['sessionId']); p = eligibility(view, plant)
|
|
if type(request['expectedRevision']) is not int or request['expectedRevision'] < 0:
|
|
raise ValueError('Expected revision must be an integer')
|
|
if request['expectedPlanId'] != p['planId'] or request['expectedRevision'] != p['configRevision']:
|
|
raise ValueError('Source plan/revision changed; review again')
|
|
seconds = request['durationSeconds']
|
|
if type(seconds) is not int or not 30 <= seconds <= MAX_SECONDS:
|
|
raise ValueError('Trial duration must be 30..1800 seconds')
|
|
for k in ('managerId', 'batteryInstanceId'):
|
|
if type(request[k]) is not int or not 1 <= request[k] <= 99999:
|
|
raise ValueError('Explicit local instance binding required')
|
|
if request['managerId'] == request['batteryInstanceId']:
|
|
raise ValueError('Manager and battery instance must differ')
|
|
if request['assetId'] not in p['controlContext']['batteries']:
|
|
raise ValueError('Wrong trial battery')
|
|
if type(request['acceptEstimatedPeak']) is not bool:
|
|
raise ValueError('Explicit estimated-peak policy required')
|
|
if p.get('peakCostIsEstimate') and not request['acceptEstimatedPeak']:
|
|
raise ValueError('Estimated peak has not been accepted for this trial')
|
|
evidence = request['actuatorWatchdogEvidenceId']
|
|
if not isinstance(evidence, str) or not 8 <= len(evidence) <= 160:
|
|
raise ValueError('Device-side command-loss watchdog proof required')
|
|
value = {'kind': 'controlled_trial_grant', 'version': 1, 'sessionId': session,
|
|
'installationId': plant, 'issuedAt': now.isoformat(),
|
|
'expiresAt': (now + timedelta(seconds=seconds)).isoformat(),
|
|
'revision': p['configRevision'], 'family': p['sourceFamily'],
|
|
'assetId': request['assetId'], 'managerId': request['managerId'],
|
|
'batteryInstanceId': request['batteryInstanceId'],
|
|
'maxChargeW': power(request['maxChargeW']),
|
|
'maxDischargeW': power(request['maxDischargeW']),
|
|
'acceptEstimatedPeak': request['acceptEstimatedPeak'],
|
|
'accountingEvidenceId': accounting(p),
|
|
'actuatorWatchdogEvidenceId': evidence,
|
|
'controlContext': deepcopy(p['controlContext'])}
|
|
with store.con:
|
|
existing = store.con.execute('SELECT value,revoked_at FROM planner_controlled_trials WHERE plant=?', (plant,)).fetchone()
|
|
if existing and existing[1] is None and timestamp(json.loads(existing[0])['expiresAt']) > now:
|
|
raise ValueError('Existing trial must first end; implicit extension forbidden')
|
|
# Reusing an expired/revoked session ID must never resurrect it.
|
|
used = store.con.execute("SELECT 1 FROM planner_audit WHERE plant=? AND kind='trial_arm' AND detail=?", (plant, session)).fetchone()
|
|
if used:
|
|
raise ValueError('Session ID already used')
|
|
store.con.execute('INSERT INTO planner_controlled_trials VALUES(?,?,?,NULL) ON CONFLICT(plant) DO UPDATE SET session_id=excluded.session_id,value=excluded.value,revoked_at=NULL',
|
|
(plant, session, json.dumps(value, sort_keys=True, allow_nan=False)))
|
|
store.con.execute("INSERT INTO planner_audit(plant,at,kind,detail) VALUES(?,?,'trial_arm',?)", (plant, now.isoformat(), session))
|
|
return value
|
|
|
|
|
|
def revoke(store, plant, session, now):
|
|
uuid(session)
|
|
with store.con:
|
|
store.con.execute('UPDATE planner_controlled_trials SET revoked_at=? WHERE plant=? AND session_id=?', (now.isoformat(), plant, session))
|
|
return {'status': 'revoked', 'sessionId': session, 'remoteRevocationMaxSeconds': AUTHORITY_TTL_SECONDS}
|
|
|
|
|
|
def authority(store, plant, view, now, allowed_plants):
|
|
"""Separate authorization envelope, not a mutation of the shadow plan."""
|
|
if plant not in allowed_plants:
|
|
return None
|
|
row = store.con.execute('SELECT value,revoked_at FROM planner_controlled_trials WHERE plant=?', (plant,)).fetchone()
|
|
if not row or row[1] is not None:
|
|
return None
|
|
grant = json.loads(row[0])
|
|
try:
|
|
p = eligibility(view, plant)
|
|
if not timestamp(grant['issuedAt']) <= now < timestamp(grant['expiresAt']):
|
|
return None
|
|
if (p['configRevision'] != grant['revision'] or p['sourceFamily'] != grant['family']
|
|
or p['controlContext'] != grant['controlContext']
|
|
or accounting(p) != grant['accountingEvidenceId']
|
|
or p.get('peakCostIsEstimate') and not grant['acceptEstimatedPeak']):
|
|
return None
|
|
except (ValueError, KeyError, TypeError):
|
|
return None
|
|
until = min(timestamp(grant['expiresAt']), timestamp(p['validUntil']),
|
|
now + timedelta(seconds=AUTHORITY_TTL_SECONDS))
|
|
return {**grant, 'kind': 'controlled_trial_authority',
|
|
'sourceShadowPlanId': p['planId'], 'checkedAt': now.isoformat(),
|
|
'validUntil': until.isoformat(), 'sourcePlanRemainsShadow': True}
|