diff --git a/.gitignore b/.gitignore
index b7c1fbd..5c21de0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,8 @@
# Build artifacts
bin/
dist/
+android/pryon/build/
+android/amazon-helper/build/
# Embedded payload — staged by `make payload`, never committed
internal/host/assets/payload/
@@ -29,6 +31,12 @@ captures/
.echolocal.yaml
*.local.yaml
+# User-owned Amazon firmware artifacts used only for local inspection
+.local-amazon/
+.local-device-backup/
+proprietary/
+*.apk
+
# Python (HACS integration)
__pycache__/
*.py[cod]
diff --git a/Makefile b/Makefile
index aa4ead5..ed0ed9e 100644
--- a/Makefile
+++ b/Makefile
@@ -20,6 +20,8 @@ LDFLAGS := -X '$(BUILDVARS).Version=$(VERSION)' \
BUILD_DIR := bin
ASSET_DIR := internal/host/assets/payload
BOOT_IMAGE := images/echolocal-boot.img
+PRYON_APK ?= android/pryon/build/EchoLocalPryon.apk
+ANDROID_MEDIA ?= android/amazon-helper/build/amazon-helper.jar
# echod targets the Echo Dot 2: MT8163, Android 5.1 (API 22). Amazon ships a 32-bit userspace but
# the SoC and kernel are arm64 and /system/lib64 is present, so echod is built 64-bit: the wake word
@@ -117,10 +119,16 @@ check: fmt vet lint test ## Format, vet, lint and test
.PHONY: payload
payload: build-echod ## Stage echod and the boot image for embedding into echoctl
@mkdir -p $(ASSET_DIR)
+ @test -f $(PRYON_APK) || { echo "missing $(PRYON_APK); build android/pryon first"; exit 1; }
+ @test -f $(ANDROID_MEDIA) || { echo "missing $(ANDROID_MEDIA); build android/amazon-helper first"; exit 1; }
cp $(BUILD_DIR)/echod $(ASSET_DIR)/echod
cp $(BOOT_IMAGE) $(ASSET_DIR)/boot.img
+ cp $(PRYON_APK) $(ASSET_DIR)/EchoLocalPryon.apk
+ cp $(ANDROID_MEDIA) $(ASSET_DIR)/amazon-helper.jar
@shasum -a 256 $(ASSET_DIR)/echod | awk '{print $$1}' > $(ASSET_DIR)/echod.sha256
@shasum -a 256 $(ASSET_DIR)/boot.img | awk '{print $$1}' > $(ASSET_DIR)/boot.img.sha256
+ @shasum -a 256 $(ASSET_DIR)/EchoLocalPryon.apk | awk '{print $$1}' > $(ASSET_DIR)/EchoLocalPryon.apk.sha256
+ @shasum -a 256 $(ASSET_DIR)/amazon-helper.jar | awk '{print $$1}' > $(ASSET_DIR)/amazon-helper.jar.sha256
.PHONY: dist
dist: payload ## Full build: echod, the boot image, then echoctl carrying both
diff --git a/README.md b/README.md
index 3af41b0..5d13c5b 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,10 @@ Requires a device unlocked with TWRP or similar — see [xdaforums](https://xdaf
**100% on-device local wake words.** Supports [openWakeWord](https://github.com/dscripka/openWakeWord)
and [microWakeWord](https://github.com/kahrendt/microWakeWord) models, including "stop" detection.
+On compatible original firmware, the installer also enables the Dot's own native Amazon Pryon
+detector as a selectable **Alexa** wake word. Pryon performs wake detection only; EchoLocal still
+owns the Home Assistant Assist, LED, capture, TTS, media, ducking and Sendspin paths.
+[Technical details and rollback guidance](docs/pryon.md) are available for maintainers.
**LED ring.** Twelve individually addressable segments, multiple animation effects across ambient, motion, alert and
room-reactive behavior, and a color picker per segment. The ring can follow the room's volume.
@@ -42,17 +46,40 @@ Everything above works with stock Home Assistant. The
## Installing
-You need a 2nd-generation Echo Dot, connected via a USB cable, and a device that has been unlocked with TWRP as its
-recovery partition. `echoctl` does the rest. It'll prompt for wifi configuration if it hasn't been setup and provide espHome encryption key
+You need a 2nd-generation Echo Dot connected by USB and unlocked with TWRP as its recovery
+partition. `echoctl` discovers the attached Dot's own Pryon libraries, SpeechInteractionManager APK
+and locale model manifests; no Amazon binary or model is shipped by EchoLocal. It prompts for Wi-Fi,
+generates an ESPHome encryption key, reboots when Android must scan the wake-only companion, and
+does not report completion until the native API, mDNS EchoLocal identity, Pryon detector and shared
+live microphone path are ready.
+
+For an unlocked Dot with TWRP recovery on Windows, run the complete source-tree provisioner:
+
+```powershell
+.\provision-echo-dot.ps1 -Name "Kitchen Echo"
+```
+
+Pass `-Serial` when more than one device is attached. The script refuses non-`biscuit` hardware,
+saves a gitignored rollback snapshot, builds and embeds all EchoLocal-owned payloads, installs and
+reboots the Dot, verifies ESPHome plus Pryon/Alexa, saves a private credential receipt inside that
+snapshot, and prints the unique 32-byte ESPHome encryption key last. Connect the Dot to local Wi-Fi
+when prompted; an Amazon account or Amazon registration is
+not required. When the running Android image is not already root and permissive, the script uses
+EchoLocal's verified boot image and TWRP recovery before changing `/system`.
```sh
-echoctl install --name living-room
+echoctl install --name living-room --reboot
```

It then turns up in Home Assistant on its own, and the key `echoctl` printed is what pairs it:
+After pairing, choose **Alexa** in the assistant's Wake word select. The other installed
+openWakeWord and microWakeWord choices remain available. `echoctl status` reports the ESPHome API,
+Android-media bridge and Pryon configuration. Use `--no-pryon` only when intentionally installing
+the legacy direct-ALSA runtime.
+
@@ -65,6 +92,10 @@ make build-echod # cross-compile the daemon for the Dot
make install-echod # build, install, and restart it on a connected device
```
+For a self-contained installer, first build `android/pryon` and `android/amazon-helper`, then run
+`make dist`. Their generated APK/JAR are our code and are embedded in `echoctl`; firmware-owned
+libraries, APKs and models are always read in place from the user's attached Dot.
+
## How it fits together
- **echod** runs on the Dot: the hardware, the wake word engines, the conversation, and an ESPHome
diff --git a/android/amazon-helper/README.md b/android/amazon-helper/README.md
new file mode 100644
index 0000000..e3d1f4d
--- /dev/null
+++ b/android/amazon-helper/README.md
@@ -0,0 +1,16 @@
+# EchoLocal Android media helper
+
+This API-22 `app_process32` helper preserves EchoLocal's Android-media protocol: 16 kHz PCM,
+48 kHz stereo playback, and wake-event delivery. When Pryon is selected it reads live PCM from a
+second reader on Pryon's firmware-owned Amazon `AudioStream`; it does not open a competing
+`AudioRecord`. It also accepts wake events on a separate abstract socket named `echolocal-pryon`.
+
+The Pryon socket accepts bounded version-1 JSON only from the Android UID recorded in
+`/data/misc/echolocal/pryon.uid`, verified with `LocalSocket.getPeerCredentials()`. A valid
+Alexa event is converted to the helper's existing `MSG_WAKE` frame. No audio crosses the
+Pryon socket, and logcat is not used as an event transport.
+
+The separate `echolocal-pryon-pcm` socket is local, root-authenticated and carries only the shared
+16 kHz mono PCM frames from the Pryon audio provider to this helper. Audio remains on the Dot.
+
+Build on Windows with `./build.ps1`. Generated artifacts stay under ignored `build/`.
diff --git a/android/amazon-helper/build.ps1 b/android/amazon-helper/build.ps1
new file mode 100644
index 0000000..d9bd64b
--- /dev/null
+++ b/android/amazon-helper/build.ps1
@@ -0,0 +1,51 @@
+[CmdletBinding()]
+param(
+ [string]$SdkRoot = "$env:LOCALAPPDATA\Android\Sdk",
+ [string]$BuildToolsVersion = "36.0.0",
+ [string]$PlatformVersion = "android-36"
+)
+
+$ErrorActionPreference = "Stop"
+$projectDir = [IO.Path]::GetFullPath($PSScriptRoot)
+$buildDir = [IO.Path]::GetFullPath((Join-Path $projectDir "build"))
+$projectPrefix = $projectDir.TrimEnd([IO.Path]::DirectorySeparatorChar) `
+ + [IO.Path]::DirectorySeparatorChar
+if (-not $buildDir.StartsWith($projectPrefix, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Refusing to clean build directory outside project: $buildDir"
+}
+
+$androidJar = Join-Path $SdkRoot "platforms\$PlatformVersion\android.jar"
+$d8 = Join-Path $SdkRoot "build-tools\$BuildToolsVersion\d8.bat"
+foreach ($path in @($androidJar, $d8)) {
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
+ throw "Required build input is missing: $path"
+ }
+}
+
+if (Test-Path -LiteralPath $buildDir) {
+ Remove-Item -Recurse -Force -LiteralPath $buildDir
+}
+$classesDir = Join-Path $buildDir "classes"
+$dexDir = Join-Path $buildDir "dex"
+New-Item -ItemType Directory -Force -Path $classesDir, $dexDir | Out-Null
+
+$sources = Get-ChildItem -Recurse -File -Filter "*.java" -LiteralPath (Join-Path $projectDir "src")
+& javac -source 8 -target 8 -Xlint:all -d $classesDir -cp $androidJar $sources.FullName
+if ($LASTEXITCODE -ne 0) { throw "javac failed with exit code $LASTEXITCODE" }
+
+$classFiles = Get-ChildItem -Recurse -File -Filter "*.class" -LiteralPath $classesDir
+& $d8 --min-api 22 --lib $androidJar --output $dexDir $classFiles.FullName
+if ($LASTEXITCODE -ne 0) { throw "d8 failed with exit code $LASTEXITCODE" }
+
+$jarPath = Join-Path $buildDir "amazon-helper.jar"
+Push-Location $dexDir
+try {
+ & jar cf $jarPath "classes.dex"
+ if ($LASTEXITCODE -ne 0) { throw "jar failed with exit code $LASTEXITCODE" }
+} finally {
+ Pop-Location
+}
+
+$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $jarPath
+Write-Output "Built: $jarPath"
+Write-Output "SHA256: $($hash.Hash.ToLowerInvariant())"
diff --git a/android/amazon-helper/src/echolocal/AmazonHelper.java b/android/amazon-helper/src/echolocal/AmazonHelper.java
new file mode 100644
index 0000000..196a7ea
--- /dev/null
+++ b/android/amazon-helper/src/echolocal/AmazonHelper.java
@@ -0,0 +1,428 @@
+package echolocal;
+
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioTrack;
+import android.net.Credentials;
+import android.net.LocalServerSocket;
+import android.net.LocalSocket;
+import android.net.LocalSocketAddress;
+import android.util.Log;
+
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+
+/** Android media bridge used by the deployed EchoLocal runtime. */
+public final class AmazonHelper {
+ static final int SAMPLE_RATE = 16000;
+ static final int PLAY_RATE = 48000;
+ static final int PLAYBACK_PERIODS = 2;
+ static final int FRAME_BYTES = 640;
+ static final int MAX_PAYLOAD = 1024 * 1024;
+
+ static final int MSG_WAKE = 1;
+ static final int MSG_AUDIO = 2;
+ static final int MSG_START_CAPTURE = 3;
+ static final int MSG_STOP_CAPTURE = 4;
+ static final int MSG_PLAY = 5;
+ static final int MSG_PLAY_STOP = 6;
+
+ static final String SOCKET = "echolocal-amazon";
+ static final String PRYON_SOCKET = "echolocal-pryon";
+ static final String PRYON_PCM_SOCKET = "echolocal-pryon-pcm";
+ static final String PRYON_UID_PATH = "/data/misc/echolocal/pryon.uid";
+ static final String TAG = "echolocal-helper";
+ static final int MAX_EVENT_BYTES = 1024;
+
+ private AmazonHelper() { }
+
+ public static void main(String[] args) {
+ Log.i(TAG, "starting with shared Pryon PCM capture");
+ new Server().run();
+ }
+
+ static final class Server {
+ private volatile Connection current;
+ private long lastPryonMonotonicMs = -1;
+
+ void run() {
+ Thread pryon = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ servePryon();
+ }
+ }, "pryon-events");
+ pryon.setDaemon(true);
+ pryon.start();
+
+ while (true) {
+ LocalServerSocket server = null;
+ try {
+ server = new LocalServerSocket(SOCKET);
+ Log.i(TAG, "listening on abstract socket @" + SOCKET);
+ while (true) {
+ LocalSocket socket = server.accept();
+ Log.i(TAG, "echod connected");
+ Connection connection = new Connection(socket);
+ current = connection;
+ connection.serve();
+ if (current == connection) current = null;
+ Log.i(TAG, "echod disconnected");
+ }
+ } catch (IOException error) {
+ Log.e(TAG, "server socket error: " + error);
+ closeQuietly(server);
+ sleep(2000);
+ }
+ }
+ }
+
+ private void servePryon() {
+ while (true) {
+ LocalServerSocket server = null;
+ try {
+ server = new LocalServerSocket(PRYON_SOCKET);
+ Log.i(TAG, "listening for Pryon events on @" + PRYON_SOCKET);
+ while (true) {
+ LocalSocket socket = server.accept();
+ if (!authorized(socket)) {
+ closeQuietly(socket);
+ continue;
+ }
+ Log.i(TAG, "Pryon companion connected");
+ servePryonConnection(socket);
+ Log.i(TAG, "Pryon companion disconnected");
+ }
+ } catch (IOException error) {
+ Log.e(TAG, "Pryon socket error: " + error);
+ closeQuietly(server);
+ sleep(2000);
+ }
+ }
+ }
+
+ private boolean authorized(LocalSocket socket) {
+ try {
+ int expected = readUID();
+ Credentials peer = socket.getPeerCredentials();
+ if (peer == null || peer.getUid() != expected) {
+ Log.w(TAG, "Pryon peer rejected uid="
+ + (peer == null ? "unknown" : peer.getUid())
+ + " expected=" + expected);
+ return false;
+ }
+ return true;
+ } catch (Throwable error) {
+ Log.w(TAG, "Pryon peer credentials unavailable: " + error);
+ return false;
+ }
+ }
+
+ private void servePryonConnection(LocalSocket socket) {
+ try {
+ InputStream input = socket.getInputStream();
+ DataOutputStream output = new DataOutputStream(socket.getOutputStream());
+ while (true) {
+ byte[] frame = readLine(input);
+ if (frame == null) return;
+ boolean accepted;
+ try {
+ accepted = forwardPryon(new JSONObject(new String(frame, UTF8)));
+ } catch (Throwable error) {
+ Log.w(TAG, "Pryon event rejected: " + error);
+ accepted = false;
+ }
+ output.write(accepted ? ACK_OK : ACK_RETRY);
+ output.flush();
+ }
+ } catch (IOException error) {
+ Log.w(TAG, "Pryon connection error: " + error);
+ } finally {
+ closeQuietly(socket);
+ }
+ }
+
+ private synchronized boolean forwardPryon(JSONObject event) throws Exception {
+ validateKeys(event);
+ if (event.getInt("version") != 1) throw new IOException("bad version");
+ if (!"wake".equals(event.getString("event"))) throw new IOException("bad event");
+ if (!"alexa".equalsIgnoreCase(event.getString("word"))) {
+ throw new IOException("bad word");
+ }
+ int confidence = event.getInt("confidence");
+ if (confidence < 0 || confidence > 1000) throw new IOException("bad confidence");
+ int detectionType = event.getInt("detection_type");
+ long monotonicMs = event.getLong("monotonic_ms");
+ if (monotonicMs <= 0) throw new IOException("bad monotonic_ms");
+
+ if (monotonicMs == lastPryonMonotonicMs) return true;
+ Connection connection = current;
+ if (connection == null) {
+ Log.w(TAG, "Pryon wake waiting for echod");
+ return false;
+ }
+ if (!connection.sendWake(phrase("Alexa"))) return false;
+ lastPryonMonotonicMs = monotonicMs;
+ Log.i(TAG, "Pryon wake forwarded confidence=" + confidence
+ + " detection_type=" + detectionType);
+ return true;
+ }
+ }
+
+ static final class Connection {
+ private volatile boolean capturing;
+ private volatile LocalSocket captureSocket;
+ private final DataInputStream in;
+ private final DataOutputStream out;
+ private final LocalSocket socket;
+ private AudioTrack track;
+
+ Connection(LocalSocket socket) throws IOException {
+ this.socket = socket;
+ in = new DataInputStream(socket.getInputStream());
+ out = new DataOutputStream(socket.getOutputStream());
+ }
+
+ void serve() {
+ try {
+ while (true) {
+ int type = in.readUnsignedByte();
+ int length = in.readInt();
+ if (length < 0 || length > MAX_PAYLOAD) {
+ throw new IOException("bad frame length " + length);
+ }
+ byte[] payload = new byte[length];
+ in.readFully(payload);
+ switch (type) {
+ case MSG_START_CAPTURE:
+ startCapture();
+ break;
+ case MSG_STOP_CAPTURE:
+ stopCapture();
+ break;
+ case MSG_PLAY:
+ play(payload);
+ break;
+ case MSG_PLAY_STOP:
+ playStop();
+ break;
+ default:
+ Log.w(TAG, "unknown echod message " + type);
+ }
+ }
+ } catch (EOFException ignored) {
+ // Normal disconnect.
+ } catch (IOException error) {
+ Log.w(TAG, "connection read error: " + error);
+ } finally {
+ stopCapture();
+ playStop();
+ closeQuietly(socket);
+ }
+ }
+
+ synchronized boolean send(int type, byte[] payload) {
+ try {
+ out.writeByte(type);
+ out.writeInt(payload == null ? 0 : payload.length);
+ if (payload != null && payload.length > 0) out.write(payload);
+ out.flush();
+ return true;
+ } catch (IOException error) {
+ Log.w(TAG, "send failed: " + error);
+ return false;
+ }
+ }
+
+ boolean sendWake(byte[] payload) { return send(MSG_WAKE, payload); }
+
+ private synchronized void startCapture() {
+ if (capturing) return;
+ capturing = true;
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ capture();
+ }
+ }, "capture").start();
+ Log.i(TAG, "capture started");
+ }
+
+ private synchronized void stopCapture() {
+ capturing = false;
+ closeQuietly(captureSocket);
+ captureSocket = null;
+ }
+
+ private void capture() {
+ int failures = 0;
+ while (capturing) {
+ LocalSocket shared = null;
+ try {
+ shared = new LocalSocket();
+ // The timeout overload throws UnsupportedOperationException on Fire OS 5.
+ // This is a local abstract socket, and the outer loop already retries a failed
+ // connection, so use the API-22-compatible overload.
+ shared.connect(new LocalSocketAddress(
+ PRYON_PCM_SOCKET, LocalSocketAddress.Namespace.ABSTRACT));
+ captureSocket = shared;
+ InputStream input = shared.getInputStream();
+ Log.i(TAG, "shared Pryon capture connected");
+ failures = 0;
+ byte[] frame = new byte[FRAME_BYTES];
+ boolean first = true;
+ while (capturing) {
+ int count = input.read(frame, 0, frame.length);
+ if (count < 0) throw new EOFException("shared Pryon PCM ended");
+ if (count == 0) continue;
+ if (!send(MSG_AUDIO, Arrays.copyOf(frame, count))) break;
+ if (first) {
+ Log.i(TAG, "shared Pryon capture first frame bytes=" + count);
+ first = false;
+ }
+ }
+ } catch (IOException error) {
+ failures++;
+ if (capturing && (failures == 1 || failures % 20 == 0)) {
+ Log.i(TAG, "waiting for shared Pryon capture attempt=" + failures
+ + " reason=" + error.getMessage());
+ }
+ } catch (Throwable error) {
+ if (capturing) Log.w(TAG, "shared Pryon capture error", error);
+ } finally {
+ if (captureSocket == shared) captureSocket = null;
+ closeQuietly(shared);
+ }
+ if (capturing) sleep(250);
+ }
+ Log.i(TAG, "shared Pryon capture stopped");
+ }
+
+ private void play(byte[] payload) {
+ if (payload.length == 0) return;
+ if (track == null) {
+ try {
+ int buffer = Math.max(AudioTrack.getMinBufferSize(PLAY_RATE,
+ AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT),
+ payload.length * PLAYBACK_PERIODS);
+ AudioTrack candidate = new AudioTrack(AudioManager.STREAM_MUSIC, PLAY_RATE,
+ AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT,
+ buffer, AudioTrack.MODE_STREAM);
+ if (candidate.getState() != AudioTrack.STATE_INITIALIZED) {
+ Log.e(TAG, "AudioTrack not initialized");
+ candidate.release();
+ return;
+ }
+ candidate.play();
+ track = candidate;
+ Log.i(TAG, "playback started");
+ } catch (Throwable error) {
+ Log.e(TAG, "AudioTrack ctor failed: " + error);
+ return;
+ }
+ }
+ track.write(payload, 0, payload.length);
+ }
+
+ private void playStop() {
+ AudioTrack old = track;
+ track = null;
+ if (old == null) return;
+ try {
+ old.pause();
+ old.flush();
+ old.stop();
+ } catch (Throwable ignored) { }
+ old.release();
+ Log.i(TAG, "playback stopped");
+ }
+ }
+
+ static final Charset UTF8 = Charset.forName("UTF-8");
+ static final byte[] ACK_OK = "ok\n".getBytes(UTF8);
+ static final byte[] ACK_RETRY = "retry\n".getBytes(UTF8);
+ static final Set EVENT_KEYS = new HashSet<>(Arrays.asList(
+ "version", "event", "word", "confidence", "detection_type", "monotonic_ms"));
+
+ static byte[] readLine(InputStream input) throws IOException {
+ ByteArrayOutputStream line = new ByteArrayOutputStream();
+ while (true) {
+ int value = input.read();
+ if (value < 0) return line.size() == 0 ? null : line.toByteArray();
+ if (value == '\n') return line.toByteArray();
+ if (line.size() >= MAX_EVENT_BYTES) throw new IOException("Pryon event too large");
+ line.write(value);
+ }
+ }
+
+ static void validateKeys(JSONObject event) throws IOException {
+ Set found = new HashSet<>();
+ Iterator keys = event.keys();
+ while (keys.hasNext()) found.add(keys.next());
+ if (!found.equals(EVENT_KEYS)) throw new IOException("unexpected fields " + found);
+ }
+
+ static int readUID() throws IOException {
+ File file = new File(PRYON_UID_PATH);
+ if (!file.isFile()) throw new IOException("missing " + PRYON_UID_PATH);
+ FileInputStream input = new FileInputStream(file);
+ try {
+ byte[] raw = new byte[32];
+ int count = input.read(raw);
+ int uid = Integer.parseInt(new String(raw, 0, Math.max(count, 0), UTF8).trim());
+ if (uid < 10000) throw new IOException("invalid UID " + uid);
+ return uid;
+ } catch (NumberFormatException error) {
+ throw new IOException("invalid UID", error);
+ } finally {
+ input.close();
+ }
+ }
+
+ static byte[] phrase(String value) {
+ byte[] text = value.getBytes(UTF8);
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ DataOutputStream output = new DataOutputStream(bytes);
+ try {
+ output.writeShort(text.length);
+ output.write(text);
+ output.writeInt(0);
+ output.writeLong(0);
+ output.writeLong(0);
+ return bytes.toByteArray();
+ } catch (IOException impossible) {
+ return new byte[0];
+ }
+ }
+
+ static void sleep(long milliseconds) {
+ try {
+ Thread.sleep(milliseconds);
+ } catch (InterruptedException error) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ static void closeQuietly(LocalServerSocket socket) {
+ if (socket == null) return;
+ try { socket.close(); } catch (IOException ignored) { }
+ }
+
+ static void closeQuietly(LocalSocket socket) {
+ if (socket == null) return;
+ try { socket.close(); } catch (IOException ignored) { }
+ }
+}
diff --git a/android/pryon/AndroidManifest.xml b/android/pryon/AndroidManifest.xml
new file mode 100644
index 0000000..d108b3d
--- /dev/null
+++ b/android/pryon/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/pryon/README.md b/android/pryon/README.md
new file mode 100644
index 0000000..0775fc2
--- /dev/null
+++ b/android/pryon/README.md
@@ -0,0 +1,68 @@
+# EchoLocal Pryon wake companion
+
+This API-22 privileged APK is deliberately limited to Pryon wake detection and wake-event delivery:
+
+- create the firmware-owned Amazon `AudioStream` in a separate Binder process;
+- initialize `NativeWakeWordServiceCore` with paths discovered on the attached Dot;
+- let `libwakewordserver_jni.so` own the privileged 16 kHz HOTWORD recorder;
+- expose a root-authenticated second `AudioStream` reader to EchoLocal's media helper, so live
+ conversation audio and Pryon detection use the same recorder instead of racing for two inputs;
+- print `PRYON_WAKEWORD_DETECTED word=alexa ...` for accepted live detections;
+- deliver a bounded, versioned JSON wake event to EchoLocal's authenticated filesystem socket;
+- disable and destroy the native service during an orderly shutdown.
+- exit both isolated ART processes after teardown because Amazon's JNI libraries are
+ process-global and cannot be safely reloaded by a second `DexClassLoader`.
+
+It does not contain proprietary files, send audio anywhere, or invoke EchoLocal's voice,
+LED, media, Home Assistant, TTS, or ducking paths. It sends only wake metadata to
+the abstract socket `@echolocal-pryon`; the Android media helper authenticates the peer
+UID and forwards the existing wake frame to `echod`, which owns all response behavior.
+
+## Build on Windows
+
+```powershell
+.\build.ps1
+```
+
+The script uses the installed Android SDK, Java compiler, and the user's standard debug
+keystore. Generated files remain under the ignored `build/` directory.
+
+## Device configuration
+
+Install the signed APK as `/system/priv-app/EchoLocalPryon/EchoLocalPryon.apk`,
+reboot so Android grants system-app permissions, then supply the paths found during the
+read-only device inventory:
+
+```text
+am startservice -n com.echolocal.pryon/.PryonDetectorService \
+ --es amazon_apk /system/priv-app/SpeechInteractionManager/SpeechInteractionManager.apk \
+ --es alexa_model /system/local/models/keyword/en-GB/ALEXA/pryon.manifest \
+ --es aed_model /system/local/models/AED/pryon.manifest
+```
+
+All three paths are required together, validated as readable absolute files, and persisted
+for restart/reboot tests. The source contains no firmware-specific proprietary path default.
+
+Observe only the companion tag:
+
+```text
+adb logcat -v time -s EchoLocalPryon:I '*:S'
+```
+
+Startup verification requires `PRYON_CAPTURE_ACTIVE`, `PRYON_SHARED_PCM_FIRST_FRAME` and
+`PRYON_READY`, proving that the native frame counter and EchoLocal's shared reader both receive live
+microphone audio. Speaking near the physical Dot should then produce repeated deterministic
+`PRYON_WAKEWORD_DETECTED word=alexa` lines.
+WAV injection is not accepted as proof of live microphone operation.
+
+## Rollback boundary
+
+The only installed system path owned by this companion is:
+
+```text
+/system/priv-app/EchoLocalPryon
+```
+
+Rollback must force-stop `com.echolocal.pryon`, remove exactly that directory while
+`/system` is writable, remount `/system` read-only, and reboot. Do not remove or replace any
+Amazon APK, native library, or model.
diff --git a/android/pryon/build.ps1 b/android/pryon/build.ps1
new file mode 100644
index 0000000..222217d
--- /dev/null
+++ b/android/pryon/build.ps1
@@ -0,0 +1,74 @@
+[CmdletBinding()]
+param(
+ [string]$SdkRoot = "$env:LOCALAPPDATA\Android\Sdk",
+ [string]$BuildToolsVersion = "36.0.0",
+ [string]$PlatformVersion = "android-36",
+ [string]$KeyStore = "$env:USERPROFILE\.android\debug.keystore"
+)
+
+$ErrorActionPreference = "Stop"
+$projectDir = [IO.Path]::GetFullPath($PSScriptRoot)
+$buildDir = [IO.Path]::GetFullPath((Join-Path $projectDir "build"))
+$projectPrefix = $projectDir.TrimEnd([IO.Path]::DirectorySeparatorChar) `
+ + [IO.Path]::DirectorySeparatorChar
+if (-not $buildDir.StartsWith($projectPrefix, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Refusing to clean build directory outside project: $buildDir"
+}
+
+$toolsDir = Join-Path $SdkRoot "build-tools\$BuildToolsVersion"
+$androidJar = Join-Path $SdkRoot "platforms\$PlatformVersion\android.jar"
+$aapt = Join-Path $toolsDir "aapt.exe"
+$d8 = Join-Path $toolsDir "d8.bat"
+$zipalign = Join-Path $toolsDir "zipalign.exe"
+$apksigner = Join-Path $toolsDir "apksigner.bat"
+$required = @($androidJar, $aapt, $d8, $zipalign, $apksigner, $KeyStore)
+foreach ($path in $required) {
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
+ throw "Required build input is missing: $path"
+ }
+}
+
+if (Test-Path -LiteralPath $buildDir) {
+ Remove-Item -Recurse -Force -LiteralPath $buildDir
+}
+$classesDir = Join-Path $buildDir "classes"
+$dexDir = Join-Path $buildDir "dex"
+New-Item -ItemType Directory -Force -Path $classesDir, $dexDir | Out-Null
+
+$sources = Get-ChildItem -Recurse -File -Filter "*.java" -LiteralPath (Join-Path $projectDir "src")
+if ($sources.Count -eq 0) { throw "No Java sources found" }
+
+& javac -source 8 -target 8 -Xlint:all -d $classesDir -cp $androidJar $sources.FullName
+if ($LASTEXITCODE -ne 0) { throw "javac failed with exit code $LASTEXITCODE" }
+
+$classFiles = Get-ChildItem -Recurse -File -Filter "*.class" -LiteralPath $classesDir
+& $d8 --min-api 22 --lib $androidJar --output $dexDir $classFiles.FullName
+if ($LASTEXITCODE -ne 0) { throw "d8 failed with exit code $LASTEXITCODE" }
+
+$unsignedApk = Join-Path $buildDir "EchoLocalPryon.unsigned.apk"
+$alignedApk = Join-Path $buildDir "EchoLocalPryon.aligned.apk"
+$signedApk = Join-Path $buildDir "EchoLocalPryon.apk"
+& $aapt package -f -M (Join-Path $projectDir "AndroidManifest.xml") `
+ -F $unsignedApk -I $androidJar
+if ($LASTEXITCODE -ne 0) { throw "aapt failed with exit code $LASTEXITCODE" }
+
+Push-Location $dexDir
+try {
+ & $aapt add -f $unsignedApk "classes.dex"
+ if ($LASTEXITCODE -ne 0) { throw "aapt add failed with exit code $LASTEXITCODE" }
+} finally {
+ Pop-Location
+}
+
+& $zipalign -f -p 4 $unsignedApk $alignedApk
+if ($LASTEXITCODE -ne 0) { throw "zipalign failed with exit code $LASTEXITCODE" }
+
+& $apksigner sign --ks $KeyStore --ks-key-alias androiddebugkey `
+ --ks-pass pass:android --key-pass pass:android --out $signedApk $alignedApk
+if ($LASTEXITCODE -ne 0) { throw "apksigner failed with exit code $LASTEXITCODE" }
+& $apksigner verify --verbose --print-certs $signedApk
+if ($LASTEXITCODE -ne 0) { throw "APK signature verification failed" }
+
+$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $signedApk
+Write-Output "Built: $signedApk"
+Write-Output "SHA256: $($hash.Hash.ToLowerInvariant())"
diff --git a/android/pryon/src/com/echolocal/pryon/AudioProviderService.java b/android/pryon/src/com/echolocal/pryon/AudioProviderService.java
new file mode 100644
index 0000000..bff9161
--- /dev/null
+++ b/android/pryon/src/com/echolocal/pryon/AudioProviderService.java
@@ -0,0 +1,195 @@
+package com.echolocal.pryon;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.media.AudioFormat;
+import android.net.Credentials;
+import android.net.LocalServerSocket;
+import android.net.LocalSocket;
+import android.os.Binder;
+import android.os.IBinder;
+import android.os.Parcel;
+import android.os.Process;
+import android.os.RemoteException;
+import android.util.Log;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.lang.reflect.Method;
+
+import dalvik.system.DexClassLoader;
+
+/** Creates the shared Amazon AudioStream; the native detector is its only writer. */
+public final class AudioProviderService extends Service {
+ private Object stream;
+ private Class> streamClass;
+ private volatile LocalServerSocket pcmServer;
+ private volatile boolean stopping;
+
+ private final Binder binder = new Binder() {
+ @Override
+ protected boolean onTransact(int code, Parcel data, Parcel reply, int flags)
+ throws RemoteException {
+ try {
+ data.enforceInterface(PryonProtocol.DESCRIPTOR);
+ if (code == PryonProtocol.GET_STREAM) {
+ ensureStream();
+ reply.writeNoException();
+ streamClass.getMethod("writeToParcel", Parcel.class, int.class)
+ .invoke(stream, reply, 0);
+ return true;
+ }
+ } catch (Throwable error) {
+ Log.e(PryonProtocol.TAG, "PRYON_AUDIO_PROVIDER_ERROR", error);
+ reply.writeException(new IllegalStateException(error));
+ return true;
+ }
+ return super.onTransact(code, data, reply, flags);
+ }
+ };
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ Log.i(PryonProtocol.TAG, "PRYON_AUDIO_PROVIDER_BOUND");
+ return binder;
+ }
+
+ private synchronized void ensureStream() throws Exception {
+ if (stream != null) return;
+
+ PryonConfig config = PryonConfig.load(this);
+ config.validate();
+ File dexDir = getDir("amazon_audio_dex", Context.MODE_PRIVATE);
+ DexClassLoader loader = new DexClassLoader(
+ config.amazonApk, dexDir.getAbsolutePath(), "/system/lib", getClassLoader());
+ streamClass = Class.forName("amazon.speech.audio.AudioStream", true, loader);
+
+ AudioFormat format = new AudioFormat.Builder()
+ .setSampleRate(16000)
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
+ .setChannelMask(AudioFormat.CHANNEL_IN_MONO)
+ .build();
+ stream = streamClass.getMethod("create", String.class, AudioFormat.class, int.class)
+ .invoke(null, "EchoLocalPryon_Microphone", format, 720000);
+ if (stream == null) {
+ throw new IllegalStateException("Amazon AudioStream.create returned null");
+ }
+ Log.i(PryonProtocol.TAG, "PRYON_AUDIO_STREAM_READY sample_rate=16000 channels=1 pcm=16");
+ startPcmServer();
+ }
+
+ private synchronized void startPcmServer() {
+ if (pcmServer != null) return;
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ servePcm();
+ }
+ }, "pryon-pcm").start();
+ }
+
+ private void servePcm() {
+ LocalServerSocket server = null;
+ try {
+ server = new LocalServerSocket(PryonProtocol.PCM_SOCKET);
+ pcmServer = server;
+ Log.i(PryonProtocol.TAG, "PRYON_SHARED_PCM_LISTENING socket=@"
+ + PryonProtocol.PCM_SOCKET);
+ while (!stopping) {
+ LocalSocket socket = server.accept();
+ Credentials peer = socket.getPeerCredentials();
+ if (peer == null || peer.getUid() != 0) {
+ Log.w(PryonProtocol.TAG, "PRYON_SHARED_PCM_REJECTED uid="
+ + (peer == null ? "unknown" : peer.getUid()));
+ closeQuietly(socket);
+ continue;
+ }
+ servePcmClient(socket);
+ }
+ } catch (Throwable error) {
+ if (!stopping) Log.e(PryonProtocol.TAG, "PRYON_SHARED_PCM_SERVER_ERROR", error);
+ } finally {
+ closeQuietly(server);
+ pcmServer = null;
+ }
+ }
+
+ private void servePcmClient(LocalSocket socket) {
+ Object reader = null;
+ try {
+ Object currentStream;
+ Class> currentClass;
+ synchronized (this) {
+ currentStream = stream;
+ currentClass = streamClass;
+ }
+ if (currentStream == null || currentClass == null) {
+ throw new IllegalStateException("Amazon AudioStream is unavailable");
+ }
+ reader = currentClass.getMethod("openReader").invoke(currentStream);
+ Method synchronize = reader.getClass().getMethod("synchronize");
+ long position = ((Number) synchronize.invoke(reader)).longValue();
+ Method read = reader.getClass().getMethod(
+ "read", byte[].class, int.class, int.class);
+ OutputStream output = socket.getOutputStream();
+ byte[] frame = new byte[640];
+ boolean first = true;
+ Log.i(PryonProtocol.TAG, "PRYON_SHARED_PCM_CONNECTED uid=0 position=" + position);
+ while (!stopping) {
+ int count = ((Number) read.invoke(reader, frame, 0, frame.length)).intValue();
+ if (count < 0) {
+ // The socket can connect in the short interval between AudioStream creation and
+ // the native detector starting its writer. Let the helper reconnect quietly.
+ Log.i(PryonProtocol.TAG, "PRYON_SHARED_PCM_WAITING_FOR_WRITER result=" + count);
+ return;
+ }
+ if (count == 0) continue;
+ output.write(frame, 0, count);
+ if (first) {
+ Log.i(PryonProtocol.TAG, "PRYON_SHARED_PCM_FIRST_FRAME bytes=" + count);
+ first = false;
+ }
+ }
+ } catch (IOException error) {
+ if (!stopping) Log.i(PryonProtocol.TAG,
+ "PRYON_SHARED_PCM_CLIENT_CLOSED reason=" + error.getMessage());
+ } catch (Throwable error) {
+ if (!stopping) Log.w(PryonProtocol.TAG, "PRYON_SHARED_PCM_CLIENT_ERROR", error);
+ } finally {
+ if (reader != null) {
+ try {
+ reader.getClass().getMethod("close").invoke(reader);
+ } catch (Throwable ignored) { }
+ }
+ closeQuietly(socket);
+ Log.i(PryonProtocol.TAG, "PRYON_SHARED_PCM_DISCONNECTED");
+ }
+ }
+
+ private static void closeQuietly(LocalSocket socket) {
+ if (socket == null) return;
+ try { socket.close(); } catch (IOException ignored) { }
+ }
+
+ private static void closeQuietly(LocalServerSocket server) {
+ if (server == null) return;
+ try { server.close(); } catch (IOException ignored) { }
+ }
+
+ @Override
+ public void onDestroy() {
+ stopping = true;
+ closeQuietly(pcmServer);
+ pcmServer = null;
+ stream = null;
+ streamClass = null;
+ Log.i(PryonProtocol.TAG, "PRYON_AUDIO_PROVIDER_EXIT");
+ super.onDestroy();
+ // Amazon's audio JNI is process-global and cannot be loaded by a second
+ // DexClassLoader in the same ART process. A fresh provider process is the
+ // deterministic restart boundary.
+ Process.killProcess(Process.myPid());
+ }
+}
diff --git a/android/pryon/src/com/echolocal/pryon/BootReceiver.java b/android/pryon/src/com/echolocal/pryon/BootReceiver.java
new file mode 100644
index 0000000..5ee5301
--- /dev/null
+++ b/android/pryon/src/com/echolocal/pryon/BootReceiver.java
@@ -0,0 +1,15 @@
+package com.echolocal.pryon;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+public final class BootReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.i(PryonProtocol.TAG, "PRYON_BOOT_RECEIVER action="
+ + (intent == null ? "null" : intent.getAction()));
+ context.startService(new Intent(context, PryonDetectorService.class));
+ }
+}
diff --git a/android/pryon/src/com/echolocal/pryon/PryonConfig.java b/android/pryon/src/com/echolocal/pryon/PryonConfig.java
new file mode 100644
index 0000000..6b045ad
--- /dev/null
+++ b/android/pryon/src/com/echolocal/pryon/PryonConfig.java
@@ -0,0 +1,85 @@
+package com.echolocal.pryon;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+
+import java.io.File;
+
+final class PryonConfig {
+ private static final String PREFS = "pryon";
+
+ final String amazonApk;
+ final String alexaModel;
+ final String aedModel;
+
+ private PryonConfig(String amazonApk, String alexaModel, String aedModel) {
+ this.amazonApk = amazonApk;
+ this.alexaModel = alexaModel;
+ this.aedModel = aedModel;
+ }
+
+ static PryonConfig load(Context context) {
+ SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ return new PryonConfig(
+ prefs.getString(PryonProtocol.EXTRA_AMAZON_APK, null),
+ prefs.getString(PryonProtocol.EXTRA_ALEXA_MODEL, null),
+ prefs.getString(PryonProtocol.EXTRA_AED_MODEL, null));
+ }
+
+ static boolean updateFromIntent(Context context, Intent intent) {
+ if (intent == null) return false;
+ boolean any = intent.hasExtra(PryonProtocol.EXTRA_AMAZON_APK)
+ || intent.hasExtra(PryonProtocol.EXTRA_ALEXA_MODEL)
+ || intent.hasExtra(PryonProtocol.EXTRA_AED_MODEL);
+ if (!any) return false;
+
+ String amazonApk = intent.getStringExtra(PryonProtocol.EXTRA_AMAZON_APK);
+ String alexaModel = intent.getStringExtra(PryonProtocol.EXTRA_ALEXA_MODEL);
+ String aedModel = intent.getStringExtra(PryonProtocol.EXTRA_AED_MODEL);
+ PryonConfig supplied = new PryonConfig(amazonApk, alexaModel, aedModel);
+ supplied.validate();
+
+ PryonConfig current = load(context);
+ if (same(current.amazonApk, supplied.amazonApk)
+ && same(current.alexaModel, supplied.alexaModel)
+ && same(current.aedModel, supplied.aedModel)) {
+ return false;
+ }
+
+ SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ if (!prefs.edit()
+ .putString(PryonProtocol.EXTRA_AMAZON_APK, amazonApk)
+ .putString(PryonProtocol.EXTRA_ALEXA_MODEL, alexaModel)
+ .putString(PryonProtocol.EXTRA_AED_MODEL, aedModel)
+ .commit()) {
+ throw new IllegalStateException("Unable to persist Pryon configuration");
+ }
+ return true;
+ }
+
+ private static boolean same(String left, String right) {
+ return left == null ? right == null : left.equals(right);
+ }
+
+ void validate() {
+ requireReadableFile("SpeechInteractionManager APK", amazonApk);
+ requireReadableFile("Alexa manifest", alexaModel);
+ requireReadableFile("AED manifest", aedModel);
+ }
+
+ String describe() {
+ return "amazon_apk=" + amazonApk + ", alexa_model=" + alexaModel
+ + ", aed_model=" + aedModel;
+ }
+
+ private static void requireReadableFile(String label, String path) {
+ if (path == null || path.length() == 0) {
+ throw new IllegalStateException(label + " path was not configured");
+ }
+ File file = new File(path);
+ if (!file.isAbsolute() || !file.isFile() || !file.canRead()) {
+ throw new IllegalStateException(label + " is not a readable absolute file: " + path);
+ }
+ }
+}
diff --git a/android/pryon/src/com/echolocal/pryon/PryonDetectorService.java b/android/pryon/src/com/echolocal/pryon/PryonDetectorService.java
new file mode 100644
index 0000000..10865ab
--- /dev/null
+++ b/android/pryon/src/com/echolocal/pryon/PryonDetectorService.java
@@ -0,0 +1,347 @@
+package com.echolocal.pryon;
+
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.media.AudioFormat;
+import android.os.IBinder;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.Process;
+import android.os.SystemClock;
+import android.util.Log;
+
+import java.io.File;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import dalvik.system.DexClassLoader;
+
+/** Wake-only companion for the firmware-owned Pryon detector. */
+public final class PryonDetectorService extends Service {
+ private final AtomicBoolean initializing = new AtomicBoolean();
+ private final Object nativeLock = new Object();
+
+ private volatile IBinder audioProvider;
+ private volatile boolean bindRequested;
+ private volatile boolean initialized;
+ private volatile boolean nativeCreated;
+ private Object core;
+ private Class> coreClass;
+ private Object inputStream;
+ private Object metadataStream;
+ private Object[] callbackProxies;
+ private Class> streamClass;
+ private long lastDetectionMs;
+ private PryonEventClient eventClient;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ eventClient = new PryonEventClient();
+ Log.i(PryonProtocol.TAG, "PRYON_SERVICE_CREATED");
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ private final ServiceConnection connection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ audioProvider = service;
+ Log.i(PryonProtocol.TAG, "PRYON_AUDIO_PROVIDER_CONNECTED component=" + name);
+ initializeAsync();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ Log.w(PryonProtocol.TAG, "PRYON_AUDIO_PROVIDER_DISCONNECTED component=" + name);
+ audioProvider = null;
+ bindRequested = false;
+ cleanupNative("audio_provider_disconnected");
+ }
+ };
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ try {
+ boolean configChanged = PryonConfig.updateFromIntent(this, intent);
+ PryonConfig config = PryonConfig.load(this);
+ config.validate();
+ Log.i(PryonProtocol.TAG, "PRYON_CONFIG " + config.describe());
+
+ if (configChanged && (initialized || core != null)) {
+ cleanupNative("configuration_changed");
+ }
+ startOrBind();
+ } catch (Throwable error) {
+ Log.e(PryonProtocol.TAG, "PRYON_CONFIG_ERROR", error);
+ stopSelf(startId);
+ }
+ return START_STICKY;
+ }
+
+ private void startOrBind() {
+ if (audioProvider != null) {
+ initializeAsync();
+ return;
+ }
+ if (!bindRequested) {
+ bindRequested = bindService(new Intent(this, AudioProviderService.class), connection,
+ Context.BIND_AUTO_CREATE);
+ if (!bindRequested) {
+ throw new IllegalStateException("Unable to bind AudioProviderService");
+ }
+ }
+ }
+
+ private void initializeAsync() {
+ if (initialized || !initializing.compareAndSet(false, true)) return;
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ initialize();
+ } catch (Throwable error) {
+ Log.e(PryonProtocol.TAG, "PRYON_INITIALIZATION_FAILED", error);
+ cleanupNative("initialization_failed");
+ stopSelf();
+ } finally {
+ initializing.set(false);
+ }
+ }
+ }, "pryon-initialize").start();
+ }
+
+ private void initialize() throws Exception {
+ PryonConfig config = PryonConfig.load(this);
+ config.validate();
+ IBinder provider = audioProvider;
+ if (provider == null) throw new IllegalStateException("Audio provider is unavailable");
+
+ File dexDir = getDir("amazon_detector_dex", Context.MODE_PRIVATE);
+ DexClassLoader loader = new DexClassLoader(
+ config.amazonApk, dexDir.getAbsolutePath(), "/system/lib", getClassLoader());
+ streamClass = Class.forName("amazon.speech.audio.AudioStream", true, loader);
+ inputStream = requestAudioStream(provider);
+
+ AudioFormat metadataFormat = new AudioFormat.Builder()
+ .setSampleRate(16000)
+ .setEncoding(AudioFormat.ENCODING_PCM_8BIT)
+ .setChannelMask(AudioFormat.CHANNEL_IN_MONO)
+ .build();
+ metadataStream = streamClass.getMethod("create", String.class, AudioFormat.class, int.class)
+ .invoke(null, "EchoLocalPryon_Metadata", metadataFormat, 40000);
+ if (metadataStream == null) {
+ throw new IllegalStateException("Metadata AudioStream.create returned null");
+ }
+
+ coreClass = Class.forName(
+ "amazon.speech.wakewordservice.NativeWakeWordServiceCore", true, loader);
+ Constructor> constructor = findCallbackConstructor(coreClass);
+ Class>[] callbackTypes = constructor.getParameterTypes();
+ callbackProxies = new Object[callbackTypes.length];
+ for (int i = 0; i < callbackTypes.length; i++) {
+ callbackProxies[i] = Proxy.newProxyInstance(
+ loader, new Class>[]{callbackTypes[i]},
+ new PryonCallback(callbackTypes[i].getName()));
+ }
+ constructor.setAccessible(true);
+ core = constructor.newInstance(callbackProxies);
+
+ Method create = findMethod(coreClass, "nCreateNativeService", 16);
+ create.setAccessible(true);
+ int result = ((Number) create.invoke(core,
+ getPackageName(), inputStream, metadataStream,
+ config.alexaModel, config.aedModel, null, new String[]{"ALEXA"},
+ false, false, false, false, false, false, 10, -1, 0)).intValue();
+ Log.i(PryonProtocol.TAG, "PRYON_NATIVE_CREATE result=" + result);
+ if (result != 0) throw new IllegalStateException("nCreateNativeService result=" + result);
+ nativeCreated = true;
+
+ Method enable = findMethod(coreClass, "nSetDetectorEnabled", 1);
+ enable.setAccessible(true);
+ int enableResult = ((Number) enable.invoke(core, true)).intValue();
+ Method getEnabled = findMethod(coreClass, "nGetDetectorEnabled", 0);
+ getEnabled.setAccessible(true);
+ boolean enabled = (Boolean) getEnabled.invoke(core);
+ if (enableResult != 0 || !enabled) {
+ throw new IllegalStateException(
+ "Detector enable failed result=" + enableResult + " enabled=" + enabled);
+ }
+
+ Method frames = findMethod(coreClass, "nGetInputFrameCount", 0);
+ frames.setAccessible(true);
+ long before = ((Number) frames.invoke(core)).longValue();
+ Thread.sleep(1500);
+ long after = ((Number) frames.invoke(core)).longValue();
+ if (after <= before) {
+ Log.e(PryonProtocol.TAG, "PRYON_CAPTURE_STALLED before=" + before + " after=" + after);
+ throw new IllegalStateException("Pryon input frame counter did not advance");
+ }
+
+ initialized = true;
+ Log.i(PryonProtocol.TAG, "PRYON_CAPTURE_ACTIVE before=" + before + " after=" + after
+ + " delta=" + (after - before));
+ Log.i(PryonProtocol.TAG,
+ "PRYON_READY enabled=true recorder=HOTWORD sample_rate=16000 word=alexa");
+ }
+
+ private Object requestAudioStream(IBinder provider) throws Exception {
+ Parcel data = Parcel.obtain();
+ Parcel reply = Parcel.obtain();
+ try {
+ data.writeInterfaceToken(PryonProtocol.DESCRIPTOR);
+ if (!provider.transact(PryonProtocol.GET_STREAM, data, reply, 0)) {
+ throw new IllegalStateException("GET_STREAM transaction was rejected");
+ }
+ reply.readException();
+ Parcelable.Creator> creator =
+ (Parcelable.Creator>) streamClass.getField("CREATOR").get(null);
+ Object value = creator.createFromParcel(reply);
+ if (value == null) throw new IllegalStateException("Provider returned a null AudioStream");
+ return value;
+ } finally {
+ data.recycle();
+ reply.recycle();
+ }
+ }
+
+ private final class PryonCallback implements InvocationHandler {
+ private final String callbackType;
+
+ PryonCallback(String callbackType) {
+ this.callbackType = callbackType;
+ }
+
+ @Override
+ public Object invoke(Object proxy, Method method, Object[] args) {
+ String methodName = method.getName();
+ if ("onEnumeratedResult".equals(methodName) && args != null && args.length > 7) {
+ String result = String.valueOf(args[1]);
+ int confidence = args[6] instanceof Number ? ((Number) args[6]).intValue() : 0;
+ int detectionType = args[7] instanceof Number ? ((Number) args[7]).intValue() : 0;
+ Log.i(PryonProtocol.TAG, "PRYON_RESULT word=" + result.toLowerCase()
+ + " confidence=" + confidence + " detection_type=" + detectionType);
+ if ("ALEXA".equalsIgnoreCase(result) && detectionType != 1) {
+ dispatchAlexa(confidence, detectionType);
+ }
+ } else if (methodName.toLowerCase().contains("status")) {
+ Log.i(PryonProtocol.TAG, "PRYON_STATUS callback=" + callbackType
+ + " method=" + methodName + " args=" + Arrays.toString(args));
+ }
+ return defaultValue(method.getReturnType());
+ }
+ }
+
+ private synchronized void dispatchAlexa(int confidence, int detectionType) {
+ long now = SystemClock.elapsedRealtime();
+ if (now - lastDetectionMs < 1500) {
+ Log.i(PryonProtocol.TAG, "PRYON_DUPLICATE_SUPPRESSED delta_ms="
+ + (now - lastDetectionMs));
+ return;
+ }
+ lastDetectionMs = now;
+ Log.i(PryonProtocol.TAG, "PRYON_WAKEWORD_DETECTED word=alexa confidence=" + confidence
+ + " detection_type=" + detectionType + " monotonic_ms=" + now);
+ eventClient.sendWake(confidence, detectionType, now);
+ }
+
+ private void cleanupNative(String reason) {
+ synchronized (nativeLock) {
+ initialized = false;
+ if (nativeCreated && core != null && coreClass != null) {
+ try {
+ Method disable = findMethod(coreClass, "nSetDetectorEnabled", 1);
+ disable.setAccessible(true);
+ Object result = disable.invoke(core, false);
+ Log.i(PryonProtocol.TAG, "PRYON_NATIVE_DISABLED result=" + result
+ + " reason=" + reason);
+ } catch (Throwable error) {
+ Log.w(PryonProtocol.TAG, "Unable to disable native detector", error);
+ }
+ try {
+ Method destroy = findMethod(coreClass, "nDestroyNativeService", 0);
+ destroy.setAccessible(true);
+ Object result = destroy.invoke(core);
+ Log.i(PryonProtocol.TAG, "PRYON_NATIVE_DESTROYED result=" + result
+ + " reason=" + reason);
+ } catch (Throwable error) {
+ Log.w(PryonProtocol.TAG, "Unable to destroy native detector", error);
+ }
+ }
+ nativeCreated = false;
+ core = null;
+ coreClass = null;
+ inputStream = null;
+ metadataStream = null;
+ callbackProxies = null;
+ streamClass = null;
+ }
+ }
+
+ private static Constructor> findCallbackConstructor(Class> type) throws Exception {
+ for (Constructor> constructor : type.getDeclaredConstructors()) {
+ Class>[] parameters = constructor.getParameterTypes();
+ if (parameters.length != 3) continue;
+ boolean interfaces = true;
+ for (Class> parameter : parameters) interfaces &= parameter.isInterface();
+ if (interfaces) return constructor;
+ }
+ throw new NoSuchMethodException("Expected three-callback constructor on " + type.getName());
+ }
+
+ private static Method findMethod(Class> type, String name, int parameterCount)
+ throws Exception {
+ for (Method method : type.getDeclaredMethods()) {
+ if (name.equals(method.getName())
+ && method.getParameterTypes().length == parameterCount) return method;
+ }
+ throw new NoSuchMethodException(name + "/" + parameterCount + " on " + type.getName());
+ }
+
+ private static Object defaultValue(Class> type) {
+ if (!type.isPrimitive() || type == void.class) return null;
+ if (type == boolean.class) return false;
+ if (type == byte.class) return (byte) 0;
+ if (type == short.class) return (short) 0;
+ if (type == int.class) return 0;
+ if (type == long.class) return 0L;
+ if (type == float.class) return 0f;
+ if (type == double.class) return 0d;
+ if (type == char.class) return (char) 0;
+ return null;
+ }
+
+ @Override
+ public void onDestroy() {
+ cleanupNative("service_destroyed");
+ if (eventClient != null) {
+ eventClient.close();
+ eventClient = null;
+ }
+ if (bindRequested) {
+ try {
+ unbindService(connection);
+ } catch (Throwable error) {
+ Log.w(PryonProtocol.TAG, "Unable to unbind audio provider", error);
+ }
+ }
+ audioProvider = null;
+ bindRequested = false;
+ Log.i(PryonProtocol.TAG, "PRYON_SERVICE_DESTROYED");
+ super.onDestroy();
+ // libwakewordserver_jni.so is process-global. Exiting after orderly native
+ // destruction prevents a later service start from reusing an incompatible
+ // DexClassLoader/native namespace in this ART process.
+ Process.killProcess(Process.myPid());
+ }
+}
diff --git a/android/pryon/src/com/echolocal/pryon/PryonEventClient.java b/android/pryon/src/com/echolocal/pryon/PryonEventClient.java
new file mode 100644
index 0000000..557726f
--- /dev/null
+++ b/android/pryon/src/com/echolocal/pryon/PryonEventClient.java
@@ -0,0 +1,159 @@
+package com.echolocal.pryon;
+
+import android.net.LocalSocket;
+import android.net.LocalSocketAddress;
+import android.os.SystemClock;
+import android.util.Log;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** Bounded, wake-only transport to echod's authenticated filesystem socket. */
+final class PryonEventClient implements AutoCloseable {
+ private static final String SOCKET_NAME = "echolocal-pryon";
+ private static final Charset UTF8 = Charset.forName("UTF-8");
+ private static final long MAX_EVENT_AGE_MS = 5000;
+
+ private final ArrayBlockingQueue queue = new ArrayBlockingQueue<>(8);
+ private final AtomicBoolean running = new AtomicBoolean(true);
+ private final Thread worker;
+
+ private LocalSocket socket;
+ private InputStream input;
+ private OutputStream output;
+
+ PryonEventClient() {
+ worker = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ work();
+ }
+ }, "pryon-event-client");
+ worker.start();
+ }
+
+ void sendWake(int confidence, int detectionType, long monotonicMs) {
+ String json = "{\"version\":1,\"event\":\"wake\",\"word\":\"alexa\""
+ + ",\"confidence\":" + confidence
+ + ",\"detection_type\":" + detectionType
+ + ",\"monotonic_ms\":" + monotonicMs + "}\n";
+ Pending pending = new Pending(json.getBytes(UTF8), monotonicMs + MAX_EVENT_AGE_MS);
+ if (!queue.offer(pending)) {
+ queue.poll();
+ if (!queue.offer(pending)) {
+ Log.w(PryonProtocol.TAG, "PRYON_EVENT_DROPPED reason=queue_full");
+ }
+ }
+ }
+
+ private void work() {
+ while (running.get()) {
+ Pending pending;
+ try {
+ pending = queue.poll(1, TimeUnit.SECONDS);
+ } catch (InterruptedException interrupted) {
+ continue;
+ }
+ if (pending == null) continue;
+
+ long waitMs = 200;
+ while (running.get() && SystemClock.elapsedRealtime() <= pending.expiresAtMs) {
+ try {
+ connect();
+ output.write(pending.bytes);
+ output.flush();
+ String acknowledgement = readLine(input);
+ if (!"ok".equals(acknowledgement)) {
+ throw new IOException("helper replied " + acknowledgement);
+ }
+ Log.i(PryonProtocol.TAG, "PRYON_EVENT_DELIVERED event=wake word=alexa");
+ pending = null;
+ break;
+ } catch (IOException error) {
+ disconnect();
+ Log.w(PryonProtocol.TAG, "PRYON_EVENT_RETRY in_ms=" + waitMs
+ + " error=" + error.getMessage());
+ SystemClock.sleep(waitMs);
+ waitMs = Math.min(waitMs * 2, 1000);
+ }
+ }
+ if (pending != null) {
+ Log.w(PryonProtocol.TAG, "PRYON_EVENT_DROPPED reason=delivery_timeout");
+ }
+ }
+ disconnect();
+ }
+
+ private synchronized void connect() throws IOException {
+ if (!running.get()) throw new IOException("client is stopping");
+ if (socket != null && output != null) return;
+ LocalSocket next = new LocalSocket();
+ try {
+ next.connect(new LocalSocketAddress(
+ SOCKET_NAME, LocalSocketAddress.Namespace.ABSTRACT));
+ next.setSoTimeout(1000);
+ socket = next;
+ input = next.getInputStream();
+ output = next.getOutputStream();
+ Log.i(PryonProtocol.TAG, "PRYON_EVENT_CONNECTED socket=@" + SOCKET_NAME);
+ } catch (IOException error) {
+ try {
+ next.close();
+ } catch (IOException ignored) {
+ // The original connect error is the useful one.
+ }
+ throw error;
+ }
+ }
+
+ private synchronized void disconnect() {
+ input = null;
+ output = null;
+ if (socket != null) {
+ try {
+ socket.close();
+ } catch (IOException ignored) {
+ // Closing a failed local connection has no recovery action.
+ }
+ socket = null;
+ }
+ }
+
+ private static String readLine(InputStream input) throws IOException {
+ StringBuilder line = new StringBuilder();
+ while (line.length() <= 16) {
+ int value = input.read();
+ if (value < 0) throw new IOException("helper closed without acknowledgement");
+ if (value == '\n') return line.toString();
+ line.append((char) value);
+ }
+ throw new IOException("helper acknowledgement too large");
+ }
+
+ @Override
+ public void close() {
+ if (!running.compareAndSet(true, false)) return;
+ worker.interrupt();
+ disconnect();
+ try {
+ worker.join(2000);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static final class Pending {
+ final byte[] bytes;
+ final long expiresAtMs;
+
+ Pending(byte[] bytes, long expiresAtMs) {
+ this.bytes = bytes;
+ this.expiresAtMs = expiresAtMs;
+ }
+ }
+}
diff --git a/android/pryon/src/com/echolocal/pryon/PryonProtocol.java b/android/pryon/src/com/echolocal/pryon/PryonProtocol.java
new file mode 100644
index 0000000..2582a2e
--- /dev/null
+++ b/android/pryon/src/com/echolocal/pryon/PryonProtocol.java
@@ -0,0 +1,14 @@
+package com.echolocal.pryon;
+
+final class PryonProtocol {
+ static final String TAG = "EchoLocalPryon";
+ static final String DESCRIPTOR = "com.echolocal.pryon.AudioProvider";
+ static final String PCM_SOCKET = "echolocal-pryon-pcm";
+ static final int GET_STREAM = 0x455001;
+
+ static final String EXTRA_AMAZON_APK = "amazon_apk";
+ static final String EXTRA_ALEXA_MODEL = "alexa_model";
+ static final String EXTRA_AED_MODEL = "aed_model";
+
+ private PryonProtocol() { }
+}
diff --git a/docs/pryon.md b/docs/pryon.md
new file mode 100644
index 0000000..8a64ac2
--- /dev/null
+++ b/docs/pryon.md
@@ -0,0 +1,88 @@
+# Native Amazon wake-word support
+
+EchoLocal can use the Pryon detector already present in compatible second-generation Echo Dot
+firmware. This adds **Alexa** to the wake-word select exposed through ESPHome and Home Assistant.
+The detector is wake-only: EchoLocal continues to own audio capture after the wake, Home Assistant
+Assist, LEDs, TTS, media, ducking and Sendspin.
+
+## Requirements
+
+- an Echo Dot 2 (`biscuit`) unlocked with TWRP recovery;
+- the original firmware files that supply Amazon's SpeechInteractionManager, Pryon libraries and
+ locale models;
+- local Wi-Fi for ESPHome/Home Assistant discovery;
+- Windows PowerShell 5.1 or later, Go, ADB/Fastboot, Java and an Android SDK when building from the
+ source tree.
+
+Amazon account registration is not required after the device is unlocked. EchoLocal does not ship,
+copy off the device or redistribute Amazon binaries or model files.
+
+## Complete source-tree installation
+
+Connect one unlocked Dot by USB, then run:
+
+```powershell
+.\provision-echo-dot.ps1 -Name "Kitchen Echo"
+```
+
+Use `-Serial G090XXXXXXXXXXXX` when more than one ADB/Fastboot device is attached. The script:
+
+1. selects only the requested `biscuit` device and verifies its firmware and boot image;
+2. builds the EchoLocal daemon, Android media helper and Pryon companion;
+3. saves a gitignored rollback snapshot;
+4. obtains root with the verified EchoLocal boot image when required;
+5. discovers the required Amazon files on the attached Dot;
+6. installs EchoLocal, reboots when Android must scan the privileged companion, and restores
+ `/system` read-only;
+7. verifies the ESPHome API, mDNS identity, Android media bridge, Pryon frame counter and shared
+ live microphone path; and
+8. saves a private Home Assistant credential receipt in the gitignored rollback directory and
+ prints the device's unique ESPHome encryption key last.
+
+`-SkipBuild` reuses already built local payloads. `-BuildOnly` builds and validates the installer
+without touching a device.
+
+The equivalent lower-level command is:
+
+```sh
+echoctl install --name kitchen-echo --reboot
+```
+
+Use `--no-pryon` only when deliberately installing the legacy direct-ALSA audio path.
+
+## Home Assistant
+
+Add the discovered EchoLocal ESPHome device and paste the encryption key printed by the installer.
+The same key is saved as `home-assistant-credentials.txt` inside the rollback snapshot reported by
+the script, so it can be recovered after the terminal closes. Keep that file private.
+Choose **Alexa** in the desired assistant's Wake word select. Bundled microWakeWord choices and
+downloaded openWakeWord/microWakeWord models remain available in other assistant slots.
+
+Only **Alexa** is exposed by this integration. Some Amazon firmware images also contain native
+models for `Amazon`, `Computer` or `Echo`, but changing Pryon models requires restarting its Android
+process. Those words should be added as a separate, tested change rather than inferred from files
+that may differ by firmware and locale.
+
+## Architecture and security boundary
+
+The privileged `com.echolocal.pryon` APK initializes Amazon's on-device detector and sends bounded
+wake metadata to EchoLocal. Pryon's native service owns the single privileged `AudioRecord` and its
+Amazon `AudioStream`; EchoLocal's Android media helper reads conversation PCM through a second
+authenticated reader on that same stream. This avoids the Fire OS single-input race caused by two
+independent recorders. The helper continues to own `AudioTrack` playback. EchoLocal authenticates
+the companion process by Android UID before accepting wake events. No audio is sent to Amazon or
+another remote service by this integration.
+
+## Verification and rollback
+
+`echoctl status` reports the ESPHome API, Android media bridge and Pryon configuration. A successful
+install requires `PRYON_CAPTURE_ACTIVE`, `PRYON_SHARED_PCM_FIRST_FRAME` and `PRYON_READY`; speaking
+“Alexa” should then produce a wake event and start the selected Home Assistant Assist pipeline.
+The cyan startup walk waits up to 60 seconds for Home Assistant to subscribe to a voice pipeline,
+then fades out rather than looking like a recovery or boot loop.
+
+The companion owns `/system/priv-app/EchoLocalPryon`, while the media helper and Pryon UID marker are
+stored under `/data/misc/echolocal`. To roll back, force-stop `com.echolocal.pryon`, remove only the
+EchoLocal-owned companion directory while `/system` is writable, restore the previous EchoLocal
+files from the installer's snapshot, remount `/system` read-only and reboot. Never remove or replace
+Amazon's SpeechInteractionManager, native libraries or model directories.
diff --git a/internal/android/amazon/client.go b/internal/android/amazon/client.go
new file mode 100644
index 0000000..79989b2
--- /dev/null
+++ b/internal/android/amazon/client.go
@@ -0,0 +1,214 @@
+// Package amazon owns the small Android-media bridge used on Pryon-capable installations.
+// The helper supplies 16 kHz mono PCM, accepts 48 kHz stereo PCM, and forwards wake metadata.
+package amazon
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/ygelfand/echolocal/internal/component"
+ "github.com/ygelfand/echolocal/internal/layout"
+ "github.com/ygelfand/echolocal/internal/lib/hook"
+ "github.com/ygelfand/echolocal/internal/service"
+)
+
+const socketName = "echolocal-amazon"
+
+type Client struct {
+ mu sync.Mutex
+ conn *net.UnixConn
+ proc process
+
+ writeMu sync.Mutex
+ audio hook.Hook[[]byte]
+ wake hook.Hook[Wake]
+}
+
+var (
+ once sync.Once
+ shared *Client
+)
+
+func init() {
+ if Enabled() {
+ component.Register(component.Hardware, Get(), component.Order(1),
+ component.Supervise(service.Required(), service.Restart(time.Second, 30*time.Second)))
+ }
+}
+
+// Enabled reports whether this installation chose Android media rather than direct ALSA.
+func Enabled() bool {
+ st, err := os.Stat(layout.AndroidMediaJar)
+ return err == nil && st.Mode().IsRegular()
+}
+
+func Get() *Client {
+ once.Do(func() { shared = &Client{} })
+ return shared
+}
+
+func (c *Client) Name() string { return "amazon media" }
+
+func (c *Client) ListenAudio(fn func([]byte)) func() { return c.audio.Listen(fn) }
+func (c *Client) ListenWake(fn func(Wake)) func() { return c.wake.Listen(fn) }
+
+func (c *Client) Connected() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.conn != nil
+}
+
+// Start launches the user-owned helper and establishes its abstract local socket before the
+// microphone or speaker services start.
+func (c *Client) Start(ctx context.Context) error {
+ _ = c.Close()
+
+ conn, err := dial()
+ if err != nil {
+ proc, startErr := startProcess()
+ if startErr != nil {
+ return startErr
+ }
+ c.mu.Lock()
+ c.proc = proc
+ c.mu.Unlock()
+
+ conn, err = waitForSocket(ctx, 10*time.Second)
+ if err != nil {
+ _ = c.Close()
+ return err
+ }
+ }
+
+ c.mu.Lock()
+ c.conn = conn
+ c.mu.Unlock()
+ if err := c.send(msgStartCapture, nil); err != nil {
+ _ = c.Close()
+ return err
+ }
+ slog.Info("amazon media connected", "socket", "@"+socketName)
+ return nil
+}
+
+func dial() (*net.UnixConn, error) {
+ return net.DialUnix("unix", nil, &net.UnixAddr{Name: "\x00" + socketName, Net: "unix"})
+}
+
+func waitForSocket(ctx context.Context, timeout time.Duration) (*net.UnixConn, error) {
+ deadline := time.Now().Add(timeout)
+ var last error
+ for time.Now().Before(deadline) {
+ conn, err := dial()
+ if err == nil {
+ return conn, nil
+ }
+ last = err
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+ return nil, fmt.Errorf("amazon: helper socket did not appear within %s: %w", timeout, last)
+}
+
+// Run owns the socket reader. Closing the connection on cancellation is what unblocks ReadFull.
+func (c *Client) Run(ctx context.Context) error {
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+ if conn == nil {
+ return errors.New("amazon: helper is not connected")
+ }
+
+ closed := make(chan struct{})
+ go func() {
+ select {
+ case <-ctx.Done():
+ _ = conn.Close()
+ case <-closed:
+ }
+ }()
+ defer close(closed)
+
+ for {
+ kind, payload, err := readFrame(conn)
+ if err != nil {
+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) {
+ return nil
+ }
+ return err
+ }
+ switch kind {
+ case msgAudio:
+ c.audio.Emit(payload)
+ case msgWake:
+ wake, err := decodeWake(payload)
+ if err != nil {
+ slog.Warn("amazon wake event rejected", "err", err)
+ continue
+ }
+ c.wake.Emit(wake)
+ default:
+ slog.Warn("amazon helper sent unknown message", "type", kind, "bytes", len(payload))
+ }
+ }
+}
+
+func (c *Client) Play(pcm []byte) error { return c.send(msgPlay, pcm) }
+func (c *Client) StopPlayback() error { return c.send(msgPlayStop, nil) }
+
+func (c *Client) send(kind byte, payload []byte) error {
+ encoded, err := frame(kind, payload)
+ if err != nil {
+ return err
+ }
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+ if conn == nil {
+ return errors.New("amazon: helper is not connected")
+ }
+
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ _, err = conn.Write(encoded)
+ return err
+}
+
+func (c *Client) Close() error {
+ c.mu.Lock()
+ conn, proc := c.conn, c.proc
+ c.conn, c.proc = nil, nil
+ c.mu.Unlock()
+
+ var errs []error
+ if conn != nil {
+ // Best effort: either message may fail because the reader already observed the disconnect.
+ c.writeMu.Lock()
+ if p, err := frame(msgStopCapture, nil); err == nil {
+ _, _ = conn.Write(p)
+ }
+ if p, err := frame(msgPlayStop, nil); err == nil {
+ _, _ = conn.Write(p)
+ }
+ c.writeMu.Unlock()
+ if err := conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
+ errs = append(errs, err)
+ }
+ }
+ if proc != nil {
+ if err := proc.Stop(); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return errors.Join(errs...)
+}
diff --git a/internal/android/amazon/process_linux.go b/internal/android/amazon/process_linux.go
new file mode 100644
index 0000000..bbdd58d
--- /dev/null
+++ b/internal/android/amazon/process_linux.go
@@ -0,0 +1,57 @@
+//go:build linux
+
+package amazon
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "syscall"
+ "time"
+
+ "github.com/ygelfand/echolocal/internal/layout"
+)
+
+type process interface{ Stop() error }
+
+type child struct {
+ cmd *exec.Cmd
+ done chan error
+}
+
+func startProcess() (process, error) {
+ cmd := exec.Command("/system/bin/app_process32", "/system/bin", "echolocal.AmazonHelper", "1")
+ cmd.Env = append(os.Environ(), "CLASSPATH="+layout.AndroidMediaJar)
+ cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM}
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("amazon: starting Android media helper: %w", err)
+ }
+ c := &child{cmd: cmd, done: make(chan error, 1)}
+ go func() { c.done <- cmd.Wait() }()
+ return c, nil
+}
+
+func (c *child) Stop() error {
+ if c.cmd.Process != nil {
+ if err := c.cmd.Process.Signal(syscall.SIGTERM); err != nil && !errors.Is(err, os.ErrProcessDone) {
+ return err
+ }
+ }
+ var err error
+ select {
+ case err = <-c.done:
+ case <-time.After(2 * time.Second):
+ if killErr := c.cmd.Process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) {
+ return killErr
+ }
+ err = <-c.done
+ }
+ if err != nil {
+ var exit *exec.ExitError
+ if !errors.As(err, &exit) {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/android/amazon/process_other.go b/internal/android/amazon/process_other.go
new file mode 100644
index 0000000..a1bb05e
--- /dev/null
+++ b/internal/android/amazon/process_other.go
@@ -0,0 +1,11 @@
+//go:build !linux
+
+package amazon
+
+import "errors"
+
+type process interface{ Stop() error }
+
+func startProcess() (process, error) {
+ return nil, errors.New("amazon: Android media helper is available only on the device")
+}
diff --git a/internal/android/amazon/protocol.go b/internal/android/amazon/protocol.go
new file mode 100644
index 0000000..866eaa1
--- /dev/null
+++ b/internal/android/amazon/protocol.go
@@ -0,0 +1,90 @@
+package amazon
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "io"
+)
+
+const (
+ msgWake byte = 1
+ msgAudio byte = 2
+ msgStartCapture byte = 3
+ msgStopCapture byte = 4
+ msgPlay byte = 5
+ msgPlayStop byte = 6
+
+ maxPayload = 1024 * 1024
+)
+
+// Wake is the metadata the Android helper attaches to an external wake event. Only Phrase decides
+// routing; the remaining fields are diagnostic and may be zero on older helper builds.
+type Wake struct {
+ Phrase string
+ Confidence uint32
+ StartedAt uint64
+ DetectedAt uint64
+}
+
+func frame(kind byte, payload []byte) ([]byte, error) {
+ if len(payload) > maxPayload {
+ return nil, fmt.Errorf("amazon: payload is %d bytes, max %d", len(payload), maxPayload)
+ }
+ out := make([]byte, 5+len(payload))
+ out[0] = kind
+ binary.BigEndian.PutUint32(out[1:5], uint32(len(payload)))
+ copy(out[5:], payload)
+ return out, nil
+}
+
+func readFrame(r io.Reader) (byte, []byte, error) {
+ header := make([]byte, 5)
+ if _, err := io.ReadFull(r, header); err != nil {
+ return 0, nil, err
+ }
+ n := binary.BigEndian.Uint32(header[1:])
+ if n > maxPayload {
+ return 0, nil, fmt.Errorf("amazon: frame is %d bytes, max %d", n, maxPayload)
+ }
+ payload := make([]byte, int(n))
+ if _, err := io.ReadFull(r, payload); err != nil {
+ return 0, nil, err
+ }
+ return header[0], payload, nil
+}
+
+func decodeWake(payload []byte) (Wake, error) {
+ r := bytes.NewReader(payload)
+ var n uint16
+ if err := binary.Read(r, binary.BigEndian, &n); err != nil {
+ return Wake{}, fmt.Errorf("amazon: wake phrase length: %w", err)
+ }
+ if int(n) > r.Len() || n == 0 || n > 128 {
+ return Wake{}, fmt.Errorf("amazon: invalid wake phrase length %d", n)
+ }
+ phrase := make([]byte, int(n))
+ if _, err := io.ReadFull(r, phrase); err != nil {
+ return Wake{}, err
+ }
+
+ w := Wake{Phrase: string(phrase)}
+ // The deployed protocol carries these fields. Tolerating their absence keeps event routing
+ // compatible with the earliest helper while still rejecting a partially encoded field.
+ if r.Len() == 0 {
+ return w, nil
+ }
+ if r.Len() != 20 {
+ return Wake{}, fmt.Errorf("amazon: wake metadata is %d bytes, want 20", r.Len())
+ }
+ if err := binary.Read(r, binary.BigEndian, &w.Confidence); err != nil {
+ return Wake{}, err
+ }
+ if err := binary.Read(r, binary.BigEndian, &w.StartedAt); err != nil {
+ return Wake{}, err
+ }
+ if err := binary.Read(r, binary.BigEndian, &w.DetectedAt); err != nil {
+ return Wake{}, err
+ }
+ return w, nil
+}
diff --git a/internal/android/amazon/protocol_test.go b/internal/android/amazon/protocol_test.go
new file mode 100644
index 0000000..0b60164
--- /dev/null
+++ b/internal/android/amazon/protocol_test.go
@@ -0,0 +1,46 @@
+package amazon
+
+import (
+ "bytes"
+ "encoding/binary"
+ "testing"
+)
+
+func TestFrameRoundTrip(t *testing.T) {
+ want := []byte{1, 2, 3, 4}
+ encoded, err := frame(msgAudio, want)
+ if err != nil {
+ t.Fatal(err)
+ }
+ kind, got, err := readFrame(bytes.NewReader(encoded))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if kind != msgAudio || !bytes.Equal(got, want) {
+ t.Fatalf("got type=%d payload=%v, want type=%d payload=%v", kind, got, msgAudio, want)
+ }
+}
+
+func TestDecodeWake(t *testing.T) {
+ var payload bytes.Buffer
+ _ = binary.Write(&payload, binary.BigEndian, uint16(len("Alexa")))
+ payload.WriteString("Alexa")
+ _ = binary.Write(&payload, binary.BigEndian, uint32(912))
+ _ = binary.Write(&payload, binary.BigEndian, uint64(123))
+ _ = binary.Write(&payload, binary.BigEndian, uint64(456))
+
+ got, err := decodeWake(payload.Bytes())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Phrase != "Alexa" || got.Confidence != 912 || got.StartedAt != 123 || got.DetectedAt != 456 {
+ t.Fatalf("decoded %+v", got)
+ }
+}
+
+func TestDecodeWakeRejectsPartialMetadata(t *testing.T) {
+ payload := []byte{0, 5, 'A', 'l', 'e', 'x', 'a', 0}
+ if _, err := decodeWake(payload); err == nil {
+ t.Fatal("partial metadata was accepted")
+ }
+}
diff --git a/internal/cli/echoctl/install.go b/internal/cli/echoctl/install.go
index 1c34458..77691ee 100644
--- a/internal/cli/echoctl/install.go
+++ b/internal/cli/echoctl/install.go
@@ -34,15 +34,18 @@ var (
func newInstallCmd() *cobra.Command {
var (
- serial string
- echod string
- name string
- bootImage string
- zeroPSK bool
- flashOnly bool
- assumeYes bool
- doReboot bool
- noReboot bool
+ serial string
+ echod string
+ name string
+ bootImage string
+ pryonAPK string
+ androidMedia string
+ zeroPSK bool
+ noPryon bool
+ flashOnly bool
+ assumeYes bool
+ doReboot bool
+ noReboot bool
)
c := &cobra.Command{
@@ -65,7 +68,7 @@ func newInstallCmd() *cobra.Command {
return err
}
- cfg := installer.Config{ZeroPSK: zeroPSK}
+ cfg := installer.Config{ZeroPSK: zeroPSK, Pryon: !noPryon}
// The image is only resolved when it is going to be written. A device that already has root
// and a permissive kernel needs none, so a build that ships no payload can still install to
@@ -101,6 +104,14 @@ func newInstallCmd() *cobra.Command {
if cfg.Echod, _, err = payload(assets.Echod(), echod, "echod binary"); err != nil {
return err
}
+ if cfg.Pryon {
+ if cfg.PryonAPK, _, err = payload(assets.PryonAPK(), pryonAPK, "Pryon companion APK"); err != nil {
+ return err
+ }
+ if cfg.AndroidMedia, _, err = payload(assets.AndroidMedia(), androidMedia, "Android media helper"); err != nil {
+ return err
+ }
+ }
chosen, err := resolveName(cmd.Context(), out, d, name)
if err != nil {
return err
@@ -134,9 +145,28 @@ func newInstallCmd() *cobra.Command {
// Before the pairing key rather than after, so the one thing to carry off the screen is the
// last thing printed.
- if err := offerReboot(cmd.Context(), out, d, settles, rebootChoiceOf(doReboot, noReboot)); err != nil {
+ rebooted, err := offerReboot(cmd.Context(), out, d, settles, rebootChoiceOf(doReboot, noReboot))
+ if err != nil {
return err
}
+ if cfg.Pryon {
+ if !rebooted {
+ scanned, scanErr := installer.PryonScanned(d)
+ if scanErr != nil {
+ return scanErr
+ }
+ if !scanned {
+ return errors.New("Pryon companion is staged but Android has not scanned it; rerun with --reboot to complete the install")
+ }
+ }
+ if err := render(cmd.Context(), out, "Finalizing Alexa and ESPHome",
+ "✓ EchoLocal is discoverable in ESPHome with Pryon/Alexa ready",
+ func(report installer.Reporter) error {
+ return installer.FinalizePryon(cmd.Context(), d, cfg, report)
+ }); err != nil {
+ return err
+ }
+ }
return printPairing(out, d, chosen)
},
}
@@ -144,6 +174,9 @@ func newInstallCmd() *cobra.Command {
c.Flags().StringVar(&serial, "serial", "", "device serial, when more than one is connected")
c.Flags().StringVar(&echod, "echod", "", "echod binary to install, instead of the one shipped")
c.Flags().StringVar(&bootImage, "boot-image", "", "boot image to write, instead of the one shipped")
+ c.Flags().StringVar(&pryonAPK, "pryon-apk", "", "Pryon companion APK to install, instead of the one shipped")
+ c.Flags().StringVar(&androidMedia, "android-media", "", "Android media helper JAR to install, instead of the one shipped")
+ c.Flags().BoolVar(&noPryon, "no-pryon", false, "install direct-ALSA EchoLocal without Pryon/Alexa")
c.Flags().BoolVar(&flashOnly, "flash-only", false,
"write the boot image and stop, without installing echod")
c.Flags().BoolVarP(&assumeYes, "yes", "y", false,
diff --git a/internal/cli/echoctl/reboot.go b/internal/cli/echoctl/reboot.go
index 80ad850..23360ba 100644
--- a/internal/cli/echoctl/reboot.go
+++ b/internal/cli/echoctl/reboot.go
@@ -29,42 +29,38 @@ func rebootChoiceOf(yes, no bool) rebootChoice {
return rebootAsk
}
-// offerReboot restarts the device when that is wanted, and then waits for echod to come back by itself.
-//
-// settles is the installer's own answer to whether anything changed that the device will only act on
-// when it next starts: an init service gated, a package hidden while it was running, a boot script
-// stubbed, the ledcontroller link newly pointed at echod. It is deliberately not "did any step do
-// work" — writing the binary, remounting /system and restarting the service happen on every run and a
-// reboot settles none of them, so counting those would raise the question every time and teach anyone
-// re-running an install to dismiss it.
-func offerReboot(ctx context.Context, out io.Writer, d *device.Device, settles bool, choice rebootChoice) error {
+// offerReboot restarts the device when requested, waits for echod, and reports whether the reboot
+// actually happened. Pryon finalization needs that distinction because Android scans a new system APK
+// only during boot.
+func offerReboot(ctx context.Context, out io.Writer, d *device.Device, settles bool, choice rebootChoice) (bool, error) {
switch choice {
case rebootNo:
- return nil
+ return false, nil
case rebootAsk:
if !settles {
- return nil
+ return false, nil
}
if !isTerminal() {
fmt.Fprintf(out, "%s\n", styleSkip.Render(
"• some of this takes effect on the next boot; pass --reboot to have it done here"))
- return nil
+ return false, nil
}
yes, err := confirm(ctx, out,
"Reboot now? Some of what was installed only takes effect on the next boot.")
if err != nil && !errors.Is(err, ErrCancelled) {
- return err
+ return false, err
}
if !yes {
fmt.Fprintf(out, "%s\n", styleSkip.Render("• not rebooting; the rest lands whenever it next boots"))
- return nil
+ return false, nil
}
}
- return render(ctx, out, "Rebooting", "✓ device came back and echod started on its own",
+ err := render(ctx, out, "Rebooting", "✓ device came back and echod started on its own",
func(report installer.Reporter) error {
return installer.RebootAndWait(ctx, d, report)
})
+ return err == nil, err
}
diff --git a/internal/cli/echoctl/status.go b/internal/cli/echoctl/status.go
index 4d6241f..e09c43d 100644
--- a/internal/cli/echoctl/status.go
+++ b/internal/cli/echoctl/status.go
@@ -78,6 +78,24 @@ func newStatusCmd() *cobra.Command {
if s.AgentState != "" {
row("agent", s.AgentState)
}
+ if s.APIListening {
+ row("ESPHome API", styleDone.Render("listening")+fmt.Sprintf(" on tcp/%d", layout.Port))
+ } else {
+ row("ESPHome API", styleFail.Render("not listening"))
+ }
+ if s.AndroidMedia {
+ row("audio", styleDone.Render("Android media bridge"))
+ } else {
+ row("audio", styleSkip.Render("direct ALSA"))
+ }
+ switch {
+ case s.PryonInstalled && s.PryonConfigured:
+ row("Alexa", styleDone.Render("Pryon installed and configured"))
+ case s.PryonInstalled:
+ row("Alexa", styleFail.Render("Pryon installed but not finalized"))
+ default:
+ row("Alexa", styleSkip.Render("not installed"))
+ }
if running, ok := s.RunningFor(); ok {
row("running", fmt.Sprintf("%s %s", running.Round(time.Second),
styleDetail.Render("since uptime "+s.StartedAt+"s")))
diff --git a/internal/component/all/all.go b/internal/component/all/all.go
index 5c6c143..5557d58 100644
--- a/internal/component/all/all.go
+++ b/internal/component/all/all.go
@@ -23,6 +23,7 @@ import (
_ "github.com/ygelfand/echolocal/internal/feature/media"
_ "github.com/ygelfand/echolocal/internal/feature/microphone"
_ "github.com/ygelfand/echolocal/internal/feature/mute"
+ _ "github.com/ygelfand/echolocal/internal/feature/pryon"
_ "github.com/ygelfand/echolocal/internal/feature/recording"
_ "github.com/ygelfand/echolocal/internal/feature/room"
_ "github.com/ygelfand/echolocal/internal/feature/sendspin"
diff --git a/internal/feature/detect/backends.go b/internal/feature/detect/backends.go
index 511baf6..066477d 100644
--- a/internal/feature/detect/backends.go
+++ b/internal/feature/detect/backends.go
@@ -35,6 +35,9 @@ type backend interface {
// newBackend builds the engine for one of them.
func newBackend(k wake.Kind) (backend, error) {
+ if k == wake.KindPryon {
+ return nil, fmt.Errorf("wake: Pryon is an external event backend")
+ }
if k == wake.KindOpenWakeWord {
front, err := oww.New()
if err != nil {
diff --git a/internal/feature/detect/detect.go b/internal/feature/detect/detect.go
index 2bb6140..aa18315 100644
--- a/internal/feature/detect/detect.go
+++ b/internal/feature/detect/detect.go
@@ -85,7 +85,8 @@ func newDetect() *Detect {
ours := wake.Lib().Ours()
slog.Info("wake words installed", "count", len(ours),
"openwakeword", len(wake.OfKind(ours, wake.KindOpenWakeWord)),
- "microwakeword", len(wake.OfKind(ours, wake.KindMicroWakeWord)))
+ "microwakeword", len(wake.OfKind(ours, wake.KindMicroWakeWord)),
+ "pryon", len(wake.OfKind(ours, wake.KindPryon)))
return d
}
@@ -122,6 +123,14 @@ func (d *Detect) load(ids []string) []string {
d.engine.Clear(slot)
continue
}
+ if m.Kind == wake.KindPryon {
+ // Pryon scores in Amazon's privileged Android process. Keeping this slot out of the
+ // PCM engine is the boundary that prevents a fake model path or a second detector.
+ d.engine.Clear(slot)
+ accepted = append(accepted, m.ID)
+ slog.Info("external wake word selected", "slot", slot+1, "id", m.ID, "engine", m.Kind)
+ continue
+ }
if err := d.engine.Use(slot, m); err != nil {
slog.Error("loading the selected wake word failed", "slot", slot+1, "id", m.ID, "err", err)
d.engine.Clear(slot)
diff --git a/internal/feature/media/media_test.go b/internal/feature/media/media_test.go
index e8e62e9..6812086 100644
--- a/internal/feature/media/media_test.go
+++ b/internal/feature/media/media_test.go
@@ -7,10 +7,31 @@ import (
"io"
"strings"
"testing"
+ "time"
"github.com/ygelfand/echolocal/internal/hardware/speaker"
)
+func TestVoiceTurnSuppressesOnlyAutomaticVolumeFeedback(t *testing.T) {
+ p := &Player{}
+ now := time.Now()
+
+ p.VoiceTurn(true)
+ if p.volumeFeedback(now) {
+ t.Fatal("showed Home Assistant volume feedback during a voice turn")
+ }
+
+ p.VoiceTurn(false)
+ if p.volumeFeedback(time.Now()) {
+ t.Fatal("showed Home Assistant volume feedback during its restore tail")
+ }
+
+ p.volumeQuietUntil.Store(now.Add(-time.Second).UnixNano())
+ if !p.volumeFeedback(now) {
+ t.Fatal("kept suppressing volume feedback after the restore tail")
+ }
+}
+
// wave builds a RIFF stream: the header, the chunks given, then samples.
func wave(chunks []byte, samples []byte) []byte {
var b bytes.Buffer
diff --git a/internal/feature/media/player.go b/internal/feature/media/player.go
index 43de88c..8770e1a 100644
--- a/internal/feature/media/player.go
+++ b/internal/feature/media/player.go
@@ -36,6 +36,10 @@ const VolumeSteps = speaker.VolumeSteps
// volumeFlash is how long the ring shows the level after a change.
const volumeFlash = 2 * time.Second
+// turnVolumeSettle keeps Home Assistant's assistant-volume restore out of the ring after the turn
+// itself closes. Those commands are transport housekeeping, not volume changes somebody made.
+const turnVolumeSettle = 3 * time.Second
+
type Player struct {
mp *esphome.MediaPlayer
jack *esphome.BinarySensor
@@ -59,6 +63,11 @@ type Player struct {
external atomic.Bool
+ // volumeQuietUntil is the end of the interval in which volume commands arriving from Home
+ // Assistant belong to a voice turn. MaxInt64 means the turn is still open. Physical controls do
+ // not consult it: their volume arc is real feedback and must remain visible.
+ volumeQuietUntil atomic.Int64
+
step int
}
@@ -263,7 +272,7 @@ func (p *Player) sound() {
// it to a goroutine and returns.
func (p *Player) command(c esphome.MediaCommand) {
if c.HasVolume {
- p.Set(int(math.Round(float64(c.Volume) * VolumeSteps)))
+ p.set(int(math.Round(float64(c.Volume)*VolumeSteps)), p.volumeFeedback(time.Now()))
}
// An announcement is a url too, but a short one at the pipeline's rate, and it interrupts rather
@@ -377,12 +386,33 @@ func (p *Player) state() esphome.MediaPlayerState {
// Set applies a level and remembers it.
func (p *Player) Set(step int) {
- applied := p.apply(step, true)
+ p.set(step, true)
+}
+
+// set applies and remembers a level. tell controls only user-facing feedback; assistant-managed
+// volume still has to reach the speaker and Home Assistant while its arc stays off the ring.
+func (p *Player) set(step int, tell bool) {
+ applied := p.apply(step, tell)
if err := config.Set().Speaker().Volume(applied); err != nil {
slog.Error("saving volume failed", "err", err)
}
}
+// VoiceTurn marks the interval in which Home Assistant may temporarily move the media player's
+// volume for an assistant response. The short tail includes its restore commands, which can arrive
+// just after the conversation pipeline reports that the turn is over.
+func (p *Player) VoiceTurn(on bool) {
+ until := time.Now().Add(turnVolumeSettle).UnixNano()
+ if on {
+ until = math.MaxInt64
+ }
+ p.volumeQuietUntil.Store(until)
+}
+
+func (p *Player) volumeFeedback(at time.Time) bool {
+ return at.UnixNano() > p.volumeQuietUntil.Load()
+}
+
// apply drives the speaker and reports the step it settled on. tell is false when nothing happened that
// anyone needs to see or read about, which is a restore: the arc is a response to being turned up, not a
// readout of the current level.
diff --git a/internal/feature/pryon/pryon.go b/internal/feature/pryon/pryon.go
new file mode 100644
index 0000000..b02ef88
--- /dev/null
+++ b/internal/feature/pryon/pryon.go
@@ -0,0 +1,42 @@
+// Package pryon connects Amazon's native detector to EchoLocal's existing turn boundary.
+// It owns no audio or conversation state: the Android companion reports a wake, and the selected
+// Home Assistant slot decides which ordinary voice pipeline starts.
+package pryon
+
+import (
+ "log/slog"
+ "strings"
+
+ "github.com/ygelfand/echolocal/internal/android/amazon"
+ "github.com/ygelfand/echolocal/internal/feature/voice"
+ "github.com/ygelfand/echolocal/internal/lib/wake"
+)
+
+func init() { amazon.Get().ListenWake(deliver) }
+
+func deliver(event amazon.Wake) {
+ if !strings.EqualFold(strings.TrimSpace(event.Phrase), "Alexa") {
+ slog.Warn("Pryon wake ignored", "phrase", event.Phrase)
+ return
+ }
+
+ v := voice.Get()
+ slot := selectedSlot(v.ActiveWakeWords())
+ if slot < 0 {
+ slog.Info("Pryon wake ignored, Alexa is not selected", "phrase", event.Phrase)
+ return
+ }
+
+ slog.Info("Pryon wake", "phrase", event.Phrase, "slot", slot+1,
+ "confidence", event.Confidence)
+ v.Start(slot)
+}
+
+func selectedSlot(active []string) int {
+ for slot, id := range active {
+ if id == wake.PryonID {
+ return slot
+ }
+ }
+ return -1
+}
diff --git a/internal/feature/pryon/pryon_test.go b/internal/feature/pryon/pryon_test.go
new file mode 100644
index 0000000..11f914a
--- /dev/null
+++ b/internal/feature/pryon/pryon_test.go
@@ -0,0 +1,22 @@
+package pryon
+
+import (
+ "testing"
+
+ "github.com/ygelfand/echolocal/internal/lib/wake"
+)
+
+func TestSelectedSlot(t *testing.T) {
+ for name, tc := range map[string]struct {
+ active []string
+ want int
+ }{
+ "first": {[]string{wake.PryonID, "okay_nabu"}, 0},
+ "second": {[]string{"okay_nabu", wake.PryonID}, 1},
+ "not armed": {[]string{"okay_nabu"}, -1},
+ } {
+ if got := selectedSlot(tc.active); got != tc.want {
+ t.Errorf("%s: got slot %d, want %d", name, got, tc.want)
+ }
+ }
+}
diff --git a/internal/feature/voice/conversation.go b/internal/feature/voice/conversation.go
index e0c0113..e7578b8 100644
--- a/internal/feature/voice/conversation.go
+++ b/internal/feature/voice/conversation.go
@@ -469,18 +469,23 @@ func (c *conversation) start(n nextTurn) {
c.slot = slot
c.followUp = n.followUp
+ c.player.VoiceTurn(true)
- // Before the chime, and before Home Assistant is told anything. Ducking is what the room hears
- // first, and it has a second of queued music to get through, so every step it waits behind is a
- // step of full-volume music over somebody who has already started talking.
+ // Visual detection feedback begins locally, before ducking or the request to Home Assistant.
+ if effect := wakeword.Effect(slot); effect != "" {
+ c.claim.Play(effect, c.ring.Base())
+ }
+
+ // Before Home Assistant is told anything. Ducking is what the room hears first, and it has queued
+ // music to get through, so every step it waits behind is a step of full-volume music over somebody
+ // who has already started talking.
//
- // It also keeps the chime out of the duck: the chime is mixed into the queue after this, so it
- // sounds at its own level rather than being faded along with the track underneath it.
+ // The chime follows the duck so re-scaling queued background audio cannot attenuate it. This is a
+ // local queue operation, not a network round trip, and the tone remains optional per wake-word slot.
c.hold(true)
-
- // A follow-up chimes like any other turn: the microphone is open with nothing said to say so.
- // It is not a wake, though, so it does not report a phrase nobody spoke.
wakeword.Chime(slot)
+
+ // A follow-up reports no wake phrase, because nobody spoke one.
if !n.followUp {
c.log.Woke(phrase)
}
@@ -488,6 +493,7 @@ func (c *conversation) start(n nextTurn) {
recording.Get().Opens(c.turn.ID(), slot)
if err := c.vs.StartTurn(phrase, audioSettings()); err != nil {
slog.Error("starting the turn failed", "slot", slot+1, "err", err)
+ c.idle("start failed", activity.Failed)
c.trouble()
return
}
@@ -496,10 +502,6 @@ func (c *conversation) start(n nextTurn) {
c.turn.Listening()
c.reply = reply{}
- if effect := wakeword.Effect(slot); effect != "" {
- c.claim.Play(effect, c.ring.Base())
- }
-
c.arm(c.listenFor(n))
c.startAudio(slot)
// The phrase is logged for a follow-up too, because it is what chose the pipeline — not because
@@ -592,6 +594,7 @@ func (c *conversation) idle(why string, how activity.Outcome) {
c.enter(phaseIdle)
c.claim.Clear()
+ c.player.VoiceTurn(false)
c.player.Sounding(false)
c.reply = reply{}
c.hold(c.pending != nil)
@@ -921,7 +924,12 @@ func activeWakeWords(models []wake.Model, slots int) []string {
// whatever this device does have — a device carrying one model somebody copied on should listen for
// that one rather than for nothing.
if len(active) == 0 {
- if m, ok := wake.Find(models, wake.DefaultModel); ok {
+ // A complete Pryon install was explicitly chosen by the installer, so Alexa is the useful
+ // first-run default there. Saved choices still win above, and direct-ALSA installations keep
+ // the established Okay Nabu default.
+ if m, ok := wake.Find(models, wake.PryonID); ok {
+ active = []string{m.ID}
+ } else if m, ok := wake.Find(models, wake.DefaultModel); ok {
active = []string{m.ID}
} else if len(models) > 0 {
active = []string{models[0].ID}
diff --git a/internal/feature/voice/voice_test.go b/internal/feature/voice/voice_test.go
index 961cc63..16bc6ff 100644
--- a/internal/feature/voice/voice_test.go
+++ b/internal/feature/voice/voice_test.go
@@ -18,6 +18,7 @@ func TestWakeWordsPreselectsTheDefault(t *testing.T) {
"the default sorts last": {[]string{"alexa", wake.DefaultModel}, wake.DefaultModel},
"the default is not installed": {[]string{"hey_jarvis"}, "hey_jarvis"},
"nothing installed": {nil, ""},
+ "Pryon install prefers Alexa": {[]string{wake.DefaultModel, wake.PryonID}, wake.PryonID},
} {
models := make([]wake.Model, 0, len(tc.installed))
for _, id := range tc.installed {
diff --git a/internal/hardware/led/splash.go b/internal/hardware/led/splash.go
index d89331d..dacfd72 100644
--- a/internal/hardware/led/splash.go
+++ b/internal/hardware/led/splash.go
@@ -15,47 +15,61 @@ const (
// SplashConfirm is how long the comet runs once the pipeline is listening.
SplashConfirm = 2 * time.Second
+
+ // SplashWait bounds the Home Assistant wait indication. A missing subscription is useful state
+ // during startup, but leaving the cyan ring spinning forever looks like recovery or a boot loop.
+ SplashWait = 60 * time.Second
)
// Splash animates the ring while the device comes up, then fades out. It steps around the ring
-// until ready reports true, then runs the comet for SplashConfirm, so the ring says whether the
-// device is merely running or actually able to answer. A nil ready goes straight to the comet.
-//
-// ctx cancellation ends it wherever it has got to, so a device Home Assistant never talks to does
-// not step around forever.
+// until ready reports true or SplashWait expires. A ready device runs the confirmation comet; a
+// device Home Assistant has not subscribed to simply fades out instead of looking stuck in recovery.
+// A nil ready goes straight to the comet.
func Splash(ctx context.Context, r *Ring, ready func() bool) error {
+ return splash(ctx, r, ready, SplashWait, SplashConfirm, 250*time.Millisecond)
+}
+
+func splash(ctx context.Context, r *Ring, ready func() bool, wait, confirm, fadeDuration time.Duration) error {
+ confirmed := ready == nil
if ready != nil {
- if err := until(ctx, r, walk(HomeAssistant), ready); err != nil {
+ waiting, cancel := context.WithTimeout(ctx, wait)
+ var err error
+ confirmed, err = until(waiting, r, walk(HomeAssistant), ready)
+ cancel()
+ if err != nil {
return err
}
}
- if err := play(ctx, r, SplashConfirm, comet(Palette{HomeAssistant})); err != nil {
- return err
+ if confirmed {
+ if err := play(ctx, r, confirm, comet(Palette{HomeAssistant})); err != nil {
+ return err
+ }
}
// ctx may be done by now, so the fade needs its own.
fade, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
defer cancel()
- return fadeOut(fade, r, 250*time.Millisecond)
+ return fadeOut(fade, r, fadeDuration)
}
-// until animates frame until ready reports true, or ctx is cancelled.
-func until(ctx context.Context, r *Ring, frame Frame, ready func() bool) error {
+// until animates frame until ready reports true, or ctx is cancelled. The boolean distinguishes a
+// real ready signal from a timeout so callers do not show a false confirmation animation.
+func until(ctx context.Context, r *Ring, frame Frame, ready func() bool) (bool, error) {
t := time.NewTicker(FrameInterval)
defer t.Stop()
start := time.Now()
for !ready() {
if err := r.SetSegments(frame(time.Since(start))); err != nil {
- return err
+ return false, err
}
select {
case <-ctx.Done():
- return nil
+ return false, nil
case <-t.C:
}
}
- return nil
+ return true, nil
}
// walk lights one segment at a time, hopping two positions per step so it lands on every other
diff --git a/internal/hardware/led/splash_test.go b/internal/hardware/led/splash_test.go
index 22b2bee..874e14b 100644
--- a/internal/hardware/led/splash_test.go
+++ b/internal/hardware/led/splash_test.go
@@ -1,10 +1,62 @@
package led
import (
+ "context"
+ "os"
+ "path/filepath"
+ "sync/atomic"
"testing"
"time"
)
+func testRing(t *testing.T) *Ring {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "frame"), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return &Ring{Path: dir}
+}
+
+func TestSplashTimesOutAndTurnsRingOff(t *testing.T) {
+ r := testRing(t)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ if err := splash(ctx, r, func() bool { return false }, 2*FrameInterval,
+ 2*FrameInterval, 2*FrameInterval); err != nil {
+ t.Fatal(err)
+ }
+ frame, err := r.Frame()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, value := range frame {
+ if value != 0 {
+ t.Fatalf("channel %d is %d after timed-out splash, want off", i, value)
+ }
+ }
+}
+
+func TestUntilReportsReadySeparatelyFromTimeout(t *testing.T) {
+ r := testRing(t)
+ var ready atomic.Bool
+ go func() {
+ time.Sleep(FrameInterval)
+ ready.Store(true)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*FrameInterval)
+ defer cancel()
+ confirmed, err := until(ctx, r, walk(HomeAssistant), ready.Load)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !confirmed {
+ t.Fatal("until reported timeout after ready became true")
+ }
+}
+
// The boot walk has to advance evenly all the way round, including across the wrap. An uneven step
// there reads as the light jumping backwards.
func TestWalkStepsEvenlyAroundTheRing(t *testing.T) {
diff --git a/internal/hardware/mic/mic.go b/internal/hardware/mic/mic.go
index 16c210d..022775d 100644
--- a/internal/hardware/mic/mic.go
+++ b/internal/hardware/mic/mic.go
@@ -4,6 +4,7 @@ package mic
import (
"context"
+ "encoding/binary"
"errors"
"fmt"
"log/slog"
@@ -11,6 +12,7 @@ import (
"sync/atomic"
"time"
+ "github.com/ygelfand/echolocal/internal/android/amazon"
"github.com/ygelfand/echolocal/internal/android/prop"
"github.com/ygelfand/echolocal/internal/component"
"github.com/ygelfand/echolocal/internal/config"
@@ -154,6 +156,13 @@ func (s *Source) Name() string { return "capture" }
// Start takes the capture device, off Android if it got there first, the same way the speaker does.
func (s *Source) Start(context.Context) error {
+ if amazon.Enabled() {
+ if !amazon.Get().Connected() {
+ return errors.New("mic: Android media helper is not connected")
+ }
+ slog.Info("capture using Android media helper", "rate", Rate, "channels", 1, "bits", 16)
+ return nil
+ }
err := s.open()
if err == nil || !errors.Is(err, alsa.ErrBusy) {
return err
@@ -291,6 +300,9 @@ func decode(raw []byte, first, n int) [][]int16 {
// Run reads until ctx is cancelled. It reads whether or not anyone is listening, because a stream
// left unread overruns and the hardware ring is only 160 ms deep.
func (s *Source) Run(ctx context.Context) error {
+ if amazon.Enabled() {
+ return s.runAmazon(ctx)
+ }
pcm := s.device()
if pcm == nil {
return errors.New("mic: the capture device is not held")
@@ -314,6 +326,35 @@ func (s *Source) Run(ctx context.Context) error {
}
}
+func (s *Source) runAmazon(ctx context.Context) error {
+ frames := make(chan []byte, 16)
+ unlisten := amazon.Get().ListenAudio(func(frame []byte) {
+ select {
+ case frames <- frame:
+ default:
+ s.dropped.Add(1)
+ }
+ })
+ defer unlisten()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+ case raw := <-frames:
+ if len(raw) == 0 || len(raw)%2 != 0 {
+ slog.Warn("Android media helper sent malformed audio", "bytes", len(raw))
+ continue
+ }
+ mono := make([]int16, len(raw)/2)
+ for i := range mono {
+ mono[i] = int16(binary.LittleEndian.Uint16(raw[i*2:]))
+ }
+ s.broadcastMono(mono)
+ }
+ }
+}
+
// broadcast hands the frame to every listener, dropping it for any that is behind. The mono mix is
// only computed when something wants it.
func (s *Source) broadcast(raw []byte) {
@@ -338,6 +379,33 @@ func (s *Source) broadcast(raw []byte) {
}
s.findFacing(mics)
+ s.deliver(frame)
+
+ if len(s.raw) == 0 {
+ return
+ }
+
+ // The reader reuses its buffer, so raw listeners get their own copy.
+ interleaved := make([]byte, len(raw))
+ copy(interleaved, raw)
+ for _, ch := range s.raw {
+ select {
+ case ch <- interleaved:
+ default:
+ }
+ }
+}
+
+// broadcastMono accepts the already mixed 16-bit stream Android's AudioRecord produces.
+func (s *Source) broadcastMono(frame []int16) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.deliver(frame)
+}
+
+// deliver applies processing common to direct ALSA and Android media and fans a mono frame out.
+// Called with mu held.
+func (s *Source) deliver(frame []int16) {
// Turning leveling off throws away what it learned, so a room it has adapted badly to is
// recovered by switching it off and on rather than by restarting anything.
on := s.leveling.Load()
@@ -365,20 +433,6 @@ func (s *Source) broadcast(raw []byte) {
s.dropped.Add(1)
}
}
-
- if len(s.raw) == 0 {
- return
- }
-
- // The reader reuses its buffer, so raw listeners get their own copy.
- interleaved := make([]byte, len(raw))
- copy(interleaved, raw)
- for _, ch := range s.raw {
- select {
- case ch <- interleaved:
- default:
- }
- }
}
// Dropped is how many frames a listener has missed.
@@ -387,6 +441,9 @@ func (s *Source) Dropped() uint64 { return s.dropped.Load() }
// Close lets the device go. The Source stays usable and its listeners stay subscribed: Start can take
// the hardware again, which is how a restart works.
func (s *Source) Close() error {
+ if amazon.Enabled() {
+ return nil
+ }
s.devMu.Lock()
pcm := s.pcm
s.pcm = nil
diff --git a/internal/hardware/speaker/acquire.go b/internal/hardware/speaker/acquire.go
index 69545cb..7c407a8 100644
--- a/internal/hardware/speaker/acquire.go
+++ b/internal/hardware/speaker/acquire.go
@@ -6,6 +6,7 @@ import (
"log/slog"
"time"
+ "github.com/ygelfand/echolocal/internal/android/amazon"
"github.com/ygelfand/echolocal/internal/android/prop"
"github.com/ygelfand/echolocal/internal/lib/alsa"
)
@@ -24,6 +25,13 @@ const (
// so a restart can leave us with no speaker. Stopping the service releases it; it is started again
// either way, because leaving it down trips the framework watchdog.
func (p *Player) Start(context.Context) error {
+ if amazon.Enabled() {
+ if !amazon.Get().Connected() {
+ return errors.New("speaker: Android media helper is not connected")
+ }
+ slog.Info("playback using Android media helper", "rate", Rate, "channels", Channels, "bits", Bits)
+ return nil
+ }
err := p.open()
if err == nil || !errors.Is(err, alsa.ErrBusy) {
return err
diff --git a/internal/hardware/speaker/speaker.go b/internal/hardware/speaker/speaker.go
index 3ef55eb..d9eb1a6 100644
--- a/internal/hardware/speaker/speaker.go
+++ b/internal/hardware/speaker/speaker.go
@@ -12,6 +12,7 @@ import (
"sync/atomic"
"time"
+ "github.com/ygelfand/echolocal/internal/android/amazon"
"github.com/ygelfand/echolocal/internal/component"
"github.com/ygelfand/echolocal/internal/config"
"github.com/ygelfand/echolocal/internal/lib/alsa"
@@ -268,6 +269,9 @@ func (p *Player) apply(seq []kctl) {
// Run feeds the stream until ctx is cancelled, writing silence when nothing is queued.
func (p *Player) Run(ctx context.Context) error {
+ if amazon.Enabled() {
+ return p.runAmazon(ctx)
+ }
pb, _ := p.device()
if pb == nil {
return errors.New("speaker: the playback device is not held")
@@ -307,6 +311,26 @@ func (p *Player) Run(ctx context.Context) error {
}
}
+func (p *Player) runAmazon(ctx context.Context) error {
+ buf := make([]byte, period*Channels*Bits/8)
+ slog.Info("playback path up", "output", "android-media")
+ p.OnOutput.Emit(p.Output())
+
+ for {
+ if err := ctx.Err(); err != nil {
+ return nil
+ }
+ p.fill(buf)
+ if err := amazon.Get().Play(buf); err != nil {
+ if ctx.Err() != nil {
+ return nil
+ }
+ return fmt.Errorf("speaker: Android media write: %w", err)
+ }
+ p.written.Add(period)
+ }
+}
+
// send writes one period, putting the same buffer back after an underrun rather than refilling. fill
// has already taken these frames off the queue, so starting over would play the period after them and
// lose these.
@@ -388,7 +412,7 @@ func (p *Player) render() []int16 {
// queue then, so it would grow for as long as the speaker stayed away — and a device that cannot play
// should say so in the log rather than in memory.
func (p *Player) Play(samples []int16) {
- if pb, _ := p.device(); pb == nil {
+ if !p.available() {
if n := p.deaf.Add(1); n == 1 || n%100 == 0 {
slog.Warn("audio dropped, no playback device", "times", n)
}
@@ -400,6 +424,14 @@ func (p *Player) Play(samples []int16) {
p.mu.Unlock()
}
+func (p *Player) available() bool {
+ if amazon.Enabled() {
+ return amazon.Get().Connected()
+ }
+ pb, _ := p.device()
+ return pb != nil
+}
+
// Take empties the queue and hands back what had not been played, so a sound that yields to another
// can carry on from where it was rather than skipping whatever it had queued.
func (p *Player) Take() []int16 {
@@ -505,7 +537,7 @@ func (p *Player) Chime(level float64, notes ...Note) {
// Overlay mixes samples into what is already queued, extending the queue if they outlast it. Sums are
// clamped: two things at once are louder than either, and wrapping would turn that into a crack.
func (p *Player) Overlay(samples []int16) {
- if pb, _ := p.device(); pb == nil {
+ if !p.available() {
p.Play(samples)
return
}
@@ -569,6 +601,12 @@ func (p *Player) Volume() float32 { return math.Float32frombits(p.volume.Load())
// Close mutes the codec, turns the amplifier off and lets the device go. The Player stays usable:
// Start can take it again, which is how a restart works.
func (p *Player) Close() error {
+ if amazon.Enabled() {
+ if amazon.Get().Connected() {
+ return amazon.Get().StopPlayback()
+ }
+ return nil
+ }
p.apply(initSequence)
p.devMu.Lock()
diff --git a/internal/host/assets/assets.go b/internal/host/assets/assets.go
index 0252819..b63bd4e 100644
--- a/internal/host/assets/assets.go
+++ b/internal/host/assets/assets.go
@@ -11,5 +11,13 @@ func Echod() []byte { return echod }
// BootImage is the boot image written to boot_a_x, or empty in a build without a payload.
func BootImage() []byte { return bootImage }
+// PryonAPK is EchoLocal's own wake-only privileged companion. It contains no Amazon code or models.
+func PryonAPK() []byte { return pryonAPK }
+
+// AndroidMedia is EchoLocal's own app_process bridge for AudioRecord and AudioTrack.
+func AndroidMedia() []byte { return androidMedia }
+
// Embedded reports whether this build carries both.
-func Embedded() bool { return len(echod) > 0 && len(bootImage) > 0 }
+func Embedded() bool {
+ return len(echod) > 0 && len(bootImage) > 0 && len(pryonAPK) > 0 && len(androidMedia) > 0
+}
diff --git a/internal/host/assets/nopayload.go b/internal/host/assets/nopayload.go
index af37e7b..e525307 100644
--- a/internal/host/assets/nopayload.go
+++ b/internal/host/assets/nopayload.go
@@ -3,6 +3,8 @@
package assets
var (
- echod []byte
- bootImage []byte
+ echod []byte
+ bootImage []byte
+ pryonAPK []byte
+ androidMedia []byte
)
diff --git a/internal/host/assets/payload.go b/internal/host/assets/payload.go
index ab0103a..9679272 100644
--- a/internal/host/assets/payload.go
+++ b/internal/host/assets/payload.go
@@ -12,3 +12,9 @@ var echod []byte
//go:embed payload/boot.img
var bootImage []byte
+
+//go:embed payload/EchoLocalPryon.apk
+var pryonAPK []byte
+
+//go:embed payload/amazon-helper.jar
+var androidMedia []byte
diff --git a/internal/host/installer/flash.go b/internal/host/installer/flash.go
index 2e9d7a6..23a6f1f 100644
--- a/internal/host/installer/flash.go
+++ b/internal/host/installer/flash.go
@@ -41,6 +41,7 @@ var flashSteps = []step{
{"check target partition", checkPartition},
{"write the boot image", writeImage},
{"verify what was written", verifyImage},
+ {"unmount recovery userdata", unmountRecoveryUserdata},
{"reboot to android", bootAndroid},
{"confirm root and policy", confirmPolicy},
}
@@ -338,6 +339,102 @@ func verifyImage(r *run) (string, bool, error) {
return got[:12] + " matches", false, nil
}
+const userdataNode = "/dev/block/platform/mtk-msdc.0/by-name/userdata"
+
+// unmountRecoveryUserdata leaves TWRP's automatic userdata mounts in a clean state before Android
+// boots. Some TWRP builds mount the same ext4 filesystem at both /data and /sdcard; rebooting while
+// those mounts are live can abort its journal and make Android remount /data read-only.
+func unmountRecoveryUserdata(r *run) (string, bool, error) {
+ if detail, skip := r.done(); skip {
+ return detail, true, nil
+ }
+
+ node, err := r.d.Shell("readlink -f " + userdataNode)
+ if err != nil {
+ return "", false, fmt.Errorf("resolving userdata: %w", err)
+ }
+ node = strings.TrimSpace(node)
+ if node == "" || !strings.HasPrefix(node, "/dev/block/") {
+ return "", false, fmt.Errorf("userdata resolved to unsafe block device %q", node)
+ }
+
+ mounts, err := recoveryUserdataMounts(r.d, node)
+ if err != nil {
+ return "", false, err
+ }
+ if len(mounts) == 0 {
+ return "already unmounted", false, nil
+ }
+
+ if _, err := r.d.Shell("sync"); err != nil {
+ return "", false, fmt.Errorf("syncing recovery filesystems: %w", err)
+ }
+ // /sdcard is the second mount on the affected TWRP image, so release it before /data.
+ for _, target := range []string{"/sdcard", "/data"} {
+ if !contains(mounts, target) {
+ continue
+ }
+ if _, err := r.d.Shell("umount " + target); err != nil {
+ return "", false, fmt.Errorf("unmounting userdata from %s: %w", target, err)
+ }
+ }
+ if _, err := r.d.Shell("sync"); err != nil {
+ return "", false, fmt.Errorf("syncing after unmount: %w", err)
+ }
+
+ remaining, err := recoveryUserdataMounts(r.d, node)
+ if err != nil {
+ return "", false, err
+ }
+ if len(remaining) != 0 {
+ return "", false, fmt.Errorf("userdata is still mounted at %s; refusing to reboot",
+ strings.Join(remaining, ", "))
+ }
+ return strings.Join(mounts, " and "), false, nil
+}
+
+func recoveryUserdataMounts(d *device.Device, node string) ([]string, error) {
+ raw, err := d.Shell("cat /proc/mounts")
+ if err != nil {
+ return nil, fmt.Errorf("reading recovery mounts: %w", err)
+ }
+ return parseUserdataMounts(raw, node)
+}
+
+func parseUserdataMounts(raw, node string) ([]string, error) {
+ found := map[string]bool{}
+ for line := range strings.SplitSeq(raw, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) < 2 || (fields[0] != node && fields[0] != userdataNode) {
+ continue
+ }
+ switch fields[1] {
+ case "/data", "/sdcard":
+ found[fields[1]] = true
+ default:
+ return nil, fmt.Errorf("userdata is unexpectedly mounted at %s; refusing to reboot", fields[1])
+ }
+ }
+
+ // Keep the unmount order deterministic and safe for TWRP's duplicate mount.
+ var mounts []string
+ for _, target := range []string{"/sdcard", "/data"} {
+ if found[target] {
+ mounts = append(mounts, target)
+ }
+ }
+ return mounts, nil
+}
+
+func contains(values []string, want string) bool {
+ for _, value := range values {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
+
func bootAndroid(r *run) (string, bool, error) {
if detail, skip := r.done(); skip {
return detail, true, nil
diff --git a/internal/host/installer/flash_test.go b/internal/host/installer/flash_test.go
index 03aac05..4c3dba4 100644
--- a/internal/host/installer/flash_test.go
+++ b/internal/host/installer/flash_test.go
@@ -1,6 +1,7 @@
package installer
import (
+ "reflect"
"strings"
"testing"
)
@@ -106,3 +107,41 @@ func TestCheckImageRefusesAnEmptyOne(t *testing.T) {
t.Error("accepted an empty image")
}
}
+
+func TestParseUserdataMounts(t *testing.T) {
+ raw := strings.Join([]string{
+ "rootfs / rootfs rw 0 0",
+ "/dev/block/mmcblk0p16 /data ext4 rw 0 0",
+ "/dev/block/mmcblk0p16 /sdcard ext4 rw 0 0",
+ "/dev/block/mmcblk0p1 /system ext4 ro 0 0",
+ }, "\n")
+
+ got, err := parseUserdataMounts(raw, "/dev/block/mmcblk0p16")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"/sdcard", "/data"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+}
+
+func TestParseUserdataMountsAcceptsByNameSource(t *testing.T) {
+ raw := userdataNode + " /data ext4 rw 0 0\n"
+
+ got, err := parseUserdataMounts(raw, "/dev/block/mmcblk0p16")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"/data"}; !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+}
+
+func TestParseUserdataMountsRefusesUnexpectedTarget(t *testing.T) {
+ raw := "/dev/block/mmcblk0p16 /unexpected ext4 rw 0 0\n"
+
+ if _, err := parseUserdataMounts(raw, "/dev/block/mmcblk0p16"); err == nil {
+ t.Fatal("accepted an unexpected userdata mount")
+ }
+}
diff --git a/internal/host/installer/installer.go b/internal/host/installer/installer.go
index 1c214ae..f546036 100644
--- a/internal/host/installer/installer.go
+++ b/internal/host/installer/installer.go
@@ -41,6 +41,12 @@ type Config struct {
// same way.
Echod []byte
+ // Pryon installs the wake-only privileged APK and the user-owned Android media bridge.
+ // The Amazon libraries, APK and models stay on the attached Dot and are discovered there.
+ Pryon bool
+ PryonAPK []byte
+ AndroidMedia []byte
+
// Name is what Home Assistant calls the device. Only needed on a device that has none.
Name string
@@ -66,12 +72,15 @@ type step struct {
var steps = []step{
{"check device", checkDevice},
+ {"inspect Pryon firmware", inspectPryon},
{"hide Amazon packages", hidePackages},
{"gate Amazon init services", gateProps},
{"device name", installName},
{"encryption key", installKey},
{"default wake words", installModels},
+ {"install Android media bridge", installAndroidMedia},
{"remount /system rw", remountRW},
+ {"install Pryon companion", installPryonAPK},
{"install echod", installBinary},
{"back up stock ledcontroller", backupService},
{"take over ledcontroller service", takeOverService},
@@ -89,11 +98,14 @@ var restartSteps = []step{
var uninstallSteps = []step{
{"stop echod", stopService},
+ {"stop Pryon companion", stopPryon},
{"remount /system rw", remountRW},
+ {"remove Pryon companion", removePryonAPK},
{"close the API port", removeFirewallHook},
{"restore the boot animation", restoreBootAnimation},
{"restore stock ledcontroller", restoreService},
{"remove echod", removeBinary},
+ {"remove Android media bridge", removeAndroidMedia},
{"remount /system ro", remountRO},
{"start ledcontroller", startStock},
}
@@ -108,6 +120,7 @@ type run struct {
// state is what the device last said about itself. The flash stage reads it before deciding to
// write anything and again afterwards to judge whether it worked.
state state
+ pryon pryonPaths
// reboot is or-ed by the steps that change something init only acts on at start-up. The rest of a
// run — checks, remounts, writing the binary, restarting the service — happens every time and
@@ -179,6 +192,14 @@ func checkDevice(r *run) (string, bool, error) {
if len(r.cfg.Echod) == 0 {
return "", false, errors.New("no echod binary given")
}
+ if r.cfg.Pryon {
+ if len(r.cfg.PryonAPK) == 0 {
+ return "", false, errors.New("Pryon enabled but no companion APK was given")
+ }
+ if len(r.cfg.AndroidMedia) == 0 {
+ return "", false, errors.New("Pryon enabled but no Android media helper was given")
+ }
+ }
sdk, err := r.d.Getprop("ro.build.version.sdk")
if err != nil {
diff --git a/internal/host/installer/pryon.go b/internal/host/installer/pryon.go
new file mode 100644
index 0000000..08ae031
--- /dev/null
+++ b/internal/host/installer/pryon.go
@@ -0,0 +1,408 @@
+package installer
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/ygelfand/echolocal/internal/host/device"
+ "github.com/ygelfand/echolocal/internal/layout"
+)
+
+type pryonPaths struct {
+ amazonAPK string
+ alexa string
+ aed string
+}
+
+// A newly installed SpeechInteractionManager can spend over 30 seconds in dex2oat before Pryon can
+// load its native model. Keep this bounded, but allow enough time for the first boot on the Dot.
+const pryonReadyTimeout = 90 * time.Second
+
+func inspectPryon(r *run) (string, bool, error) {
+ if !r.cfg.Pryon {
+ return "not requested", true, nil
+ }
+
+ for _, library := range []string{
+ "/system/lib/libpryon.so",
+ "/system/lib/libwakewordserver_jni.so",
+ "/system/lib/libaudiostream.so",
+ "/system/lib/libaudiostream_jni.so",
+ } {
+ have, err := r.d.Exists(library)
+ if err != nil {
+ return "", false, err
+ }
+ if !have {
+ return "", false, fmt.Errorf("required firmware library is missing: %s", library)
+ }
+ }
+
+ path, err := packageAPK(r.d, "amazon.speech.sim")
+ if err != nil {
+ return "", false, fmt.Errorf("locating SpeechInteractionManager: %w", err)
+ }
+
+ language, _ := r.d.Getprop("persist.sys.language")
+ country, _ := r.d.Getprop("persist.sys.country")
+ locales := []string{}
+ if language != "" && country != "" {
+ locales = append(locales, language+"-"+country)
+ }
+ if language != "" {
+ locales = append(locales, language)
+ }
+ // Fire OS often reports en-GB while the physical model is reached through an en-GB symlink.
+ locales = append(locales, "en-GB", "en-US")
+
+ var searched []string
+ for _, locale := range unique(locales) {
+ candidate := "/system/local/models/keyword/" + locale + "/ALEXA/pryon.manifest"
+ searched = append(searched, candidate)
+ if have, _ := r.d.Exists(candidate); have {
+ r.pryon.alexa = candidate
+ break
+ }
+ }
+ if r.pryon.alexa == "" {
+ found, _ := r.d.Shell("find /system/local/models/keyword -path '*/ALEXA/pryon.manifest' 2>/dev/null")
+ r.pryon.alexa = firstLine(found)
+ }
+ if r.pryon.alexa == "" {
+ return "", false, fmt.Errorf("unable to locate the Alexa Pryon manifest; searched: %s",
+ strings.Join(searched, ", "))
+ }
+
+ r.pryon.aed = "/system/local/models/AED/pryon.manifest"
+ if have, _ := r.d.Exists(r.pryon.aed); !have {
+ found, _ := r.d.Shell("find /system/local/models -path '*/AED/pryon.manifest' 2>/dev/null")
+ r.pryon.aed = firstLine(found)
+ }
+ if r.pryon.aed == "" {
+ return "", false, errors.New("unable to locate the required AED Pryon manifest under /system/local/models")
+ }
+
+ r.pryon.amazonAPK = path
+ return fmt.Sprintf("SIM=%s, Alexa=%s, AED=%s", path, r.pryon.alexa, r.pryon.aed), false, nil
+}
+
+// packageAPK resolves an installed package even after `pm hide`. Fire OS 5 makes `pm path` return
+// exit 1 for a hidden package, while `pm list packages -f -u` still reports the system APK. Pryon is
+// finalized after a reboot with Amazon's audio packages hidden, so both views are required.
+func packageAPK(d *device.Device, name string) (string, error) {
+ direct, directCode, err := d.ShellCode("pm path " + name)
+ if err != nil {
+ return "", err
+ }
+ if directCode == 0 {
+ if path := packagePath(direct, name); path != "" {
+ return path, nil
+ }
+ }
+
+ all, listCode, err := d.ShellCode("pm list packages -f -u " + name)
+ if err != nil {
+ return "", err
+ }
+ if listCode == 0 {
+ if path := packagePath(all, name); path != "" {
+ return path, nil
+ }
+ }
+ return "", fmt.Errorf("package %s reported no APK path (pm path exit %d, hidden-package list exit %d)",
+ name, directCode, listCode)
+}
+
+func packagePath(output, name string) string {
+ for line := range strings.SplitSeq(output, "\n") {
+ line = strings.TrimSpace(line)
+ if !strings.HasPrefix(line, "package:") {
+ continue
+ }
+ value := strings.TrimPrefix(line, "package:")
+ if path, packageName, found := strings.Cut(value, "="); found {
+ if strings.TrimSpace(packageName) != name {
+ continue
+ }
+ return strings.TrimSpace(path)
+ }
+ if value != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+func unique(in []string) []string {
+ seen := map[string]bool{}
+ out := make([]string, 0, len(in))
+ for _, v := range in {
+ if v != "" && !seen[v] {
+ seen[v] = true
+ out = append(out, v)
+ }
+ }
+ return out
+}
+
+func firstLine(s string) string {
+ line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
+ return strings.TrimSpace(line)
+}
+
+func installAndroidMedia(r *run) (string, bool, error) {
+ if !r.cfg.Pryon {
+ return "not requested", true, nil
+ }
+ if _, err := r.d.Shell("mkdir -p " + layout.StateDir); err != nil {
+ return "", false, err
+ }
+ if same, err := sameRemote(r.d, layout.AndroidMediaJar, r.cfg.AndroidMedia); err != nil {
+ return "", false, err
+ } else if same {
+ return "already installed", true, nil
+ }
+ if err := r.d.WriteFile(layout.AndroidMediaJar, r.cfg.AndroidMedia, 0o644); err != nil {
+ return "", false, err
+ }
+ return layout.AndroidMediaJar, false, nil
+}
+
+func installPryonAPK(r *run) (string, bool, error) {
+ if !r.cfg.Pryon {
+ return "not requested", true, nil
+ }
+ if same, err := sameRemote(r.d, layout.PryonAPK, r.cfg.PryonAPK); err != nil {
+ return "", false, err
+ } else if same {
+ return "already installed", true, nil
+ }
+ // Android may still have the previous APK and its native libraries mapped. Replacing that file
+ // underneath a live privileged process can keep /system busy and make the safety remount to
+ // read-only fail. A fresh install is harmlessly force-stopped too.
+ if _, err := r.d.Shell("am force-stop " + layout.PryonPackage); err != nil {
+ return "", false, fmt.Errorf("stopping the existing Pryon companion: %w", err)
+ }
+ if _, err := r.d.Shell("mkdir -p " + layout.PryonDir); err != nil {
+ return "", false, err
+ }
+ if err := r.d.WriteFile(layout.PryonAPK, r.cfg.PryonAPK, 0o644); err != nil {
+ return "", false, err
+ }
+ if err := r.d.Chcon(layout.OurLabel, layout.PryonAPK); err != nil {
+ return "", false, err
+ }
+ r.reboot = true
+ return layout.PryonAPK + " (effective after reboot)", false, nil
+}
+
+func sameRemote(d *device.Device, path string, want []byte) (bool, error) {
+ have, err := d.Exists(path)
+ if err != nil || !have {
+ return false, err
+ }
+ got, err := d.ReadFile(path)
+ if err != nil {
+ return false, err
+ }
+ return bytes.Equal(got, want), nil
+}
+
+func stopPryon(r *run) (string, bool, error) {
+ have, err := r.d.Exists(layout.PryonAPK)
+ if err != nil || !have {
+ return "not installed", true, err
+ }
+ _, err = r.d.Shell("am force-stop " + layout.PryonPackage)
+ return layout.PryonPackage, false, err
+}
+
+func removePryonAPK(r *run) (string, bool, error) {
+ have, err := r.d.Exists(layout.PryonDir)
+ if err != nil || !have {
+ return "not installed", true, err
+ }
+ _, err = r.d.Shell("rm -rf " + layout.PryonDir)
+ return layout.PryonDir + " (package removal settles after reboot)", false, err
+}
+
+func removeAndroidMedia(r *run) (string, bool, error) {
+ paths := layout.AndroidMediaJar + " " + layout.PryonUIDPath
+ _, err := r.d.Shell("rm -f " + paths)
+ return paths, false, err
+}
+
+var finalizePryonSteps = []step{
+ {"rediscover Pryon firmware", inspectPryon},
+ {"record Pryon package UID", recordPryonUID},
+ {"configure Pryon detector", configurePryon},
+ {"restart echod with Android media", stopService},
+ {"start echod with Alexa capability", startService},
+ {"verify EchoLocal ESPHome service", verifyESPHome},
+ {"verify Pryon detector", verifyPryon},
+}
+
+// FinalizePryon runs after Android has scanned the newly installed privileged APK. It pins the
+// package UID used by socket authentication, persists firmware paths in the companion and verifies
+// both the ESPHome-facing runtime and the native detector.
+func FinalizePryon(ctx context.Context, d *device.Device, cfg Config, report Reporter) error {
+ if !cfg.Pryon {
+ return nil
+ }
+ return execute(ctx, finalizePryonSteps, &run{d: d, cfg: cfg, ctx: ctx}, report)
+}
+
+var userIDPattern = regexp.MustCompile(`(?m)\buserId=(\d+)\b`)
+
+// PryonScanned reports whether Android's package manager has loaded the privileged companion.
+// A newly copied system APK is not visible until the next boot.
+func PryonScanned(d *device.Device) (bool, error) {
+ dump, code, err := d.ShellCode("dumpsys package " + layout.PryonPackage)
+ if err != nil {
+ return false, err
+ }
+ return code == 0 && userIDPattern.MatchString(dump), nil
+}
+
+func recordPryonUID(r *run) (string, bool, error) {
+ dump, err := r.d.Shell("dumpsys package " + layout.PryonPackage)
+ if err != nil {
+ return "", false, err
+ }
+ match := userIDPattern.FindStringSubmatch(dump)
+ if len(match) != 2 {
+ return "", false, fmt.Errorf("package %s has no userId after reboot", layout.PryonPackage)
+ }
+ uid, err := strconv.Atoi(match[1])
+ if err != nil || uid < 10000 {
+ return "", false, fmt.Errorf("package %s reported invalid UID %q", layout.PryonPackage, match[1])
+ }
+ want := []byte(strconv.Itoa(uid) + "\n")
+ if same, err := sameRemote(r.d, layout.PryonUIDPath, want); err != nil {
+ return "", false, err
+ } else if same {
+ return strconv.Itoa(uid), true, nil
+ }
+ if err := r.d.WriteFile(layout.PryonUIDPath, want, 0o600); err != nil {
+ return "", false, err
+ }
+ return strconv.Itoa(uid), false, nil
+}
+
+func configurePryon(r *run) (string, bool, error) {
+ cmd := fmt.Sprintf("am startservice -n %s/.PryonDetectorService --es amazon_apk %s --es alexa_model %s --es aed_model %s",
+ layout.PryonPackage, r.pryon.amazonAPK, r.pryon.alexa, r.pryon.aed)
+ out, err := r.d.Shell(cmd)
+ if err != nil {
+ return "", false, err
+ }
+ return strings.TrimSpace(out), false, nil
+}
+
+func verifyESPHome(r *run) (string, bool, error) {
+ name, err := ReadName(r.d)
+ if err != nil || name == "" {
+ return "", false, fmt.Errorf("EchoLocal device name is unavailable: %w", err)
+ }
+ if _, err := KeyOrError(r.d); err != nil && !r.cfg.ZeroPSK {
+ return "", false, err
+ }
+ mac, err := r.d.Shell("cat " + layout.MACPath)
+ if err != nil || layout.MAC(mac) == "" {
+ return "", false, fmt.Errorf("EchoLocal device MAC is unavailable: %w", err)
+ }
+ // startService proves that init launched this binary, not that all of its components have reached
+ // their listeners. The Android media helper can take several seconds to create AudioRecord and
+ // AudioTrack after a cold boot, so wait for the resident state and port rather than racing them.
+ readyDeadline := time.Now().Add(30 * time.Second)
+ var state string
+ var listening bool
+ for time.Now().Before(readyDeadline) {
+ state, err = r.d.Getprop(layout.StateProp)
+ if err != nil {
+ return "", false, err
+ }
+ if state == "resident" {
+ port := strings.ToUpper(fmt.Sprintf("%04X", layout.Port))
+ _, code, portErr := r.d.ShellCode("cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep ':" + port + " '")
+ if portErr != nil {
+ return "", false, portErr
+ }
+ listening = code == 0
+ if listening {
+ break
+ }
+ }
+ select {
+ case <-r.ctx.Done():
+ return "", false, r.ctx.Err()
+ case <-time.After(250 * time.Millisecond):
+ }
+ }
+ if state != "resident" || !listening {
+ return "", false, fmt.Errorf("ESPHome native API not ready within 30s (echod state=%q, tcp/%d listening=%t)",
+ state, layout.Port, listening)
+ }
+
+ mdnsDeadline := time.Now().Add(15 * time.Second)
+ for time.Now().Before(mdnsDeadline) {
+ logs, _ := r.d.Shell("logcat -d -s echolocal:I '*:S'")
+ if strings.Contains(logs, "advertising over mdns") {
+ return fmt.Sprintf("%s, %s, %s %s/%s on tcp/%d with mDNS",
+ name, layout.MAC(mac), layout.Manufacturer, layout.Model, layout.Platform, layout.Port), false, nil
+ }
+ select {
+ case <-r.ctx.Done():
+ return "", false, r.ctx.Err()
+ case <-time.After(500 * time.Millisecond):
+ }
+ }
+ return "", false, errors.New("ESPHome API is listening but mDNS discovery was not advertised within 15s; verify Wi-Fi before completing installation")
+}
+
+func verifyPryon(r *run) (string, bool, error) {
+ deadline := time.Now().Add(pryonReadyTimeout)
+ var logs string
+ for time.Now().Before(deadline) {
+ // Fire OS routes privileged-app logs to amazon_main rather than the default buffer. `-b all`
+ // keeps verification independent of which Android UID emitted each half of the handshake.
+ poc, _ := r.d.Shell("logcat -b all -d -s EchoLocalPryon:I '*:S'")
+ helper, _ := r.d.Shell("logcat -b all -d -s echolocal-helper:I '*:S'")
+ echo, _ := r.d.Shell("logcat -b all -d -s echolocal:I '*:S'")
+ logs = "Pryon: " + lastLines(poc, 6) + " | Helper: " + lastLines(helper, 4) +
+ " | EchoLocal: " + lastLines(echo, 4)
+ if pryonRuntimeReady(poc, helper, echo) {
+ return "native detector and shared microphone ready; Alexa advertised", false, nil
+ }
+ select {
+ case <-r.ctx.Done():
+ return "", false, r.ctx.Err()
+ case <-time.After(500 * time.Millisecond):
+ }
+ }
+ return "", false, fmt.Errorf("Pryon and its shared live microphone did not report ready within %s; recent state: %s",
+ pryonReadyTimeout, strings.TrimSpace(logs))
+}
+
+func pryonRuntimeReady(poc, helper, echo string) bool {
+ echoReady := strings.Contains(echo, "pryon=1") ||
+ strings.Contains(echo, "id=pryon_alexa engine=pryon")
+ return strings.Contains(poc, "PRYON_READY") &&
+ strings.Contains(poc, "PRYON_CAPTURE_ACTIVE") &&
+ strings.Contains(poc, "PRYON_SHARED_PCM_FIRST_FRAME") &&
+ strings.Contains(helper, "shared Pryon capture first frame") && echoReady
+}
+
+func lastLines(s string, n int) string {
+ lines := strings.Split(strings.TrimSpace(s), "\n")
+ if len(lines) > n {
+ lines = lines[len(lines)-n:]
+ }
+ return strings.Join(lines, " | ")
+}
diff --git a/internal/host/installer/pryon_test.go b/internal/host/installer/pryon_test.go
new file mode 100644
index 0000000..75c3ebc
--- /dev/null
+++ b/internal/host/installer/pryon_test.go
@@ -0,0 +1,80 @@
+package installer
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestUniqueLocales(t *testing.T) {
+ got := unique([]string{"en-GB", "en", "en-GB", "", "en-US"})
+ want := []string{"en-GB", "en", "en-US"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+}
+
+func TestFirstLine(t *testing.T) {
+ if got := firstLine("\n/a/first\n/a/second\n"); got != "/a/first" {
+ t.Fatalf("got %q", got)
+ }
+}
+
+func TestPryonUserIDPattern(t *testing.T) {
+ match := userIDPattern.FindStringSubmatch("Packages:\n userId=32065\n")
+ if len(match) != 2 || match[1] != "32065" {
+ t.Fatalf("got %v", match)
+ }
+}
+
+func TestPackagePath(t *testing.T) {
+ tests := map[string]struct {
+ output string
+ want string
+ }{
+ "visible": {
+ output: "package:/system/priv-app/SpeechInteractionManager/SpeechInteractionManager.apk\n",
+ want: "/system/priv-app/SpeechInteractionManager/SpeechInteractionManager.apk",
+ },
+ "hidden": {
+ output: "package:/system/priv-app/SpeechInteractionManager/SpeechInteractionManager.apk=amazon.speech.sim\n",
+ want: "/system/priv-app/SpeechInteractionManager/SpeechInteractionManager.apk",
+ },
+ "wrong package": {
+ output: "package:/system/other.apk=example.other\n",
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ if got := packagePath(test.output, "amazon.speech.sim"); got != test.want {
+ t.Fatalf("got %q, want %q", got, test.want)
+ }
+ })
+ }
+}
+
+func TestPryonRuntimeReadyRequiresLiveAudioEndToEnd(t *testing.T) {
+ poc := "PRYON_CAPTURE_ACTIVE\nPRYON_SHARED_PCM_FIRST_FRAME\nPRYON_READY"
+ helper := "shared Pryon capture first frame bytes=512"
+ echo := "id=pryon_alexa engine=pryon"
+ if !pryonRuntimeReady(poc, helper, echo) {
+ t.Fatal("complete live microphone handshake was not ready")
+ }
+
+ tests := map[string]struct {
+ poc, helper, echo string
+ }{
+ "native detector not ready": {poc: "PRYON_CAPTURE_ACTIVE\nPRYON_SHARED_PCM_FIRST_FRAME", helper: helper, echo: echo},
+ "native recorder stalled": {poc: "PRYON_SHARED_PCM_FIRST_FRAME\nPRYON_READY", helper: helper, echo: echo},
+ "shared reader empty": {poc: "PRYON_CAPTURE_ACTIVE\nPRYON_READY", helper: helper, echo: echo},
+ "helper received no PCM": {poc: poc, echo: echo},
+ "Alexa not advertised": {poc: poc, helper: helper},
+ }
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ if pryonRuntimeReady(test.poc, test.helper, test.echo) {
+ t.Fatal("incomplete handshake reported ready")
+ }
+ })
+ }
+}
diff --git a/internal/host/installer/status.go b/internal/host/installer/status.go
index 661615d..8619b61 100644
--- a/internal/host/installer/status.go
+++ b/internal/host/installer/status.go
@@ -21,10 +21,14 @@ type State struct {
Name string
Provisioned bool
- Installed bool
- LinkTarget string
- HaveBackup bool
- Version string
+ Installed bool
+ LinkTarget string
+ HaveBackup bool
+ Version string
+ AndroidMedia bool
+ PryonInstalled bool
+ PryonConfigured bool
+ APIListening bool
ServiceState string
AgentState string
@@ -70,7 +74,17 @@ func ReadState(d *device.Device) (State, error) {
if s.Installed {
// A binary that will not execute is what a file listing hides.
if out, err := d.Shell(layout.Binary + " --version"); err == nil {
- s.Version = strings.TrimSpace(out)
+ // Package initializers may log before Cobra handles --version. Keep only the actual
+ // version line so status remains readable on both old and new builds.
+ for line := range strings.SplitSeq(out, "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "echod version ") {
+ s.Version = line
+ }
+ }
+ if s.Version == "" {
+ s.Version = strings.TrimSpace(out)
+ }
} else {
s.Version = "will not run: " + err.Error()
}
@@ -84,6 +98,13 @@ func ReadState(d *device.Device) (State, error) {
return s, err
}
s.Provisioned = key != ""
+ s.AndroidMedia, _ = d.Exists(layout.AndroidMediaJar)
+ s.PryonInstalled, _ = d.Exists(layout.PryonAPK)
+ s.PryonConfigured, _ = d.Exists(layout.PryonUIDPath)
+ port := strings.ToUpper(strconv.FormatInt(layout.Port, 16))
+ port = strings.Repeat("0", 4-len(port)) + port
+ _, code, _ := d.ShellCode("cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | grep ':" + port + " '")
+ s.APIListening = code == 0
s.ServiceState, _ = d.Getprop("init.svc." + layout.ServiceName)
s.AgentState, _ = d.Getprop(layout.StateProp)
diff --git a/internal/layout/layout.go b/internal/layout/layout.go
index 1aa50d9..7f79b42 100644
--- a/internal/layout/layout.go
+++ b/internal/layout/layout.go
@@ -18,6 +18,16 @@ const (
KeyPath = StateDir + "/psk"
NamePath = StateDir + "/name"
+ // AndroidMediaJar is our 32-bit app_process helper. It uses Android's AudioRecord and
+ // AudioTrack so Amazon Pryon and EchoLocal share the firmware audio service instead of
+ // competing for the raw ALSA capture device.
+ AndroidMediaJar = StateDir + "/amazon-helper.jar"
+ PryonUIDPath = StateDir + "/pryon.uid"
+
+ PryonPackage = "com.echolocal.pryon"
+ PryonDir = "/system/priv-app/EchoLocalPryon"
+ PryonAPK = PryonDir + "/EchoLocalPryon.apk"
+
// PrevBinary is the binary an update replaced, kept until the new one has proved itself. Its
// presence at boot is what says a trial never finished, so nothing may leave one lying around.
// OldBinary is where a proven update files it, one generation back.
diff --git a/internal/lib/wake/library.go b/internal/lib/wake/library.go
index 3597f31..5b627fa 100644
--- a/internal/lib/wake/library.go
+++ b/internal/lib/wake/library.go
@@ -3,6 +3,7 @@ package wake
import (
"context"
"log/slog"
+ "os"
"slices"
"strings"
"sync"
@@ -27,8 +28,9 @@ type Library struct {
dir string
// ours is cached because reading it parses every model to work out which engine runs it.
- muOurs sync.Mutex
- ours []Model
+ muOurs sync.Mutex
+ ours []Model
+ virtual []Model
muOffers sync.Mutex
offers map[string]esphome.ExternalWakeWord
@@ -48,7 +50,12 @@ var (
// Lib is the device's library, built on first use.
func Lib() *Library {
- once.Do(func() { lib = NewLibrary(layout.ModelDir) })
+ once.Do(func() {
+ lib = NewLibrary(layout.ModelDir)
+ if _, err := os.Stat(layout.PryonUIDPath); err == nil {
+ lib.AddVirtual(PryonModel())
+ }
+ })
return lib
}
@@ -67,7 +74,25 @@ func (l *Library) Dir() string { return l.dir }
func (l *Library) Ours() []Model {
l.muOurs.Lock()
defer l.muOurs.Unlock()
- return l.ours
+ out := make([]Model, 0, len(l.ours)+len(l.virtual))
+ out = append(out, l.ours...)
+ out = append(out, l.virtual...)
+ return out
+}
+
+// AddVirtual adds a detector that is installed outside the TFLite model directory. Replacing by ID
+// makes the operation safe to repeat and gives tests a way to describe device capabilities without
+// manufacturing a model file.
+func (l *Library) AddVirtual(model Model) {
+ l.muOurs.Lock()
+ defer l.muOurs.Unlock()
+ for i := range l.virtual {
+ if l.virtual[i].ID == model.ID {
+ l.virtual[i] = model
+ return
+ }
+ }
+ l.virtual = append(l.virtual, model)
}
// Reload re-reads the directory, and is called wherever what is on disk changes.
@@ -77,7 +102,6 @@ func (l *Library) Reload() {
slog.Error("listing wake words failed", "dir", l.dir, "err", err)
return
}
-
l.muOurs.Lock()
l.ours = models
l.muOurs.Unlock()
diff --git a/internal/lib/wake/library_test.go b/internal/lib/wake/library_test.go
index 6d93751..7f869cc 100644
--- a/internal/lib/wake/library_test.go
+++ b/internal/lib/wake/library_test.go
@@ -107,3 +107,19 @@ func TestAdvertiseIsStableAcrossCalls(t *testing.T) {
t.Errorf("advertised %q, want the first by id", first[0].ID)
}
}
+
+func TestAdvertiseIncludesVirtualPryon(t *testing.T) {
+ l := installed(t, map[string]string{"okay_nabu": "Okay Nabu"})
+ l.AddVirtual(PryonModel())
+
+ words, shadowed := l.Advertise()
+ if shadowed != 0 {
+ t.Fatalf("shadowed %d, want none", shadowed)
+ }
+ for _, word := range words {
+ if word.ID == PryonID && word.Phrase == "Alexa" {
+ return
+ }
+ }
+ t.Fatalf("Pryon/Alexa missing from %+v", words)
+}
diff --git a/internal/lib/wake/models.go b/internal/lib/wake/models.go
index b952733..a39afee 100644
--- a/internal/lib/wake/models.go
+++ b/internal/lib/wake/models.go
@@ -31,8 +31,24 @@ type Kind string
const (
KindOpenWakeWord Kind = "openwakeword"
KindMicroWakeWord Kind = "microwakeword"
+ // KindPryon is Amazon's firmware detector. It has no TFLite path: the privileged Android
+ // companion owns its model and microphone, and sends only a wake event to echod.
+ KindPryon Kind = "pryon"
)
+const PryonID = "pryon_alexa"
+
+// PryonModel is the virtual model Home Assistant selects when the device-side companion is
+// installed. It deliberately carries no path or inference configuration.
+func PryonModel() Model {
+ return Model{
+ ID: PryonID,
+ Phrase: "Alexa",
+ Languages: []string{"en"},
+ Kind: KindPryon,
+ }
+}
+
type Model struct {
// ID is the file's base name. Home Assistant selects by it.
ID string
diff --git a/provision-echo-dot.ps1 b/provision-echo-dot.ps1
new file mode 100644
index 0000000..d218140
--- /dev/null
+++ b/provision-echo-dot.ps1
@@ -0,0 +1,513 @@
+#requires -Version 5.1
+
+<#
+.SYNOPSIS
+Build and provision EchoLocal with local Amazon Pryon/Alexa wake detection.
+
+.DESCRIPTION
+This is the supported source-tree installer for an unlocked Echo Dot 2 with TWRP recovery. It
+selects only an Amazon "biscuit" device, proves SDK 22, builds every EchoLocal-owned payload, saves a
+rollback snapshot, installs the verified root/permissive boot image when needed, installs and reboots
+the Dot, verifies the ESPHome native API and Pryon, then saves and prints the encryption key.
+
+Amazon libraries, models, and SpeechInteractionManager remain on the user's own Dot and are
+discovered there. They are never copied into the source tree or embedded in the installer.
+
+.EXAMPLE
+.\provision-echo-dot.ps1 -Name "Kitchen Echo"
+
+.EXAMPLE
+.\provision-echo-dot.ps1 -Serial G090XXXXXXXXXXXX -Name "Kitchen Echo"
+#>
+[CmdletBinding()]
+param(
+ [string]$Serial,
+ [string]$Name,
+ [string]$SdkRoot,
+ [string]$BuildToolsVersion,
+ [string]$PlatformVersion,
+ [string]$KeyStore,
+ [switch]$SkipBuild,
+ [switch]$BuildOnly
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+if (Test-Path Variable:\PSNativeCommandUseErrorActionPreference) {
+ $PSNativeCommandUseErrorActionPreference = $false
+}
+
+$projectDir = [IO.Path]::GetFullPath($PSScriptRoot)
+$binDir = Join-Path $projectDir "bin"
+$payloadDir = Join-Path $projectDir "internal\host\assets\payload"
+$echoctlPath = Join-Path $binDir "echoctl-provision.exe"
+$echodPath = Join-Path $binDir "echod-provision"
+
+function Write-Section {
+ param([Parameter(Mandatory = $true)][string]$Text)
+ Write-Host ""
+ Write-Host "== $Text ==" -ForegroundColor Cyan
+}
+
+function Resolve-Executable {
+ param(
+ [Parameter(Mandatory = $true)][string]$Name,
+ [string[]]$Candidates = @()
+ )
+
+ foreach ($candidate in $Candidates) {
+ if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) {
+ return [IO.Path]::GetFullPath($candidate)
+ }
+ }
+
+ $command = Get-Command $Name -ErrorAction SilentlyContinue | Select-Object -First 1
+ if ($null -eq $command) {
+ throw "Required program '$Name' was not found."
+ }
+ return $command.Source
+}
+
+function Invoke-Program {
+ param(
+ [Parameter(Mandatory = $true)][string]$FilePath,
+ [string[]]$ArgumentList = @(),
+ [string]$Description = $FilePath
+ )
+
+ & $FilePath @ArgumentList
+ if ($LASTEXITCODE -ne 0) {
+ throw "$Description failed with exit code $LASTEXITCODE."
+ }
+}
+
+function Get-ProgramOutput {
+ param(
+ [Parameter(Mandatory = $true)][string]$FilePath,
+ [string[]]$ArgumentList = @(),
+ [string]$Description = $FilePath
+ )
+
+ $lines = @(& $FilePath @ArgumentList 2>&1)
+ if ($LASTEXITCODE -ne 0) {
+ $detail = ($lines | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
+ throw "$Description failed with exit code $LASTEXITCODE.`n$detail"
+ }
+ return (($lines | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine).Trim()
+}
+
+function Find-AndroidSdk {
+ if ($SdkRoot) {
+ return [IO.Path]::GetFullPath($SdkRoot)
+ }
+ foreach ($candidate in @(
+ $env:ANDROID_SDK_ROOT,
+ $env:ANDROID_HOME,
+ (Join-Path $env:LOCALAPPDATA "Android\Sdk")
+ )) {
+ if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Container)) {
+ return [IO.Path]::GetFullPath($candidate)
+ }
+ }
+ throw "Android SDK not found. Pass -SdkRoot or set ANDROID_SDK_ROOT."
+}
+
+function Find-BuildToolsVersion {
+ param([Parameter(Mandatory = $true)][string]$AndroidSdk)
+ if ($BuildToolsVersion) {
+ return $BuildToolsVersion
+ }
+
+ $root = Join-Path $AndroidSdk "build-tools"
+ $versions = @(Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue |
+ Where-Object {
+ (Test-Path -LiteralPath (Join-Path $_.FullName "aapt.exe") -PathType Leaf) -and
+ (Test-Path -LiteralPath (Join-Path $_.FullName "d8.bat") -PathType Leaf) -and
+ (Test-Path -LiteralPath (Join-Path $_.FullName "zipalign.exe") -PathType Leaf) -and
+ (Test-Path -LiteralPath (Join-Path $_.FullName "apksigner.bat") -PathType Leaf)
+ } | Sort-Object {
+ try { [version]$_.Name } catch { [version]"0.0" }
+ } -Descending)
+ if ($versions.Count -eq 0) {
+ throw "No complete Android build-tools installation was found under $root."
+ }
+ return $versions[0].Name
+}
+
+function Find-PlatformVersion {
+ param([Parameter(Mandatory = $true)][string]$AndroidSdk)
+ if ($PlatformVersion) {
+ return $PlatformVersion
+ }
+
+ $root = Join-Path $AndroidSdk "platforms"
+ $platforms = @(Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue |
+ Where-Object {
+ $_.Name -match '^android-(\d+)$' -and
+ (Test-Path -LiteralPath (Join-Path $_.FullName "android.jar") -PathType Leaf)
+ } | Sort-Object { [int]($_.Name.Substring("android-".Length)) } -Descending)
+ if ($platforms.Count -eq 0) {
+ throw "No Android platform with android.jar was found under $root."
+ }
+ return $platforms[0].Name
+}
+
+function Ensure-DebugKeyStore {
+ param([Parameter(Mandatory = $true)][string]$Path)
+ if (Test-Path -LiteralPath $Path -PathType Leaf) {
+ return
+ }
+
+ Write-Host "Creating Android debug signing key: $Path"
+ $keytool = Resolve-Executable -Name "keytool.exe"
+ $parent = Split-Path -Parent $Path
+ New-Item -ItemType Directory -Force -Path $parent | Out-Null
+ Invoke-Program -FilePath $keytool -Description "keytool" -ArgumentList @(
+ "-genkeypair", "-keystore", $Path,
+ "-storepass", "android", "-alias", "androiddebugkey", "-keypass", "android",
+ "-dname", "CN=Android Debug,O=Android,C=US",
+ "-keyalg", "RSA", "-keysize", "2048", "-validity", "10000"
+ )
+}
+
+function Build-Installer {
+ Write-Section "Checking build tools"
+ $go = Resolve-Executable -Name "go.exe"
+ [void](Resolve-Executable -Name "javac.exe")
+ [void](Resolve-Executable -Name "jar.exe")
+
+ $androidSdk = Find-AndroidSdk
+ $toolsVersion = Find-BuildToolsVersion -AndroidSdk $androidSdk
+ $platform = Find-PlatformVersion -AndroidSdk $androidSdk
+ if (-not $KeyStore) {
+ $script:KeyStore = Join-Path $env:USERPROFILE ".android\debug.keystore"
+ }
+ $resolvedKeyStore = [IO.Path]::GetFullPath($KeyStore)
+ Ensure-DebugKeyStore -Path $resolvedKeyStore
+
+ Write-Host "Android SDK: $androidSdk"
+ Write-Host "Build tools: $toolsVersion"
+ Write-Host "Android platform: $platform"
+
+ Write-Section "Building EchoLocal Pryon companion"
+ & (Join-Path $projectDir "android\pryon\build.ps1") `
+ -SdkRoot $androidSdk `
+ -BuildToolsVersion $toolsVersion `
+ -PlatformVersion $platform `
+ -KeyStore $resolvedKeyStore
+
+ Write-Section "Building Android media bridge"
+ & (Join-Path $projectDir "android\amazon-helper\build.ps1") `
+ -SdkRoot $androidSdk `
+ -BuildToolsVersion $toolsVersion `
+ -PlatformVersion $platform
+
+ New-Item -ItemType Directory -Force -Path $binDir, $payloadDir | Out-Null
+
+ Write-Section "Building EchoLocal device agent"
+ $savedGoos = $env:GOOS
+ $savedGoarch = $env:GOARCH
+ $savedCgo = $env:CGO_ENABLED
+ try {
+ $env:GOOS = "linux"
+ $env:GOARCH = "arm64"
+ $env:CGO_ENABLED = "0"
+ Invoke-Program -FilePath $go -Description "echod build" -ArgumentList @(
+ "build", "-trimpath", "-ldflags", "-s -w", "-o", $echodPath, ".\cmd\echod"
+ )
+ } finally {
+ $env:GOOS = $savedGoos
+ $env:GOARCH = $savedGoarch
+ $env:CGO_ENABLED = $savedCgo
+ }
+
+ Write-Section "Embedding the complete installer payload"
+ $payloads = @{
+ "echod" = $echodPath
+ "boot.img" = (Join-Path $projectDir "images\echolocal-boot.img")
+ "EchoLocalPryon.apk" = (Join-Path $projectDir "android\pryon\build\EchoLocalPryon.apk")
+ "amazon-helper.jar" = (Join-Path $projectDir "android\amazon-helper\build\amazon-helper.jar")
+ }
+ foreach ($item in $payloads.GetEnumerator()) {
+ if (-not (Test-Path -LiteralPath $item.Value -PathType Leaf)) {
+ throw "Required payload is missing: $($item.Value)"
+ }
+ Copy-Item -Force -LiteralPath $item.Value -Destination (Join-Path $payloadDir $item.Key)
+ }
+
+ $savedGoos = $env:GOOS
+ $savedGoarch = $env:GOARCH
+ $savedCgo = $env:CGO_ENABLED
+ try {
+ Remove-Item Env:\GOOS -ErrorAction SilentlyContinue
+ Remove-Item Env:\GOARCH -ErrorAction SilentlyContinue
+ $env:CGO_ENABLED = "0"
+ Invoke-Program -FilePath $go -Description "echoctl build" -ArgumentList @(
+ "build", "-trimpath", "-tags", "payload", "-o", $echoctlPath, ".\cmd\echoctl"
+ )
+ } finally {
+ $env:GOOS = $savedGoos
+ $env:GOARCH = $savedGoarch
+ $env:CGO_ENABLED = $savedCgo
+ }
+
+ foreach ($path in @(
+ $echodPath,
+ $echoctlPath,
+ (Join-Path $payloadDir "EchoLocalPryon.apk"),
+ (Join-Path $payloadDir "amazon-helper.jar")
+ )) {
+ $file = Get-Item -LiteralPath $path
+ $hash = Get-FileHash -Algorithm SHA256 -LiteralPath $path
+ Write-Host ("{0} {1} bytes sha256={2}" -f $file.Name, $file.Length, $hash.Hash.ToLowerInvariant())
+ }
+}
+
+function Resolve-Adb {
+ $candidates = @()
+ if ($SdkRoot) {
+ $candidates += (Join-Path $SdkRoot "platform-tools\adb.exe")
+ }
+ if ($env:ANDROID_SDK_ROOT) {
+ $candidates += (Join-Path $env:ANDROID_SDK_ROOT "platform-tools\adb.exe")
+ }
+ if ($env:ANDROID_HOME) {
+ $candidates += (Join-Path $env:ANDROID_HOME "platform-tools\adb.exe")
+ }
+ $candidates += (Join-Path $env:LOCALAPPDATA "Android\Sdk\platform-tools\adb.exe")
+ return Resolve-Executable -Name "adb.exe" -Candidates $candidates
+}
+
+function Select-Biscuit {
+ param([Parameter(Mandatory = $true)][string]$Adb)
+
+ $listing = Get-ProgramOutput -FilePath $Adb -ArgumentList @("devices", "-l") -Description "adb devices"
+ $rows = @()
+ foreach ($line in ($listing -split "`r?`n")) {
+ if ($line -match '^(?\S+)\s+(?\S+)(?.*)$' -and
+ $Matches.serial -ne "List") {
+ $rows += [PSCustomObject]@{
+ Serial = $Matches.serial
+ State = $Matches.state
+ Details = $Matches.details
+ }
+ }
+ }
+
+ if ($Serial) {
+ $selected = @($rows | Where-Object { $_.Serial -eq $Serial })
+ if ($selected.Count -ne 1) {
+ throw "Device '$Serial' is not connected. adb reports:`n$listing"
+ }
+ if ($selected[0].State -ne "device") {
+ throw "Device '$Serial' is $($selected[0].State), not ready."
+ }
+ if ($selected[0].Details -notmatch '(?:^|\s)device:biscuit(?:\s|$)') {
+ throw "Refusing device '$Serial': it is not an Echo Dot 2 (biscuit). Details:$($selected[0].Details)"
+ }
+ return $Serial
+ }
+
+ $biscuits = @($rows | Where-Object {
+ $_.State -eq "device" -and $_.Details -match '(?:^|\s)device:biscuit(?:\s|$)'
+ })
+ if ($biscuits.Count -eq 0) {
+ throw "No ready Echo Dot 2 (biscuit) is connected. adb reports:`n$listing"
+ }
+ if ($biscuits.Count -gt 1) {
+ $ids = ($biscuits | ForEach-Object { $_.Serial }) -join ", "
+ throw "More than one biscuit is connected ($ids). Re-run with -Serial."
+ }
+ return $biscuits[0].Serial
+}
+
+function Get-AdbShell {
+ param(
+ [Parameter(Mandatory = $true)][string]$Adb,
+ [Parameter(Mandatory = $true)][string]$DeviceSerial,
+ [Parameter(Mandatory = $true)][string]$Command
+ )
+ return Get-ProgramOutput -FilePath $Adb `
+ -ArgumentList @("-s", $DeviceSerial, "shell", $Command) `
+ -Description "adb shell $Command"
+}
+
+function Assert-CompatibleBiscuit {
+ param(
+ [Parameter(Mandatory = $true)][string]$Adb,
+ [Parameter(Mandatory = $true)][string]$DeviceSerial
+ )
+
+ $product = Get-AdbShell -Adb $Adb -DeviceSerial $DeviceSerial -Command "getprop ro.product.device"
+ $sdk = Get-AdbShell -Adb $Adb -DeviceSerial $DeviceSerial -Command "getprop ro.build.version.sdk"
+ $identity = Get-AdbShell -Adb $Adb -DeviceSerial $DeviceSerial -Command "id"
+ $selinux = Get-AdbShell -Adb $Adb -DeviceSerial $DeviceSerial -Command "getenforce"
+ $booted = Get-AdbShell -Adb $Adb -DeviceSerial $DeviceSerial -Command "getprop sys.boot_completed"
+
+ if ($product -ne "biscuit") {
+ throw "Refusing ${DeviceSerial}: ro.product.device is '$product', want 'biscuit'."
+ }
+ if ($sdk -ne "22") {
+ throw "Refusing ${DeviceSerial}: Android SDK is '$sdk', want '22'."
+ }
+ if ($booted -ne "1") {
+ throw "Refusing ${DeviceSerial}: Android has not completed booting."
+ }
+
+ Write-Host "Target: $DeviceSerial"
+ Write-Host "Device: $product / SDK $sdk"
+ if ($identity -match 'uid=0\(root\)' -and $selinux -eq "Permissive") {
+ Write-Host "Boot: already root and SELinux Permissive"
+ } else {
+ Write-Host "Boot: stock runtime ($identity; SELinux $selinux)"
+ Write-Host " echoctl will verify and install its boot image through TWRP recovery."
+ }
+}
+
+function Save-RollbackSnapshot {
+ param(
+ [Parameter(Mandatory = $true)][string]$Adb,
+ [Parameter(Mandatory = $true)][string]$DeviceSerial
+ )
+
+ $stamp = Get-Date -Format "yyyyMMdd-HHmmss"
+ $backupDir = Join-Path $projectDir ".local-device-backup\$stamp-$DeviceSerial-before-provision"
+ New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
+
+ $inventoryCommand = @'
+echo '### identity'
+id
+getenforce
+getprop
+echo '### mounts'
+mount
+echo '### modified-path candidates'
+ls -ldZ /system/bin/ledcontroller /system/bin/ledcontroller.orig /system/bin/start_animation.sh /system/bin/start_animation.sh.orig /system/bin/stop_animation.sh /system/bin/stop_animation.sh.orig /system/bin/greengrass_firewall.sh /system/app/echod /system/priv-app/EchoLocalPryon /data/misc/echolocal 2>/dev/null
+echo '### visible packages'
+pm list packages
+echo '### all packages including hidden'
+pm list packages -u
+'@
+ $inventory = Get-AdbShell -Adb $Adb -DeviceSerial $DeviceSerial -Command $inventoryCommand
+ Set-Content -LiteralPath (Join-Path $backupDir "device-inventory.txt") -Value $inventory -Encoding UTF8
+
+ $paths = @(
+ "/system/bin/ledcontroller",
+ "/system/bin/ledcontroller.orig",
+ "/system/bin/start_animation.sh",
+ "/system/bin/start_animation.sh.orig",
+ "/system/bin/stop_animation.sh",
+ "/system/bin/stop_animation.sh.orig",
+ "/system/bin/greengrass_firewall.sh",
+ "/system/app/echod/echod",
+ "/system/priv-app/EchoLocalPryon/EchoLocalPryon.apk",
+ "/data/misc/echolocal/amazon-helper.jar",
+ "/data/misc/echolocal/name",
+ "/data/misc/echolocal/psk",
+ "/data/misc/echolocal/pryon.uid",
+ "/data/misc/echolocal/state.json"
+ )
+ foreach ($remotePath in $paths) {
+ $exists = Get-AdbShell -Adb $Adb -DeviceSerial $DeviceSerial `
+ -Command "if [ -e '$remotePath' ]; then echo yes; else echo no; fi"
+ if ($exists -ne "yes") {
+ continue
+ }
+ $safeName = $remotePath.TrimStart('/').Replace('/', '__')
+ $destination = Join-Path $backupDir $safeName
+ Invoke-Program -FilePath $Adb -Description "back up $remotePath" `
+ -ArgumentList @("-s", $DeviceSerial, "pull", $remotePath, $destination) | Out-Host
+ }
+
+ $hashLines = @(Get-ChildItem -LiteralPath $backupDir -File |
+ Where-Object { $_.Name -ne "sha256.txt" } |
+ ForEach-Object {
+ $hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
+ "$($hash.Hash.ToLowerInvariant()) $($_.Name)"
+ })
+ Set-Content -LiteralPath (Join-Path $backupDir "sha256.txt") -Value $hashLines -Encoding ASCII
+ Write-Host "Rollback snapshot: $backupDir"
+ return $backupDir
+}
+
+if ($SkipBuild -and $BuildOnly) {
+ throw "-SkipBuild and -BuildOnly cannot be used together."
+}
+
+Push-Location $projectDir
+try {
+ if (-not $SkipBuild) {
+ Build-Installer
+ } elseif (-not (Test-Path -LiteralPath $echoctlPath -PathType Leaf)) {
+ throw "-SkipBuild was requested but $echoctlPath does not exist."
+ }
+
+ if ($BuildOnly) {
+ Write-Host ""
+ Write-Host "Build complete: $echoctlPath" -ForegroundColor Green
+ return
+ }
+
+ Write-Section "Selecting and validating the Echo Dot"
+ $adb = Resolve-Adb
+ $targetSerial = Select-Biscuit -Adb $adb
+ Assert-CompatibleBiscuit -Adb $adb -DeviceSerial $targetSerial
+
+ Write-Section "Saving a rollback snapshot"
+ $backupDir = Save-RollbackSnapshot -Adb $adb -DeviceSerial $targetSerial
+
+ # echoctl and its Android subprocesses locate adb by name.
+ $adbDirectory = Split-Path -Parent $adb
+ if (($env:Path -split ';') -notcontains $adbDirectory) {
+ $env:Path = "$adbDirectory;$env:Path"
+ }
+
+ Write-Section "Provisioning EchoLocal, Alexa, and ESPHome"
+ $installArgs = @("install", "--serial", $targetSerial, "--reboot", "--yes")
+ if ($Name) {
+ $installArgs += @("--name", $Name)
+ }
+ Invoke-Program -FilePath $echoctlPath -ArgumentList $installArgs -Description "EchoLocal installation"
+
+ $key = Get-ProgramOutput -FilePath $echoctlPath `
+ -ArgumentList @("key", "--serial", $targetSerial, "show") `
+ -Description "reading the ESPHome encryption key"
+ try {
+ $keyBytes = [Convert]::FromBase64String($key)
+ } catch {
+ throw "echoctl returned an invalid ESPHome encryption key."
+ }
+ if ($keyBytes.Length -ne 32) {
+ throw "echoctl returned a $($keyBytes.Length)-byte key; ESPHome requires 32 bytes."
+ }
+
+ $deviceName = Get-AdbShell -Adb $adb -DeviceSerial $targetSerial `
+ -Command "cat /data/misc/echolocal/name"
+ $receiptPath = Join-Path $backupDir "home-assistant-credentials.txt"
+ $receipt = @(
+ "EchoLocal Home Assistant credentials",
+ "",
+ "Device name: $deviceName",
+ "ADB serial: $targetSerial",
+ "",
+ "In Home Assistant, open Settings -> Devices & services -> ESPHome,",
+ "select the discovered EchoLocal device, and paste this encryption key:",
+ "",
+ $key
+ )
+ Set-Content -LiteralPath $receiptPath -Value $receipt -Encoding UTF8
+
+ Write-Section "Final device status"
+ Invoke-Program -FilePath $echoctlPath `
+ -ArgumentList @("status", "--serial", $targetSerial) `
+ -Description "EchoLocal status"
+
+ Write-Host ""
+ Write-Host "Provisioning complete." -ForegroundColor Green
+ Write-Host "Home Assistant: Settings -> Devices & services -> ESPHome, select the discovered EchoLocal device."
+ Write-Host "Credential receipt (keep private): $receiptPath" -ForegroundColor Green
+ Write-Host "ESPHome encryption key (paste when prompted):" -ForegroundColor Green
+ Write-Output $key
+} finally {
+ Pop-Location
+}