From 4d5f1549d779da1fc7b86242a3875f48accbfe63 Mon Sep 17 00:00:00 2001 From: baileyboy0304 Date: Sat, 15 Aug 2026 16:11:01 +0100 Subject: [PATCH 1/8] feat(android): add shared media transport --- android/amazon-helper/README.md | 12 + android/amazon-helper/build.ps1 | 51 +++ .../src/echolocal/AmazonHelper.java | 424 ++++++++++++++++++ internal/android/amazon/client.go | 214 +++++++++ internal/android/amazon/process_linux.go | 57 +++ internal/android/amazon/process_other.go | 11 + internal/android/amazon/protocol.go | 90 ++++ internal/android/amazon/protocol_test.go | 46 ++ internal/hardware/mic/mic.go | 85 +++- internal/hardware/speaker/acquire.go | 8 + internal/hardware/speaker/speaker.go | 42 +- 11 files changed, 1024 insertions(+), 16 deletions(-) create mode 100644 android/amazon-helper/README.md create mode 100644 android/amazon-helper/build.ps1 create mode 100644 android/amazon-helper/src/echolocal/AmazonHelper.java create mode 100644 internal/android/amazon/client.go create mode 100644 internal/android/amazon/process_linux.go create mode 100644 internal/android/amazon/process_other.go create mode 100644 internal/android/amazon/protocol.go create mode 100644 internal/android/amazon/protocol_test.go diff --git a/android/amazon-helper/README.md b/android/amazon-helper/README.md new file mode 100644 index 0000000..18f7a15 --- /dev/null +++ b/android/amazon-helper/README.md @@ -0,0 +1,12 @@ +# EchoLocal Android media helper + +This API-22 `app_process32` helper preserves the protocol used by the currently deployed +EchoLocal Android-media build: 16 kHz PCM capture, 48 kHz stereo playback, and wake-event +delivery. It adds 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. + +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..dcf8803 --- /dev/null +++ b/android/amazon-helper/src/echolocal/AmazonHelper.java @@ -0,0 +1,424 @@ +package echolocal; + +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.media.AudioTrack; +import android.net.Credentials; +import android.net.LocalServerSocket; +import android.net.LocalSocket; +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 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_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) { + int source = 1; + if (args.length > 0) { + try { + source = Integer.parseInt(args[0]); + } catch (NumberFormatException error) { + Log.w(TAG, "ignoring bad audio source '" + args[0] + "'"); + } + } + Log.i(TAG, "starting, audio source " + source); + new Server(source).run(); + } + + static final class Server { + private final int audioSource; + private volatile Connection current; + private long lastPryonMonotonicMs = -1; + + Server(int source) { + audioSource = source; + } + + 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, audioSource); + 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 final int audioSource; + private volatile boolean capturing; + private final DataInputStream in; + private final DataOutputStream out; + private final LocalSocket socket; + private AudioTrack track; + + Connection(LocalSocket socket, int source) throws IOException { + this.socket = socket; + in = new DataInputStream(socket.getInputStream()); + out = new DataOutputStream(socket.getOutputStream()); + audioSource = source; + } + + 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; } + + private void capture() { + AudioRecord recorder = null; + try { + int buffer = Math.max(AudioRecord.getMinBufferSize(SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT), 5120); + recorder = new AudioRecord(audioSource, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, buffer); + if (recorder.getState() != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "AudioRecord not initialized"); + return; + } + recorder.startRecording(); + byte[] frame = new byte[FRAME_BYTES]; + while (capturing) { + int count = recorder.read(frame, 0, frame.length); + if (count < 0) { + Log.w(TAG, "AudioRecord.read returned " + count); + break; + } + if (count > 0) send(MSG_AUDIO, Arrays.copyOf(frame, count)); + } + } catch (Throwable error) { + Log.e(TAG, "capture error: " + error); + } finally { + if (recorder != null) { + try { recorder.stop(); } catch (Throwable ignored) { } + recorder.release(); + } + Log.i(TAG, "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 * 8); + 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/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/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() From 2517fee400d88d7f4331d7c7375ed287e52d8a6c Mon Sep 17 00:00:00 2001 From: baileyboy0304 Date: Sat, 15 Aug 2026 16:11:08 +0100 Subject: [PATCH 2/8] feat(pryon): add native Alexa wake-word routing --- android/pryon/AndroidManifest.xml | 30 ++ android/pryon/README.md | 64 ++++ android/pryon/build.ps1 | 74 ++++ .../echolocal/pryon/AudioProviderService.java | 85 +++++ .../src/com/echolocal/pryon/BootReceiver.java | 15 + .../src/com/echolocal/pryon/PryonConfig.java | 85 +++++ .../echolocal/pryon/PryonDetectorService.java | 335 ++++++++++++++++++ .../com/echolocal/pryon/PryonEventClient.java | 159 +++++++++ .../com/echolocal/pryon/PryonProtocol.java | 13 + internal/component/all/all.go | 1 + internal/feature/detect/backends.go | 3 + internal/feature/detect/detect.go | 11 +- internal/feature/pryon/pryon.go | 42 +++ internal/feature/pryon/pryon_test.go | 22 ++ internal/feature/voice/conversation.go | 7 +- internal/feature/voice/voice_test.go | 1 + internal/layout/layout.go | 10 + internal/lib/wake/library.go | 34 +- internal/lib/wake/library_test.go | 16 + internal/lib/wake/models.go | 16 + 20 files changed, 1016 insertions(+), 7 deletions(-) create mode 100644 android/pryon/AndroidManifest.xml create mode 100644 android/pryon/README.md create mode 100644 android/pryon/build.ps1 create mode 100644 android/pryon/src/com/echolocal/pryon/AudioProviderService.java create mode 100644 android/pryon/src/com/echolocal/pryon/BootReceiver.java create mode 100644 android/pryon/src/com/echolocal/pryon/PryonConfig.java create mode 100644 android/pryon/src/com/echolocal/pryon/PryonDetectorService.java create mode 100644 android/pryon/src/com/echolocal/pryon/PryonEventClient.java create mode 100644 android/pryon/src/com/echolocal/pryon/PryonProtocol.java create mode 100644 internal/feature/pryon/pryon.go create mode 100644 internal/feature/pryon/pryon_test.go diff --git a/android/pryon/AndroidManifest.xml b/android/pryon/AndroidManifest.xml new file mode 100644 index 0000000..1d89f5b --- /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..ecc08a2 --- /dev/null +++ b/android/pryon/README.md @@ -0,0 +1,64 @@ +# 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; +- 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' +``` + +Success requires `PRYON_READY`, followed by repeated deterministic +`PRYON_WAKEWORD_DETECTED word=alexa` lines when a person speaks to the physical Dot. +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..acde5e6 --- /dev/null +++ b/android/pryon/src/com/echolocal/pryon/AudioProviderService.java @@ -0,0 +1,85 @@ +package com.echolocal.pryon; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.media.AudioFormat; +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 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 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"); + } + + @Override + public void onDestroy() { + 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..cd6a896 --- /dev/null +++ b/android/pryon/src/com/echolocal/pryon/PryonDetectorService.java @@ -0,0 +1,335 @@ +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); + } + + initialized = true; + 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..0418e12 --- /dev/null +++ b/android/pryon/src/com/echolocal/pryon/PryonProtocol.java @@ -0,0 +1,13 @@ +package com.echolocal.pryon; + +final class PryonProtocol { + static final String TAG = "EchoLocalPryon"; + static final String DESCRIPTOR = "com.echolocal.pryon.AudioProvider"; + 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/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/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..80c30cf 100644 --- a/internal/feature/voice/conversation.go +++ b/internal/feature/voice/conversation.go @@ -921,7 +921,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/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 From 1fd1e62cf769758ac7b0405d88f27a00698156a9 Mon Sep 17 00:00:00 2001 From: baileyboy0304 Date: Sat, 15 Aug 2026 16:11:14 +0100 Subject: [PATCH 3/8] feat(installer): provision and verify Pryon --- .gitignore | 8 + Makefile | 8 + internal/cli/echoctl/install.go | 55 ++- internal/cli/echoctl/reboot.go | 26 +- internal/cli/echoctl/status.go | 18 + internal/host/assets/assets.go | 10 +- internal/host/assets/nopayload.go | 6 +- internal/host/assets/payload.go | 6 + internal/host/installer/installer.go | 21 ++ internal/host/installer/pryon.go | 389 ++++++++++++++++++++ internal/host/installer/pryon_test.go | 54 +++ internal/host/installer/status.go | 31 +- provision-echo-dot.ps1 | 495 ++++++++++++++++++++++++++ 13 files changed, 1093 insertions(+), 34 deletions(-) create mode 100644 internal/host/installer/pryon.go create mode 100644 internal/host/installer/pryon_test.go create mode 100644 provision-echo-dot.ps1 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/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/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/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..b978724 --- /dev/null +++ b/internal/host/installer/pryon.go @@ -0,0 +1,389 @@ +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 +} + +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 + } + 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(30 * time.Second) + 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'") + echo, _ := r.d.Shell("logcat -b all -d -s echolocal:I '*:S'") + logs = "Pryon: " + lastLines(poc, 4) + " | EchoLocal: " + lastLines(echo, 4) + echoReady := strings.Contains(echo, "pryon=1") || + strings.Contains(echo, "id=pryon_alexa engine=pryon") + if strings.Contains(poc, "PRYON_READY") && echoReady { + return "native detector 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 did not report ready with Alexa installed within 30s; recent state: %s", + strings.TrimSpace(logs)) +} + +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..033a6e0 --- /dev/null +++ b/internal/host/installer/pryon_test.go @@ -0,0 +1,54 @@ +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) + } + }) + } +} 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/provision-echo-dot.ps1 b/provision-echo-dot.ps1 new file mode 100644 index 0000000..181c11e --- /dev/null +++ b/provision-echo-dot.ps1 @@ -0,0 +1,495 @@ +#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 prints the encryption key last. + +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) + } + + $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" +} + +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" + 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" + + Write-Section "Final device status" + Invoke-Program -FilePath $echoctlPath ` + -ArgumentList @("status", "--serial", $targetSerial) ` + -Description "EchoLocal status" + + $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." + } + + Write-Host "" + Write-Host "Provisioning complete." -ForegroundColor Green + Write-Host "Home Assistant: Settings -> Devices & services -> ESPHome, select the discovered EchoLocal device." + Write-Host "ESPHome encryption key (paste when prompted):" -ForegroundColor Green + Write-Output $key +} finally { + Pop-Location +} From 0c1c3f6c0dc853e5948f6434705911ff1fcbeafe Mon Sep 17 00:00:00 2001 From: baileyboy0304 Date: Sat, 15 Aug 2026 16:11:20 +0100 Subject: [PATCH 4/8] docs: document native Alexa provisioning --- README.md | 35 ++++++++++++++++++++-- docs/pryon.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 docs/pryon.md diff --git a/README.md b/README.md index 3af41b0..dc08e4d 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,38 @@ 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 and Pryon detector 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, 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 +90,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/docs/pryon.md b/docs/pryon.md new file mode 100644 index 0000000..c87be34 --- /dev/null +++ b/docs/pryon.md @@ -0,0 +1,80 @@ +# 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 and Pryon readiness; and +8. 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. +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. A separate Android media helper uses `AudioRecord` and `AudioTrack` so +Pryon and EchoLocal share the firmware audio service instead of competing for raw ALSA capture. +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 +startup includes `PRYON_READY`; speaking “Alexa” should then produce a wake event and start the +selected Home Assistant Assist pipeline. + +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. From e9175f0a5dedce378c006ef8f2e89d590069517f Mon Sep 17 00:00:00 2001 From: baileyboy0304 Date: Sun, 16 Aug 2026 06:45:09 +0100 Subject: [PATCH 5/8] fix(installer): harden fresh-device provisioning --- internal/host/installer/flash.go | 97 +++++++++++++++++++++++++++ internal/host/installer/flash_test.go | 39 +++++++++++ internal/host/installer/pryon.go | 10 ++- 3 files changed, 143 insertions(+), 3 deletions(-) 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/pryon.go b/internal/host/installer/pryon.go index b978724..48cf7f8 100644 --- a/internal/host/installer/pryon.go +++ b/internal/host/installer/pryon.go @@ -20,6 +20,10 @@ type pryonPaths struct { 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 @@ -357,7 +361,7 @@ func verifyESPHome(r *run) (string, bool, error) { } func verifyPryon(r *run) (string, bool, error) { - deadline := time.Now().Add(30 * time.Second) + 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` @@ -376,8 +380,8 @@ func verifyPryon(r *run) (string, bool, error) { case <-time.After(500 * time.Millisecond): } } - return "", false, fmt.Errorf("Pryon did not report ready with Alexa installed within 30s; recent state: %s", - strings.TrimSpace(logs)) + return "", false, fmt.Errorf("Pryon did not report ready with Alexa installed within %s; recent state: %s", + pryonReadyTimeout, strings.TrimSpace(logs)) } func lastLines(s string, n int) string { From 79a4f634fed9804e39877491c35002d9768d380e Mon Sep 17 00:00:00 2001 From: baileyboy0304 Date: Sun, 16 Aug 2026 07:18:31 +0100 Subject: [PATCH 6/8] fix(pryon): share and verify live microphone audio --- android/amazon-helper/README.md | 10 +- .../src/echolocal/AmazonHelper.java | 97 +++++++-------- android/pryon/AndroidManifest.xml | 2 +- android/pryon/README.md | 8 +- .../echolocal/pryon/AudioProviderService.java | 110 ++++++++++++++++++ .../echolocal/pryon/PryonDetectorService.java | 12 ++ .../com/echolocal/pryon/PryonProtocol.java | 1 + internal/host/installer/pryon.go | 27 ++++- internal/host/installer/pryon_test.go | 26 +++++ 9 files changed, 234 insertions(+), 59 deletions(-) diff --git a/android/amazon-helper/README.md b/android/amazon-helper/README.md index 18f7a15..e3d1f4d 100644 --- a/android/amazon-helper/README.md +++ b/android/amazon-helper/README.md @@ -1,12 +1,16 @@ # EchoLocal Android media helper -This API-22 `app_process32` helper preserves the protocol used by the currently deployed -EchoLocal Android-media build: 16 kHz PCM capture, 48 kHz stereo playback, and wake-event -delivery. It adds a separate abstract socket named `echolocal-pryon`. +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/src/echolocal/AmazonHelper.java b/android/amazon-helper/src/echolocal/AmazonHelper.java index dcf8803..f4c53b8 100644 --- a/android/amazon-helper/src/echolocal/AmazonHelper.java +++ b/android/amazon-helper/src/echolocal/AmazonHelper.java @@ -2,11 +2,11 @@ import android.media.AudioFormat; import android.media.AudioManager; -import android.media.AudioRecord; 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; @@ -41,6 +41,7 @@ public final class AmazonHelper { 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; @@ -48,27 +49,14 @@ public final class AmazonHelper { private AmazonHelper() { } public static void main(String[] args) { - int source = 1; - if (args.length > 0) { - try { - source = Integer.parseInt(args[0]); - } catch (NumberFormatException error) { - Log.w(TAG, "ignoring bad audio source '" + args[0] + "'"); - } - } - Log.i(TAG, "starting, audio source " + source); - new Server(source).run(); + Log.i(TAG, "starting with shared Pryon PCM capture"); + new Server().run(); } static final class Server { - private final int audioSource; private volatile Connection current; private long lastPryonMonotonicMs = -1; - Server(int source) { - audioSource = source; - } - void run() { Thread pryon = new Thread(new Runnable() { @Override @@ -87,7 +75,7 @@ public void run() { while (true) { LocalSocket socket = server.accept(); Log.i(TAG, "echod connected"); - Connection connection = new Connection(socket, audioSource); + Connection connection = new Connection(socket); current = connection; connection.serve(); if (current == connection) current = null; @@ -194,18 +182,17 @@ private synchronized boolean forwardPryon(JSONObject event) throws Exception { } static final class Connection { - private final int audioSource; 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, int source) throws IOException { + Connection(LocalSocket socket) throws IOException { this.socket = socket; in = new DataInputStream(socket.getInputStream()); out = new DataOutputStream(socket.getOutputStream()); - audioSource = source; } void serve() { @@ -273,38 +260,54 @@ public void run() { Log.i(TAG, "capture started"); } - private synchronized void stopCapture() { capturing = false; } + private synchronized void stopCapture() { + capturing = false; + closeQuietly(captureSocket); + captureSocket = null; + } private void capture() { - AudioRecord recorder = null; - try { - int buffer = Math.max(AudioRecord.getMinBufferSize(SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT), 5120); - recorder = new AudioRecord(audioSource, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, buffer); - if (recorder.getState() != AudioRecord.STATE_INITIALIZED) { - Log.e(TAG, "AudioRecord not initialized"); - return; - } - recorder.startRecording(); - byte[] frame = new byte[FRAME_BYTES]; - while (capturing) { - int count = recorder.read(frame, 0, frame.length); - if (count < 0) { - Log.w(TAG, "AudioRecord.read returned " + count); - break; + 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; + } } - if (count > 0) send(MSG_AUDIO, Arrays.copyOf(frame, count)); - } - } catch (Throwable error) { - Log.e(TAG, "capture error: " + error); - } finally { - if (recorder != null) { - try { recorder.stop(); } catch (Throwable ignored) { } - recorder.release(); + } 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); } - Log.i(TAG, "capture stopped"); + if (capturing) sleep(250); } + Log.i(TAG, "shared Pryon capture stopped"); } private void play(byte[] payload) { diff --git a/android/pryon/AndroidManifest.xml b/android/pryon/AndroidManifest.xml index 1d89f5b..d108b3d 100644 --- a/android/pryon/AndroidManifest.xml +++ b/android/pryon/AndroidManifest.xml @@ -1,6 +1,6 @@ diff --git a/android/pryon/README.md b/android/pryon/README.md index ecc08a2..0775fc2 100644 --- a/android/pryon/README.md +++ b/android/pryon/README.md @@ -5,6 +5,8 @@ This API-22 privileged APK is deliberately limited to Pryon wake detection and w - 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. @@ -47,8 +49,10 @@ Observe only the companion tag: adb logcat -v time -s EchoLocalPryon:I '*:S' ``` -Success requires `PRYON_READY`, followed by repeated deterministic -`PRYON_WAKEWORD_DETECTED word=alexa` lines when a person speaks to the physical Dot. +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 diff --git a/android/pryon/src/com/echolocal/pryon/AudioProviderService.java b/android/pryon/src/com/echolocal/pryon/AudioProviderService.java index acde5e6..bff9161 100644 --- a/android/pryon/src/com/echolocal/pryon/AudioProviderService.java +++ b/android/pryon/src/com/echolocal/pryon/AudioProviderService.java @@ -4,6 +4,9 @@ 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; @@ -12,6 +15,9 @@ 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; @@ -19,6 +25,8 @@ 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 @@ -69,10 +77,112 @@ private synchronized void ensureStream() throws Exception { 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"); diff --git a/android/pryon/src/com/echolocal/pryon/PryonDetectorService.java b/android/pryon/src/com/echolocal/pryon/PryonDetectorService.java index cd6a896..10865ab 100644 --- a/android/pryon/src/com/echolocal/pryon/PryonDetectorService.java +++ b/android/pryon/src/com/echolocal/pryon/PryonDetectorService.java @@ -178,7 +178,19 @@ private void initialize() throws Exception { "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"); } diff --git a/android/pryon/src/com/echolocal/pryon/PryonProtocol.java b/android/pryon/src/com/echolocal/pryon/PryonProtocol.java index 0418e12..2582a2e 100644 --- a/android/pryon/src/com/echolocal/pryon/PryonProtocol.java +++ b/android/pryon/src/com/echolocal/pryon/PryonProtocol.java @@ -3,6 +3,7 @@ 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"; diff --git a/internal/host/installer/pryon.go b/internal/host/installer/pryon.go index 48cf7f8..08ae031 100644 --- a/internal/host/installer/pryon.go +++ b/internal/host/installer/pryon.go @@ -183,6 +183,12 @@ func installPryonAPK(r *run) (string, bool, error) { } 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 } @@ -367,12 +373,12 @@ func verifyPryon(r *run) (string, bool, error) { // 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, 4) + " | EchoLocal: " + lastLines(echo, 4) - echoReady := strings.Contains(echo, "pryon=1") || - strings.Contains(echo, "id=pryon_alexa engine=pryon") - if strings.Contains(poc, "PRYON_READY") && echoReady { - return "native detector ready; Alexa advertised", false, nil + 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(): @@ -380,10 +386,19 @@ func verifyPryon(r *run) (string, bool, error) { case <-time.After(500 * time.Millisecond): } } - return "", false, fmt.Errorf("Pryon did not report ready with Alexa installed within %s; recent state: %s", + 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 { diff --git a/internal/host/installer/pryon_test.go b/internal/host/installer/pryon_test.go index 033a6e0..75c3ebc 100644 --- a/internal/host/installer/pryon_test.go +++ b/internal/host/installer/pryon_test.go @@ -52,3 +52,29 @@ func TestPackagePath(t *testing.T) { }) } } + +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") + } + }) + } +} From 23332210c4ecc56216007b66bbfc3ca48bba1a75 Mon Sep 17 00:00:00 2001 From: baileyboy0304 Date: Sun, 16 Aug 2026 07:18:44 +0100 Subject: [PATCH 7/8] fix(installer): persist pairing details and bound startup --- README.md | 8 +++-- docs/pryon.md | 24 ++++++++----- internal/hardware/led/splash.go | 42 ++++++++++++++-------- internal/hardware/led/splash_test.go | 52 ++++++++++++++++++++++++++++ provision-echo-dot.ps1 | 34 +++++++++++++----- 5 files changed, 127 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index dc08e4d..5d13c5b 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ You need a 2nd-generation Echo Dot connected by USB and unlocked with TWRP as it 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 and Pryon detector are ready. +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: @@ -60,8 +61,9 @@ For an unlocked Dot with TWRP recovery on Windows, run the complete source-tree 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, 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 +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`. diff --git a/docs/pryon.md b/docs/pryon.md index c87be34..8a64ac2 100644 --- a/docs/pryon.md +++ b/docs/pryon.md @@ -34,8 +34,10 @@ Use `-Serial G090XXXXXXXXXXXX` when more than one ADB/Fastboot device is attache 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 and Pryon readiness; and -8. prints the device's unique ESPHome encryption key last. +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. @@ -51,6 +53,8 @@ Use `--no-pryon` only when deliberately installing the legacy direct-ALSA audio ## 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. @@ -62,16 +66,20 @@ 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. A separate Android media helper uses `AudioRecord` and `AudioTrack` so -Pryon and EchoLocal share the firmware audio service instead of competing for raw ALSA capture. -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. +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 -startup includes `PRYON_READY`; speaking “Alexa” should then produce a wake event and start the -selected Home Assistant Assist pipeline. +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 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/provision-echo-dot.ps1 b/provision-echo-dot.ps1 index 181c11e..d218140 100644 --- a/provision-echo-dot.ps1 +++ b/provision-echo-dot.ps1 @@ -8,7 +8,7 @@ Build and provision EchoLocal with local Amazon Pryon/Alexa wake detection. 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 prints the encryption key last. +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. @@ -416,7 +416,7 @@ pm list packages -u $safeName = $remotePath.TrimStart('/').Replace('/', '__') $destination = Join-Path $backupDir $safeName Invoke-Program -FilePath $Adb -Description "back up $remotePath" ` - -ArgumentList @("-s", $DeviceSerial, "pull", $remotePath, $destination) + -ArgumentList @("-s", $DeviceSerial, "pull", $remotePath, $destination) | Out-Host } $hashLines = @(Get-ChildItem -LiteralPath $backupDir -File | @@ -427,6 +427,7 @@ pm list packages -u }) Set-Content -LiteralPath (Join-Path $backupDir "sha256.txt") -Value $hashLines -Encoding ASCII Write-Host "Rollback snapshot: $backupDir" + return $backupDir } if ($SkipBuild -and $BuildOnly) { @@ -453,7 +454,7 @@ try { Assert-CompatibleBiscuit -Adb $adb -DeviceSerial $targetSerial Write-Section "Saving a rollback snapshot" - Save-RollbackSnapshot -Adb $adb -DeviceSerial $targetSerial + $backupDir = Save-RollbackSnapshot -Adb $adb -DeviceSerial $targetSerial # echoctl and its Android subprocesses locate adb by name. $adbDirectory = Split-Path -Parent $adb @@ -468,11 +469,6 @@ try { } Invoke-Program -FilePath $echoctlPath -ArgumentList $installArgs -Description "EchoLocal installation" - Write-Section "Final device status" - Invoke-Program -FilePath $echoctlPath ` - -ArgumentList @("status", "--serial", $targetSerial) ` - -Description "EchoLocal status" - $key = Get-ProgramOutput -FilePath $echoctlPath ` -ArgumentList @("key", "--serial", $targetSerial, "show") ` -Description "reading the ESPHome encryption key" @@ -485,9 +481,31 @@ try { 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 { From c112f7c7702e5d982778abfb68d965e63736c9a7 Mon Sep 17 00:00:00 2001 From: baileyboy0304 Date: Tue, 18 Aug 2026 03:50:54 +0100 Subject: [PATCH 8/8] fix: make wake feedback immediate --- .../src/echolocal/AmazonHelper.java | 3 +- internal/feature/media/media_test.go | 21 ++++++++++++ internal/feature/media/player.go | 34 +++++++++++++++++-- internal/feature/voice/conversation.go | 27 ++++++++------- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/android/amazon-helper/src/echolocal/AmazonHelper.java b/android/amazon-helper/src/echolocal/AmazonHelper.java index f4c53b8..196a7ea 100644 --- a/android/amazon-helper/src/echolocal/AmazonHelper.java +++ b/android/amazon-helper/src/echolocal/AmazonHelper.java @@ -29,6 +29,7 @@ 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; @@ -316,7 +317,7 @@ private void play(byte[] payload) { try { int buffer = Math.max(AudioTrack.getMinBufferSize(PLAY_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT), - payload.length * 8); + 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); 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/voice/conversation.go b/internal/feature/voice/conversation.go index 80c30cf..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)