Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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]
Expand Down
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -42,17 +46,40 @@ Everything above works with stock Home Assistant. The

## Installing

You need a 2nd-generation Echo Dot, connected via a USB cable, and a device that has been unlocked with TWRP as its
recovery partition. `echoctl` does the rest. It'll prompt for wifi configuration if it hasn't been setup and provide espHome encryption key
You need a 2nd-generation Echo Dot connected by USB and unlocked with TWRP as its recovery
partition. `echoctl` discovers the attached Dot's own Pryon libraries, SpeechInteractionManager APK
and locale model manifests; no Amazon binary or model is shipped by EchoLocal. It prompts for Wi-Fi,
generates an ESPHome encryption key, reboots when Android must scan the wake-only companion, and
does not report completion until the native API, mDNS EchoLocal identity, Pryon detector and shared
live microphone path are ready.

For an unlocked Dot with TWRP recovery on Windows, run the complete source-tree provisioner:

```powershell
.\provision-echo-dot.ps1 -Name "Kitchen Echo"
```

Pass `-Serial` when more than one device is attached. The script refuses non-`biscuit` hardware,
saves a gitignored rollback snapshot, builds and embeds all EchoLocal-owned payloads, installs and
reboots the Dot, verifies ESPHome plus Pryon/Alexa, saves a private credential receipt inside that
snapshot, and prints the unique 32-byte ESPHome encryption key last. Connect the Dot to local Wi-Fi
when prompted; an Amazon account or Amazon registration is
not required. When the running Android image is not already root and permissive, the script uses
EchoLocal's verified boot image and TWRP recovery before changing `/system`.

```sh
echoctl install --name living-room
echoctl install --name living-room --reboot
```

![echoctl install, from flashing the boot image to the device coming back on wifi](docs/images/install.gif)

It then turns up in Home Assistant on its own, and the key `echoctl` printed is what pairs it:

After pairing, choose **Alexa** in the assistant's Wake word select. The other installed
openWakeWord and microWakeWord choices remain available. `echoctl status` reports the ESPHome API,
Android-media bridge and Pryon configuration. Use `--no-pryon` only when intentionally installing
the legacy direct-ALSA runtime.

<p align="center">
<img src="docs/images/echolocal_discovery.png" alt="Home Assistant discovering the device as an ESPHome node" height="230">
<img src="docs/images/echolocal_discovery_add.png" alt="The confirmation dialog for adding the discovered device" height="230">
Expand All @@ -65,6 +92,10 @@ make build-echod # cross-compile the daemon for the Dot
make install-echod # build, install, and restart it on a connected device
```

For a self-contained installer, first build `android/pryon` and `android/amazon-helper`, then run
`make dist`. Their generated APK/JAR are our code and are embedded in `echoctl`; firmware-owned
libraries, APKs and models are always read in place from the user's attached Dot.

## How it fits together

- **echod** runs on the Dot: the hardware, the wake word engines, the conversation, and an ESPHome
Expand Down
16 changes: 16 additions & 0 deletions android/amazon-helper/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# EchoLocal Android media helper

This API-22 `app_process32` helper preserves EchoLocal's Android-media protocol: 16 kHz PCM,
48 kHz stereo playback, and wake-event delivery. When Pryon is selected it reads live PCM from a
second reader on Pryon's firmware-owned Amazon `AudioStream`; it does not open a competing
`AudioRecord`. It also accepts wake events on a separate abstract socket named `echolocal-pryon`.

The Pryon socket accepts bounded version-1 JSON only from the Android UID recorded in
`/data/misc/echolocal/pryon.uid`, verified with `LocalSocket.getPeerCredentials()`. A valid
Alexa event is converted to the helper's existing `MSG_WAKE` frame. No audio crosses the
Pryon socket, and logcat is not used as an event transport.

The separate `echolocal-pryon-pcm` socket is local, root-authenticated and carries only the shared
16 kHz mono PCM frames from the Pryon audio provider to this helper. Audio remains on the Dot.

Build on Windows with `./build.ps1`. Generated artifacts stay under ignored `build/`.
51 changes: 51 additions & 0 deletions android/amazon-helper/build.ps1
Original file line number Diff line number Diff line change
@@ -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())"
Loading