feat: card presets move to a server-side store; the server owns the SCP80 counter (v3.8.0)

The presets lived in the browser's localStorage and the PWA wrote the SCP80
counter back after every operation, so an increment could be lost when the
response never reached the tab, when two browser contexts (or the demo origin
plus a local one) held separate copies, or when the tab was closed mid-run.
The counter is card state and the server performs every card operation, so the
store now lives next to the server:

- pysim_simple_server/presets.py: one JSON file
  (~/.pysim-simple-server/card_presets.json, --card-presets overrides; Path.home()
  resolves the same way on Windows), atomic writes (temp + fsync + os.replace),
  an RLock, stable uuid ids, the ICCID normalisation the PWA uses (digits /
  spaced / raw EF hex), a duplicate-ICCID refusal and a monotonic set_counter;
  every counter change is appended to card_presets.json.audit.jsonl.
- API: GET /api/presets (card-free), POST /api/presets and
  /api/presets/update|delete|import; the import also takes the old localStorage
  export shape - that is the migration path, there is no automatic migration.
- Operations name a preset_id and the server persists the counter they
  consumed: /api/send-ota (the plain SCP80 path reports final_cntr now too),
  the RAM install/install-app/compat steps (_ram_send_gp_apdu) and the test
  runner's SCP80 steps (_preset_counter_persist).
- PWA: the Cards tab is a thin client of the store (load on connect, CRUD via
  the API, export/import), the counter write-backs became local display syncs
  (the SCP80 form's counter input stays a deliberate human edit) and the
  SCP80/RAM bodies carry preset_id; a new pysimFetchOk() surfaces the server's
  error bodies instead of silently using them.

Policy: card keys in logs and local files are not a risk for this workbench
tool (AGENTS.md) - the store is not encrypted and no redaction is required.

Tests: tests/test_presets.py (store: ICCID forms, counter monotonicity and
wrap, atomic writes, corrupt-file recovery, import), the preset endpoints in
tests/test_fetch_routes.py (incl. the counter persistence via /api/send-ota)
and the updated PWA tests.  sw cache simple-v334.
This commit is contained in:
2026-09-30 13:59:47 +03:00
parent 212bdf6fa9
commit 795a1fea49
14 changed files with 1189 additions and 112 deletions
+9
View File
@@ -14,6 +14,7 @@ from pySim.cards import UiccCardBase
from .shell import load_pysim_app
from . import fastinit
from . import gsmtap
from . import presets
from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _netstate_read, _netstate_install, _LineFilter
@@ -63,6 +64,8 @@ def main():
parser.add_argument('--mcc-mnc-list', default=_default_mcc_mnc_list(), metavar='PATH',
help='Worldwide MCC/MNC operator list (JSON) for the network-simulation operator picker (default: the bundled data/mcc-mnc-list.json)')
parser.add_argument('--log-requests', action='store_true', default=False, help='Log request/response payloads to stderr')
parser.add_argument('--card-presets', default=None, metavar='PATH',
help='Card preset store (default: ~/.pysim-simple-server/card_presets.json)')
parser.add_argument('--sms-oa', default='12345', metavar='DIGITS',
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)')
parser.add_argument('--sms-sm-sc', default='12345678912', metavar='DIGITS',
@@ -278,6 +281,12 @@ def main():
server.cli_terminal_profile = opts.terminal_profile
server.web_dir = opts.web_dir
server.mcc_mnc_path = opts.mcc_mnc_list
# Card presets live server-side (the SCP80 counter is card state the server
# persists with the operation that consumed it); --card-presets overrides
# the default store in the user's home.
server.card_presets = presets.PresetStore(opts.card_presets)
sys.stderr.write('PRESETS: %s (%d presets)\n'
% (server.card_presets.path, len(server.card_presets.list())))
server.sim_menu = sim_menu
server.event_list = event_list
server.menu_active = False
+366
View File
@@ -0,0 +1,366 @@
"""Server-side card presets: SCP80/SCP81 key material, counters and per-card
parameters, kept in one JSON file.
The presets used to live in the browser's localStorage and the PWA wrote the
SCP80 counter back after every operation. The counter is card state (the card
rejects a packet whose counter is not above its own), so the server - which
performs every card operation and already computes the accepted counter - now
owns the store: an accepted counter is persisted in the same operation, and a
closed tab, a lost response or a second browser window can no longer lose an
increment.
Layout (``--card-presets`` overrides the file; ``Path.home()`` resolves the
same way on Linux, macOS and Windows)::
~/.pysim-simple-server/card_presets.json
~/.pysim-simple-server/card_presets.json.audit.jsonl (counter changes)
Writes are atomic (temp file + fsync + ``os.replace``) and every mutation
holds an ``RLock``, so request handlers and the operation threads can share one
store. Presets carry a stable uuid - never array indices. The JSON is
deliberately human-readable: hand-editing a counter or moving the file between
machines is part of the workbench workflow, and the Cards tab's export/import
uses the same shape (which is also how old localStorage presets move over -
there is no automatic migration).
"""
import json
import os
import re
import sys
import tempfile
import threading
import time
import uuid
from pathlib import Path
DEFAULT_DIR = '.pysim-simple-server'
DEFAULT_FILENAME = 'card_presets.json'
SCHEMA_VERSION = 1
# The fields a preset carries: the localStorage shape plus the id. Defaults
# mirror the Cards form (spi1/spi2/counter and the per-target TARs).
PRESET_FIELDS = ('name', 'iccid', 'adm', 'kic', 'kid', 'spi1', 'spi2',
'tar', 'uiccTar', 'usimTar', 'cntr', 'kicKey', 'kidKey',
'pskIdentity', 'pskKey')
HEX_FIELDS = ('adm', 'kic', 'kid', 'spi1', 'spi2', 'tar', 'uiccTar', 'usimTar',
'cntr', 'kicKey', 'kidKey', 'pskKey')
TAR_DEFAULTS = {'tar': '000000', 'uiccTar': 'B00000', 'usimTar': 'B00001'}
COUNTER_BITS = 40 # the SCP80 counter is 5 bytes (TS 31.115)
COUNTER_WIDTH = 10 # ... shown as 10 hex digits
def default_path():
"""The default store: ``~/.pysim-simple-server/card_presets.json``."""
return Path.home() / DEFAULT_DIR / DEFAULT_FILENAME
def _swap_nibbles(s):
"""Nibble-swap a hex string; an odd trailing nibble becomes 'F' (the JS
``swapNibbles`` the PWA uses)."""
out = []
for i in range(0, len(s), 2):
out.append(s[i + 1] if i + 1 < len(s) else 'F')
out.append(s[i])
return ''.join(out).upper()
def normalize_iccid(value):
"""ICCID -> plain digits, accepting every stored form (the PWA's
``cardsNormIccid``): digits, spaced digits, or the raw nibble-swapped EF
hex - the swap applies only when the value carries A-F, because a raw
value without hex letters is indistinguishable from digits. Empty string
means "no ICCID"."""
s = re.sub(r'[\s-]', '', str(value or '')).upper()
if not s:
return ''
if re.search(r'[A-F]', s):
s = _swap_nibbles(re.sub(r'[^0-9A-F]', '', s))
s = re.sub(r'F+$', '', s)
return re.sub(r'^0+', '', re.sub(r'\D', '', s))
def counter_value(cntr):
"""The counter as an int, or None when it is not hex."""
try:
return int(re.sub(r'[^0-9A-Fa-f]', '', str(cntr or '')), 16)
except ValueError:
return None
def counter_hex(value):
"""The fixed-width 10-hex-digit counter form the card protocol uses."""
v = counter_value(value)
if v is None:
return str(value or '').strip().upper()
return '%0*X' % (COUNTER_WIDTH, v % (1 << COUNTER_BITS))
def counter_ahead(old, new):
"""True when ``new`` is a plausible forward step from ``old``: the counter
only moves forward, and a value that wrapped around the 40-bit field
(top of the range -> small value) counts as forward."""
o, n = counter_value(old), counter_value(new)
if o is None or n is None:
return False
if n > o:
return True
return (o - n) > (1 << (COUNTER_BITS - 1))
class PresetError(Exception):
"""A refused mutation (validation, duplicate ICCID, ...)."""
class PresetStore:
"""The card preset store. All public methods are thread-safe and return
copies - callers can never mutate the store's own state."""
def __init__(self, path=None):
self.path = Path(path) if path else default_path()
self.audit_path = self.path.with_name(self.path.name + '.audit.jsonl')
self._lock = threading.RLock()
self._presets = []
self._load()
# ------------------------------------------------------------------ disk
def _load(self):
try:
raw = self.path.read_text(encoding='utf-8')
except FileNotFoundError:
self._presets = []
return
except OSError as e:
sys.stderr.write('PRESETS: cannot read %s: %s\n' % (self.path, e))
self._presets = []
return
try:
data = json.loads(raw)
except ValueError as e:
# Never silently overwrite an unreadable store: keep the file for
# the user, start empty (the next save would destroy it).
bad = self.path.with_name(self.path.name + '.bad-%s'
% time.strftime('%Y%m%d-%H%M%S'))
try:
self.path.replace(bad)
sys.stderr.write('PRESETS: %s is not valid JSON (%s) - kept as %s, '
'starting empty\n' % (self.path, e, bad))
except OSError:
sys.stderr.write('PRESETS: %s is not valid JSON (%s)\n' % (self.path, e))
self._presets = []
return
items = data.get('presets') if isinstance(data, dict) else None
if not isinstance(items, list):
items = []
self._presets = [self._normalise(p) for p in items if isinstance(p, dict)]
def _save(self):
data = {'version': SCHEMA_VERSION, 'presets': self._presets}
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_fd, tmp_name = tempfile.mkstemp(prefix=self.path.name + '.',
dir=str(self.path.parent))
try:
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.write('\n')
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_name, self.path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
try:
# Make the rename durable. Best effort: opening a directory is not
# portable (Windows) - a failure here never fails the mutation.
dir_fd = os.open(str(self.path.parent), os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
def _audit(self, pid, name, old, new, source):
"""One JSONL line per counter change: the forensic trail for "did an
increment get lost?" questions."""
try:
line = json.dumps({'ts': time.strftime('%Y-%m-%dT%H:%M:%S'),
'id': pid, 'name': name, 'old': old, 'new': new,
'source': source}, ensure_ascii=False)
self.path.parent.mkdir(parents=True, exist_ok=True)
with open(self.audit_path, 'a', encoding='utf-8') as fh:
fh.write(line + '\n')
except OSError as e:
sys.stderr.write('PRESETS: audit write failed: %s\n' % e)
# ------------------------------------------------------------- shaping
def _normalise(self, fields):
"""The stored shape: the known fields, cleaned; unknown keys dropped."""
out = {}
for k in PRESET_FIELDS:
v = fields.get(k)
v = '' if v is None else str(v).strip()
if k in HEX_FIELDS:
v = re.sub(r'\s', '', v).upper()
out[k] = v
out['spi1'] = out['spi1'] or '16'
out['spi2'] = out['spi2'] or '01'
out['cntr'] = counter_hex(out['cntr'] or '1')
for k, d in TAR_DEFAULTS.items():
out[k] = out[k] or d
pid = str(fields.get('id') or '').strip().lower()
out['id'] = pid or uuid.uuid4().hex
return out
def _validate(self, p, skip_id=None):
if not p['name']:
raise PresetError('name is required')
for key in ('kic', 'kid', 'kicKey', 'kidKey'):
if not p[key]:
raise PresetError('%s is required' % key)
if bool(p['pskIdentity']) != bool(p['pskKey']):
raise PresetError('PSK identity and PSK key must be set together')
if p['pskKey'] and not re.fullmatch(r'[0-9A-F]{32}', p['pskKey']):
raise PresetError('PSK key must be 32 hex characters')
norm = normalize_iccid(p['iccid'])
if norm:
for other in self._presets:
if other['id'] == skip_id:
continue
if normalize_iccid(other['iccid']) == norm:
raise PresetError('card with this ICCID already exists: %s'
% (other['name'] or other['id']))
return p
def _find(self, pid):
want = str(pid or '').strip().lower()
if not want:
return None
for p in self._presets:
if p['id'] == want:
return p
return None
# -------------------------------------------------------------- public
def list(self):
with self._lock:
return [dict(p) for p in self._presets]
def get(self, pid):
with self._lock:
p = self._find(pid)
return dict(p) if p else None
def find_by_iccid(self, iccid):
norm = normalize_iccid(iccid)
if not norm:
return None
with self._lock:
for p in self._presets:
if normalize_iccid(p['iccid']) == norm:
return dict(p)
return None
def add(self, fields):
with self._lock:
p = self._normalise(fields or {})
self._validate(p)
self._presets.append(p)
self._save()
sys.stderr.write('PRESETS: added %s (%s)\n' % (p['name'], p['id']))
return dict(p)
def update(self, pid, fields):
"""Partial or full update (the human edit path): the counter is written
as given - the Cards tab is the place to resync it deliberately. The
counter an *operation* accepted goes through set_counter()."""
with self._lock:
cur = self._find(pid)
if cur is None:
return None
merged = dict(cur)
for k in PRESET_FIELDS:
if isinstance(fields, dict) and k in fields:
merged[k] = fields[k]
new = self._normalise(merged)
new['id'] = cur['id']
self._validate(new, skip_id=cur['id'])
old_cntr = cur['cntr']
self._presets[self._presets.index(cur)] = new
self._save()
if new['cntr'] != old_cntr:
self._audit(new['id'], new['name'], old_cntr, new['cntr'], 'edit')
sys.stderr.write('PRESETS: counter %s %s -> %s (edit)\n'
% (new['name'], old_cntr, new['cntr']))
return dict(new)
def remove(self, pid):
with self._lock:
p = self._find(pid)
if p is None:
return False
self._presets.remove(p)
self._save()
sys.stderr.write('PRESETS: removed %s (%s)\n' % (p['name'], p['id']))
return True
def set_counter(self, pid, cntr, source='operation'):
"""Persist the counter an operation accepted. Monotonic: the counter
only moves forward, so a stale caller can never regress it. Returns
the stored preset (or None when the id is unknown)."""
with self._lock:
p = self._find(pid)
if p is None:
return None
new = counter_hex(cntr)
if new == p['cntr']:
return dict(p)
if not counter_ahead(p['cntr'], new):
sys.stderr.write('PRESETS: ignoring backwards counter %s -> %s (%s)\n'
% (p['cntr'], new, source))
return dict(p)
old = p['cntr']
p['cntr'] = new
self._save()
self._audit(p['id'], p['name'], old, new, source)
sys.stderr.write('PRESETS: counter %s %s -> %s (%s)\n'
% (p['name'], old, new, source))
return dict(p)
def import_presets(self, items, mode='merge'):
"""Import exported (or old localStorage) presets: entries are validated
one by one; a duplicate ICCID or an already-used id is skipped, invalid
entries are reported instead of failing the whole import. ``replace``
wipes the store first."""
added, skipped, errors = 0, 0, []
with self._lock:
if mode == 'replace':
self._presets = []
for i, entry in enumerate(items or []):
if not isinstance(entry, dict):
skipped += 1
continue
try:
p = self._normalise(entry)
if self._find(p['id']):
p['id'] = uuid.uuid4().hex
self._validate(p)
except PresetError as e:
skipped += 1
errors.append('%s: %s' % (entry.get('name') or ('#' + str(i + 1)), e))
continue
self._presets.append(p)
added += 1
if added or mode == 'replace':
self._save()
sys.stderr.write('PRESETS: import %s: %d added, %d skipped\n'
% (mode, added, skipped))
return {'added': added, 'skipped': skipped, 'errors': errors[:10]}
def info(self):
with self._lock:
return {'path': str(self.path), 'count': len(self._presets),
'version': SCHEMA_VERSION}
+132 -11
View File
@@ -21,6 +21,7 @@ from pysim_simple_server import scp81
from pysim_simple_server import esim
from pysim_simple_server import capmem
from pysim_simple_server import testscript
from pysim_simple_server import presets
from smartcard.CardMonitoring import CardMonitor, CardObserver
from cmd2.exceptions import CommandSetRegistrationError
@@ -31,7 +32,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.7.1'
VERSION = '3.8.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1411,6 +1412,24 @@ def _ram_next_cntr(cntr, advance):
return '%010X' % ((int(cntr, 16) + 1) % (2 ** 40))
def _preset_counter_persist(server, preset_id, cntr, source):
"""Persist the counter an operation accepted into the server-side preset
store (v3.8.0). The counter is card state, so the server writes it with
the operation that consumed it - a closed tab, a lost response or a second
browser window can no longer lose an increment. The store itself is
monotonic (a stale value never regresses it). Best effort: a store
failure must never fail the card operation."""
if not preset_id or not cntr:
return
store = getattr(server, 'card_presets', None)
if store is None:
return
try:
store.set_counter(preset_id, cntr, source)
except Exception as e:
sys.stderr.write('PRESETS: counter persist failed (%s): %s\n' % (source, e))
# RAM command formats (TS 102 226 5.2.1): the bare C-APDU (compact) or the
# Command TLV '22' inside the 'AA' scripting template (expanded) - the form
# the reference terminal traces use. The format is detected per operation
@@ -1448,7 +1467,7 @@ def _ram_detect_format(server, scc, sp, state):
for fmt, apdu in (('compact', _RAM_PROBE_APDU),
('expanded', _wrap_expanded_apdu(_RAM_PROBE_APDU))):
scratch = {'steps': [], 'encode_error': None, 'failure': {},
'cntr': state['cntr']}
'cntr': state['cntr'], 'preset_id': state.get('preset_id')}
ok = _ram_send_gp_apdu(server, scc, sp, scratch,
'FORMAT CHECK (%s)' % fmt, apdu)
state['steps'].extend(scratch['steps'])
@@ -1605,6 +1624,11 @@ def _ram_send_gp_apdu(server, scc, sp, state, step_name, apdu_hex, silent=False)
# Advance the counter only for an accepted packet
state['cntr'] = _ram_next_cntr(
state['cntr'], step.get('por_status') in ('por_ok', 'no_por'))
# ... and persist it right away: the server owns the counter now, so a
# crash mid-install cannot leave the preset behind the card (v3.8.0).
if step.get('por_status') in ('por_ok', 'no_por'):
_preset_counter_persist(server, state.get('preset_id'),
state['cntr'], 'ram-%s' % step_name.split()[0].lower())
if step_error:
if not silent:
state['failure']['error'] = '%s: %s' % (step_name, step_error)
@@ -5105,7 +5129,7 @@ _CARD_FREE_GET = frozenset([
'/api/stk-status', '/api/poll-status', '/api/proactive-log',
'/api/menu', '/api/events', '/api/terminal-profile',
'/api/pli-qualifiers', '/api/pli-dict', '/api/commands',
'/api/net-state', '/api/mcc-mnc',
'/api/net-state', '/api/mcc-mnc', '/api/presets',
'/api/scp81/status', '/api/bip/status', '/api/scp81/script',
'/api/scp81/log', '/api/bip/log',
])
@@ -5296,8 +5320,12 @@ def _test_run_scp80(server, step, ctx):
source, tar or '-', spi1, spi2, counter)
if sw:
# The card answered, so the SCP80 counter was consumed: advance the
# working value (the PWA writes the final one back to the preset).
# working value and persist it into the server-side preset (v3.8.0 -
# the server owns the counter, so the run survives a lost response and
# the PWA's counter write-back dance is gone).
ctx['counter'] = _increment_counter_hex(counter)
_preset_counter_persist(server, (preset or {}).get('id'),
ctx['counter'], 'test-run')
return data, sw, sent, por, counter
@@ -5595,6 +5623,17 @@ class PysimHandler(BaseHTTPRequestHandler):
length = int(self.headers.get('Content-Length', 0))
return json.loads(self.rfile.read(length))
def _preset_store_or_503(self):
"""The card preset store, or None after answering 503 (a server built
without one - e.g. a test harness - must not crash the endpoints)."""
store = getattr(self.server, 'card_presets', None)
if store is None:
resp = {'error': 'preset store not initialized'}
self._send_json(resp, 503)
self._log_resp(resp)
return None
return store
def _log_req(self, body=None):
if self.server.log_requests:
if body is not None:
@@ -5958,6 +5997,15 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = _scp81_script_state()
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/presets':
self._log_req()
store = self._preset_store_or_503()
if store is None:
return
resp = store.info()
resp['presets'] = store.list()
self._send_json(resp)
self._log_resp(resp)
elif self.path.startswith('/api/'):
self._send_json({'error': _err('not_found', lang)}, 404)
self._log_resp({'error': _err('not_found', lang)})
@@ -6688,7 +6736,8 @@ class PysimHandler(BaseHTTPRequestHandler):
ram_format = _ram_normalize_format(body.get('ram_format'))
if ram_format == 'auto':
probe_state = {'steps': [], 'encode_error': None,
'failure': {}, 'cntr': cntr}
'failure': {}, 'cntr': cntr,
'preset_id': body.get('preset_id')}
sp_probe = {'spi1': spi1, 'spi2': spi2, 'kic': kic,
'kid': kid, 'tar': tar, 'kic_key': kic_key,
'kid_key': kid_key,
@@ -6701,6 +6750,7 @@ class PysimHandler(BaseHTTPRequestHandler):
else:
# Regular SCP80: use pre-built secured packet
sp_hex = sp
cntr = body.get('cntr', '')
spi2_val = int(body.get('spi2', '00'), 16)
# Capture the PoR from either transport: inline in the
# ENVELOPE response or as a proactive SEND SHORT MESSAGE (some
@@ -6797,14 +6847,18 @@ class PysimHandler(BaseHTTPRequestHandler):
sys.stderr.write('OTA PoR[%s]: undecodable raw=%s\n' % (por_src, str(por_hex)))
else:
sys.stderr.write('OTA PoR[%s]: none\n' % por_src)
accepted = ((por is None) or por.get('response_status') in
('por_ok', 'actual_response_sms_submit'))
if ram_format is not None:
accepted = ((por is None) or por.get('response_status') in
('por_ok', 'actual_response_sms_submit'))
resp['ram_format'] = ram_format
resp['final_cntr'] = _ram_next_cntr(cntr, accepted)
# The counter to use next, reported for the RAM and the
# plain SCP80 path alike; the server persists it (v3.8.0).
resp['final_cntr'] = _ram_next_cntr(cntr, accepted)
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
_preset_counter_persist(self.server, body.get('preset_id'),
resp.get('final_cntr'), 'send-ota')
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
@@ -6896,7 +6950,7 @@ class PysimHandler(BaseHTTPRequestHandler):
steps = []
state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr}
'cntr': cntr, 'preset_id': body.get('preset_id')}
sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)}
@@ -7030,7 +7084,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'kic_key': body.get('kicKey', ''), 'kid_key': body.get('kidKey', ''),
'include_cpi': body.get('includeCpi', True)}
state = {'steps': [], 'encode_error': None, 'failure': {},
'cntr': sp_state['cntr']}
'cntr': sp_state['cntr'], 'preset_id': body.get('preset_id')}
# Live progress for the PWA's operation modal (see /api/ram-install)
_ram_progress_begin('install-app', 2)
try:
@@ -7136,7 +7190,7 @@ class PysimHandler(BaseHTTPRequestHandler):
kid_key = body.get('kidKey', '')
steps = []
state = {'steps': steps, 'encode_error': None, 'failure': {},
'cntr': cntr}
'cntr': cntr, 'preset_id': body.get('preset_id')}
sp_state = {'spi1': spi1, 'spi2': spi2, 'kic': kic, 'kid': kid,
'tar': tar, 'kic_key': kic_key, 'kid_key': kid_key,
'include_cpi': body.get('includeCpi', True)}
@@ -7317,6 +7371,73 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = _test_state_snapshot()
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/presets':
body = self._read_body()
self._log_req(body)
store = self._preset_store_or_503()
if store is None:
return
try:
preset = store.add(body)
except presets.PresetError as e:
resp = {'error': str(e)}
self._send_json(resp, 400)
self._log_resp(resp)
return
resp = {'ok': True, 'preset': preset}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/presets/update':
body = self._read_body()
self._log_req(body)
store = self._preset_store_or_503()
if store is None:
return
fields = body.get('fields')
if not isinstance(fields, dict):
fields = {k: v for k, v in body.items() if k not in ('id', 'fields')}
try:
preset = store.update(body.get('id'), fields)
except presets.PresetError as e:
resp = {'error': str(e)}
self._send_json(resp, 400)
self._log_resp(resp)
return
if preset is None:
resp = {'error': 'unknown preset id'}
self._send_json(resp, 404)
self._log_resp(resp)
return
resp = {'ok': True, 'preset': preset}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/presets/delete':
body = self._read_body()
self._log_req(body)
store = self._preset_store_or_503()
if store is None:
return
removed = store.remove(body.get('id'))
resp = {'ok': True, 'removed': removed}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/presets/import':
body = self._read_body()
self._log_req(body)
store = self._preset_store_or_503()
if store is None:
return
items = body.get('presets')
if not isinstance(items, list) or len(items) > 1000:
resp = {'error': 'presets must be a list of at most 1000 entries'}
self._send_json(resp, 400)
self._log_resp(resp)
return
mode = body.get('mode') if body.get('mode') in ('merge', 'replace') else 'merge'
resp = store.import_presets(items, mode)
resp['ok'] = True
self._send_json(resp)
self._log_resp(resp)
else:
self._send_json({'error': _err('not_found', lang)}, 404)
self._log_resp({'error': _err('not_found', lang)})