Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/tasks/36-chre-nanoapp-host.md
Fimeg b87e433ec7 docs: elmyra answered, and the site map lost a layer
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.
2026-08-11 21:57:14 -04:00

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]] where n is the same register as buffer_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 LoadNanoappRequest by 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 plus VerifyMessageContainerBuffer, and every field read back correct — union tag 6, app_id 0x476f6f676c00100e, version 3, target API 0x01050000, empty app_binary, filename elmyra.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: empty app_binary, app_binary_file_name = elmyra.so. The DSP opens it over HexagonFS, which hexagonrpcd already serves from /usr/share/qcom, where dsp/sdsp/elmyra.so already 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 builds target_api_version as (major << 24) | (minor << 16), per ChreDaemonBase::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 with execl("/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:

  1. 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.
  2. 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

  1. Message pump. Built — reader thread on method 4, send on method 5.
  2. Flatbuffers. Built and verified against a generated reader.
  3. Decode the responses. The reader currently prints a byte count. It needs to parse LoadNanoappResponse (success + transaction id) and NanoappMessage (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.
  4. Load elmyra. app_id and versions come from elmyra.napp_header, which parses as the standard NanoAppBinaryHeader: appId 0x476f6f676c00100e, appVersion 3, target CHRE API 1.5. TASK-13 gives an unambiguous pass/fail: squeeze the phone, get an event. The nanoapp then calls ashConfigureElmyraFilters itself — the whole point, since that symbol is unreachable from anywhere else.
  5. 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.mbn is 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_slpi handle opens from Linux and CHRE reports started.
  • elmyra loads 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).