scp81: RAM install over HTTP OTA (v2.1.8)

- shared _cap_apdu_sequence helper (INSTALL [for load] -> 240-byte LOAD
  blocks -> INSTALL [for install]); the SCP80 /api/ram-install path now uses
  it too (one source of truth; byte-level tests pin the APDUs)
- POST /api/scp81/ram-install: parses the .cap server-side and queues the
  APDU sequence as the SCP81 command script (one C-APDU per POST, runs on the
  card's next push); refused while a script is mid-run unless force
- /api/scp81/script reports the script kind (explore/none/custom/ram-install)
- tab: RAM install row (CAP file + SD AID + Queue button); RU strings
- docs: api.md, findings, AGENTS; service worker v145
This commit is contained in:
2026-09-16 02:05:20 +03:00
parent e255677dd9
commit 3463a68292
8 changed files with 257 additions and 47 deletions
+18
View File
@@ -496,6 +496,24 @@ Returns the BIP/TLS event log (open/close, SEND/RECEIVE DATA hex, TLS
handshake and HTTP request/response records). `?after=<seq>` returns only
newer entries; `seq` echoes the latest sequence number.
### `POST /api/scp81/ram-install`
Queue a RAM (GP) install as the SCP81 command script. The `.cap` is parsed
server-side (same parser as `/api/ram-install`) and expanded to the APDU
sequence INSTALL [for load] -> LOAD blocks (240-byte payloads) -> INSTALL
[for install]; the list runs on the card's next POST, one C-APDU per request.
```json
{"cap_hex": "504B0304...", "sd_aid": "A000000003000000", "privileges": "00",
"install_params": "", "stk_params": "", "make_selectable": true, "force": false}
```
`sd_aid` empty = the ISD. Refused while a script is mid-run unless `force` is
true. Responds with `{"ok": true, "queued": true, "apdus": N, "load_file_aid":
..., "module_aid": ...}`; the results appear in `/api/scp81/script` and the
R-APDU log. `GET /api/scp81/script` reports the script `kind`
(`explore`/`none`/`custom`/`ram-install`).
### `GET /api/scp81/script`
Returns the active command script and the R-APDUs collected so far:
+5 -2
View File
@@ -202,8 +202,11 @@ Full session: 7/7 commands, all `X-Admin-Script-Status: ok`.
1. **UI:** group the per-page R-APDUs under their logical command in the
SCP81 tab (page merging/decoding for ELF and application listings);
expose the framing options in the tab.
2. **Load/store over SCP81:** RAM INSTALL/LOAD via command scripts using the
same recipe (one C-APDU per POST, pagination for long responses).
2. **Load/store over SCP81:** implemented - `POST /api/scp81/ram-install`
takes a `.cap`, expands it with the shared `_cap_apdu_sequence` helper
(INSTALL [for load] -> 240-byte LOAD blocks -> INSTALL [for install]) and
queues it as the command script, one C-APDU per POST. Live install test
pending (needs a push with a suitable applet).
## Tooling
+45 -1
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3">
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.1.7</span></h1>
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.1.8</span></h1>
<div class="flex items-center gap-4">
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
@@ -997,6 +997,17 @@
<button id="scp81-start-btn" data-needs="server" onclick="scp81Start()" class="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Start">Start</button>
<button id="scp81-stop-btn" data-needs="server" onclick="scp81Stop()" class="px-3 py-1.5 text-sm rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Stop">Stop</button>
</div>
<div class="flex items-end gap-3 mt-2">
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="RAM install (CAP file)">RAM install (CAP file)</label>
<input type="file" id="scp81-cap-file" accept=".cap,.zip" class="text-xs text-gray-600 dark:text-slate-300">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="SD AID (empty = ISD)">SD AID (empty = ISD)</label>
<input id="scp81-cap-sd" placeholder="A000000003000000" maxlength="32" class="w-56 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
</div>
<button data-needs="server" onclick="scp81QueueCapInstall()" class="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Queue CAP install">Queue CAP install</button>
</div>
<div id="scp81-msg" class="text-xs mt-2 hidden"></div>
<div class="mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="The key is only sent to the local server and is never stored or logged.">The key is only sent to the local server and is never stored or logged.</div>
</div>
@@ -7251,6 +7262,32 @@ async function scp81Start() {
}
}
async function scp81QueueCapInstall() {
const fileInput = document.getElementById('scp81-cap-file');
const file = fileInput.files[0];
if (!file) { scp81Msg(t('Select a .cap file'), 'text-red-500'); return; }
if (file.size > 48 * 1024) { scp81Msg(t('CAP file exceeds 48 kB limit'), 'text-red-500'); return; }
scp81Msg(t('Reading CAP file...'), 'text-gray-500');
try {
const capHex = await ramReadFileHex(file);
const resp = await pysimFetch('/api/scp81/ram-install', {
cap_hex: capHex,
sd_aid: (document.getElementById('scp81-cap-sd').value || '').replace(/[^0-9a-fA-F]/g, ''),
});
if (resp.ok) {
scp81Msg(t('Queued') + ': ' + resp.apdus + ' ' + t('APDUs') + ' (' +
t('INSTALL / LOAD / INSTALL') + ') - ' + t('send a push to run it'),
'text-emerald-600 dark:text-emerald-400');
} else {
scp81Msg(resp.error || t('Error'), 'text-red-500');
}
scp81ResultsRefresh();
scp81LogRefresh();
} catch (err) {
scp81Msg(String(err.message || err), 'text-red-500');
}
}
async function scp81Stop() {
try {
await pysimFetch('/api/scp81/bip', { action: 'stop' });
@@ -9723,6 +9760,13 @@ const LANG_RU = {
'cascade': 'каскадно',
'Select a card preset with keys first (RAM subtab → Card preset)': 'Сначала выберите пресет карты с ключами (RAM → Пресет карты)',
'Select a .cap file': 'Выберите файл .cap',
'RAM install (CAP file)': 'RAM-установка (файл CAP)',
'Queue CAP install': 'Поставить установку CAP в очередь',
'Queued': 'В очереди',
'APDUs': 'APDU',
'INSTALL / LOAD / INSTALL': 'INSTALL / LOAD / INSTALL',
'send a push to run it': 'отправьте push для запуска',
'Script results (R-APDUs)': 'Результаты скрипта (R-APDU)',
'CAP file exceeds 48 kB limit': 'Файл CAP превышает лимит 48 кБ',
'Reading CAP file...': 'Чтение файла CAP...',
'Sending to server for install...': 'Отправка на сервер для установки...',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v144';
const CACHE = 'otaman-v145';
const URLS = [
'index.html',
'help.html',
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
version = "2.1.7"
version = "2.1.8"
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+102 -42
View File
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
VERSION = '2.1.7'
VERSION = '2.1.8'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -403,6 +403,34 @@ def _encode_scts(dt=None):
])
def _cap_apdu_sequence(loadfile_aid, module_aid, loadfile_data, sd_aid='',
privileges='00', install_params='', stk_params='',
make_selectable=True, block_size=240, instance_aid=None):
"""RAM (GP) APDU sequence for a parsed .cap: INSTALL [for load], LOAD
blocks (240-byte payloads, block counter in P2, last block P1=0x80),
INSTALL [for install]. Shared by the SCP80 delivery path and the SCP81
command-script path; keep byte-compatible with /api/ram-install."""
sd = sd_aid or 'A000000003000000'
ifl_data = _lv(loadfile_aid) + _lv(sd) + '00' + '00' + '00'
apdus = ['80E60200%02X%s00' % (len(ifl_data) // 2, ifl_data)]
loadfile_tlv = 'C4' + _ber_len(len(loadfile_data) // 2) + loadfile_data
total_bytes = len(loadfile_tlv) // 2
blocks = [loadfile_tlv[i * 2:(i + block_size) * 2]
for i in range(0, (total_bytes + block_size - 1) // block_size)]
for i, block in enumerate(blocks):
p1 = 0x80 if i == len(blocks) - 1 else 0x00
apdus.append('80E8%02X%02X%02X%s00' % (p1, i % 256, len(block) // 2, block))
instance = instance_aid or module_aid
params = install_params if install_params else 'C900'
if stk_params:
params += stk_params
p1_install = 0x0C if make_selectable else 0x04
ifi_data = (_lv(loadfile_aid) + _lv(module_aid) + _lv(instance) +
_lv(privileges or '00') + _lv(params) + '00')
apdus.append('80E6%02X00%02X%s00' % (p1_install, len(ifi_data) // 2, ifi_data))
return apdus
def _lv(hex_str):
"""Length-prefix a hex string (1-byte length)."""
n = len(hex_str) // 2
@@ -1366,6 +1394,26 @@ _SCP81_SCRIPT_RESULTS = []
_SCP81_SCRIPT_INSERTED = []
_SCP81_PAGES = 0
SCP81_MAX_PAGES = 24
# What the queued script is ('explore', 'none', 'custom' or 'ram-install').
_SCP81_SCRIPT_KIND = 'explore'
def _scp81_queue_script(apdus, kind='custom', force=False):
"""Replace the SCP81 command script with a new APDU list. Refuses while
a script is mid-run unless forced; the list runs on the card's next POST."""
global _SCP81_SCRIPT, _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS
global _SCP81_SCRIPT_INSERTED, _SCP81_PAGES, _SCP81_SCRIPT_KIND
if not force and 0 < _SCP81_SCRIPT_SENT < len(_SCP81_SCRIPT):
return {'queued': False, 'reason': 'script in progress',
'sent': _SCP81_SCRIPT_SENT, 'of': len(_SCP81_SCRIPT)}
_SCP81_SCRIPT = [a.upper().replace(' ', '') for a in apdus]
_SCP81_SCRIPT_KIND = kind
_SCP81_SCRIPT_SENT = 0
_SCP81_SCRIPT_RESULTS = []
_SCP81_SCRIPT_INSERTED = []
_SCP81_PAGES = 0
_BIP.log('script-queued', script_kind=kind, apdus=len(_SCP81_SCRIPT))
return {'queued': True, 'apdus': len(_SCP81_SCRIPT)}
_SCP81_SCRIPT_TEMPLATE = 'indefinite'
_SCP81_SCRIPT_CR_TAG = False
# None = short per-command Next-URI ('/N'); '' = omit the header (spec: the
@@ -1583,6 +1631,7 @@ def _scp81_response_headers():
def _scp81_bip_control(body):
global _SCP81_LISTENER, _SCP81_PSK
global _SCP81_SCRIPT, _SCP81_SCRIPT_SENT, _SCP81_SCRIPT_RESULTS
global _SCP81_SCRIPT_INSERTED, _SCP81_PAGES, _SCP81_SCRIPT_KIND
global _SCP81_SCRIPT_TEMPLATE, _SCP81_SCRIPT_CR_TAG, _SCP81_NEXT_URI
global _SCP81_LINK_EVENTS, _SCP81_TARGETED_APP, _SCP81_APACHE_HEADERS
global _SCP81_CHUNKED
@@ -1621,12 +1670,16 @@ def _scp81_bip_control(body):
script = body.get('script', 'explore')
if isinstance(script, list):
_SCP81_SCRIPT = [re.sub(r'\s', '', s) for s in script if s]
_SCP81_SCRIPT_KIND = 'custom'
elif script in _SCP81_SCRIPTS:
_SCP81_SCRIPT = list(_SCP81_SCRIPTS[script])
_SCP81_SCRIPT_KIND = script
else:
return {'ok': False, 'error': 'unknown script preset: %s' % script}
_SCP81_SCRIPT_SENT = 0
_SCP81_SCRIPT_RESULTS = []
_SCP81_SCRIPT_INSERTED = []
_SCP81_PAGES = 0
template = body.get('script_template', 'indefinite')
if template not in ('indefinite', 'definite'):
return {'ok': False, 'error': 'script_template must be indefinite or definite'}
@@ -2731,6 +2784,7 @@ class PysimHandler(BaseHTTPRequestHandler):
elif self.path == '/api/scp81/script':
self._log_req()
resp = {'script': _SCP81_SCRIPT, 'sent': _SCP81_SCRIPT_SENT,
'kind': _SCP81_SCRIPT_KIND,
'template': _SCP81_SCRIPT_TEMPLATE, 'cr_tag': _SCP81_SCRIPT_CR_TAG,
'results': _SCP81_SCRIPT_RESULTS}
self._send_json(resp)
@@ -3456,53 +3510,29 @@ class PysimHandler(BaseHTTPRequestHandler):
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
# Step 1: INSTALL [for load]
sys.stderr.write('RAM-INSTALL: Step 1 — INSTALL [for load] loadfile_aid=%s\n' % loadfile_aid)
ifl_data = _lv(loadfile_aid) + _lv(sd_aid) + '00' + '00' + '00'
ifl_apdu = '80E60200%02X%s00' % (len(ifl_data) // 2, ifl_data)
if not _send_gp_apdu(ifl_apdu, 'INSTALL [for load]'):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': 'INSTALL [for load] failed', 'load_file_aid': loadfile_aid, 'module_aid': module_aid}
self._send_json(resp)
self._log_resp(resp)
return
# Step 2: LOAD blocks
loadfile_tlv = 'C4' + _ber_len(len(loadfile_data) // 2) + loadfile_data
block_size = 240
total_bytes = len(loadfile_tlv) // 2
blocks = [loadfile_tlv[i * 2:(i + block_size) * 2] for i in range(0, (total_bytes + block_size - 1) // block_size)]
sys.stderr.write('RAM-INSTALL: Step 2 — LOAD %d bytes in %d blocks\n' % (total_bytes, len(blocks)))
for block_idx, block in enumerate(blocks):
is_last = (block_idx == len(blocks) - 1)
p1 = 0x80 if is_last else 0x00
p2 = block_idx % 256
load_apdu = '80E8%02X%02X%02X%s00' % (p1, p2, len(block) // 2, block)
if not _send_gp_apdu(load_apdu, 'LOAD (%d/%d)' % (block_idx + 1, len(blocks))):
# INSTALL [for load] -> LOAD blocks -> INSTALL [for install]
seq = _cap_apdu_sequence(
loadfile_aid, module_aid, loadfile_data, sd_aid=sd_aid,
privileges=privileges_hex,
install_params=install_params_hex, stk_params=stk_params_hex,
make_selectable=make_selectable)
sys.stderr.write('RAM-INSTALL: %d APDUs (INSTALL / %d x LOAD / INSTALL) loadfile_aid=%s\n' % (
len(seq), len(seq) - 2, loadfile_aid))
for apdu_idx, gp_apdu in enumerate(seq):
if apdu_idx == 0:
step_name = 'INSTALL [for load]'
elif apdu_idx == len(seq) - 1:
step_name = 'INSTALL [for install]'
else:
step_name = 'LOAD (%d/%d)' % (apdu_idx, len(seq) - 2)
if not _send_gp_apdu(gp_apdu, step_name):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': 'LOAD block %d failed' % (block_idx + 1),
'error': '%s failed' % step_name,
'load_file_aid': loadfile_aid, 'module_aid': module_aid}
self._send_json(resp)
self._log_resp(resp)
return
# Step 3: INSTALL [for install]
sys.stderr.write('RAM-INSTALL: Step 3 — INSTALL [for install]\n')
instance_aid = module_aid
privileges = privileges_hex
inst_params = install_params_hex if install_params_hex else 'C900'
if stk_params_hex:
inst_params += stk_params_hex
p1_install = 0x0C if make_selectable else 0x04
ifi_data = _lv(loadfile_aid) + _lv(module_aid) + _lv(instance_aid) + _lv(privileges) + _lv(inst_params) + '00'
ifi_apdu = '80E6%02X00%02X%s00' % (p1_install, len(ifi_data) // 2, ifi_data)
if not _send_gp_apdu(ifi_apdu, 'INSTALL [for install]'):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': 'INSTALL [for install] failed', 'load_file_aid': loadfile_aid, 'module_aid': module_aid}
self._send_json(resp)
self._log_resp(resp)
return
resp = {'success': True, 'steps': steps, 'load_file_aid': loadfile_aid,
'module_aid': module_aid, 'final_cntr': cntr}
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
@@ -3524,6 +3554,36 @@ class PysimHandler(BaseHTTPRequestHandler):
resp = {'ok': False, 'error': str(e)}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/scp81/ram-install':
body = self._read_body()
self._log_req(body)
cap_hex = (body.get('cap_hex') or '').replace(' ', '')
if not cap_hex:
resp = {'ok': False, 'error': 'No cap_hex provided'}
elif _SCP81_LISTENER is None:
resp = {'ok': False, 'error': 'SCP81 listener is not running'}
else:
try:
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
seq = _cap_apdu_sequence(
loadfile_aid, module_aid, loadfile_data,
sd_aid=(body.get('sd_aid') or '').replace(' ', ''),
privileges=(body.get('privileges') or '').replace(' ', '') or '00',
install_params=(body.get('install_params') or '').replace(' ', ''),
stk_params=(body.get('stk_params') or '').replace(' ', ''),
make_selectable=bool(body.get('make_selectable', True)))
queued = _scp81_queue_script(seq, kind='ram-install',
force=bool(body.get('force', False)))
resp = dict(queued, ok=bool(queued.get('queued')),
load_file_aid=loadfile_aid,
module_aid=module_aid, apdus=len(seq))
if queued.get('queued'):
resp['note'] = ('queued as the SCP81 command script; '
'runs on the card next POST (push/trigger)')
except Exception as e:
resp = {'ok': False, 'error': 'cap parse failed: %s' % e}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/scp81/log-clear':
body = self._read_body()
self._log_req(body)
+50
View File
@@ -836,3 +836,53 @@ class TestSmsReassembly(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
class CapApduSequenceTest(unittest.TestCase):
"""RAM APDU sequence shared by the SCP80 and SCP81 install paths."""
def _mini_cap(self):
import io, zipfile
# Header: tag(1) size(2) magic(4) minor(1) major(1) flags(1)
# pkg minor(1) pkg major(1) aid_len(1) aid(N)
header = (b'\x01\x00\x11' + b'\xde\xca\xff\xed' + b'\x00\x01\x00' +
b'\x00\x01' + b'\x06' + b'\xa0\x00\x00\x01\x00\x01')
# Applet: tag(1) size(2) count(1) aid_len(1) module_aid(N) offset(2)
applet = (b'\x03\x00\x0a\x01\x05' + b'\xa0\x00\x00\x01\x00' + b'\x00\x08')
buf = io.BytesIO()
zf = zipfile.ZipFile(buf, 'w')
zf.writestr('pkg/Header.cap', header)
zf.writestr('pkg/Applet.cap', applet)
zf.close()
return buf.getvalue().hex().upper()
def test_cap_parse(self):
from pysim_otaman_server.server import _cap_parse
loadfile_aid, module_aid, data = _cap_parse(self._mini_cap())
self.assertEqual(loadfile_aid, 'A00000010001')
self.assertEqual(module_aid, 'A000000100')
# Header then Applet, per the CAP component order.
self.assertTrue(data.startswith('010011DECAFFED'))
self.assertIn('03000A01', data)
def test_sequence_install_load_install(self):
from pysim_otaman_server.server import _cap_apdu_sequence
seq = _cap_apdu_sequence('A00000010001', 'A000000100', 'AABBCCDD')
# INSTALL [for load]: lv(pkg aid) + lv(ISD) + 000000
self.assertEqual(seq[0],
'80E6020013' + '06A00000010001' + '08A000000003000000' + '000000' + '00')
# One LOAD block (small payload, last -> P1=0x80, P2=0)
self.assertEqual(seq[1][:8], '80E88000')
self.assertTrue(seq[1].endswith('00'))
# INSTALL [for install]: C9 00 install params appended to the lv chain
self.assertTrue(seq[2].startswith('80E60C00'))
self.assertIn('06A00000010001' + '05A000000100' + '05A000000100' + '0100', seq[2])
def test_load_blocks_split_and_counter(self):
from pysim_otaman_server.server import _cap_apdu_sequence
data = 'AB' * 700 # 700 bytes -> C4 TLV 703 -> 3 x 240-byte blocks
seq = _cap_apdu_sequence('A00000010001', 'A000000100', data)
self.assertEqual(len(seq), 5) # INSTALL + 3 LOAD + INSTALL
self.assertEqual(seq[1][:8], '80E80000')
self.assertEqual(seq[2][:8], '80E80001')
self.assertEqual(seq[3][:8], '80E88002') # last block: P1=0x80
+35
View File
@@ -755,3 +755,38 @@ class TargetedAppTest(unittest.TestCase):
self.assertEqual(rapdus[0][1], '6A88')
# A status-only POST (no body, e.g. unknown-application) parses empty.
self.assertEqual(server._scp81_parse_response(b''), (0, []))
class QueueScriptTest(unittest.TestCase):
def test_queue_replaces_and_resets(self):
server._SCP81_SCRIPT = ['80CAFF2100']
server._SCP81_SCRIPT_SENT = 1
server._SCP81_SCRIPT_RESULTS = [{'index': 1, 'sw': '9000', 'apdu': '80CAFF2100', 'rapdu': ''}]
try:
r = server._scp81_queue_script(['80E6020013' + '00' * 20, '80E88000' + '00' * 4],
kind='ram-install')
self.assertTrue(r['queued'])
self.assertEqual(server._SCP81_SCRIPT_KIND, 'ram-install')
self.assertEqual(server._SCP81_SCRIPT_SENT, 0)
self.assertEqual(server._SCP81_SCRIPT_RESULTS, [])
self.assertEqual(len(server._SCP81_SCRIPT), 2)
finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_RESULTS = []
server._SCP81_SCRIPT_KIND = 'explore'
def test_queue_refuses_while_running(self):
server._SCP81_SCRIPT = ['80CAFF2100', '80F28002024F0000']
server._SCP81_SCRIPT_SENT = 1
try:
r = server._scp81_queue_script(['80E60200'], kind='ram-install')
self.assertFalse(r['queued'])
self.assertEqual(r['sent'], 1)
r = server._scp81_queue_script(['80E60200'], kind='ram-install', force=True)
self.assertTrue(r['queued'])
self.assertEqual(server._SCP81_SCRIPT, ['80E60200'])
finally:
server._SCP81_SCRIPT = list(server._SCP81_SCRIPTS['explore'])
server._SCP81_SCRIPT_SENT = 0
server._SCP81_SCRIPT_KIND = 'explore'