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 ``` ![echoctl install, from flashing the boot image to the device coming back on wifi](docs/images/install.gif) 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. +

Home Assistant discovering the device as an ESPHome node The confirmation dialog for adding the discovered device @@ -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 +}