TASK-36 said the load path had never executed. It ran 2026-08-11 — LoadNanoappResponse decoded, success byte set, and six gauges came off a flat baseline at 2502 and 2990 against a 2500 floor. Recorded with Casey's numbers and the two things that would otherwise compress away: the margin carries intent, and the proximity veto is a separate job from the threshold. TASK-66 corrected. The shipped front page promoted ViewTop to a peer of the substrate, the OS and the agent; the canonical map never agreed. Three layers now, plus the delivery defects found by auditing served bytes.
15 KiB
TASK 36 — CHRE nanoapp host for the SLPI
Status: open, opened 2026-07-27 out of TASK-13. Size: real bring-up —
a new FastRPC client, not a config change. Repo: Pixel3Arch (daemon +
package), reference blobs in Pixel3Arch/blobs/los-vendor/.
Goal
Load and run Google's CHRE nanoapps on our SLPI from Linux, and exchange messages with them, so the sensor behaviour that lives inside nanoapps becomes available to SouveraineOS.
Why this exists
TASK-13 chased Active Edge to the last inch and stopped at a wall that is not
Active-Edge-shaped. The squeeze front end has a control surface —
ashConfigureElmyraFilters / ashControlElmyraFilters / elmyra_haptics_control
— which is present in the slpi.mbn we already load and callable only from
inside a CHRE nanoapp. elmyra.so is that nanoapp. Reading elmyra_raw
over libssc gets the ADC in whatever state it defaults to; it cannot configure
it. See TASK-13's 2026-07-27 evening entry for the symbol-level evidence.
Active Edge is the first user, not the point. preloaded_nanoapps.json lists
nine apps in the same directory behind the same door:
activity cc blue drop elmyra geofence gesture imu_cal smartbatching
Significant-motion, fall/drop detection, geofencing and IMU calibration are all things we would otherwise write badly ourselves.
What we have (all extracted, in-tree)
Pixel3Arch/blobs/los-vendor/:
| Path | What it is |
|---|---|
bin/chre_daemon_msm |
The Android host daemon. The protocol reference. |
dsp/sdsp/libchre_slpi_skel.so |
The remote end — what chre_slpi resolves to. |
dsp/sdsp/*.so + *.napp_header |
The nine nanoapps themselves. |
etc/chre/preloaded_nanoapps.json |
The manifest (source dir + app list). |
bin/hw/android.hardware.contexthub@1.2-service.generic, lib64/android.hardware.contexthub@1.*.so |
The HAL above the daemon. Protocol reference only — a Linux port has no HIDL. |
lib64/libsdsprpc.so |
The FastRPC client the daemon links. |
Nanoapp binary format is confirmed standard CHRE: elmyra.napp_header parses
as NanoAppBinaryHeader — magic NANO, appId 0x476f6f676c00100e,
appVersion 3, target CHRE API 1.5.
MOST OF THIS ALREADY EXISTS (found 2026-07-27, after the task was written)
The original framing — "open the chre_slpi handle from Linux" — was wrong
about the starting line. We already do that, and CHRE is already started on
the SLPI right now.
hexagonrpc-blueline ships /usr/libexec/hexagonrpc/chrecd, and hexagonrpcd
spawns it (hexagonrpcd … -p /usr/libexec/hexagonrpc/chrecd). Both are running
on the phone today (pid 863 under pid 729). blueline-hexagonrpcd-sdsp is
active, so the doorbell that TASK-13's 07-26 entry recorded as crash-looping is
healthy.
The gap is precise and small. ~/Projects/hexagonrpc/chrecd/interfaces/chre_slpi.def
defines exactly two remote methods:
HEXAGONRPC_DEFINE_REMOTE_METHOD(0, chre_slpi_start_thread, 0, 0, 0, 0)
HEXAGONRPC_DEFINE_REMOTE_METHOD(1, chre_slpi_wait_on_thread_exit, 0, 0, 0, 0)
chrecd starts the CHRE thread and waits for it to exit. That is all it does. There is no nanoapp loading and no host↔CHRE messaging — so CHRE is running on this device with zero nanoapps in it.
What the vendor daemon uses beyond those two is visible in
blobs/los-vendor/bin/chre_daemon_msm: loadNanoapp, a "MsgToHost thread",
and get_message_to_host returned unexpected error (%d). The skel side
(dsp/sdsp/libchre_slpi_skel.so) dispatches through chre_slpi_skel_invoke
and carries the matching host-message strings ("Cannot deliver NULL pointer
nanoapp message from host", "Rejecting message of size %zu bytes").
So the missing methods are stop-thread, get-message-to-host, and deliver-message-from-host.
The method table — SOLVED 2026-07-27, by disassembly
chre_slpi.idl is not in AOSP (checked system/chre platform/slpi: only
fastrpc.h, with return-code macros). It is Qualcomm-side. But the whole table
is readable out of the vendor host daemon: each of the six stubs in
blobs/los-vendor/bin/chre_daemon_msm loads a literal into w1 immediately
before bl remote_handle_invoke, and w1 is the scalar.
0x00000000 method 0 0 in, 0 out start_thread
0x01000000 method 1 0 in, 0 out wait_on_thread_exit
0x02000000 method 2 0 in, 0 out initialize_reverse_monitor
0x03000000 method 3 0 in, 0 out stop_thread
0x04010200 method 4 1 in, 2 out get_message_to_host
0x05020000 method 5 2 in, 0 out deliver_message_from_host
The counts are buffers, not scalars, and the argument layout at each call site is what pins the identity down:
- method 4 — in
[u32 buffer_len]; out[u32 actual_len],[u8 buf[n]]wherenis the same register asbuffer_len. Takes a length, returns a length plus that many bytes: the receive path. The daemon agrees — "MsgToHost thread started", "get_message_to_host returned unexpected error (%d)". - method 5 — in
[u32 msg_len],[u8 msg[n]]; nothing out. The send path.
There is no load_nanoapp RPC, and a chre_slpi.def entry claiming one at
method 4 is wrong — it aims the receive path at a send-shaped call. Loading is
a host-protocol message: AOSP platform/shared/idl/host_messages.fbs defines
LoadNanoappRequest {transaction_id, app_id, app_version, app_binary, …} in the
ChreMessage union, with fragmentation fields for binaries too large for one
message. The loadNanoapp string in the vendor daemon is its own C++ helper
that builds that flatbuffer — not a remote method name. Nor is the binary
delivered over the HexagonFS file bridge; it rides in app_binary.
Landed as hexagonrpc blueline-slpi 3743745 / pkgs/hexagonrpc patch 0016,
pkgrel 5 — interface only, no behaviour change. The defs are spelled with
in_bufs/out_bufs rather than *_nums because libhexagonrpc writes each
buffer's length into the first in-buffer itself (allocate_first_inbuf +
prepare_outbufs), which reproduces the vendor daemon's 4-byte in-buffer
exactly; spelling them as nums yields SC 0x04010100, one out-buffer short.
Verified: each def re-derives its disassembled scalar and in-buffer size.
BUILT 2026-07-27 — shipped, not yet proven on hardware
hexagonrpc-blueline pkgrel 6 (patch 0017, hexagonrpc blueline-slpi
abe8278). chrecd now:
- Encodes
LoadNanoappRequestby hand (chrecd/chre_host_message.c). No flatc, no C++ toolchain pulled into a C daemon to encode one message. It was verified rather than trusted: the bytes were fed to a flatc-generated reader plusVerifyMessageContainerBuffer, and every field read back correct — union tag 6, app_id0x476f6f676c00100e, version 3, target API0x01050000, emptyapp_binary, filenameelmyra.so. That check caught a real bug: pre-alignment padding landed between a vector's length prefix and its data, so a 10-byte filename decoded as an empty string. FlatBuffers pre-aligns before writing; padding after is silently wrong. - Names a file rather than shipping one. AOSP's
encodeLoadNanoappRequestForFile: emptyapp_binary,app_binary_file_name=elmyra.so. The DSP opens it over HexagonFS, which hexagonrpcd already serves from/usr/share/qcom, wheredsp/sdsp/elmyra.soalready sits. So no fragmentation code is needed at all — the 41 KB never crosses in a message. This also settles TASK-40's tiering question in hardware: the raw gauges stay on the SLPI. - Reads the header (
.napp_header, 40 bytes) for app_id/version, and buildstarget_api_versionas(major << 24) | (minor << 16), perChreDaemonBase::loadNanoapp. - Runs a reader thread on
get_message_to_host, started before any load so a nanoapp's first messages are never queued with nobody reading. - Takes its nanoapp list from
$CHRE_NANOAPPS. Not a constant: which nanoapps a device runs is policy and belongs in the unit. The environment rather than argv because hexagonrpcd spawns its client withexecl("/usr/bin/env", prog, NULL)— no arguments can reach it — but the environment is inherited through that fork.
Untested on hardware. Enabling it wedged the device before any load ran; see below. The load path has never executed once.
The device was broken getting here — read this before retrying
The package was first built with a bare makepkg instead of ./build.sh,
which is the only thing that sets MESON_CROSS_FILE. That produces a package
labelled aarch64 and filled with x86_64 binaries. Installing it gave
cannot execute binary file: Exec format error, the SDSP doorbell crash-looped,
and tearing the daemon down while CHRE held the DSP session left the FastRPC
attach wedged: Could not attach to FastRPC node: Connection timed out, then
Could not fetch next FastRPC message: Broken pipe, failed after 5 restarts.
An SLPI SSR (systemctl restart blueline-slpi, remoteproc2 stop/start) did
not recover it — which is TASK-34's thesis, now reproduced deliberately. The
device needed a cold boot.
Two things follow, and both matter more than the nanoapp:
- Verify the arch before installing anything cross-built, every time:
tar -xOf <pkg>.pkg.tar.zst usr/local/bin/hexagonrpcd | file -. The PKGBUILD now says so at the top. - The load was NOT the cause. With the nanoapp drop-in removed the doorbell still broken-piped, so the instability is the pre-existing TASK-34 condition, not the load request. Do not re-diagnose 0017 for it.
Retrying after a cold boot
# confirm the doorbell is healthy FIRST — never layer onto a sick SLPI
systemctl is-active blueline-hexagonrpcd-sdsp # want: active
timeout 8 monitor-sensor --light # want: live lux
printf '[Service]\nEnvironment=CHRE_NANOAPPS=elmyra\n' | \
sudo tee /etc/systemd/system/blueline-hexagonrpcd-sdsp.service.d/nanoapps.conf
sudo systemctl daemon-reload && sudo systemctl restart blueline-hexagonrpcd-sdsp
journalctl -u blueline-hexagonrpcd-sdsp -f | grep -i "chrecd\|nanoapp\|host message"
Expect chrecd: loading elmyra.so (id 0x476f6f676c00100e v3, target CHRE 1.5)
then either a host message, N bytes (the LoadNanoappResponse — decoding it is
the next piece of work) or a deliver_message_from_host failed with an errno.
If the doorbell dies again on the load, THAT is a real signal about the load; if it dies without the drop-in, it is TASK-34.
2026-08-11 — the load ran, and elmyra answered
Casey's account the same evening, recorded as his evidence and not yet re-verified by a second observer:
Fifty-two bytes climbing back out of the DSP through HexagonFS … a root at offset sixteen and a vtable at six. Message type seven. LoadNanoappResponse. Transaction one … Byte forty-seven: one.
So LoadNanoappResponse decodes, and the success byte is set — item 3 of the
remaining work is done for the load path, and item 4 (load elmyra) has its
unambiguous pass. The response was hand-decoded at offsets rather than through
a generated reader; the verification discipline that caught the FlatBuffers
padding bug above still applies to NanoappMessage.
The squeeze itself produced numbers, against a baseline that had been flat:
Six gauges … 0.240, 0.295, 0.176, 0.359, 0.329, 0.342 … and the numbers came up off that flat line: twenty-five-oh-two. Twenty-nine-ninety. Against a floor I set at twenty-five hundred.
Two observations worth keeping before they compress into "squeeze works":
- Some squeezes barely cleared 2500 and some cleared it outright. The margin is information — attenuation carries how fast the flex released and how much of the squeeze was decision versus reflex. A bare threshold discards that.
- A proximity veto already covers the pocket/tabletop case, so the 2500 floor only has to separate intent from noise once "not reaching for me at all" is ruled out. Those are two different jobs and they are correctly separate — the same shape as §4 confidence versus §10 source health.
Not yet done: the acceptance list below still wants "survives a cold boot as a unit" and "reaches the device as a package". Until those hold, this is a live rail on a running device, not a shipped capability — and nothing public should describe it as one. TASK-31's dial is still what a squeeze should open.
Shape of the remaining work
Message pump.Built — reader thread on method 4, send on method 5.Flatbuffers.Built and verified against a generated reader.- Decode the responses. The reader currently prints a byte count. It needs
to parse
LoadNanoappResponse(success + transaction id) andNanoappMessage(what elmyra will actually send on a squeeze). Same hand-decode approach, same verification method — read the bytes back with a flatc-generated reader before believing them. - Load
elmyra.app_idand versions come fromelmyra.napp_header, which parses as the standardNanoAppBinaryHeader: appId0x476f6f676c00100e, appVersion 3, target CHRE API 1.5. TASK-13 gives an unambiguous pass/fail: squeeze the phone, get an event. The nanoapp then callsashConfigureElmyraFiltersitself — the whole point, since that symbol is unreachable from anywhere else. - Surface it. A squeeze becomes an input event through the gesture/ keyboard routing system (INTERFACE-ARCHITECTURE §4), never a hardcoded binding — and the radial dial (TASK-31) is what it opens.
Method of record
The scalar-extraction recipe, since it will be wanted again for other Qualcomm interfaces with no public IDL:
aarch64-linux-gnu-objdump -d bin/chre_daemon_msm > daemon.asm
# for each `bl remote_handle_invoke@plt`, walk back for the w1 write
# (mov w1,#imm / movz+movk pairs), then decode:
# method = (sc >> 24) & 0x1f in_bufs = (sc >> 16) & 0xff
# out_bufs = (sc >> 8) & 0xff
# and read the remote_arg array construction (stp ptr,len pairs) for the
# argument shapes.
Watch for
- Signing. The nanoapps are signed by "Google Inc. Hexagon Signing" under
a Qualcomm root;
slpi.mbnis the image that validates them. We run that same image, so this should hold — but if a load is rejected, this is the first thing to check, and it is not something we can work around. - Don't fold this into TASK-13. Active Edge stays a sensor task; if CHRE turns out to be blocked, TASK-13 should record that and stop, not grow a workaround.
- Don't write a squeeze driver in the kernel. Same rule as TASK-13.
Acceptance
- The
chre_slpihandle opens from Linux and CHRE reports started. elmyraloads and a physical squeeze produces a host message.- It survives a cold boot as a unit, ordered after the SLPI is up.
- Reaches the device as a package (TASK-25), not a hand-copied binary.
Connects to
TASK-13 (the evidence trail and the first user), TASK-34 (SLPI bring-up sequencing — the doorbell must be healthy before any of this), TASK-31 (the dial — what a squeeze opens), TASK-25 (packaging).