The Consumer Physics SCiO is a pocket near-infrared spectrometer, sold 2015-2019 and now discontinued. It has no offline mode: the device emits opaque blobs and a vendor server turns them into spectra. This repository documents the device end-to-end - transport, framing, every command, the data formats, the calibration rules and the server API - and provides a working Python implementation, so that a SCiO you own stays usable.
Two paths exist:
| status | |
|---|---|
| Capture -> vendor server -> spectrum | works. Verified against 2020/2021 stored spectra to ~1e-15. Needs an active Consumer Physics account. |
| Capture -> spectrum, offline | unsolved. Scan bodies are opaque, high-entropy and fixed-length; both the class of the transform (encryption or proprietary coding) and where it runs are undetermined. Current state and next steps: dev/HANDOVER.md. |
Capture itself needs no network. A scan is written as a self-contained record and can be converted to a spectrum later, from anywhere. That separation is the point: the server may be switched off at any time, and a record captured today must still be convertible the day someone breaks the offline path.
- Quick start
- Transport
- Command reference
- Scan data anatomy
- Firmware and calibration files
- White reference and calibration
- Server API
- Practical gotchas
- Repository layout
Hardware (boards, chips, sensor, debug access, teardown photos):
documentation/HARDWARE.md.
conda env create -f scio_env.yml # or: conda activate tp
jupyter lab 01_scio_scan_to_spectrum.ipynbNotebook 01_scio_scan_to_spectrum.ipynb is the whole workflow: name the scan,
connect, capture, upload, plot. Everything it does is a call into src/scio/:
import sys; sys.path.insert(0, "src")
from scio import credentials, session, usb
dev = usb.ScioUSB("COM5").open()
path = session.capture(dev, "bark", comment="north-facing trunk") # offline
dev.close()
out = session.process(path, credentials.get_token()) # online, laterRaw records land in 01_rawdata/scans/, spectra in 02_processed_data/.
session.process_pending() uploads a backlog whenever you next have a
connection.
Three notebooks, numbered by role - 01 is the one to open:
| notebook | what it is for |
|---|---|
01_scio_scan_to_spectrum.ipynb |
the full workflow: annotate, connect, capture, upload, plot |
02_scio_device_health.ipynb |
identifiers, battery, temperature, firmware file headers |
03_scio_probe.ipynb |
read-only probing of undocumented opcodes |
All three import only scio; offline-decoding research is in dev/notebooks/,
and superseded notebooks are in archive/notebooks/.
The device must be fully awake. A steady blue LED means it answers commands. A slow light/dark pulse means it is idle or charging, and its USB endpoint goes silent or disappears. Fix: unplug, long-press off, long-press on until steady blue, replug. It re-idles by itself after a period without commands.
Remote power control: no immediate-off or remote-on command has been confirmed.
The app does support an automatic-off timer. On an open ScioUSB instance named
dev, dev.read_power_saver() queries it;
dev.set_power_saver(minutes, allow_write=True) sends the app-compatible setting
for 1–30 minutes. Its signed-byte adjustment means the encoded duration can differ
from the requested minutes: inspect encoded_seconds in the return value.
These optional USB write helpers are hardware-unverified and can alter device
behavior. Nothing writes by default. The setter never resets automatically;
the app resets after successful writing, so dev.reset_device(allow_write=True)
is a separate, disruptive operation, not a power-off/on substitute. Do not retry
a timed-out reset blindly. The same helpers exist on ScioBLE (section 2); over BLE
they are equally unverified.
See power evidence and limitations.
The read itself was verified on the connected firmware-147 unit: it returned
360 seconds (6 minutes), between matching identity controls. Actual shutdown
timing, writes, reset behavior and remote wake remain unverified.
Scan location: every new record stores where it was taken in a mobile_GPS
block shaped like the phone app's (latitude, longitude, locality,
country, admin_area, address_line), plus location_meta (source, accuracy,
time, why a lookup failed). The coordinates come from the capturing computer's
own location service: on Windows the built-in location API, which needs
Settings → Privacy & security → Location → Let desktop apps access your
location; on Linux GeoClue; on macOS CoreLocationCLI. There is no IP-based
lookup and no reverse geocoding, so nothing leaves the machine and the text
fields stay empty. When no fix is available the fields are empty and the capture
goes ahead. It is off by default. Turn it on with capture(..., geolocate=True)
or SCIO_GEOLOCATION=1, or pass a phone's fix with
capture(..., location_override={"latitude": …, "longitude": …}).
The location is kept locally, in the raw record and in the processed spectrum
file. It is never sent to the server unless you consent with
session.process(path, token, send_location=True) or
session.process_pending(send_location=True).
GPS data is never published in this repository. tools/check_public_safety.py,
run by the pre-commit hook, refuses any file with a real latitude/longitude.
Empty fields and the Zurich redaction placeholder (47.3769, 8.5417) pass. A located
scan therefore stays on your machine: remove its coordinates before committing it.
The SCiO enumerates as a Texas Instruments CDC serial port, VID:PID
0451:16AA. Baud rate is irrelevant (it is a USB CDC device, not a real UART);
DTR/RTS handling does not matter. Responses arrive as one contiguous frame.
The same 0xBA command protocol runs over BLE, and scio.ble.ScioBLE implements
it with bleak (pip install bleak). It has the
same methods as ScioUSB and drops into session.capture unchanged; the
notebooks switch with TRANSPORT = "ble":
from scio import ble
print(ble.find_scio_ble()) # advertisers, likely SCiOs first
with ble.ScioBLE() as dev: # or ScioBLE("B4:99:4C:59:66:01") / ScioBLE("SCiOmyScio")
info = dev.read_device_info()
scan = dev.sample_spectrum(info["firmware_version"])- No pairing. Do not pair the SCiO in the OS Bluetooth settings; the app never bonds and neither does this library.
- It must be awake. The SCiO advertises only while on, and switches itself
off after its automatic-off timer (6 min on this unit). Press the button first.
This unit advertises as
SCiOmyScio; anything whose name contains "scio", or that advertises service3490, is treated as a SCiO. - Synchronous API. bleak is asyncio-only;
ScioBLEruns its own event loop in a background thread, so it works the same in a script and in Jupyter. - One connection at a time. While a
ScioBLEis connected the SCiO stops advertising. Ifopen()can't find it because an earlierScioBLEin the same process still holds it (e.g. a re-run notebook cell), it closes that old session, prints a note saying so, and connects; the old object then raisesScioDisconnected. With a named device (ScioBLE(address_or_name)) it skips the first scan, which cannot succeed while the device is held, so the takeover costs no extra scan cycle. A session holding a different SCiO is left alone, andble.close_all()closes every session. A connection held by another process (a second kernel or script) can't be released from here: close it or restart that kernel. If the link drops, commands fail fast withScioDisconnectedanddev.open()reconnects. - Replies are matched to requests (BLE and USB). Each reply echoes its command
id (confirmed in the 2019 BLE captures, all three scan messages included, and
in every USB probe log). A late reply to a request that timed out is dropped
rather than returned as the next answer (
dev.stale_replies; USB gives up withScioProtocolErrorafter 8 stale frames in a row), and BLE packets belonging to no reply are ignored (dev.stray_packets). - Windows (10/11, verified on hardware): the built-in Bluetooth stack is used through WinRT; nothing to configure.
- Linux (BlueZ >= 5.55 over D-Bus, not yet verified on hardware):
bluetoothdmust be running and the user allowed on the system D-Bus (the default for a desktop user; nosudoneeded). Check withbluetoothctl power onandbluetoothctl scan onthat the SCiO shows up, then run the four calls above:find_scio_ble()lists it,read_device_info()returnsble_id 01665900004c99b4/ BLE fw 125,sample_spectrumreturns 1800/1800/1656-byte blobs. If BlueZ has a stale cache after a firmware change,bluetoothctl remove <MAC>clears it. - Timing (Windows, this unit): identity read 0.3 s, a three-blob scan ~3.2 s (about 280 notifications).
Vendor GATT service 00003490-0000-1000-8000-00805f9b34fb:
| Role | UUID | Handle (this unit) |
|---|---|---|
| Control (write commands) | 00003492-… |
0x0029 |
| Reporter (scan-data notifications) | 00003491-… |
replies on 0x0025 |
| Button pressed (notification) | 00003493-… |
0x002c, reads 0x01 on press |
Standard characteristics: device name 0x2a00 (handle 0x0003), system id
00002a23-… (0x0012), manufacturer name 0x2a29 (0x001e), under services
0x1800/0x180a. Each of 3491/3492/3493 carries CCCD (0x2902) and
0x2901 descriptors.
A command is written to the control characteristic; the response arrives as
20-byte notifications on the reporter, each prefixed with the sequence byte
(unlike USB, where the frame is contiguous). A real scan sequence, written to
handle 0x0029:
01ba050000 # read battery state
01ba0e0000 # ready for white reference
01ba0b0900000000000000000000 # set indication LED (9-byte payload)
01ba040000 # read temperature
01ba020000 # sample spectrum -> replies on 0x0025
With BlueZ:
sudo gatttool -i hci0 -b <MAC> --char-write-req -a 0x0029 -n 01ba020000 --listenRequests are split the same way as replies (RequestCommandBuilder): the
first packet is 01 BA cmd len_lo len_hi plus up to 15 payload bytes, each later
one a sequence byte (2, 3, ...) plus up to 19. Every request starts at sequence 1;
the USB session counter does not apply. Writes are with response, except
READY_FOR_WR (0x0E), CLEAR_READY_FOR_WR (0x11) and FILE_DOWNLOAD (0x81).
A reply message is complete when len bytes have arrived; a scan sends two or
three messages back to back, each starting again at sequence 1. See
protocol.ble_packets and protocol.BleReassembler.
archive/notebooks/05_scio_ble_devel.ipynb holds an earlier, failed bleak
attempt. Why it failed: it wrote the command to
the control characteristic and then called read_gatt_char on that same
characteristic to get the answer. Replies never arrive there - they arrive as
notifications on the reporter characteristic, which must be subscribed to
first. (It also wrote the ASCII string "01ba040000" rather than the five bytes
bytes.fromhex("01ba040000"), so the device received garbage either way.)
[seq, 0xBA, cmd, len_lo, len_hi, payload...]
| field | size | meaning |
|---|---|---|
seq |
1 B | sequence counter; 0x01 for a first/only packet |
0xBA |
1 B | protocol marker (-70 as a signed Java byte) |
cmd |
1 B | command id, see below |
len |
2 B | payload length, little-endian uint16 |
payload |
len B |
command-specific |
There is no CRC or checksum. A response reuses the same layout, so a reader
should hunt for the 0xBA marker and resynchronise rather than assume alignment
scio.usb.ScioUSB._read_responsedoes exactly that.
Some commands answer with several frames back to back: SAMPLE_SPECTRUM
returns two or three, one per blob.
Legend: R read-only (safe), W writes or changes device state (never sent
automatically; power-saver/reset helpers require explicit opt-in), X declared in the app but unimplemented on this
firmware (147) - probed on hardware, logs in 01_rawdata/probe_logs/.
The app symbol column names the method or constant in the decompiled Android app that issues each command. Those names are the way back into the decompilation if you ever need to re-derive a layout; keep them.
| cmd | name | request payload | response | app symbol | notes |
|---|---|---|---|---|---|
0x00 |
READ_DEVICE_STATUS | - | 8 B: two u32 LE, observed {0, 1} |
CommandIDs.READ_DEVICE_STATUS |
R |
0x01 |
READ_DEVICE_ID | - | ≥26 B, see below | performReadDeviceId |
R |
0x02 |
SAMPLE_SPECTRUM | - | 2 or 3 frames of blob data | performSpectrum |
R (capture; persists nothing) |
0x04 |
READ_TEMPERATURE | - | 12 B: three u32 LE |
performReadTemperature |
R |
0x05 |
READ_BATTERY_STATE | - | 8 B, see below | performReadBattery |
R |
0x84 |
READ_BLE_ID | - | ≥130 B, see below | performReadDeviceBleId |
R |
0x85 |
READ_BLE_STATUS | - | ble status | CommandIDs.READ_BLE_STATUS |
R |
0x87 |
READ_FILE_HEADER | <I file_id |
exactly 16 B: four u32 LE |
performReadFileHeader, via SCiOBLeService.performReadFileHeader(int) |
R |
0x94 |
READ_FILE_LIST | - | n × 8 B (u32 type, u32 version) |
performReadFileList, via SCiOBLeService.performReadFileList() |
R |
0x9B |
READ_BLE | - | ble config | CommandIDs.READ_BLE |
R |
0x01 READ_DEVICE_ID
| offset | size | field |
|---|---|---|
[0:8] |
8 B | DSP id (hex, lowercase) |
[8:16] |
8 B | unclassified |
[16:24] |
8 B | Aptina id, stored with 16-bit words byte-swapped |
[24:26] |
2 B | firmware version, u16 LE |
device_id - the value the server wants - is the Aptina field with each 4-hex-char
group abcd rewritten as cdab, then uppercased:
328045ab1161f198 -> 8032AB45611198F1.
0x04 READ_TEMPERATURE - three u32 little-endian words:
cmos_t = (w0 - 375.22) / 1.4092 # Aptina CMOS sensor, degC
chip_t = w1 / 100.0 # DSP chip, degC
obj_t = w2 / 100.0 # object/surface; 0.0 on this fw-147 unit
The Android app truncates to integer twice:
cmos_t_app = trunc(trunc(raw - 375.22) / 1.4092), so raw 404 gives 19,
not 20.42. This matters: the calibration temperature rule compares the
truncated value. parse_temperature returns cmos_t, cmos_t_app and
raw_u32 so nothing is lost.
obj_t reads 0.0 on this fw-147 unit, but is non-zero and tracks the target
on at least one fw-138 unit (a hand ~32.6 °C vs ~20 °C for room-temperature
targets), so it is unit/firmware dependent, not universally 0.
ScioDevice.read_object_temperature() returns it; data points in
dev/DEVICE_FUNCTION_REFERENCE.md.
0x05 READ_BATTERY_STATE
| offset | type | field |
|---|---|---|
0 |
u16 |
charge % |
2 |
u8 |
health % |
3 |
u8 |
health status |
4 |
u16 |
charging status |
6 |
u16 |
voltage, mV (divide by 1000) |
An early notebook read the charging status as 0 = not charging, 4 = full,
6 = battery error, anything else = charging. Treat that as an unverified
hypothesis: the code carrying it masked the byte with & 3 before comparing
against 4 and 6, so its own branches were unreachable and the mapping was never
exercised. parse_battery returns the raw value and interprets nothing.
0x84 READ_BLE_ID
| offset | size | field |
|---|---|---|
[0:8] |
8 B | BLE id (hex) |
[8:10] |
2 B | BLE firmware version, u16 |
[10:40] |
30 B | serial, first 30 characters - written by 0x89 |
[40:50] |
10 B | serial, last 10 characters (writer unknown) |
[50:66] |
16 B | user device name - written by 0x91 |
[66:130] |
64 B | i2s_tag_config, e.g. 20150812-e:PRODUCTION - written by 0x93 |
Text fields are ASCII, NUL-padded. parse_ble_id returns serial_prefix,
device_serial_fragment and their concatenation device_serial. No app reads
bytes [10:50]; the full serial of this unit is
CPPCA0031C6PF0516009W6404386A1DF1816004A.
The i2s ("image to spectrum") tag identifies the binning-table generation and is mandatory in every server request.
The three text fields are rewritten with one command each. No app sends 0x89
or 0x93 (they were found on this unit, see below); all three take the same
payload as the app's rename: raw ASCII, no padding, no NUL, with the frame
length giving the size. The device zero-fills the rest of the field, and the
value persists across power cycles - no reset is needed (the app resets after a
rename anyway). An empty payload clears the field.
| cmd | field | max | frame for this unit's value |
|---|---|---|---|
0x89 |
serial [10:40] |
30 B | 01ba891e00 + CPPCA0031C6PF0516009W6404386A1 |
0x91 |
name [50:66] |
16 B | 01ba910600 + myScio |
0x93 |
i2s tag [66:130] |
64 B | 01ba931500 + 20150812-e:PRODUCTION |
Each replies with an empty frame of its own opcode.
The device always reports these values itself - they are only blank if something wiped them. Rewriting them is a repair, not part of any workflow, and a wrong value is dangerous: the i2s tag is matched exactly by the vendor server, so a wrong one is stored permanently and every scan from the device is rejected until it is rewritten. The library therefore guards each write twice:
dev.write_i2s_tag("20150812-e:PRODUCTION", allow_write=True)
dev.write_serial_prefix("CPPCA0031C6PF0516009W6404386A1", allow_write=True)allow_write=Trueis required, as for every write;- the current value is read first and a warning is shown with the current
and the new value; the write goes ahead only if the user types
yes(or aconfirm=callable returnsTrue). Otherwise nothing is sent.
If the value is already correct nothing is written or asked. The result has
previous, readback and verified (from a fresh 0x84 read); nothing resets.
Empty and over-long values are refused before anything is sent; longer
payloads were never tried on 0x89/0x93. Only write a value you know is the
device's original, e.g. from its own earlier records.
0x88 and 0x95 also accept a payload (empty, 30 B or 40 B) and acknowledge it
the same way, but change nothing in this record, nor in the device id, file list,
file headers or auto-off timer, before or after a power cycle. They are writes
of something not visible here and stay forbidden.
From 2026-09-07 every READ_BLE_ID on this unit returned [10:40] and [66:130]
zeroed. Reads at 15:52-15:58 that day had the tag; the 18:50 series did not. In
between, the reserved-opcode sweep (notebook 03) sent 0x88, 0x89, 0x93 and
0x95 with empty payloads - i.e. it cleared the serial prefix and the tag. The
missing tag made every scan unprocessable without a manual fill-in; a reset did
not bring it back.
On 2026-10-04 the fields were mapped and restored over BLE with
dev/scripts/restore_ble_id_record.py, one write per run, each between full
read-only snapshots (BLE-ID record, device id, timer, file list, ten file
headers). Raw evidence: 01_rawdata/probe_logs/ble_id_restore_20261004_*.
| time | write | effect |
|---|---|---|
| 14:29:54 | 0x93 + tag (21 B) |
tag restored at [66:130], nothing else changed |
| 14:30:50 | 0x95 + full serial (40 B) |
none |
| 14:32:21 | (power cycle, read only) | tag still present - persistent |
| 14:33:21 | 0x95 + serial prefix (30 B) |
none |
| 14:33:40 | 0x88 + serial prefix (30 B) |
none |
| 14:33:59 | 0x89 + serial prefix (30 B) |
serial restored at [10:40], nothing else changed |
Do not send 0x88, 0x89, 0x93 or 0x95 with an empty or blind payload.
The probe refuses all four.
After the repair the device reports both values again, and a scan captured over BLE at 14:44 carried them straight from the device and was processed by the server (331-point spectrum).
The library does not substitute a tag from older records: the device is the
source of truth. Should the tag be blank (this or another unit),
read_device_info flags i2s_tag_missing, the notebooks say so, and
session.process refuses the record. Repair the device with write_i2s_tag
first, using the value from its own earlier records.
0x87 READ_FILE_HEADER - request struct.pack("<I", file_id). The response
is always 16 bytes: (type, size, version, checksum) as four u32 LE.
Appending an offset/length to the request is ignored - there is no readback path
(see §5).
0x94 READ_FILE_LIST - a flat array of 8-byte (u32 file_type, u32 version)
entries covering the 87-95 band.
Never sent by this project. ScioUSB refuses them unless allow_write=True.
| cmd | name | app symbol | notes |
|---|---|---|---|
0x03 |
SET_SAMPLE_SETTINGS | - | unused by the app |
0x07 |
PARAMETER_SET | CommandIDs.PARAMETER_SET |
W |
0x0B |
SET_INDICATION_LED | - | W, 9-byte payload |
0x0E |
READY_FOR_WR | performReadyForWR |
W, LED/UX hint so the device button can trigger a white reference; firmware ≥ 144 |
0x11 |
CLEAR_READY_FOR_WR | performClearReadyForWR |
W, counterpart of 0x0E |
0x81 |
FILE_DOWNLOAD | performFileDownload |
W, host->device only - it uploads firmware, it does not read it |
0x83 |
RESET_DEVICE | CommandIDs.RESET_DEVICE |
W, disruptive |
0x88 |
(none; Cmd.UNKNOWN_WRITE_88) |
- | W, accepts a payload, no visible effect |
0x89 |
WRITE_SERIAL_PREFIX (found on this unit) | - | W, BLE-ID [10:40], ASCII ≤ 30 B, persistent |
0x91 |
WRITE_USER_DEVICE_NAME | CommandIDs.WRITE_USER_DEVICE_NAME |
W, BLE-ID [50:66], ASCII ≤ 16 B |
0x93 |
WRITE_I2S_TAG (found on this unit) | - | W, BLE-ID [66:130], ASCII ≤ 64 B, persistent |
0x95 |
(none; Cmd.UNKNOWN_WRITE_95) |
- | W, accepts a payload, no visible effect |
0x9A |
WRITE_BLE | CommandIDs.WRITE_BLE |
W |
All probed on real hardware:
| cmd | name (app symbol) | observed |
|---|---|---|
0x06 |
READ_EVENT_LOG (CommandIDs.READ_EVENT_LOG) |
X no response |
0x08 |
PARAMETER_GET (CommandIDs.PARAMETER_GET) |
X no response |
0x09 |
BIST (built-in self test) | X no response |
0x8A-0x8F |
reserved band | no response |
0x88, 0x89, 0x93 and 0x95 from the same band turned out to be writes
(table above); probing them with empty payloads cleared two BLE-ID fields.
SAMPLE_SPECTRUM (0x02) returns one frame per blob. How many depends on the
firmware (DeviceInfo.java):
- firmware < 136 -> 2 frames (dark, sample);
- firmware ≥ 153 with gradient sampling disabled -> 2;
- otherwise -> 3 (dark, sample, gradient).
Wire order is dark, sample, gradient.
Sizes depend on the i2s generation:
| i2s tag | sample | dark | gradient |
|---|---|---|---|
…-e:… (this unit) |
1800 B | 1800 B | 1656 B |
…-o:… (older) |
1800 B | 1800 B | 1416 B |
Each blob is:
[0:4] u32 LE status / type 0x00 for sample and dark, 0x6E (110) for gradient
[4:8] u32 LE varies per blob - see below; NOT proven to be a nonce
[8:] body: an exact multiple of 16 bytes, ~7.9 bits of entropy per byte
The first word is what the vendor's own source calls status: the app reads
it as getU32(0) of the sample blob (ScioInternalDevice.java:1085, 2017
build), little-endian. 0 means a healthy scan. Why the gradient carries 0x6E
there is unexplained.
The second word is, measured across the whole corpus, indistinguishable from a fresh 32-bit random draw per blob: 300 unique bodies map one-to-one onto 300 unique values with no reuse anywhere; it is not a counter (18 of 29 ascending in a burst of scans 2.13 s apart), not correlated with time (|r| <= 0.11), and not derived from the body (0/291 across eight checksum candidates). Three independent draws per scan, one per blob.
Handy check when reading base64 by eye: sample / dark / white / white-dark all
begin AAAAA (leading u32 = 0), while the two gradient blobs begin bgAAA
(0x6E). A blob whose base64 starts with anything else is not a SCiO scan blob.
What is established about the body. It is high-entropy (7.89 bits/byte, on the random control), 16-byte aligned, fixed-length regardless of content, and statistically featureless: the pooled byte histogram is flat (chi-square 256.9 on df 255) and not one of the 1792 byte positions deviates more than 4 sigma from uniform. Thirty captures of a physically unchanged target, whose spectra agree to 2.1 % worst case, produce bodies at 0.49986 bit distance from each other with no shared 16-byte block anywhere in the corpus.
What is not established: the class of the transform. Full avalanche looks like encryption, but it does not discriminate - the 2.1 % agreement is measured after binning ~2.7 pixels per band, per-pixel sensor noise differs everywhere, and an entropy coder avalanches on any input difference too. Flat byte histograms are equally what good compression produces.
What the server reveals. Reflectance is a sample-domain quantity divided by a
white-domain quantity: swapping sample and white components gives a response table
that is multiplicatively separable to ~1e-16, with a stable non-unity self-response
factor C(λ). Dark handling is not a simple subtraction, per-band affine map or
shared gain. Sample, dark and white blobs are integrity-protected (any tested
change to the second header word or body is rejected with Bad_sample_signature);
the status bit and gradient edits are accepted and leave the spectrum unchanged. None of this recovers the domain vectors themselves. Evidence:
dev/RECOVERY_STATUS.md.
The class remains undetermined. The vendor's vocabulary hints at compression:
the i2s tag is called compression_version in the API, the parameter carrying it
is literally named i2sTag in the un-obfuscated 2017 build, and the app ships an
UnsupportedCompressionConversion error - you cannot convert between encryptions.
A supervised test on 92 paired records finds no body bit, byte or 16-bit word
that correlates with the returned spectrum, and no held-out linear predictor,
although the same test detects a single fixed-position count field in synthetic
data of this size (dev/HANDOVER.md). That disfavours a simple
fixed-position coder, but does not prove encryption. Encryption cannot be
confirmed by software alone: the device could
encrypt and the server decrypt, and the Android client is a verified byte-for-byte
pass-through that would look identical either way. See
dev/README.md.
READ_FILE_LIST / READ_FILE_HEADER expose the device's stored files.
A READ_FILE_HEADER response is (type, size, version, checksum). Read from
this fw-147 unit (01_rawdata/device_files/):
| id | name | size | version | checksum |
|---|---|---|---|---|
| 87 | ble_runtime |
119233 B | 125 | 12925168 |
| 89 | ble |
absent (0xFFFFFFFF in every word) |
||
| 90 | dsp_boot |
7284 B | 17 | 688456 |
| 91 | dsp_dec |
14600 B | 12 | 1548938 |
| 92 | dsp_op |
32628 B | 147 | 4151168 |
| 100 | deadPixelsIndices |
1714 B | 3 | 97267 |
| 101 | centers |
96 B | 3 | 6080 |
| 102 | bins |
140 B | 3 | 13587 |
| 103 | nPixelsPerBin |
1166 B | 3 | 36371 |
dsp_op's version equals the device firmware version, and the three table files
share version 3. Use the size and checksum to validate any flash dump.
The 2017 Android enum calls the BLE image type 89; this fw-147 device reports
type 87 as a 119233-byte BLE image. Both observations are kept in
protocol.FIRMWARE_FILES rather than reconciled away.
Only headers can be read back. Bodies cannot. FILE_DOWNLOAD (0x81) writes
to the device; READ_FILE_HEADER returns 16 bytes and ignores any offset. No
body-read operation was found in the reviewed command paths, and the reserved band
was probed without finding one. This is why offline decoding is blocked: the
transform - and any key - lives in firmware we do not have. It may run on the
BF512 (dsp_op and friends) or on the CC2540 BLE SoC (file 87, which has hardware
AES); which one is unproven. Routes to the code are a boot-flash dump, debug access
to either processor, or a phone that cached the files (see
documentation/HARDWARE_ACQUISITION.md).
Hardware (full reference with photos: documentation/HARDWARE.md).
A SparkFun teardown
shows an ADSP-BF512KBCZ-3 Blackfin DSP (no on-chip program flash), AS4C8M16SA 128 Mbit
SDRAM, a CC2540F256 BLE SoC, a probable ADP5062 charger, two unidentified small QFNs and
no visible standalone flash chip. The sensor has 12 receptors in a 3 × 4 grid, each with
its own filter, aperture and lens over a photodiode array. All the device's reported files
together (176,861 B) would fit in the CC2540's 256 KB flash. That makes it plausible, but
unverified, that the CC2540 hosts the DSP images and boots the BF512. See
documentation/HARDWARE_ACQUISITION.md.
Both SCiO apps cached these files in Android SharedPreferences
(/data/data/com.consumerphysics.consumer/shared_prefs/) as base64 with a 4-byte
little-endian checksum prefix, listed in a firmware.file.names string-set. The
consumer app deletes them after a completed upgrade, so a phone that never
finished one is the best candidate. dev/scio_offline/firmware.py extracts them.
(For this repository's unit the original phone and backup no longer exist.)
A white reference (WR) is the same SAMPLE_SPECTRUM command taken with the SCiO
in its cover, on the white surface inside. It is stored, reused across scans, and
sent to the server with every scan.
This was verified both by reading the decompiled apps and by testing the live
API. isCalibrationNeeded() walks these rules in order, each skipped when its
threshold is <= 0:
| result | condition |
|---|---|
NEVER |
no WR stored, or missing i2s tag / device id |
TIME_THRESHOLD |
WR older than time_diff |
EXCEED_SCANS_LIMIT |
scans_since_calibration >= scan_diff |
TEMP_THRESHOLD |
|current CMOS temp − WR temp| > temp_diff |
NO_NEED |
otherwise |
Mirrored exactly by store.calibration_status / store.calibration_report. The
temperature compared is the app's double-truncated value (see 0x04 above),
and the WR temperature is the mean of its before/after readings.
Thresholds come from the server but are only advice:
GET /v1/device/calibration_thresholds?device_id=<DEVICE_ID>
-> {"thresholds": {"time_diff": …, "scan_diff": …, "temp_diff": …,
"min_scans_for_new_batch": …}}time_diff is in minutes. For this device the live values are
time_diff = 1e9, scan_diff = 1e9, temp_diff = 10000 - every rule
effectively disabled, so a white reference is needed only when none exists.
min_scans_for_new_batch is parsed by the app but never read by
isCalibrationNeeded; it is stored, labelled unused.
The server never rejects a scan for a stale white reference. 62 stored scans
plus 2 deliberately mismatched cross-pairs were replayed, including a WR 2289
days old and a pairing whose WR post-dates its scan. All 64 returned spectra. The
experiment is in 02_processed_data/replay_experiment/; the tool is
tools/replay_all_scans.py.
There is a separate, purely advisory quality check:
POST /v1/device/{device_id}/user_calibration
-> {"calibration": {"is_valid": true, "calibration_id": "<uuid>"}}The app stores the WR before calling it, and a false verdict never un-stores
it.
White references are written as YYYYMMDD_HHMM_calibration.json, with the same
timestamp inside. They are never overwritten: a same-minute save becomes
..._calibration_2.json, and "latest" is computed by sorting, so the whole
calibration history is preserved. Legacy wr_*.json files are still read.
Every canonical scan record embeds its own WR blobs, temperatures and thresholds, so a single scan file is self-contained.
Base: https://api.consumerphysics.com. Requests carry
X-SCiO-Client-Version: Android 1.3.8.554.
POST /oauth/login (email + password)
-> 302 /oauth/authorize
-> 302 <redirect_uri>#access_token=<token>&expires_in=14&…
Tokens are short-lived - expires_in=14. Fetch a fresh one per request;
session.process_pending does. Credentials are asked for once and stored in
.scio_credentials.enc (Fernet, PBKDF2-HMAC-SHA256 with 200 000 iterations, key
derived from getpass.getuser() | platform.node()), which is gitignored and does
not travel between machines.
An inactive account returns HTTP 403 {"user_status": "inactive"}. That is an
account state, not a bug - Consumer Physics can reactivate it.
POST /v2/consumer/spectro-scanAll twelve fields are required unless noted:
| field | value |
|---|---|
device_id |
uppercase 16 hex, from 0x01 |
sampled_at |
ISO 8601 with offset and milliseconds |
sampled_white_at |
ditto, from the white reference |
scio_edition |
"scio_edition" |
i2s_tag_config |
e.g. 20150812-e:PRODUCTION - rejected if empty |
mobile_mac_address |
required; this project sends 02:00:00:00:00:00 |
sample |
base64 blob |
sample_dark |
base64 blob |
sample_white |
base64 blob |
sample_white_dark |
base64 blob |
sample_gradient |
base64, optional (3-frame firmware) |
sample_white_gradient |
base64, optional |
widget_scan_attributes |
[] |
One further field appears in captured request bodies but is not required:
mobile_GPS (latitude, longitude, locality, country, admin_area,
address_line), which the phone app attached to every scan. The server accepts
scans without it, so this project does not send it.
Base64 must be standard alphabet, =-padded, newline every 76 characters -
Android's Base64.DEFAULT. URL-safe base64 is rejected.
Response:
{"_type": "POST spectroscan2",
"spectrum": [ …331 floats… ],
"wavelengths": {"start": 740, "steps": 1, "num_WL": 331}}giving reflectance at 740-1070 nm in 1 nm steps.
Error taxonomy seen in the apps and on the wire: invalid_scan, low_signal,
high_ambient, material_unknown, novelty, InvalidUsage (a malformed or
missing field), plus plain HTTP 401/403 for auth and transient 502s.
GET /v1/device/{ble_id}/firmware-upgrade?…
-> {"new_version": null} # device already up to dateThe only firmware-serving endpoint, and it serves nothing for a current device. Blobs, when present, are base64 with a 4-byte little-endian checksum prefix.
device_idmust be UPPERCASE. Lowercase returns HTTP 404 - proven, see01_rawdata/probe_logs/calibration_v1_thresholds_lowercase.json.- An empty
i2s_tag_configis rejected outright (InvalidUsage). Thirty scans in this repository were captured with an empty tag; they were unusable until the tag was filled in from the device's white reference. The tag can be cleared on the device by an empty0x93write and restored withwrite_i2s_tag(section 3) - after a warning and confirmation, since a wrong tag is rejected too. Capture does not substitute one. - Base64 must be standard, not URL-safe. One legacy group in this repo stored URL-safe unpadded base64; the canonical records re-encode from the authoritative hex.
- The device must be steady blue. Pulsing = idle/charging = silent USB. Power-cycle it.
- Access tokens expire in seconds. Fetch one per request.
- The white-reference decision is yours, not the server's. If you care about photometric accuracy, take a fresh WR; the server will happily accept a six-year-old one and return a meaningless number.
- A smooth curve is not a decoded spectrum. Random bytes read as float32 look smooth. Validation requires agreement with a held-out server spectrum.
| path | contents |
|---|---|
src/scio/ |
the working library: protocol, usb, probe, store, cloud, credentials, session, logscan, corpus, reference, paths |
01-03 notebooks |
the three live notebooks (see §1) |
dev/ |
offline-decoding research (scio_offline, scripts, notebooks/, its own tests) - start at dev/HANDOVER.md; full log in dev/RECOVERY_STATUS.md |
dev/notebooks/superseded/ |
earlier decoding attempts, each labelled superseded, kept as the exploration record |
tools/ |
replay_all_scans.py, analyze_scan.py, check_public_safety.py |
tests/ |
offline tests for the working pipeline (pytest tests/) |
01_rawdata/scans/ |
canonical scio-scan/2 records - self-contained, ready to process |
01_rawdata/ (rest) |
the original captures, untouched: log_files/, log_extracted/, scan_json/, scan_json_calibration/, device_files/, probe_logs/ |
02_processed_data/ |
records plus their spectra, and the replay experiment |
documentation/ |
hardware reference and teardown photos, RE log, hardware acquisition guide, handoff, datasheets, patents |
archive/ |
superseded notebooks (notebooks/), scripts and raw notes (more_info/), frozen - see archive/README.md |
01_rawdata/scans/ holds 97 records: 26 from 2020/2021 app logs (each carrying
the spectrum the server returned at the time - a genuine regression target), 17
more recovered from logs that were never mined, 18 from a 2023 USB series, and 36
from 2026. The canonical records are additional copies; every original file
is byte-identical to what it was before.
Scans converted from the 2023 series have no white reference of their own and
borrow a later one. The server returns a spectrum, but it is not a calibrated
measurement, and every such record says so in provenance.notes.
pytest tests/ # working pipeline - passes with dev/ deleted
pytest dev/tests/ # offline-decoding researchThe two suites are deliberately independent; that is what keeps the strands decoupled.
This repository is licensed under the GNU General Public License v3.0 - see
LICENSE for the full text. That covers everything written here: the
scio and scio_offline libraries, the notebooks, the tools, the tests and this
documentation.
Not covered by the GPL, and not ours to license:
- Consumer Physics documents and trademarks. SCiO, Consumer Physics and
related names, logos and marks belong to their owners. The patents reproduced
under
documentation/(US9377396.pdf,US10330531.pdf) are their filings, included for reference only. - Other third-party documents, likewise reference-only: the Analog Devices
ADSP-BF51x datasheets (
ADSP-BF512.pdf, Rev D, andadsp-bf512-514-516-518.pdf, Rev E), the TICC2540F256.pdfdatasheet, and the notes inarchive/more_info/that came from other people. - SparkFun teardown photos in
documentation/teardown/, shown indocumentation/HARDWARE.md: © SparkFun Electronics, from "SCiO Pocket Molecular Scanner Teardown" by JOEL_E_B, licensed CC BY-SA 4.0, unmodified. - Captured device data under
01_rawdata/is measurement output from the author's own unit, not third-party material - it is shared under the same GPL as the rest.
Nothing here redistributes Consumer Physics firmware, application code or decompiled sources. This is independent interoperability work on hardware the author owns, using the vendor's own documented API and the author's own account.
Facts here were established from: the decompiled Android apps (consumer and researcher/Lab), live probing of a fw-147 device over USB, captured app logs, the live server API, the Sparkfun SCiO teardown, and the ADSP-BF512 datasheet. Corrections are welcome - especially anything that turns a stated hypothesis into a fact, or refutes one.