Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
This commit is contained in:
1 parent
27dc7917f2
commit
7a6d922d12
63 files changed
+1995
-552
No files matched your search
@@ -37,7 +37,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||||
cd hermes-agent
|
cd hermes-agent
|
||||||
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
|
|
||||||
kotlin:
|
kotlin:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||||
cd hermes-agent
|
cd hermes-agent
|
||||||
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
|
|
||||||
kotlin:
|
kotlin:
|
||||||
|
|||||||
@@ -14,18 +14,18 @@
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
|
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
|
||||||
- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
|
- Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
|
||||||
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
|
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
|
||||||
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
|
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
|
||||||
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
|
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
|
||||||
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
|
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
|
||||||
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
|
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
|
||||||
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
|
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
|
||||||
|
|
||||||
## Environment / pairing quirks
|
## Environment / pairing quirks
|
||||||
|
|
||||||
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry.
|
- Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry.
|
||||||
- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP.
|
- WS default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_WS_HOST` to the gateway's LAN IP.
|
||||||
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
|
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
|
||||||
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
|
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
|
||||||
- JDK 17; no system Gradle — always the wrapper (`./gradlew`).
|
- JDK 17; no system Gradle — always the wrapper (`./gradlew`).
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
## Testing quirks
|
## Testing quirks
|
||||||
|
|
||||||
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `ANDROID_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
|
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
|
||||||
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
|
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
|
||||||
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
|
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
|
||||||
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
|
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
|
||||||
|
|||||||
+2
-2
@@ -58,7 +58,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||||
cd hermes-agent
|
cd hermes-agent
|
||||||
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
|
|
||||||
kotlin:
|
kotlin:
|
||||||
@@ -153,7 +153,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||||
cd hermes-agent
|
cd hermes-agent
|
||||||
ANDROID_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
scripts/run_tests.sh tests/gateway/test_android.py
|
||||||
|
|
||||||
kotlin:
|
kotlin:
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
|
<!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<!-- QR pairing (docs/20): the in-app scanner reads the camera. -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="Iris"
|
android:label="Iris"
|
||||||
@@ -38,8 +41,22 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="iris" android:host="chat" />
|
<data android:scheme="iris" android:host="chat" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- QR pairing (docs/20): iris://pair deep link (scanned QR / link). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="iris" android:host="pair" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- QR pairing (docs/20): full-screen scanner launched from the
|
||||||
|
Connect screen. Not exported; started only by our own app. -->
|
||||||
|
<activity
|
||||||
|
android:name="iris.platform.QrScanActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
|
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ import iris.platform.AndroidEnv
|
|||||||
import iris.platform.AndroidSecureStore
|
import iris.platform.AndroidSecureStore
|
||||||
import iris.platform.AppBridge
|
import iris.platform.AppBridge
|
||||||
import iris.platform.syncNtfyListener
|
import iris.platform.syncNtfyListener
|
||||||
|
import iris.util.PairLink
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val deepLinkChatId = mutableStateOf<String?>(null)
|
private val deepLinkChatId = mutableStateOf<String?>(null)
|
||||||
private val deepLinkThreadId = mutableStateOf<String?>(null)
|
private val deepLinkThreadId = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
// QR pairing (docs/20): a parsed iris://pair link to prefill the Connect
|
||||||
|
// screen with.
|
||||||
|
private val pendingPair = mutableStateOf<PairLink?>(null)
|
||||||
|
|
||||||
private val notificationPermission =
|
private val notificationPermission =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
|
||||||
|
|
||||||
@@ -37,7 +42,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
val chatId by deepLinkChatId
|
val chatId by deepLinkChatId
|
||||||
val threadId by deepLinkThreadId
|
val threadId by deepLinkThreadId
|
||||||
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId)
|
val pair by pendingPair
|
||||||
|
IrisApp(
|
||||||
|
store = store,
|
||||||
|
deepLinkChatId = chatId,
|
||||||
|
deepLinkThreadId = threadId,
|
||||||
|
deepLinkPair = pair,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +75,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
* extras or an iris://chat/<id>?thread=<tid> URI). */
|
* extras or an iris://chat/<id>?thread=<tid> URI). */
|
||||||
private fun handleDeepLink(intent: Intent?) {
|
private fun handleDeepLink(intent: Intent?) {
|
||||||
val data = intent?.data
|
val data = intent?.data
|
||||||
|
// QR pairing (docs/20): iris://pair?host=…&port=…&secure=…&token=… —
|
||||||
|
// the whole payload is in the query string; parse it with the shared
|
||||||
|
// PairLink parser.
|
||||||
|
if (data?.scheme == "iris" && data.host == "pair") {
|
||||||
|
val link = PairLink.parse(data.toString())
|
||||||
|
if (link != null) pendingPair.value = link
|
||||||
|
return
|
||||||
|
}
|
||||||
val chatId =
|
val chatId =
|
||||||
intent?.getStringExtra("chat_id")
|
intent?.getStringExtra("chat_id")
|
||||||
?: data?.pathSegments?.firstOrNull()
|
?: data?.pathSegments?.firstOrNull()
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ val kcefVersion = "2025.03.23"
|
|||||||
val markdownVersion = "0.44.0"
|
val markdownVersion = "0.44.0"
|
||||||
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
|
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
|
||||||
val sqldelightVersion = "2.3.2"
|
val sqldelightVersion = "2.3.2"
|
||||||
|
// CameraX + ML Kit for in-app QR pairing (docs/20). Android-only.
|
||||||
|
val cameraxVersion = "1.5.1"
|
||||||
|
val mlKitVersion = "16.1.1"
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
android {
|
android {
|
||||||
@@ -111,6 +114,13 @@ kotlin {
|
|||||||
implementation("androidx.media3:media3-ui:1.11.0")
|
implementation("androidx.media3:media3-ui:1.11.0")
|
||||||
// SAF picker (rememberLauncherForActivityResult).
|
// SAF picker (rememberLauncherForActivityResult).
|
||||||
implementation("androidx.activity:activity-compose:1.13.0")
|
implementation("androidx.activity:activity-compose:1.13.0")
|
||||||
|
// CameraX + ML Kit for QR pairing (docs/20): the scanner activity
|
||||||
|
// uses the camera2 CameraX backend and ML Kit's barcode model.
|
||||||
|
implementation("androidx.camera:camera-core:$cameraxVersion")
|
||||||
|
implementation("androidx.camera:camera-camera2:$cameraxVersion")
|
||||||
|
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
|
||||||
|
implementation("androidx.camera:camera-view:$cameraxVersion")
|
||||||
|
implementation("com.google.mlkit:barcode-scanning:$mlKitVersion")
|
||||||
// M5: FCM push (inert without a Firebase project / google-services.json;
|
// M5: FCM push (inert without a Firebase project / google-services.json;
|
||||||
// the ntfy listener is the fallback). The google-services plugin is
|
// the ntfy listener is the fallback). The google-services plugin is
|
||||||
// applied conditionally in the app module.
|
// applied conditionally in the app module.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ actual fun postSystemNotification(
|
|||||||
threadId: String?,
|
threadId: String?,
|
||||||
) {
|
) {
|
||||||
val context = AndroidEnv.context
|
val context = AndroidEnv.context
|
||||||
val id = chatId ?: "android:default"
|
val id = chatId ?: "default"
|
||||||
// POST_NOTIFICATIONS is a runtime permission on API 33+.
|
// POST_NOTIFICATIONS is a runtime permission on API 33+.
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
// exception: the app must display them itself.
|
// exception: the app must display them itself.
|
||||||
if (message.notification != null) return
|
if (message.notification != null) return
|
||||||
val data = message.data
|
val data = message.data
|
||||||
val chatId = data["chat_id"] ?: "android:default"
|
val chatId = data["chat_id"] ?: "default"
|
||||||
val threadId = data["thread_id"]
|
val threadId = data["thread_id"]
|
||||||
val title = data["title"] ?: "Iris"
|
val title = data["title"] ?: "Iris"
|
||||||
val body = data["body"] ?: data["title"].orEmpty()
|
val body = data["body"] ?: data["title"].orEmpty()
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class NtfyListenerService : Service() {
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val chatId = data?.str("chat_id") ?: "android:default"
|
val chatId = data?.str("chat_id") ?: "default"
|
||||||
val threadId = data?.str("thread_id")
|
val threadId = data?.str("thread_id")
|
||||||
// The short preview rides in the SSE `data:` field; fall back to the
|
// The short preview rides in the SSE `data:` field; fall back to the
|
||||||
// X-Title, then a generic label.
|
// X-Title, then a generic label.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android QR scanner (docs/20): launches [QrScanActivity] and returns the
|
||||||
|
* scanned text (or null on cancel) to [onResult].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
actual fun QrScanButton(onResult: (String?) -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
val text =
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
result.data?.getStringExtra(QrScanActivity.EXTRA_QR)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
onResult(text)
|
||||||
|
}
|
||||||
|
Button(onClick = {
|
||||||
|
val activity = context.resolveActivity()
|
||||||
|
if (activity != null) {
|
||||||
|
launcher.launch(Intent(activity, QrScanActivity::class.java))
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Scan QR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk a (possibly wrapped) context to the hosting [Activity], if any. */
|
||||||
|
private fun Context.resolveActivity(): Activity? =
|
||||||
|
when (this) {
|
||||||
|
is Activity -> this
|
||||||
|
is ContextWrapper -> baseContext.resolveActivity()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.addCallback
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageAnalysis
|
||||||
|
import androidx.camera.core.ImageProxy
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||||
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen QR scanner (docs/20). Launched from the Connect screen's
|
||||||
|
* "Scan QR" button; returns the raw QR text via [EXTRA_QR] on
|
||||||
|
* [Activity.RESULT_OK], or [Activity.RESULT_CANCELED] on back/cancel.
|
||||||
|
*
|
||||||
|
* Uses the camera2 CameraX backend + ML Kit's barcode model. The camera is
|
||||||
|
* stopped in [onDestroy].
|
||||||
|
*/
|
||||||
|
class QrScanActivity : ComponentActivity() {
|
||||||
|
companion object {
|
||||||
|
/** Intent extra carrying the scanned QR text. */
|
||||||
|
const val EXTRA_QR = "qr"
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var previewView: PreviewView
|
||||||
|
private var cameraProvider: ProcessCameraProvider? = null
|
||||||
|
private var settled = false
|
||||||
|
|
||||||
|
private val permissionLauncher =
|
||||||
|
registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) startCamera() else finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
previewView =
|
||||||
|
PreviewView(this).apply {
|
||||||
|
layoutParams =
|
||||||
|
ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setContentView(previewView)
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(this) {
|
||||||
|
if (!settled) setResult(Activity.RESULT_CANCELED)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||||
|
== PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
startCamera()
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startCamera() {
|
||||||
|
val future = ProcessCameraProvider.getInstance(this)
|
||||||
|
future.addListener(
|
||||||
|
{
|
||||||
|
val provider = future.get()
|
||||||
|
cameraProvider = provider
|
||||||
|
val preview =
|
||||||
|
Preview.Builder().build().also {
|
||||||
|
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
|
}
|
||||||
|
val analyzer =
|
||||||
|
ImageAnalysis
|
||||||
|
.Builder()
|
||||||
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
provider.unbindAll()
|
||||||
|
provider.bindToLifecycle(
|
||||||
|
this,
|
||||||
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||||
|
preview,
|
||||||
|
analyzer,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// No usable camera (e.g. headless emulator): bail out.
|
||||||
|
finish()
|
||||||
|
return@addListener
|
||||||
|
}
|
||||||
|
analyzer.setAnalyzer(ContextCompat.getMainExecutor(this)) { proxy ->
|
||||||
|
analyzeImage(proxy)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ContextCompat.getMainExecutor(this),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun analyzeImage(proxy: ImageProxy) {
|
||||||
|
val mediaImage = proxy.image
|
||||||
|
if (mediaImage == null) {
|
||||||
|
proxy.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val inputImage = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees)
|
||||||
|
BarcodeScanning
|
||||||
|
.getClient()
|
||||||
|
.process(inputImage)
|
||||||
|
.addOnSuccessListener { barcodes ->
|
||||||
|
val text = barcodes.firstOrNull { it.rawValue != null }?.rawValue
|
||||||
|
if (text != null) finishWithResult(text)
|
||||||
|
}.addOnCompleteListener { proxy.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishWithResult(text: String) {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_QR, text))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
cameraProvider?.unbindAll()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
@@ -26,6 +27,7 @@ import iris.ui.theme.IrisColors
|
|||||||
import iris.ui.theme.IrisTheme
|
import iris.ui.theme.IrisTheme
|
||||||
import iris.ui.theme.LocalUserTheme
|
import iris.ui.theme.LocalUserTheme
|
||||||
import iris.ui.theme.rememberBackgroundImage
|
import iris.ui.theme.rememberBackgroundImage
|
||||||
|
import iris.util.PairLink
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root composable shared by the Android and Desktop shells.
|
* Root composable shared by the Android and Desktop shells.
|
||||||
@@ -39,6 +41,7 @@ fun IrisApp(
|
|||||||
store: SecureStore,
|
store: SecureStore,
|
||||||
deepLinkChatId: String? = null,
|
deepLinkChatId: String? = null,
|
||||||
deepLinkThreadId: String? = null,
|
deepLinkThreadId: String? = null,
|
||||||
|
deepLinkPair: PairLink? = null,
|
||||||
) {
|
) {
|
||||||
val controller = remember(store) { IrisController(store) }
|
val controller = remember(store) { IrisController(store) }
|
||||||
DisposableEffect(controller) {
|
DisposableEffect(controller) {
|
||||||
@@ -66,7 +69,8 @@ fun IrisApp(
|
|||||||
// untouched, so the UI keeps its proportions at any size.
|
// untouched, so the UI keeps its proportions at any size.
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUserTheme provides theme,
|
LocalUserTheme provides theme,
|
||||||
LocalDensity provides Density(
|
LocalDensity provides
|
||||||
|
Density(
|
||||||
density = baseDensity.density,
|
density = baseDensity.density,
|
||||||
fontScale = baseDensity.fontScale * fontScale,
|
fontScale = baseDensity.fontScale * fontScale,
|
||||||
),
|
),
|
||||||
@@ -95,20 +99,34 @@ fun IrisApp(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val s = state
|
val s = state
|
||||||
|
// QR pairing (docs/20): an iris://pair deep link prefills
|
||||||
|
// the Connect screen, taking priority over stored creds.
|
||||||
|
val pairUrl = deepLinkPair?.url ?: store.serverUrl
|
||||||
|
val pairToken = deepLinkPair?.token ?: store.token
|
||||||
when (s) {
|
when (s) {
|
||||||
GatewayClient.State.Disconnected ->
|
GatewayClient.State.Disconnected -> {
|
||||||
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
|
key(deepLinkPair) {
|
||||||
is GatewayClient.State.AuthFailed ->
|
ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is GatewayClient.State.AuthFailed -> {
|
||||||
|
key(deepLinkPair) {
|
||||||
ConnectScreen(
|
ConnectScreen(
|
||||||
controller,
|
controller,
|
||||||
prefillUrl = store.serverUrl,
|
prefillUrl = pairUrl,
|
||||||
prefillToken = store.token,
|
prefillToken = pairToken,
|
||||||
initialError = "Pairing rejected: ${s.message}",
|
initialError = "Pairing rejected: ${s.message}",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||||
// status bubble + connection banner show the link state
|
// status bubble + connection banner show the link state
|
||||||
// without blocking the view (M7's full-screen spinner is gone).
|
// without blocking the view (M7's full-screen spinner is gone).
|
||||||
else -> ChatScreen(controller)
|
else -> {
|
||||||
|
ChatScreen(controller)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// M9: full-screen HTML artifact preview (opened from an
|
// M9: full-screen HTML artifact preview (opened from an
|
||||||
// artifact card in the chat); covers everything while open.
|
// artifact card in the chat); covers everything while open.
|
||||||
|
|||||||
@@ -132,15 +132,16 @@ class ChatStore {
|
|||||||
var streamingEnabled: Boolean = true
|
var streamingEnabled: Boolean = true
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_LANE = "android:default"
|
const val DEFAULT_LANE = "default"
|
||||||
|
|
||||||
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lane helpers ──────────────────────────────────────────────────────
|
// ── Lane helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because
|
/** Lane key for a (chat, thread) pair. Uses `::` as the separator so a
|
||||||
* chat ids already contain a single `:` (e.g. `android:chan_1`). */
|
* thread lane can never collide with a chat id (chat ids are direct,
|
||||||
|
* e.g. `chan_1`, and never contain `:`). */
|
||||||
fun laneKey(
|
fun laneKey(
|
||||||
chatId: String,
|
chatId: String,
|
||||||
threadId: String?,
|
threadId: String?,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface SecureStore {
|
|||||||
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
|
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
|
||||||
var serverUrl: String
|
var serverUrl: String
|
||||||
|
|
||||||
/** ANDROID_TOKEN presented in the auth header. */
|
/** IRIS_TOKEN presented in the auth header. */
|
||||||
var token: String
|
var token: String
|
||||||
|
|
||||||
/** Stable app-generated device id (persisted). */
|
/** Stable app-generated device id (persisted). */
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import java.util.concurrent.TimeUnit
|
|||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP client for the hermes android gateway (docs/19).
|
* HTTP client for the hermes iris gateway (docs/19).
|
||||||
*
|
*
|
||||||
* HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
|
* HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
|
||||||
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
|
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "Scan QR" button that launches the platform QR scanner and delivers the
|
||||||
|
* scanned text (or null if the user cancelled) to [onResult] (docs/20).
|
||||||
|
*
|
||||||
|
* Android: opens [QrScanActivity] (CameraX + ML Kit). Desktop: renders nothing
|
||||||
|
* (the Connect screen hides it there).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
expect fun QrScanButton(onResult: (String?) -> Unit)
|
||||||
@@ -134,7 +134,7 @@ class IrisController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Home channel id (from hello.ack; default until then). */
|
/** Home channel id (from hello.ack; default until then). */
|
||||||
private val _homeChannel = MutableStateFlow("android:default")
|
private val _homeChannel = MutableStateFlow("default")
|
||||||
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
|
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
|
||||||
|
|
||||||
/** Lanes whose full history has been loaded this session (in-memory; reset
|
/** Lanes whose full history has been loaded this session (in-memory; reset
|
||||||
@@ -408,7 +408,7 @@ class IrisController(
|
|||||||
) {
|
) {
|
||||||
if (isAppForeground()) return
|
if (isAppForeground()) return
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
val id = chatId ?: "android:default"
|
val id = chatId ?: "default"
|
||||||
val chatName = channels.byId(id)?.name
|
val chatName = channels.byId(id)?.name
|
||||||
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1314,7 +1314,7 @@ private fun SystemMessage(msg: MessageItem) {
|
|||||||
|
|
||||||
/** M7: header title pill — channel avatar + channel name. Automation
|
/** M7: header title pill — channel avatar + channel name. Automation
|
||||||
* channels show their chat_id as a subtitle (tap the pill to copy it) so
|
* channels show their chat_id as a subtitle (tap the pill to copy it) so
|
||||||
* the user can target them for cron delivery (`deliver="android:<chat_id>"`)
|
* the user can target them for cron delivery (`deliver="iris:<chat_id>"`)
|
||||||
* without asking the agent which channel is in use. */
|
* without asking the agent which channel is in use. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun TitlePill(
|
private fun TitlePill(
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import iris.platform.QrScanButton
|
||||||
|
import iris.platform.isDesktop
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
import iris.ui.theme.IrisColors
|
import iris.ui.theme.IrisColors
|
||||||
|
import iris.util.PairLink
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,11 +94,25 @@ fun ConnectScreen(
|
|||||||
value = token,
|
value = token,
|
||||||
onValueChange = { token = it },
|
onValueChange = { token = it },
|
||||||
label = { Text("Pairing token") },
|
label = { Text("Pairing token") },
|
||||||
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
placeholder = { Text("IRIS_TOKEN (64 hex)") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
if (!isDesktop) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
QrScanButton { raw ->
|
||||||
|
if (raw != null) {
|
||||||
|
val link = PairLink.parse(raw)
|
||||||
|
if (link != null) {
|
||||||
|
url = link.url
|
||||||
|
token = link.token
|
||||||
|
} else {
|
||||||
|
error = "Couldn't read that QR code."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
@@ -129,7 +146,7 @@ fun ConnectScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" +
|
"Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" +
|
||||||
"hermes gateway setup on the gateway host.",
|
"hermes gateway setup on the gateway host.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package iris.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parsed `iris://pair` deep link: the gateway URL to connect to plus the
|
||||||
|
* one-time pairing token. Produced by [PairLink.parse] from a scanned QR or a
|
||||||
|
* tapped `iris://pair` link (docs/20).
|
||||||
|
*/
|
||||||
|
data class PairLink(
|
||||||
|
val url: String,
|
||||||
|
val token: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Default gateway HTTP port (docs/19). */
|
||||||
|
const val DEFAULT_PORT = 8791
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a pairing link into a [PairLink], or return null on any
|
||||||
|
* malformation.
|
||||||
|
*
|
||||||
|
* - scheme must be `iris` (case-insensitive); the URI host must be `pair`.
|
||||||
|
* - required query params: `host` (non-empty) and `token` (non-empty).
|
||||||
|
* - `port` defaults to [DEFAULT_PORT]; must be 1–65535 when present.
|
||||||
|
* - `secure` defaults to `0`; `1` selects https.
|
||||||
|
* - `host`/`token` are percent-decoded (the Python side `quote()`s them).
|
||||||
|
*/
|
||||||
|
fun parse(raw: String): PairLink? {
|
||||||
|
val qIdx = raw.indexOf('?')
|
||||||
|
val authority = if (qIdx >= 0) raw.substring(0, qIdx) else raw
|
||||||
|
val query = if (qIdx >= 0) raw.substring(qIdx + 1) else ""
|
||||||
|
|
||||||
|
// authority is "iris://pair": scheme, then "://", then the URI host.
|
||||||
|
val sep = authority.indexOf("://")
|
||||||
|
if (sep <= 0) return null
|
||||||
|
val scheme = authority.substring(0, sep)
|
||||||
|
val uriHost = authority.substring(sep + 3)
|
||||||
|
if (scheme.lowercase() != "iris") return null
|
||||||
|
if (uriHost.lowercase() != "pair") return null
|
||||||
|
|
||||||
|
val params = parseQuery(query)
|
||||||
|
val host = params["host"]?.let { percentDecode(it) }?.trim().orEmpty()
|
||||||
|
val token = params["token"]?.let { percentDecode(it) }?.trim().orEmpty()
|
||||||
|
if (host.isEmpty() || token.isEmpty()) return null
|
||||||
|
|
||||||
|
val portRaw = params["port"]
|
||||||
|
val port =
|
||||||
|
if (portRaw.isNullOrEmpty()) {
|
||||||
|
DEFAULT_PORT
|
||||||
|
} else {
|
||||||
|
portRaw.toIntOrNull() ?: return null
|
||||||
|
}
|
||||||
|
if (port !in 1..65535) return null
|
||||||
|
val secure = params["secure"]?.toIntOrNull() ?: 0
|
||||||
|
val urlScheme = if (secure == 1) "https" else "http"
|
||||||
|
return PairLink("$urlScheme://$host:$port", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a `k=v&k=v` query string into a map (values may be empty). */
|
||||||
|
private fun parseQuery(query: String): Map<String, String> {
|
||||||
|
if (query.isEmpty()) return emptyMap()
|
||||||
|
val map = LinkedHashMap<String, String>()
|
||||||
|
for (pair in query.split('&')) {
|
||||||
|
if (pair.isEmpty()) continue
|
||||||
|
val eq = pair.indexOf('=')
|
||||||
|
if (eq < 0) {
|
||||||
|
map[pair] = ""
|
||||||
|
} else {
|
||||||
|
map[pair.substring(0, eq)] = pair.substring(eq + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percent-decode `%XX` sequences (byte-wise; pairing payloads are ASCII
|
||||||
|
* — IPs and 64-hex tokens). A `%` that does not start a valid
|
||||||
|
* two-hex-digit escape is kept literally.
|
||||||
|
*/
|
||||||
|
private fun percentDecode(s: String): String {
|
||||||
|
val sb = StringBuilder(s.length)
|
||||||
|
var i = 0
|
||||||
|
while (i < s.length) {
|
||||||
|
val c = s[i]
|
||||||
|
if (c == '%' && i + 2 < s.length) {
|
||||||
|
val code = s.substring(i + 1, i + 3).toIntOrNull(16)
|
||||||
|
if (code != null) {
|
||||||
|
sb.append(code.toChar())
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append(c)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ class ChatStoreCacheTest {
|
|||||||
val store = ChatStore()
|
val store = ChatStore()
|
||||||
store.loadFromCache(
|
store.loadFromCache(
|
||||||
mapOf(
|
mapOf(
|
||||||
"android:default" to
|
"default" to
|
||||||
listOf<ChatItem>(
|
listOf<ChatItem>(
|
||||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
||||||
@@ -21,15 +21,15 @@ class ChatStoreCacheTest {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["android:default"]!!.map { it.id })
|
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun loadFromCacheEmptyIsNoOp() {
|
fun loadFromCacheEmptyIsNoOp() {
|
||||||
val store = ChatStore()
|
val store = ChatStore()
|
||||||
store.addPending("hello", "android:default")
|
store.addPending("hello", "default")
|
||||||
store.loadFromCache(emptyMap())
|
store.loadFromCache(emptyMap())
|
||||||
assertEquals(1, store.lanes.value["android:default"]!!.size)
|
assertEquals(1, store.lanes.value["default"]!!.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -39,7 +39,7 @@ class ChatStoreCacheTest {
|
|||||||
// to it, and the final answer.
|
// to it, and the final answer.
|
||||||
store.loadFromCache(
|
store.loadFromCache(
|
||||||
mapOf(
|
mapOf(
|
||||||
"android:default" to
|
"default" to
|
||||||
listOf<ChatItem>(
|
listOf<ChatItem>(
|
||||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||||
ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"),
|
ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"),
|
||||||
@@ -51,13 +51,13 @@ class ChatStoreCacheTest {
|
|||||||
// the tool card between the user message and the answer — not push
|
// the tool card between the user message and the answer — not push
|
||||||
// it to the end.
|
// it to the end.
|
||||||
store.loadHistory(
|
store.loadHistory(
|
||||||
"android:default",
|
"default",
|
||||||
listOf(
|
listOf(
|
||||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||||
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
|
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["android:default"]!!.map { it.id })
|
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -65,7 +65,7 @@ class ChatStoreCacheTest {
|
|||||||
val store = ChatStore()
|
val store = ChatStore()
|
||||||
store.loadFromCache(
|
store.loadFromCache(
|
||||||
mapOf(
|
mapOf(
|
||||||
"android:default" to
|
"default" to
|
||||||
listOf<ChatItem>(
|
listOf<ChatItem>(
|
||||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true),
|
ToolItem(id = "tool_1", index = 0, name = "bash", done = true),
|
||||||
@@ -73,11 +73,11 @@ class ChatStoreCacheTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
store.loadHistory(
|
store.loadHistory(
|
||||||
"android:default",
|
"default",
|
||||||
listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)),
|
listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)),
|
||||||
)
|
)
|
||||||
// A card whose anchor is unknown falls to the end (degenerate case).
|
// A card whose anchor is unknown falls to the end (degenerate case).
|
||||||
assertEquals(listOf("m1", "tool_1"), store.lanes.value["android:default"]!!.map { it.id })
|
assertEquals(listOf("m1", "tool_1"), store.lanes.value["default"]!!.map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -87,7 +87,7 @@ class ChatStoreCacheTest {
|
|||||||
// card minted by the PREVIOUS process.
|
// card minted by the PREVIOUS process.
|
||||||
store.loadFromCache(
|
store.loadFromCache(
|
||||||
mapOf(
|
mapOf(
|
||||||
"android:default" to
|
"default" to
|
||||||
listOf<ChatItem>(
|
listOf<ChatItem>(
|
||||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
||||||
@@ -99,7 +99,7 @@ class ChatStoreCacheTest {
|
|||||||
store.onFrame(
|
store.onFrame(
|
||||||
Frame(
|
Frame(
|
||||||
type = TYPE_TOOL_START,
|
type = TYPE_TOOL_START,
|
||||||
chatId = "android",
|
chatId = "iris:other",
|
||||||
payload =
|
payload =
|
||||||
IrisJson.instance.encodeToJsonElement(
|
IrisJson.instance.encodeToJsonElement(
|
||||||
ToolStartPayload.serializer(),
|
ToolStartPayload.serializer(),
|
||||||
@@ -107,7 +107,7 @@ class ChatStoreCacheTest {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val ids = store.lanes.value["android:default"]!!.map { it.id }
|
val ids = store.lanes.value["default"]!!.map { it.id }
|
||||||
assertEquals(ids.size, ids.toSet().size)
|
assertEquals(ids.size, ids.toSet().size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop has no camera, so there is nothing to scan. The Connect screen hides
|
||||||
|
* this button on desktop (`isDesktop`), so this is never rendered.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
actual fun QrScanButton(onResult: (String?) -> Unit) {
|
||||||
|
// No-op on desktop.
|
||||||
|
}
|
||||||
@@ -43,24 +43,24 @@ class ChatDbTest {
|
|||||||
val db = newDb()
|
val db = newDb()
|
||||||
db.saveLanes(
|
db.saveLanes(
|
||||||
mapOf(
|
mapOf(
|
||||||
"android:default" to
|
"default" to
|
||||||
listOf<ChatItem>(
|
listOf<ChatItem>(
|
||||||
msg("m1", ts = 100),
|
msg("m1", ts = 100),
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"),
|
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"),
|
||||||
msg("m2", role = "assistant", text = "hi", ts = 200),
|
msg("m2", role = "assistant", text = "hi", ts = 200),
|
||||||
),
|
),
|
||||||
"android:default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
|
"default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val loaded = db.loadLanes()
|
val loaded = db.loadLanes()
|
||||||
assertEquals(setOf("android:default", "android:default::thr_1"), loaded.keys)
|
assertEquals(setOf("default", "default::thr_1"), loaded.keys)
|
||||||
// Tool cards are persisted and restored at their anchored position
|
// Tool cards are persisted and restored at their anchored position
|
||||||
// (after the message they follow, before the answer).
|
// (after the message they follow, before the answer).
|
||||||
assertEquals(listOf("m1", "tool_1", "m2"), loaded["android:default"]!!.map { it.id })
|
assertEquals(listOf("m1", "tool_1", "m2"), loaded["default"]!!.map { it.id })
|
||||||
assertEquals(listOf("m3"), loaded["android:default::thr_1"]!!.map { it.id })
|
assertEquals(listOf("m3"), loaded["default::thr_1"]!!.map { it.id })
|
||||||
// Messages ordered by ts.
|
// Messages ordered by ts.
|
||||||
assertEquals(100L, (loaded["android:default"]!![0] as MessageItem).ts)
|
assertEquals(100L, (loaded["default"]!![0] as MessageItem).ts)
|
||||||
assertEquals(200L, (loaded["android:default"]!![2] as MessageItem).ts)
|
assertEquals(200L, (loaded["default"]!![2] as MessageItem).ts)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -68,7 +68,7 @@ class ChatDbTest {
|
|||||||
val db = newDb()
|
val db = newDb()
|
||||||
db.saveLanes(
|
db.saveLanes(
|
||||||
mapOf(
|
mapOf(
|
||||||
"android:default" to
|
"default" to
|
||||||
listOf<ChatItem>(
|
listOf<ChatItem>(
|
||||||
msg("m1", ts = 100),
|
msg("m1", ts = 100),
|
||||||
ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"),
|
ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"),
|
||||||
@@ -77,7 +77,7 @@ class ChatDbTest {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["android:default"]!!.map { it.id })
|
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["default"]!!.map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -85,7 +85,7 @@ class ChatDbTest {
|
|||||||
val db = newDb()
|
val db = newDb()
|
||||||
db.saveLanes(
|
db.saveLanes(
|
||||||
mapOf(
|
mapOf(
|
||||||
"android:default" to
|
"default" to
|
||||||
listOf<ChatItem>(
|
listOf<ChatItem>(
|
||||||
msg("m1", ts = 100),
|
msg("m1", ts = 100),
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"),
|
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"),
|
||||||
@@ -93,7 +93,7 @@ class ChatDbTest {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["android:default"]!!.map { it.id })
|
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["default"]!!.map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -101,7 +101,7 @@ class ChatDbTest {
|
|||||||
val db = newDb()
|
val db = newDb()
|
||||||
db.saveLanes(
|
db.saveLanes(
|
||||||
mapOf(
|
mapOf(
|
||||||
"android:default" to
|
"default" to
|
||||||
listOf<ChatItem>(
|
listOf<ChatItem>(
|
||||||
msg("m1", ts = 100),
|
msg("m1", ts = 100),
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"),
|
ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"),
|
||||||
@@ -109,7 +109,7 @@ class ChatDbTest {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val lane = db.loadLanes()["android:default"]!!
|
val lane = db.loadLanes()["default"]!!
|
||||||
// The process died before tool.end — the open card is closed as
|
// The process died before tool.end — the open card is closed as
|
||||||
// interrupted, not left spinning.
|
// interrupted, not left spinning.
|
||||||
val open = lane.first { it.id == "tool_1" } as ToolItem
|
val open = lane.first { it.id == "tool_1" } as ToolItem
|
||||||
@@ -123,9 +123,9 @@ class ChatDbTest {
|
|||||||
@Test
|
@Test
|
||||||
fun saveLanesReplacesPreviousSnapshot() {
|
fun saveLanesReplacesPreviousSnapshot() {
|
||||||
val db = newDb()
|
val db = newDb()
|
||||||
db.saveLanes(mapOf("android:default" to listOf(msg("m1"), msg("m2"))))
|
db.saveLanes(mapOf("default" to listOf(msg("m1"), msg("m2"))))
|
||||||
db.saveLanes(mapOf("android:default" to listOf(msg("m2"))))
|
db.saveLanes(mapOf("default" to listOf(msg("m2"))))
|
||||||
assertEquals(listOf("m2"), db.loadLanes()["android:default"]!!.map { it.id })
|
assertEquals(listOf("m2"), db.loadLanes()["default"]!!.map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -133,7 +133,7 @@ class ChatDbTest {
|
|||||||
val db = newDb()
|
val db = newDb()
|
||||||
db.saveLanes(
|
db.saveLanes(
|
||||||
mapOf(
|
mapOf(
|
||||||
"android:default" to
|
"default" to
|
||||||
listOf(
|
listOf(
|
||||||
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
|
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
|
||||||
msg("s1", role = "assistant", streaming = true, ts = 500),
|
msg("s1", role = "assistant", streaming = true, ts = 500),
|
||||||
@@ -141,7 +141,7 @@ class ChatDbTest {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val lane = db.loadLanes()["android:default"]!!
|
val lane = db.loadLanes()["default"]!!
|
||||||
val msgItem = { id: String -> lane.first { it.id == id } as MessageItem }
|
val msgItem = { id: String -> lane.first { it.id == id } as MessageItem }
|
||||||
// A pending send becomes failed (tap to retry); the gateway never
|
// A pending send becomes failed (tap to retry); the gateway never
|
||||||
// acknowledged it before the process died.
|
// acknowledged it before the process died.
|
||||||
@@ -156,7 +156,7 @@ class ChatDbTest {
|
|||||||
@Test
|
@Test
|
||||||
fun systemMessagesAreNotPersisted() {
|
fun systemMessagesAreNotPersisted() {
|
||||||
val db = newDb()
|
val db = newDb()
|
||||||
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
|
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
|
||||||
assertTrue(db.loadLanes().isEmpty())
|
assertTrue(db.loadLanes().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,8 +182,8 @@ class ChatDbTest {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(item)))
|
db.saveLanes(mapOf("default" to listOf<ChatItem>(item)))
|
||||||
val loaded = db.loadLanes()["android:default"]!!.first() as MessageItem
|
val loaded = db.loadLanes()["default"]!!.first() as MessageItem
|
||||||
assertEquals("gpt", loaded.runtime?.model)
|
assertEquals("gpt", loaded.runtime?.model)
|
||||||
assertEquals("/tmp/a.png", loaded.media.first().localPath)
|
assertEquals("/tmp/a.png", loaded.media.first().localPath)
|
||||||
}
|
}
|
||||||
@@ -193,26 +193,26 @@ class ChatDbTest {
|
|||||||
val db = newDb()
|
val db = newDb()
|
||||||
db.saveChannels(
|
db.saveChannels(
|
||||||
listOf(
|
listOf(
|
||||||
ChannelInfo(chatId = "android:default", name = "General", isDefault = true),
|
ChannelInfo(chatId = "default", name = "General", isDefault = true),
|
||||||
ChannelInfo(chatId = "android:chan_1", name = "Work"),
|
ChannelInfo(chatId = "chan_1", name = "Work"),
|
||||||
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "android:chan_1"),
|
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "chan_1"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val loaded = db.loadChannels()
|
val loaded = db.loadChannels()
|
||||||
assertEquals(3, loaded.size)
|
assertEquals(3, loaded.size)
|
||||||
assertEquals("Work", loaded.first { it.chatId == "android:chan_1" }.name)
|
assertEquals("Work", loaded.first { it.chatId == "chan_1" }.name)
|
||||||
assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind)
|
assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind)
|
||||||
assertEquals(true, loaded.first { it.chatId == "android:default" }.isDefault)
|
assertEquals(true, loaded.first { it.chatId == "default" }.isDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun metaRoundTripAndClearAll() {
|
fun metaRoundTripAndClearAll() {
|
||||||
val db = newDb()
|
val db = newDb()
|
||||||
assertNull(db.metaGet("last_lane"))
|
assertNull(db.metaGet("last_lane"))
|
||||||
db.metaPut("last_lane", "android:chan_1")
|
db.metaPut("last_lane", "chan_1")
|
||||||
assertEquals("android:chan_1", db.metaGet("last_lane"))
|
assertEquals("chan_1", db.metaGet("last_lane"))
|
||||||
db.saveLanes(mapOf("android:default" to listOf<ChatItem>(msg("m1"))))
|
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("m1"))))
|
||||||
db.saveChannels(listOf(ChannelInfo(chatId = "android:default", name = "General")))
|
db.saveChannels(listOf(ChannelInfo(chatId = "default", name = "General")))
|
||||||
db.clearAll()
|
db.clearAll()
|
||||||
assertTrue(db.loadLanes().isEmpty())
|
assertTrue(db.loadLanes().isEmpty())
|
||||||
assertEquals(emptyList<ChannelInfo>(), db.loadChannels())
|
assertEquals(emptyList<ChannelInfo>(), db.loadChannels())
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package iris.util
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser tests for `iris://pair` links (docs/20). The payload shape mirrors
|
||||||
|
* `pairing.qr_payload` on the gateway side.
|
||||||
|
*/
|
||||||
|
class PairLinkTest {
|
||||||
|
private val token = "ab".repeat(32) // 64 hex chars
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesValidLink() {
|
||||||
|
val link = PairLink.parse("iris://pair?host=192.168.1.50&port=8791&secure=0&token=$token")
|
||||||
|
assertEquals("http://192.168.1.50:8791", link?.url)
|
||||||
|
assertEquals(token, link?.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultsPortTo8791() {
|
||||||
|
val link = PairLink.parse("iris://pair?host=10.0.0.5&token=$token")
|
||||||
|
assertEquals("http://10.0.0.5:8791", link?.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun secureOneSelectsHttps() {
|
||||||
|
val link = PairLink.parse("iris://pair?host=10.0.0.5&port=8443&secure=1&token=$token")
|
||||||
|
assertEquals("https://10.0.0.5:8443", link?.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun schemeIsCaseInsensitive() {
|
||||||
|
val link = PairLink.parse("IRIS://pair?host=10.0.0.5&token=$token")
|
||||||
|
assertEquals("http://10.0.0.5:8791", link?.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun percentDecodesHost() {
|
||||||
|
// A hostname with a space would be %20 on the wire.
|
||||||
|
val link = PairLink.parse("iris://pair?host=my%20host&token=$token")
|
||||||
|
assertEquals("http://my host:8791", link?.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsMissingToken() {
|
||||||
|
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=8791"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsEmptyToken() {
|
||||||
|
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&token="))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsMissingHost() {
|
||||||
|
assertNull(PairLink.parse("iris://pair?port=8791&token=$token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsBadPort() {
|
||||||
|
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=0&token=$token"))
|
||||||
|
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=70000&token=$token"))
|
||||||
|
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=abc&token=$token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsWrongScheme() {
|
||||||
|
assertNull(PairLink.parse("foo://pair?host=10.0.0.5&token=$token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsWrongHost() {
|
||||||
|
assertNull(PairLink.parse("iris://chat?host=10.0.0.5&token=$token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsNoAuthority() {
|
||||||
|
assertNull(PairLink.parse("pair?host=10.0.0.5&token=$token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptsBoundaryPorts() {
|
||||||
|
assertTrue(PairLink.parse("iris://pair?host=h&port=1&token=$token") != null)
|
||||||
|
assertTrue(PairLink.parse("iris://pair?host=h&port=65535&token=$token") != null)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -46,7 +46,7 @@ Everything in the feature checklist below.
|
|||||||
| Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing |
|
| Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing |
|
||||||
| Reasoning shown before message | Captures + splits reasoning | Collapsible "Reasoning" block above message |
|
| Reasoning shown before message | Captures + splits reasoning | Collapsible "Reasoning" block above message |
|
||||||
| Intermediate messages | Forwards `Commentary` events | Distinct dimmed bubble |
|
| Intermediate messages | Forwards `Commentary` events | Distinct dimmed bubble |
|
||||||
| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=android:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" |
|
| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=iris:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" |
|
||||||
| Search ("everywhere" / "this chat/channel") | FTS5 session search bridge | Search UI + scope toggle |
|
| Search ("everywhere" / "this chat/channel") | FTS5 session search bridge | Search UI + scope toggle |
|
||||||
| Attach media (music/video/images/docs) | Inbound cache; outbound `send_*` | Pickers + chunked upload + preview |
|
| Attach media (music/video/images/docs) | Inbound cache; outbound `send_*` | Pickers + chunked upload + preview |
|
||||||
| Push notifications | FCM (primary) / ntfy (fallback) | FCM token / ntfy topic + notification service |
|
| Push notifications | FCM (primary) / ntfy (fallback) | FCM token / ntfy topic + notification service |
|
||||||
@@ -57,7 +57,7 @@ Everything in the feature checklist below.
|
|||||||
| Decision | Choice |
|
| Decision | Choice |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
|
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
|
||||||
| Push backend | **Both** — FCM primary, ntfy fallback (`ANDROID_PUSH_BACKEND`) |
|
| Push backend | **Both** — FCM primary, ntfy fallback (`IRIS_PUSH_BACKEND`) |
|
||||||
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
|
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
|
||||||
| Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
|
| Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ Everything in the feature checklist below.
|
|||||||
1. **`hermes-agent/` is a read-only research reference.** It lives next to this
|
1. **`hermes-agent/` is a read-only research reference.** It lives next to this
|
||||||
folder for study only. It is **git-ignored** and must **never** be committed,
|
folder for study only. It is **git-ignored** and must **never** be committed,
|
||||||
pushed, or included in any artifact. Our plugin is *installed* into a live
|
pushed, or included in any artifact. Our plugin is *installed* into a live
|
||||||
hermes home (`~/.hermes/plugins/android`); we never edit hermes core files.
|
hermes home (`~/.hermes/plugins/iris`); we never edit hermes core files.
|
||||||
2. **ADB is available and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S,
|
2. **ADB is available and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S,
|
||||||
Android 10 / API 29). Use `adb install` / `adb logcat` / `adb shell am start`
|
Android 10 / API 29). Use `adb install` / `adb logcat` / `adb shell am start`
|
||||||
to install, launch, and debug the app on-device throughout the build.
|
to install, launch, and debug the app on-device throughout the build.
|
||||||
@@ -88,6 +88,6 @@ Everything in the feature checklist below.
|
|||||||
## Naming
|
## Naming
|
||||||
|
|
||||||
- Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`).
|
- Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`).
|
||||||
- hermes platform name: **`android`** (the plugin registers `Platform("android")`).
|
- hermes platform name: **`iris`** (the plugin registers `Platform("iris")`).
|
||||||
- WS default port: **8790** (configurable).
|
- WS default port: **8790** (configurable).
|
||||||
- Default chat id: **`android:default`** (the home channel).
|
- Default chat id: **`default`** (the home channel).
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
|
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
|
||||||
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
|
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
|
||||||
│ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
|
│ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
|
||||||
│ │ │ AndroidAdapter │ JSON │ ws://host:8790/ws │ │ │
|
│ │ │ IrisAdapter │ JSON │ ws://host:8790/ws │ │ │
|
||||||
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
|
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
|
||||||
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
|
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
|
||||||
│ │ │ • media cache │ │ WSS │ │
|
│ │ │ • media cache │ │ WSS │ │
|
||||||
@@ -40,8 +40,8 @@
|
|||||||
## Process model
|
## Process model
|
||||||
|
|
||||||
- **One `hermes gateway` process** hosts the agent core, the session store, the
|
- **One `hermes gateway` process** hosts the agent core, the session store, the
|
||||||
cron scheduler, *and* our `android` platform plugin. The plugin's WebSocket
|
cron scheduler, *and* our `iris` platform plugin. The plugin's WebSocket
|
||||||
server runs on the gateway's asyncio loop (started in `AndroidAdapter.connect()`).
|
server runs on the gateway's asyncio loop (started in `IrisAdapter.connect()`).
|
||||||
- **The app is a client.** It *initiates* the WS connection to the gateway
|
- **The app is a client.** It *initiates* the WS connection to the gateway
|
||||||
(outbound), so no inbound port is needed on the phone. For LAN/remote access
|
(outbound), so no inbound port is needed on the phone. For LAN/remote access
|
||||||
the user points the app at the gateway's LAN IP / Tailscale name / a WSS
|
the user points the app at the gateway's LAN IP / Tailscale name / a WSS
|
||||||
@@ -56,7 +56,7 @@ serves Telegram/Discord/… and cron) and the **`tui_gateway`** JSON-RPC backend
|
|||||||
(used by the TUI and the existing Electron desktop app). We deliberately use the
|
(used by the TUI and the existing Electron desktop app). We deliberately use the
|
||||||
**messaging gateway** because:
|
**messaging gateway** because:
|
||||||
|
|
||||||
1. **Cron delivery is native.** Cron jobs resolve `deliver=android:<chat>[:<thread>]`
|
1. **Cron delivery is native.** Cron jobs resolve `deliver=iris:<chat>[:<thread>]`
|
||||||
through the platform registry and call our adapter's `send()`. No bridging.
|
through the platform registry and call our adapter's `send()`. No bridging.
|
||||||
2. **`send_message` tool routing** works out of the box (plugin
|
2. **`send_message` tool routing** works out of the box (plugin
|
||||||
`parse_target_ref_fn`).
|
`parse_target_ref_fn`).
|
||||||
@@ -86,7 +86,7 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
|
|||||||
|
|
||||||
1. App sends `message.send {text}` (or `/cmd`).
|
1. App sends `message.send {text}` (or `/cmd`).
|
||||||
2. Plugin builds a `MessageEvent` (+ `media_urls` if attachments) →
|
2. Plugin builds a `MessageEvent` (+ `media_urls` if attachments) →
|
||||||
`AndroidAdapter.handle_message(event)`.
|
`IrisAdapter.handle_message(event)`.
|
||||||
3. Gateway resolves the session (`chat_id`/`thread_id`), runs the agent.
|
3. Gateway resolves the session (`chat_id`/`thread_id`), runs the agent.
|
||||||
4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` →
|
4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` →
|
||||||
`adapter.send()` (first) / `adapter.edit_message()` (updates) →
|
`adapter.send()` (first) / `adapter.edit_message()` (updates) →
|
||||||
|
|||||||
+7
-7
@@ -13,10 +13,10 @@ iris_x_hermes/
|
|||||||
│
|
│
|
||||||
├── hermes-agent/ # ⚠️ READ-ONLY REFERENCE — NEVER PUSHED (git-ignored)
|
├── hermes-agent/ # ⚠️ READ-ONLY REFERENCE — NEVER PUSHED (git-ignored)
|
||||||
│
|
│
|
||||||
├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/android
|
├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/iris
|
||||||
│ ├── plugin.yaml # manifest (kind: platform, env vars, home channel)
|
│ ├── plugin.yaml # manifest (kind: platform, env vars, home channel)
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── adapter.py # AndroidAdapter(BasePlatformAdapter) + register(ctx)
|
│ ├── adapter.py # IrisAdapter(BasePlatformAdapter) + register(ctx)
|
||||||
│ ├── ws_server.py # websockets server, connection registry, framing
|
│ ├── ws_server.py # websockets server, connection registry, framing
|
||||||
│ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin)
|
│ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin)
|
||||||
│ ├── media.py # inbound cache + outbound chunked streaming
|
│ ├── media.py # inbound cache + outbound chunked streaming
|
||||||
@@ -50,9 +50,9 @@ iris_x_hermes/
|
|||||||
## Module responsibilities
|
## Module responsibilities
|
||||||
|
|
||||||
### `gateway-plugin/` (Python)
|
### `gateway-plugin/` (Python)
|
||||||
- **`plugin.yaml`** — manifest: `name: android-platform`, `kind: platform`,
|
- **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`,
|
||||||
`requires_env` / `optional_env` (surfaced in `hermes config`/setup).
|
`requires_env` / `optional_env` (surfaced in `hermes config`/setup).
|
||||||
- **`adapter.py`** — `AndroidAdapter(BasePlatformAdapter)` + `register(ctx)`.
|
- **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`.
|
||||||
The heart of the plugin. See `03-gateway-plugin.md`.
|
The heart of the plugin. See `03-gateway-plugin.md`.
|
||||||
- **`ws_server.py`** — `websockets` server, per-device connection registry,
|
- **`ws_server.py`** — `websockets` server, per-device connection registry,
|
||||||
frame encode/decode, heartbeat, broadcast routing to all connected devices.
|
frame encode/decode, heartbeat, broadcast routing to all connected devices.
|
||||||
@@ -62,7 +62,7 @@ iris_x_hermes/
|
|||||||
`media.offer`/`media.pull` chunked streaming.
|
`media.offer`/`media.pull` chunked streaming.
|
||||||
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
|
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
|
||||||
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and
|
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and
|
||||||
`NtfyBackend` (reuses hermes ntfy publish). Selected by `ANDROID_PUSH_BACKEND`.
|
`NtfyBackend` (reuses hermes ntfy publish). Selected by `IRIS_PUSH_BACKEND`.
|
||||||
- **`pairing.py`** — token generation/verification (constant-time), device
|
- **`pairing.py`** — token generation/verification (constant-time), device
|
||||||
registry (SQLite), QR payload.
|
registry (SQLite), QR payload.
|
||||||
- **`search.py`** — FTS5 query bridge over the hermes session store.
|
- **`search.py`** — FTS5 query bridge over the hermes session store.
|
||||||
@@ -83,7 +83,7 @@ Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
|
|||||||
## Build systems
|
## Build systems
|
||||||
|
|
||||||
- **Python plugin:** no build step (pure Python, stdlib + hermes core deps).
|
- **Python plugin:** no build step (pure Python, stdlib + hermes core deps).
|
||||||
Installed by copying/symlinking into `~/.hermes/plugins/android`. Tested with
|
Installed by copying/symlinking into `~/.hermes/plugins/iris`. Tested with
|
||||||
hermes's `scripts/run_tests.sh`.
|
hermes's `scripts/run_tests.sh`.
|
||||||
- **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin.
|
- **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin.
|
||||||
`./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`,
|
`./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`,
|
||||||
@@ -124,7 +124,7 @@ keystore.jks
|
|||||||
|
|
||||||
## Install layout (runtime)
|
## Install layout (runtime)
|
||||||
|
|
||||||
- **Plugin:** `~/.hermes/plugins/android/` ← copy of `gateway-plugin/`
|
- **Plugin:** `~/.hermes/plugins/iris/` ← copy of `gateway-plugin/`
|
||||||
(or a symlink for dev). Discovered by hermes's `PluginManager`.
|
(or a symlink for dev). Discovered by hermes's `PluginManager`.
|
||||||
- **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`.
|
- **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`.
|
||||||
- **App (desktop, dev):** `./gradlew :desktopApp:run`.
|
- **App (desktop, dev):** `./gradlew :desktopApp:run`.
|
||||||
+41
-34
@@ -1,6 +1,6 @@
|
|||||||
# 03 — Gateway Plugin (Python)
|
# 03 — Gateway Plugin (Python)
|
||||||
|
|
||||||
The plugin is a **community-style hermes platform plugin** named `android`.
|
The plugin is a **community-style hermes platform plugin** named `iris`.
|
||||||
It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md`
|
It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md`
|
||||||
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
|
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
|
||||||
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
|
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
|
||||||
@@ -11,7 +11,7 @@ and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
|
|||||||
## 3.1 `plugin.yaml` (manifest)
|
## 3.1 `plugin.yaml` (manifest)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: android-platform
|
name: iris-platform
|
||||||
label: Android
|
label: Android
|
||||||
kind: platform
|
kind: platform
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
@@ -22,63 +22,63 @@ description: >
|
|||||||
channels/threads, media, FTS5 search, and FCM/ntfy push.
|
channels/threads, media, FTS5 search, and FCM/ntfy push.
|
||||||
author: <you>
|
author: <you>
|
||||||
requires_env:
|
requires_env:
|
||||||
- name: ANDROID_TOKEN
|
- name: IRIS_TOKEN
|
||||||
description: "Shared pairing token the app presents on connect"
|
description: "Shared pairing token the app presents on connect"
|
||||||
prompt: "Android pairing token"
|
prompt: "Android pairing token"
|
||||||
password: true
|
password: true
|
||||||
optional_env:
|
optional_env:
|
||||||
- name: ANDROID_WS_HOST
|
- name: IRIS_WS_HOST
|
||||||
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||||
prompt: "WS host"
|
prompt: "WS host"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_WS_PORT
|
- name: IRIS_WS_PORT
|
||||||
description: "WS port (default 8790)"
|
description: "WS port (default 8790)"
|
||||||
prompt: "WS port"
|
prompt: "WS port"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_HOME_CHANNEL
|
- name: ANDROID_HOME_CHANNEL
|
||||||
description: "Default chat id for cron/notification delivery (default android:default)"
|
description: "Default chat id for cron/notification delivery (default default)"
|
||||||
prompt: "Home channel"
|
prompt: "Home channel"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_ALLOWED_USERS
|
- name: IRIS_ALLOWED_USERS
|
||||||
description: "Comma-separated allowed device_ids (empty = token-only auth)"
|
description: "Comma-separated allowed device_ids (empty = token-only auth)"
|
||||||
prompt: "Allowed device ids"
|
prompt: "Allowed device ids"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_ALLOW_ALL_USERS
|
- name: IRIS_ALLOW_ALL_USERS
|
||||||
description: "Allow any paired device (dev only)"
|
description: "Allow any paired device (dev only)"
|
||||||
prompt: "Allow all devices? (true/false)"
|
prompt: "Allow all devices? (true/false)"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_PUSH_BACKEND
|
- name: IRIS_PUSH_BACKEND
|
||||||
description: "Push backend: fcm (default) or ntfy"
|
description: "Push backend: fcm (default) or ntfy"
|
||||||
prompt: "Push backend"
|
prompt: "Push backend"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_FCM_SERVICE_ACCOUNT
|
- name: IRIS_FCM_SERVICE_ACCOUNT
|
||||||
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
|
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
|
||||||
prompt: "FCM service account path"
|
prompt: "FCM service account path"
|
||||||
password: true
|
password: true
|
||||||
- name: ANDROID_FCM_SERVER_KEY
|
- name: IRIS_FCM_SERVER_KEY
|
||||||
description: "Legacy FCM server key (fallback if no service account)"
|
description: "Legacy FCM server key (fallback if no service account)"
|
||||||
prompt: "FCM server key"
|
prompt: "FCM server key"
|
||||||
password: true
|
password: true
|
||||||
- name: NTFY_TOPIC
|
- name: NTFY_TOPIC
|
||||||
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)"
|
description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)"
|
||||||
prompt: "ntfy topic"
|
prompt: "ntfy topic"
|
||||||
password: false
|
password: false
|
||||||
- name: NTFY_SERVER_URL
|
- name: NTFY_SERVER_URL
|
||||||
description: "ntfy server URL (default https://ntfy.sh)"
|
description: "ntfy server URL (default https://ntfy.sh)"
|
||||||
prompt: "ntfy server URL"
|
prompt: "ntfy server URL"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_WS_CERT
|
- name: IRIS_WS_CERT
|
||||||
description: "TLS cert path for WSS (optional)"
|
description: "TLS cert path for WSS (optional)"
|
||||||
prompt: "WSS cert"
|
prompt: "WSS cert"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_WS_KEY
|
- name: IRIS_WS_KEY
|
||||||
description: "TLS key path for WSS (optional)"
|
description: "TLS key path for WSS (optional)"
|
||||||
prompt: "WSS key"
|
prompt: "WSS key"
|
||||||
password: false
|
password: false
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavioral (non-secret) settings live in `config.yaml` under
|
Behavioral (non-secret) settings live in `config.yaml` under
|
||||||
`gateway.platforms.android.extra` (host, port, home_channel, outbox retention,
|
`gateway.platforms.iris.extra` (host, port, home_channel, outbox retention,
|
||||||
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
|
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
|
||||||
only.)
|
only.)
|
||||||
|
|
||||||
@@ -87,21 +87,21 @@ only.)
|
|||||||
```python
|
```python
|
||||||
def register(ctx):
|
def register(ctx):
|
||||||
ctx.register_platform(
|
ctx.register_platform(
|
||||||
name="android",
|
name="iris",
|
||||||
label="Android",
|
label="Iris",
|
||||||
adapter_factory=lambda cfg: AndroidAdapter(cfg),
|
adapter_factory=lambda cfg: IrisAdapter(cfg),
|
||||||
check_fn=check_requirements, # passive: websockets importable + token set
|
check_fn=check_requirements, # passive: websockets importable + token set
|
||||||
validate_config=validate_config, # host/port/token present
|
validate_config=validate_config, # host/port/token present
|
||||||
is_connected=is_connected,
|
is_connected=is_connected,
|
||||||
required_env=["ANDROID_TOKEN"],
|
required_env=["IRIS_TOKEN"],
|
||||||
install_hint="No extra packages needed (websockets + httpx are core deps)",
|
install_hint="No extra packages needed (websockets + httpx are core deps)",
|
||||||
setup_fn=interactive_setup, # hermes gateway setup flow
|
setup_fn=interactive_setup, # hermes gateway setup flow
|
||||||
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
|
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
|
||||||
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
|
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
|
||||||
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
|
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
|
||||||
parse_target_ref_fn=_parse_target_ref, # "android:<chat>[:<thread>]"
|
parse_target_ref_fn=_parse_target_ref, # "iris:<chat>[:<thread>]"
|
||||||
allowed_users_env="ANDROID_ALLOWED_USERS",
|
allowed_users_env="IRIS_ALLOWED_USERS",
|
||||||
allow_all_env="ANDROID_ALLOW_ALL_USERS",
|
allow_all_env="IRIS_ALLOW_ALL_USERS",
|
||||||
max_message_length=0, # 0 = no limit (WS has none)
|
max_message_length=0, # 0 = no limit (WS has none)
|
||||||
emoji="📱",
|
emoji="📱",
|
||||||
pii_safe=False,
|
pii_safe=False,
|
||||||
@@ -123,26 +123,29 @@ Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
|
|||||||
`ensure_deps_fn`.
|
`ensure_deps_fn`.
|
||||||
|
|
||||||
- **`check_requirements()`** — passive probe: `import websockets` succeeds and
|
- **`check_requirements()`** — passive probe: `import websockets` succeeds and
|
||||||
`ANDROID_TOKEN` is set. Never installs.
|
`IRIS_TOKEN` is set. Never installs.
|
||||||
- **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra`
|
- **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra`
|
||||||
(host/port/home_channel/push_backend) + a `home_channel` key
|
(host/port/home_channel/push_backend) + a `home_channel` key
|
||||||
`{"chat_id": "android:default", "name": "Default"}` so `hermes gateway status`
|
`{"chat_id": "default", "name": "Default"}` so `hermes gateway status`
|
||||||
and cron home-channel resolution work without instantiating the adapter.
|
and cron home-channel resolution work without instantiating the adapter.
|
||||||
- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return
|
- **`_parse_target_ref(ref)`** — the core strips the platform prefix first, so
|
||||||
`(chat_id, thread_id)` parsed from `android:<chat>[:<thread>]`; else `None`.
|
`ref` is the direct chat id (e.g. `chan_7`, `default`) with an optional
|
||||||
|
`:t_<n>` thread suffix; friendly names resolve via the channel directory.
|
||||||
|
Returns `(chat_id, thread_id)` or `None`.
|
||||||
- **`interactive_setup()`** — prompts for token (or generates one), host/port,
|
- **`interactive_setup()`** — prompts for token (or generates one), host/port,
|
||||||
push backend + credentials, prints a QR code (pairing) and the app URL.
|
push backend + credentials, prints a QR code (pairing) and the app URL.
|
||||||
|
|
||||||
## 3.3 `AndroidAdapter(BasePlatformAdapter)`
|
## 3.3 `IrisAdapter(BasePlatformAdapter)`
|
||||||
|
|
||||||
Constructor: `super().__init__(config=config, platform=Platform("android"))`.
|
Constructor: `super().__init__(config=config, platform=Platform("iris"))`.
|
||||||
Reads `config.extra` (env overrides win). Initializes: WS server (not started
|
Reads `config.extra` (env overrides win). Initializes: WS server (not started
|
||||||
until `connect()`), connection registry, outbox (SQLite under
|
until `connect()`), connection registry, outbox (SQLite under
|
||||||
`get_hermes_home()/"android"`), push backend, pairing store, channel directory.
|
`get_hermes_home()/"iris"`), push backend, pairing store, channel directory.
|
||||||
|
|
||||||
### Lifecycle
|
### Lifecycle
|
||||||
|
|
||||||
- **`connect(*, is_reconnect=False) -> bool`**
|
- **`connect(*, is_reconnect=False) -> bool`**
|
||||||
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("android", key)`)
|
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("iris", key)`)
|
||||||
so two profiles can't bind the same port/identity.
|
so two profiles can't bind the same port/identity.
|
||||||
- Start the `websockets` server on `host:port` (TLS if cert/key set).
|
- Start the `websockets` server on `host:port` (TLS if cert/key set).
|
||||||
- `_mark_connected()`; return True.
|
- `_mark_connected()`; return True.
|
||||||
@@ -150,6 +153,7 @@ until `connect()`), connection registry, outbox (SQLite under
|
|||||||
- Stop server, close all device sockets, release lock, `_mark_disconnected()`.
|
- Stop server, close all device sockets, release lock, `_mark_disconnected()`.
|
||||||
|
|
||||||
### Inbound (app → agent)
|
### Inbound (app → agent)
|
||||||
|
|
||||||
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
|
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
|
||||||
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
|
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
|
||||||
thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…,
|
thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…,
|
||||||
@@ -171,6 +175,7 @@ until `connect()`), connection registry, outbox (SQLite under
|
|||||||
- `sync {cursor}` → `outbox.py` → replay frames since cursor.
|
- `sync {cursor}` → `outbox.py` → replay frames since cursor.
|
||||||
|
|
||||||
### Outbound (agent → app)
|
### Outbound (agent → app)
|
||||||
|
|
||||||
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
|
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
|
||||||
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
|
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
|
||||||
- If **any** device is connected: broadcast `message` frame to all.
|
- If **any** device is connected: broadcast `message` frame to all.
|
||||||
@@ -195,7 +200,9 @@ until `connect()`), connection registry, outbox (SQLite under
|
|||||||
channel directory, return it (used by cron "continuable" threads).
|
channel directory, return it (used by cron "continuable" threads).
|
||||||
|
|
||||||
### Streaming hooks
|
### Streaming hooks
|
||||||
|
|
||||||
The main gateway drives delivery through the **legacy callback path**:
|
The main gateway drives delivery through the **legacy callback path**:
|
||||||
|
|
||||||
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
|
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
|
||||||
`edit_message()` (updates) → `message.start` / `message.update`.
|
`edit_message()` (updates) → `message.start` / `message.update`.
|
||||||
- `tool_progress_callback` → progress queue → `send_progress_messages` →
|
- `tool_progress_callback` → progress queue → `send_progress_messages` →
|
||||||
@@ -230,7 +237,7 @@ verified empirically in M2 (see `13-testing.md`).
|
|||||||
`message.update` (coalesce to latest) under pressure, never drop
|
`message.update` (coalesce to latest) under pressure, never drop
|
||||||
`message`/`tool.end`/`notification`.
|
`message`/`tool.end`/`notification`.
|
||||||
|
|
||||||
## 3.5 State & storage (all under `get_hermes_home()/"android"`)
|
## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
|
||||||
|
|
||||||
> Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe).
|
> Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe).
|
||||||
> Never hardcode `~/.hermes`.
|
> Never hardcode `~/.hermes`.
|
||||||
@@ -245,9 +252,9 @@ verified empirically in M2 (see `13-testing.md`).
|
|||||||
|
|
||||||
## 3.6 Config resolution
|
## 3.6 Config resolution
|
||||||
|
|
||||||
- **Secrets (`.env`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`,
|
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
|
||||||
`ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
|
`IRIS_FCM_SERVER_KEY`, `IRIS_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
|
||||||
- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`,
|
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
|
||||||
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
||||||
`max_upload_bytes`, `tls`.
|
`max_upload_bytes`, `tls`.
|
||||||
- Env vars override `config.yaml` (hermes convention). Read secrets with the
|
- Env vars override `config.yaml` (hermes convention). Read secrets with the
|
||||||
|
|||||||
+20
-20
@@ -12,7 +12,7 @@ Every frame:
|
|||||||
"v": 1,
|
"v": 1,
|
||||||
"id": 42, // optional; present on requests + their responses
|
"id": 42, // optional; present on requests + their responses
|
||||||
"type": "message", // frame type (below)
|
"type": "message", // frame type (below)
|
||||||
"chat_id": "android:default", // optional; scope for chat-scoped frames
|
"chat_id": "default", // optional; scope for chat-scoped frames
|
||||||
"thread_id": "t_123", // optional
|
"thread_id": "t_123", // optional
|
||||||
"payload": { } // type-specific object
|
"payload": { } // type-specific object
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ Pairing succeeded.
|
|||||||
"search":true,"push":"fcm","pickers":true},
|
"search":true,"push":"fcm","pickers":true},
|
||||||
"sync_cursor":1042,
|
"sync_cursor":1042,
|
||||||
"last_pushed_cursor":1040,
|
"last_pushed_cursor":1040,
|
||||||
"channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}]
|
"channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}]
|
||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ woke the device via push (dedupe, `08-push.md` §8.7).
|
|||||||
A final / standalone message.
|
A final / standalone message.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"message","chat_id":"android:default","thread_id":null,
|
{"type":"message","chat_id":"default","thread_id":null,
|
||||||
"payload":{
|
"payload":{
|
||||||
"message_id":"m_9001","role":"assistant",
|
"message_id":"m_9001","role":"assistant",
|
||||||
"text":"Here is the answer…",
|
"text":"Here is the answer…",
|
||||||
@@ -107,7 +107,7 @@ them drop the message(s) from their cache. Also outboxed, so a device that was
|
|||||||
offline learns of the deletion on its next `sync`.
|
offline learns of the deletion on its next `sync`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null,
|
{"type":"message.deleted","id":30,"chat_id":"default","thread_id":null,
|
||||||
"payload":{"message_ids":["m_9001","m_9002"]}}
|
"payload":{"message_ids":["m_9001","m_9002"]}}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ Channel directory updates. **Broadcast to all connected devices** (no explicit
|
|||||||
subscribe; the server pushes to every open WS).
|
subscribe; the server pushes to every open WS).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports",
|
{"type":"channel.created","payload":{"chat_id":"chan_7","name":"Cron Reports",
|
||||||
"kind":"channel","parent_chat_id":null}}
|
"kind":"channel","parent_chat_id":null}}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ title.
|
|||||||
Response to a `history` request. Returns a page of messages for a chat/thread.
|
Response to a `history` request. Returns a page of messages for a chat/thread.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"history","id":20,"chat_id":"android:default","thread_id":null,
|
{"type":"history","id":20,"chat_id":"default","thread_id":null,
|
||||||
"payload":{
|
"payload":{
|
||||||
"messages":[
|
"messages":[
|
||||||
{"message_id":"m_8990","role":"user","text":"Hi","ts":1723990000000},
|
{"message_id":"m_8990","role":"user","text":"Hi","ts":1723990000000},
|
||||||
@@ -244,9 +244,9 @@ Response to a `commands.complete` request. Autocomplete matches for a typed pref
|
|||||||
Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`.
|
Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"agent.busy","chat_id":"android:default","thread_id":null,
|
{"type":"agent.busy","chat_id":"default","thread_id":null,
|
||||||
"payload":{"reason":"processing"}}
|
"payload":{"reason":"processing"}}
|
||||||
{"type":"agent.idle","chat_id":"android:default","thread_id":null,"payload":{}}
|
{"type":"agent.idle","chat_id":"default","thread_id":null,"payload":{}}
|
||||||
```
|
```
|
||||||
|
|
||||||
`reason` ∈ `processing | tool | waiting_input | cron`.
|
`reason` ∈ `processing | tool | waiting_input | cron`.
|
||||||
@@ -256,7 +256,7 @@ Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`
|
|||||||
```json
|
```json
|
||||||
{"type":"search.results","id":7,"payload":{
|
{"type":"search.results","id":7,"payload":{
|
||||||
"query":"deploy","scope":"all","hits":[
|
"query":"deploy","scope":"all","hits":[
|
||||||
{"message_id":"m_123","chat_id":"android:chan_7","thread_id":null,
|
{"message_id":"m_123","chat_id":"chan_7","thread_id":null,
|
||||||
"role":"assistant","snippet":"…deploy the service…","ts":1723900000000}]}}
|
"role":"assistant","snippet":"…deploy the service…","ts":1723900000000}]}}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ The gateway acknowledges that the agent has received and started processing
|
|||||||
the user's message. The app uses it to show ✓✓ on user bubbles.
|
the user's message. The app uses it to show ✓✓ on user bubbles.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"read.receipt","chat_id":"android:default","payload":{"message_id":"m_9001"}}
|
{"type":"read.receipt","chat_id":"default","payload":{"message_id":"m_9001"}}
|
||||||
```
|
```
|
||||||
|
|
||||||
Emitted to the originating connection when a `message.send` is accepted for
|
Emitted to the originating connection when a `message.send` is accepted for
|
||||||
@@ -319,7 +319,7 @@ First frame; auth + caps.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"hello","payload":{
|
{"type":"hello","payload":{
|
||||||
"token":"<ANDROID_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S",
|
"token":"<IRIS_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S",
|
||||||
"caps":{"min_protocol":1,"media":true,"push":"fcm"},
|
"caps":{"min_protocol":1,"media":true,"push":"fcm"},
|
||||||
"fcm_token":"<FCM token>","ntfy_topic":"<topic, if ntfy>"}}
|
"fcm_token":"<FCM token>","ntfy_topic":"<topic, if ntfy>"}}
|
||||||
```
|
```
|
||||||
@@ -329,7 +329,7 @@ First frame; auth + caps.
|
|||||||
Send text (or a `/slash-command`).
|
Send text (or a `/slash-command`).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"message.send","id":10,"chat_id":"android:default","thread_id":null,
|
{"type":"message.send","id":10,"chat_id":"default","thread_id":null,
|
||||||
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"],
|
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"],
|
||||||
"auto_thread":false}}
|
"auto_thread":false}}
|
||||||
```
|
```
|
||||||
@@ -383,8 +383,8 @@ Answer an interactive picker.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}}
|
{"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}}
|
||||||
{"type":"channel.rename","id":15,"chat_id":"android:chan_7","payload":{"name":"Reports"}}
|
{"type":"channel.rename","id":15,"chat_id":"chan_7","payload":{"name":"Reports"}}
|
||||||
{"type":"channel.set_default","id":16,"chat_id":"android:chan_7","payload":{}}
|
{"type":"channel.set_default","id":16,"chat_id":"chan_7","payload":{}}
|
||||||
```
|
```
|
||||||
|
|
||||||
`channel.delete` is a **hard delete**: the channel/thread row is removed from
|
`channel.delete` is a **hard delete**: the channel/thread row is removed from
|
||||||
@@ -396,7 +396,7 @@ removes its threads. The default channel cannot be deleted.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}}
|
{"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}}
|
||||||
{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"android:chan_7","thread_id":null}}
|
{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"chan_7","thread_id":null}}
|
||||||
```
|
```
|
||||||
|
|
||||||
`scope` ∈ `all | chat`.
|
`scope` ∈ `all | chat`.
|
||||||
@@ -408,7 +408,7 @@ broadcasts to other devices (for multi-device ✓✓ sync). The app uses it to
|
|||||||
mark messages as read locally (✓✓ on user bubbles).
|
mark messages as read locally (✓✓ on user bubbles).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"read.receipt","payload":{"chat_id":"android:default","message_id":"m_9001"}}
|
{"type":"read.receipt","payload":{"chat_id":"default","message_id":"m_9001"}}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `history`
|
### `history`
|
||||||
@@ -416,7 +416,7 @@ mark messages as read locally (✓✓ on user bubbles).
|
|||||||
Load a page of messages for a chat/thread (initial open, scroll-up pagination).
|
Load a page of messages for a chat/thread (initial open, scroll-up pagination).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"history","id":20,"chat_id":"android:default","thread_id":null,
|
{"type":"history","id":20,"chat_id":"default","thread_id":null,
|
||||||
"payload":{"before_message_id":"m_8990","limit":50}}
|
"payload":{"before_message_id":"m_8990","limit":50}}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ message already gone (pruned by retention) still yields a `message.deleted`
|
|||||||
broadcast so live caches drop it.
|
broadcast so live caches drop it.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null,
|
{"type":"message.delete","id":30,"chat_id":"default","thread_id":null,
|
||||||
"payload":{"message_ids":["m_9001","m_9002"]}}
|
"payload":{"message_ids":["m_9001","m_9002"]}}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -458,7 +458,7 @@ Autocomplete for a typed `/prefix`.
|
|||||||
Stop the current agent turn (abort generation / tool execution).
|
Stop the current agent turn (abort generation / tool execution).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"agent.stop","id":23,"chat_id":"android:default","thread_id":null,"payload":{}}
|
{"type":"agent.stop","id":23,"chat_id":"default","thread_id":null,"payload":{}}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `agent.steer`
|
### `agent.steer`
|
||||||
@@ -466,7 +466,7 @@ Stop the current agent turn (abort generation / tool execution).
|
|||||||
Inject a steering message mid-turn (redirects the agent without a new turn).
|
Inject a steering message mid-turn (redirects the agent without a new turn).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"agent.steer","id":24,"chat_id":"android:default","thread_id":null,
|
{"type":"agent.steer","id":24,"chat_id":"default","thread_id":null,
|
||||||
"payload":{"text":"Actually, focus on the error case."}}
|
"payload":{"text":"Actually, focus on the error case."}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
|
|||||||
while the user is at the bottom.
|
while the user is at the bottom.
|
||||||
|
|
||||||
**Streaming on/off.** Two levels:
|
**Streaming on/off.** Two levels:
|
||||||
- **Gateway side:** hermes `display.platforms.android.streaming` (default
|
- **Gateway side:** hermes `display.platforms.iris.streaming` (default
|
||||||
follows global). When off, the app just gets one final `message` frame.
|
follows global). When off, the app just gets one final `message` frame.
|
||||||
- **App side (per device):** Settings → "Streaming" toggle (default on). When
|
- **App side (per device):** Settings → "Streaming" toggle (default on). When
|
||||||
off, the app ignores `message.start`/`message.update` frames and
|
off, the app ignores `message.start`/`message.update` frames and
|
||||||
@@ -49,11 +49,11 @@ chosen by `reasoning_style` (`gateway/display_config.py:37`):
|
|||||||
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
|
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
|
||||||
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
|
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
|
||||||
|
|
||||||
**Plugin config.** Set for the `android` platform:
|
**Plugin config.** Set for the `iris` platform:
|
||||||
```yaml
|
```yaml
|
||||||
display:
|
display:
|
||||||
platforms:
|
platforms:
|
||||||
android:
|
iris:
|
||||||
show_reasoning: true
|
show_reasoning: true
|
||||||
reasoning_style: code # we split on the code-fence form
|
reasoning_style: code # we split on the code-fence form
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ gateway identity concepts**.
|
|||||||
|
|
||||||
| App concept | hermes primitive | Example |
|
| App concept | hermes primitive | Example |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Default chat | home channel `chat_id` | `android:default` |
|
| Default chat | home channel `chat_id` | `default` |
|
||||||
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=android:default, thread_id=t_12` |
|
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=default, thread_id=t_12` |
|
||||||
| A user-created channel | a new `chat_id` | `android:chan_7` |
|
| A user-created channel | a new `chat_id` | `chan_7` |
|
||||||
| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=android:chan_7, thread_id=t_31` |
|
| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=chan_7, thread_id=t_31` |
|
||||||
|
|
||||||
- **`chat_id`** = the conversation lane (a channel or the default chat).
|
- **`chat_id`** = the conversation lane (a channel or the default chat).
|
||||||
- **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like).
|
- **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like).
|
||||||
@@ -23,7 +23,7 @@ gateway identity concepts**.
|
|||||||
## 6.2 Default chat
|
## 6.2 Default chat
|
||||||
|
|
||||||
- On first connect, the plugin ensures a **default channel** exists:
|
- On first connect, the plugin ensures a **default channel** exists:
|
||||||
`chat_id = ANDROID_HOME_CHANNEL` (default `android:default`), `kind=default`,
|
`chat_id = ANDROID_HOME_CHANNEL` (default `default`), `kind=default`,
|
||||||
`is_default=true`, name "Default".
|
`is_default=true`, name "Default".
|
||||||
- It is also the **cron home channel** (`cron_deliver_env_var=
|
- It is also the **cron home channel** (`cron_deliver_env_var=
|
||||||
ANDROID_HOME_CHANNEL`), so `deliver=android` (bare) routes here.
|
ANDROID_HOME_CHANNEL`), so `deliver=android` (bare) routes here.
|
||||||
@@ -37,7 +37,7 @@ gateway identity concepts**.
|
|||||||
- **Threads OFF** — flat conversation; all messages use `thread_id=null`.
|
- **Threads OFF** — flat conversation; all messages use `thread_id=null`.
|
||||||
- **Threads ON** — the app groups the conversation into topic-like lanes.
|
- **Threads ON** — the app groups the conversation into topic-like lanes.
|
||||||
Each new "topic" mints a `thread_id` (via `channel.create {kind:thread,
|
Each new "topic" mints a `thread_id` (via `channel.create {kind:thread,
|
||||||
parent_chat_id:android:default}` or an implicit thread). The UI shows a
|
parent_chat_id:default}` or an implicit thread). The UI shows a
|
||||||
topic switcher (like Telegram topics) above the message list.
|
topic switcher (like Telegram topics) above the message list.
|
||||||
- Threads are **app-organized** but **gateway-real**: each `thread_id` is a
|
- Threads are **app-organized** but **gateway-real**: each `thread_id` is a
|
||||||
distinct hermes session lane, so context is isolated per thread and cron can
|
distinct hermes session lane, so context is isolated per thread and cron can
|
||||||
@@ -82,7 +82,7 @@ lane (nothing to title / session-scoped, not conversation starters).
|
|||||||
- **Requirement:** the user creates new channels so **cron job outputs can be
|
- **Requirement:** the user creates new channels so **cron job outputs can be
|
||||||
delegated to them** instead of the default chat.
|
delegated to them** instead of the default chat.
|
||||||
- **`channel.create {name, kind:"channel"}`** → plugin mints
|
- **`channel.create {name, kind:"channel"}`** → plugin mints
|
||||||
`chat_id = android:chan_<n>`, stores in directory, broadcasts
|
`chat_id = chan_<n>`, stores in directory, broadcasts
|
||||||
`channel.created` to all devices. The new channel appears in the channel list.
|
`channel.created` to all devices. The new channel appears in the channel list.
|
||||||
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
|
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
|
||||||
directory (rename broadcasts `channel.renamed`; delete is a **hard delete**
|
directory (rename broadcasts `channel.renamed`; delete is a **hard delete**
|
||||||
@@ -104,9 +104,9 @@ lane (nothing to title / session-scoped, not conversation starters).
|
|||||||
- **Cron targeting** (the key payoff): because the plugin registers
|
- **Cron targeting** (the key payoff): because the plugin registers
|
||||||
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
|
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
|
||||||
`send_message` tool can target any channel/thread:
|
`send_message` tool can target any channel/thread:
|
||||||
- `deliver="android"` → home (default) channel.
|
- `deliver="iris"` → home (default) channel.
|
||||||
- `deliver="android:android:chan_7"` → that channel.
|
- `deliver="iris:chan_7"` → that channel.
|
||||||
- `deliver="android:android:chan_7:t_31"` → that channel's thread.
|
- `deliver="iris:chan_7:t_31"` → that channel's thread.
|
||||||
- In-chat: the agent's `cronjob` tool can be told "deliver to the *Cron
|
- In-chat: the agent's `cronjob` tool can be told "deliver to the *Cron
|
||||||
Reports* channel"; the gateway resolves the name via the channel directory.
|
Reports* channel"; the gateway resolves the name via the channel directory.
|
||||||
- **In-app affordance:** each channel's menu has "Set as cron target" / shows a
|
- **In-app affordance:** each channel's menu has "Set as cron target" / shows a
|
||||||
@@ -118,7 +118,7 @@ lane (nothing to title / session-scoped, not conversation starters).
|
|||||||
- Cron resolves delivery targets in `cron/scheduler.py:2148`
|
- Cron resolves delivery targets in `cron/scheduler.py:2148`
|
||||||
(`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it
|
(`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it
|
||||||
calls `tools.send_message_tool.resolve_send_target`, which uses our
|
calls `tools.send_message_tool.resolve_send_target`, which uses our
|
||||||
`parse_target_ref_fn` to parse `android:<chat>[:<thread>]`.
|
`parse_target_ref_fn` to parse `iris:<chat>[:<thread>]`.
|
||||||
- Delivery then calls the **live adapter's `send(chat_id, text, …)`** (gateway
|
- Delivery then calls the **live adapter's `send(chat_id, text, …)`** (gateway
|
||||||
running) → our WS `message` frame (or outbox+push if the app is offline).
|
running) → our WS `message` frame (or outbox+push if the app is offline).
|
||||||
- Cron deliveries are framed with a `[Cron delivery: <name>]` header by hermes;
|
- Cron deliveries are framed with a `[Cron delivery: <name>]` header by hermes;
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
# 08 — Push Notifications, Outbox & Sync
|
# 08 — Push Notifications, Outbox & Sync
|
||||||
|
|
||||||
The gateway can't reach a sleeping phone directly. Push goes through a cloud
|
The gateway can't reach a sleeping phone directly. Push goes through a cloud
|
||||||
relay. **Decision: FCM primary, ntfy fallback** (`ANDROID_PUSH_BACKEND`).
|
relay. **Decision: FCM primary, ntfy fallback** (`IRIS_PUSH_BACKEND`).
|
||||||
|
|
||||||
## 8.1 When push fires
|
## 8.1 When push fires
|
||||||
|
|
||||||
@@ -23,13 +23,13 @@ class PushBackend(Protocol):
|
|||||||
def configured(self) -> bool: ...
|
def configured(self) -> bool: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`).
|
Selected at adapter init by `IRIS_PUSH_BACKEND` (`fcm` default, `ntfy`).
|
||||||
|
|
||||||
### 8.2.1 `FcmBackend` (primary)
|
### 8.2.1 `FcmBackend` (primary)
|
||||||
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
|
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
|
||||||
account** (`ANDROID_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
|
account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
|
||||||
OAuth2 access token (cached, refreshed before expiry).
|
OAuth2 access token (cached, refreshed before expiry).
|
||||||
- Fallback: legacy **server key** (`ANDROID_FCM_SERVER_KEY`) if no service
|
- Fallback: legacy **server key** (`IRIS_FCM_SERVER_KEY`) if no service
|
||||||
account (simpler, but legacy).
|
account (simpler, but legacy).
|
||||||
- Target = the device's **FCM token** (registered via `hello` /
|
- Target = the device's **FCM token** (registered via `hello` /
|
||||||
`fcm.register`, stored in `devices.db`).
|
`fcm.register`, stored in `devices.db`).
|
||||||
|
|||||||
+19
-18
@@ -13,19 +13,20 @@
|
|||||||
## 9.2 Pairing flow
|
## 9.2 Pairing flow
|
||||||
|
|
||||||
1. **Generate a token.** `hermes gateway setup` (our `interactive_setup`) either
|
1. **Generate a token.** `hermes gateway setup` (our `interactive_setup`) either
|
||||||
uses an existing `ANDROID_TOKEN` or generates a fresh high-entropy token
|
uses an existing `IRIS_TOKEN` or generates a fresh high-entropy token
|
||||||
(e.g. 32 bytes → 64 hex chars) and stores it in `.env`.
|
(e.g. 32 bytes → 64 hex chars) and stores it in `.env`.
|
||||||
2. **Present to the app.** Two options:
|
2. **Present to the app.** Two options:
|
||||||
- **QR code:** the setup prints a QR encoding
|
- **QR code:** the setup prints a QR encoding
|
||||||
`iris://pair?host=<lan-ip>&port=8790&token=<token>` (or a WSS URL). The
|
`iris://pair?host=<lan-ip>&port=8791&secure=0&token=<token>` (or a WSS
|
||||||
phone scans it with the app's camera (or a system scanner) → pre-fills
|
URL when `secure=1`). The phone scans it with the app's **Scan QR**
|
||||||
|
button (or a system scanner → `iris://pair` deep link) → pre-fills
|
||||||
settings.
|
settings.
|
||||||
- **Manual:** user types the server URL + token in the app's Connect screen.
|
- **Manual:** user types the server URL + token in the app's Connect screen.
|
||||||
3. **App connects.** First WS frame is `hello {token, device_id, device_name,
|
3. **App connects.** First WS frame is `hello {token, device_id, device_name,
|
||||||
caps, fcm_token?}`.
|
caps, fcm_token?}`.
|
||||||
4. **Server verifies.** Constant-time compare of `token` vs `ANDROID_TOKEN`
|
4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN`
|
||||||
(`hmac.compare_digest`). Optionally check `device_id` against
|
(`hmac.compare_digest`). Optionally check `device_id` against
|
||||||
`ANDROID_ALLOWED_USERS` (if set) or `ANDROID_ALLOW_ALL_USERS`.
|
`IRIS_ALLOWED_USERS` (if set) or `IRIS_ALLOW_ALL_USERS`.
|
||||||
5. **On success:** register the device in `devices.db`, send `hello.ack`.
|
5. **On success:** register the device in `devices.db`, send `hello.ack`.
|
||||||
**On failure:** send `error {code:"auth"}` and close.
|
**On failure:** send `error {code:"auth"}` and close.
|
||||||
|
|
||||||
@@ -36,22 +37,22 @@ security principal (the token is).
|
|||||||
## 9.3 Auth model
|
## 9.3 Auth model
|
||||||
|
|
||||||
- **Token = the security principal.** Any connection presenting the valid
|
- **Token = the security principal.** Any connection presenting the valid
|
||||||
`ANDROID_TOKEN` is authorized (it's the user's own token).
|
`IRIS_TOKEN` is authorized (it's the user's own token).
|
||||||
- **Allowlist (optional):** `ANDROID_ALLOWED_USERS` (comma-separated
|
- **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
|
||||||
`device_id`s) restricts which *devices* may connect even with the token —
|
`device_id`s) restricts which *devices* may connect even with the token —
|
||||||
useful if the token is shared. `ANDROID_ALLOW_ALL_USERS=true` disables the
|
useful if the token is shared. `IRIS_ALLOW_ALL_USERS=true` disables the
|
||||||
allowlist (dev only).
|
allowlist (dev only).
|
||||||
- **Per-device tokens (stretch):** mint a unique token per device at pairing
|
- **Per-device tokens (stretch):** mint a unique token per device at pairing
|
||||||
(revocable) instead of one shared token. v1 uses the shared token + optional
|
(revocable) instead of one shared token. v1 uses the shared token + optional
|
||||||
device allowlist.
|
device allowlist.
|
||||||
- **Re-pairing:** rotating `ANDROID_TOKEN` invalidates all devices; they must
|
- **Re-pairing:** rotating `IRIS_TOKEN` invalidates all devices; they must
|
||||||
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
|
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
|
||||||
|
|
||||||
## 9.4 Transport security
|
## 9.4 Transport security
|
||||||
|
|
||||||
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
|
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
|
||||||
network.
|
network.
|
||||||
- **WSS (recommended for remote):** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY`
|
- **WSS (recommended for remote):** set `IRIS_WS_CERT` / `IRIS_WS_KEY`
|
||||||
(self-signed or CA-signed). The app pins/accepts the cert (self-signed → user
|
(self-signed or CA-signed). The app pins/accepts the cert (self-signed → user
|
||||||
confirms fingerprint on first pair, like a SSH host key).
|
confirms fingerprint on first pair, like a SSH host key).
|
||||||
- **Remote reachability options** (documented, user's choice):
|
- **Remote reachability options** (documented, user's choice):
|
||||||
@@ -61,11 +62,11 @@ security principal (the token is).
|
|||||||
at the edge, forward WS to `127.0.0.1:8790`.
|
at the edge, forward WS to `127.0.0.1:8790`.
|
||||||
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
|
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
|
||||||
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
|
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
|
||||||
over plain HTTP (`ANDROID_HTTP_PORT`, default 8791) for the app's
|
over plain HTTP (`IRIS_HTTP_PORT`, default 8791) for the app's
|
||||||
fallback transport. It is a *second door with the same lock*: the same
|
fallback transport. It is a *second door with the same lock*: the same
|
||||||
Bearer token (constant-time `verify_token`) + the same device allowlist
|
Bearer token (constant-time `verify_token`) + the same device allowlist
|
||||||
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as
|
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as
|
||||||
the WS. Optional TLS via `ANDROID_HTTP_CERT` / `ANDROID_HTTP_KEY`.
|
the WS. Optional TLS via `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`.
|
||||||
`GET /v1/health` is unauthenticated by design (liveness only — it must
|
`GET /v1/health` is unauthenticated by design (liveness only — it must
|
||||||
not reflect tokens, device ids, or versions).
|
not reflect tokens, device ids, or versions).
|
||||||
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
|
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
|
||||||
@@ -73,7 +74,7 @@ security principal (the token is).
|
|||||||
|
|
||||||
## 9.5 Secret & PII handling
|
## 9.5 Secret & PII handling
|
||||||
|
|
||||||
- **Tokens/keys never logged.** Redact `ANDROID_TOKEN`, FCM tokens/keys, ntfy
|
- **Tokens/keys never logged.** Redact `IRIS_TOKEN`, FCM tokens/keys, ntfy
|
||||||
tokens in all log output (hermes PII policy; `agent/redact.py` patterns).
|
tokens in all log output (hermes PII policy; `agent/redact.py` patterns).
|
||||||
- **`device_id`** is a random UUID (not PII). `device_name` is user-chosen.
|
- **`device_id`** is a random UUID (not PII). `device_name` is user-chosen.
|
||||||
- **Media pull** is gated by hermes `validate_media_delivery_path` + delivery
|
- **Media pull** is gated by hermes `validate_media_delivery_path` + delivery
|
||||||
@@ -84,7 +85,7 @@ security principal (the token is).
|
|||||||
|
|
||||||
## 9.6 Profile safety
|
## 9.6 Profile safety
|
||||||
|
|
||||||
- All plugin state lives under `get_hermes_home()/"android"` (profile-aware).
|
- All plugin state lives under `get_hermes_home()/"iris"` (profile-aware).
|
||||||
- Secrets are read with the scope-aware `_get_scoped_secret` pattern (see
|
- Secrets are read with the scope-aware `_get_scoped_secret` pattern (see
|
||||||
`plugins/platforms/irc/adapter.py:42`) so multiplexed profiles don't leak
|
`plugins/platforms/irc/adapter.py:42`) so multiplexed profiles don't leak
|
||||||
each other's tokens (fail-closed under `gateway.multiplex_profiles`).
|
each other's tokens (fail-closed under `gateway.multiplex_profiles`).
|
||||||
@@ -110,16 +111,16 @@ M7 research pass. "verified" = implemented and covered by
|
|||||||
"gap" = known limitation with the planned mitigation.
|
"gap" = known limitation with the planned mitigation.
|
||||||
|
|
||||||
| # | Item | Status | Evidence / mitigation |
|
| # | Item | Status | Evidence / mitigation |
|
||||||
|---|------|--------|-----------------------|
|
| --- | ------ | -------- | ----------------------- |
|
||||||
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
|
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
|
||||||
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`ws_server.py:47`, `broadcast`/`send_to`). Inbound: per-connection token bucket on JSON frames (20/s, burst 40) → `error {code:"rate_limited"}` + close on exceed (`ws_server.py:55`, `_TokenBucket`, `_on_frame`); binary upload chunks exempt (see gap 1) |
|
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`ws_server.py:47`, `broadcast`/`send_to`). Inbound: per-connection token bucket on JSON frames (20/s, burst 40) → `error {code:"rate_limited"}` + close on exceed (`ws_server.py:55`, `_TokenBucket`, `_on_frame`); binary upload chunks exempt (see gap 1) |
|
||||||
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | `serve(max_size=adapter.max_upload_bytes)` (`ws_server.py:139`); per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
|
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | `serve(max_size=adapter.max_upload_bytes)` (`ws_server.py:139`); per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
|
||||||
| 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` |
|
| 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` |
|
||||||
| 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned |
|
| 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned |
|
||||||
| 6 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`ANDROID_WS_CERT`/`ANDROID_WS_KEY`, `ws_server.py:122`); the app builds a default `OkHttpClient` with no `CertificatePinner` (`app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt:87`). Mitigation: remote access requires CA-signed WSS until pinning lands; LAN `ws://` stays the default |
|
| 6 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`IRIS_WS_CERT`/`IRIS_WS_KEY`, `ws_server.py:122`); the app builds a default `OkHttpClient` with no `CertificatePinner` (`app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt:87`). Mitigation: remote access requires CA-signed WSS until pinning lands; LAN `ws://` stays the default |
|
||||||
| 7 | Outbox retention cap + prune | verified | `gateway-plugin/outbox.py:48` (`retention_hours` default 72h, `max_rows` cap, `take_overflow_pruned`); `test_outbox_row_cap_prunes_oldest` |
|
| 7 | Outbox retention cap + prune | verified | `gateway-plugin/outbox.py:48` (`retention_hours` default 72h, `max_rows` cap, `take_overflow_pruned`); `test_outbox_row_cap_prunes_oldest` |
|
||||||
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `ANDROID_TOKEN`/`ANDROID_WS_CERT`/`ANDROID_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
|
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `IRIS_TOKEN`/`IRIS_WS_CERT`/`IRIS_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
|
||||||
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: token bucket in `ws_server.py` (JSON frames only). Binary upload chunks are exempt — a 100 MB upload is 400 × 256 KiB frames in a tight loop and would exhaust any sane bucket; uploads are already bounded by per-frame `max_size` + the per-upload total cap |
|
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: token bucket in `ws_server.py` (JSON frames only). Binary upload chunks are exempt — a 100 MB upload is 400 × 256 KiB frames in a tight loop and would exhaust any sane bucket; uploads are already bounded by per-frame `max_size` + the per-upload total cap |
|
||||||
| 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) |
|
| 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) |
|
||||||
| 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` |
|
| 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` |
|
||||||
| 12 | Gap: in-app QR scanner | gap | Pairing is manual URL+token only; the server prints a QR (`gateway-plugin/adapter.py:648-654`) that any system scanner can read. Plan: in-app camera scan later |
|
| 12 | Gap: in-app QR scanner | implemented | `hermes gateway setup` renders a terminal QR (`gateway-plugin/qr.py`, pure-stdlib encoder) and the app's Connect screen has a **Scan QR** button (CameraX + ML Kit, `QrScanActivity`) plus an `iris://pair` deep link (`PairLink.parse`); `docs/20` |
|
||||||
@@ -115,7 +115,7 @@ app/shared/src/
|
|||||||
|
|
||||||
- `ToolCard` renders `tool.start/progress/end` frames.
|
- `ToolCard` renders `tool.start/progress/end` frames.
|
||||||
- The gateway always supplies the **full** tool data: it forces
|
- The gateway always supplies the **full** tool data: it forces
|
||||||
`display.platforms.android.tool_progress: verbose` (so the progress line
|
`display.platforms.iris.tool_progress: verbose` (so the progress line
|
||||||
carries the full args JSON → `tool.start.args`) and captures each completed
|
carries the full args JSON → `tool.start.args`) and captures each completed
|
||||||
call via the `post_tool_call` hook (→ `tool.end` `output_preview` /
|
call via the `post_tool_call` hook (→ `tool.end` `output_preview` /
|
||||||
`duration` / `ok`). The app decides how much to show.
|
`duration` / `ok`). The app decides how much to show.
|
||||||
@@ -317,5 +317,13 @@ Storage: `AndroidSqliteDriver` (app database dir) on Android,
|
|||||||
- First launch → **Connect**: server URL + token (or scan QR). "Test connection"
|
- First launch → **Connect**: server URL + token (or scan QR). "Test connection"
|
||||||
does a real `hello` (not just a TCP probe — per hermes desktop guidance, the
|
does a real `hello` (not just a TCP probe — per hermes desktop guidance, the
|
||||||
auth leg must be exercised). On success → save (secure storage) → main.
|
auth leg must be exercised). On success → save (secure storage) → main.
|
||||||
|
- **Scan QR** (Android only, `docs/20`): a button below the token field opens
|
||||||
|
`QrScanActivity` (CameraX + ML Kit, on-device, no Play services), requests the
|
||||||
|
`CAMERA` permission, and pre-fills URL + token from the decoded
|
||||||
|
`iris://pair…` payload via `PairLink.parse`. It never auto-connects — the
|
||||||
|
user still taps "Test & Connect". A non-pairing QR sets an error and leaves
|
||||||
|
the fields untouched. The same payload also arrives as an `iris://pair` deep
|
||||||
|
link (system scanner / other phones) and pre-fills the screen the same way.
|
||||||
|
Hidden on desktop (no camera).
|
||||||
- States: connecting / connected / reconnecting / degraded / auth-failed — each
|
- States: connecting / connected / reconnecting / degraded / auth-failed — each
|
||||||
with honest copy and a way out.
|
with honest copy and a way out.
|
||||||
+14
-14
@@ -57,8 +57,8 @@ hermes --version # sanity
|
|||||||
```bash
|
```bash
|
||||||
# install the plugin (dev: symlink)
|
# install the plugin (dev: symlink)
|
||||||
mkdir -p ~/.hermes/plugins
|
mkdir -p ~/.hermes/plugins
|
||||||
ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/android
|
ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/iris
|
||||||
hermes gateway status # should list "android"
|
hermes gateway status # should list "iris"
|
||||||
hermes gateway # run
|
hermes gateway # run
|
||||||
```
|
```
|
||||||
- Tests use hermes's hermetic runner (never bare `pytest`):
|
- Tests use hermes's hermetic runner (never bare `pytest`):
|
||||||
@@ -73,43 +73,43 @@ hermes --version # sanity
|
|||||||
`dev.iris.app`). Download `google-services.json` → `app/androidApp/`.
|
`dev.iris.app`). Download `google-services.json` → `app/androidApp/`.
|
||||||
3. Create a **service account** (Project settings → Service accounts → Generate
|
3. Create a **service account** (Project settings → Service accounts → Generate
|
||||||
new private key) → download the JSON. Store its path in
|
new private key) → download the JSON. Store its path in
|
||||||
`ANDROID_FCM_SERVICE_ACCOUNT` (in `~/.hermes/.env`).
|
`IRIS_FCM_SERVICE_ACCOUNT` (in `~/.hermes/.env`).
|
||||||
4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and
|
4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and
|
||||||
registers it via `hello` / `fcm.register`.
|
registers it via `hello` / `fcm.register`.
|
||||||
|
|
||||||
> Skip Firebase → set `ANDROID_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` /
|
> Skip Firebase → set `IRIS_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` /
|
||||||
> `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`.
|
> `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`.
|
||||||
|
|
||||||
## 12.6 Environment variables (summary)
|
## 12.6 Environment variables (summary)
|
||||||
|
|
||||||
**Secrets (`~/.hermes/.env`):**
|
**Secrets (`~/.hermes/.env`):**
|
||||||
```
|
```
|
||||||
ANDROID_TOKEN=<64-hex>
|
IRIS_TOKEN=<64-hex>
|
||||||
ANDROID_PUSH_BACKEND=fcm # or ntfy
|
IRIS_PUSH_BACKEND=fcm # or ntfy
|
||||||
ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
|
IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
|
||||||
# ANDROID_FCM_SERVER_KEY=<legacy key> # fallback if no service account
|
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
|
||||||
# NTFY_TOPIC=iris-push # when ntfy
|
# NTFY_TOPIC=iris-push # when ntfy
|
||||||
# NTFY_SERVER_URL=https://ntfy.sh
|
# NTFY_SERVER_URL=https://ntfy.sh
|
||||||
# ANDROID_WS_CERT=/path/cert.pem # WSS
|
# IRIS_WS_CERT=/path/cert.pem # WSS
|
||||||
# ANDROID_WS_KEY=/path/key.pem
|
# IRIS_WS_KEY=/path/key.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
**Behavioral (`~/.hermes/config.yaml`):**
|
**Behavioral (`~/.hermes/config.yaml`):**
|
||||||
```yaml
|
```yaml
|
||||||
gateway:
|
gateway:
|
||||||
platforms:
|
platforms:
|
||||||
android:
|
iris:
|
||||||
enabled: true
|
enabled: true
|
||||||
extra:
|
extra:
|
||||||
host: 127.0.0.1 # 0.0.0.0 for LAN
|
host: 127.0.0.1 # 0.0.0.0 for LAN
|
||||||
port: 8790
|
port: 8790
|
||||||
home_channel: android:default
|
home_channel: default
|
||||||
push_backend: fcm
|
push_backend: fcm
|
||||||
outbox_retention_hours: 72
|
outbox_retention_hours: 72
|
||||||
max_upload_bytes: 104857600 # 100 MB
|
max_upload_bytes: 104857600 # 100 MB
|
||||||
display:
|
display:
|
||||||
platforms:
|
platforms:
|
||||||
android:
|
iris:
|
||||||
show_reasoning: true
|
show_reasoning: true
|
||||||
reasoning_style: code
|
reasoning_style: code
|
||||||
streaming: true
|
streaming: true
|
||||||
@@ -128,7 +128,7 @@ import asyncio, json, websockets
|
|||||||
async def main():
|
async def main():
|
||||||
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
||||||
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
||||||
"token":"<ANDROID_TOKEN>","device_id":"test","device_name":"probe",
|
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
|
||||||
"caps":{"min_protocol":1}}}))
|
"caps":{"min_protocol":1}}}))
|
||||||
print("recv:", await ws.recv())
|
print("recv:", await ws.recv())
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
+5
-5
@@ -19,7 +19,7 @@ without the app (critical for verifying frame shapes early).
|
|||||||
- `register(ctx)` produces a valid `PlatformEntry` (name, cron env var,
|
- `register(ctx)` produces a valid `PlatformEntry` (name, cron env var,
|
||||||
parse_target_ref).
|
parse_target_ref).
|
||||||
- `check_requirements` / `validate_config` / `is_connected` truth table.
|
- `check_requirements` / `validate_config` / `is_connected` truth table.
|
||||||
- `_parse_target_ref`: `android:<chat>`, `android:<chat>:<thread>`, non-android
|
- `_parse_target_ref`: `iris:<chat>`, `iris:<chat>:<thread>`, non-android
|
||||||
→ None.
|
→ None.
|
||||||
- **Reasoning split:** given a `show_reasoning`-style final text, `send()`
|
- **Reasoning split:** given a `show_reasoning`-style final text, `send()`
|
||||||
emits `message {reasoning, text}` correctly; no-prefix → no reasoning field.
|
emits `message {reasoning, text}` correctly; no-prefix → no reasoning field.
|
||||||
@@ -54,7 +54,7 @@ Kotlin client.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
hermes gateway & # with the android plugin
|
hermes gateway & # with the android plugin
|
||||||
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
|
python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
|
||||||
--send "list the files and summarize"
|
--send "list the files and summarize"
|
||||||
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
|
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
|
||||||
# commentary, message.stop {reasoning,…}, …
|
# commentary, message.stop {reasoning,…}, …
|
||||||
@@ -112,7 +112,7 @@ adb logcat -d > /tmp/logcat.txt
|
|||||||
verbosity in Settings → rendering changes.
|
verbosity in Settings → rendering changes.
|
||||||
5. **Intermediate:** a multi-step prompt → commentary bubble appears dimmed.
|
5. **Intermediate:** a multi-step prompt → commentary bubble appears dimmed.
|
||||||
6. **Channels:** create "Cron Reports" → appears in list; set as cron target.
|
6. **Channels:** create "Cron Reports" → appears in list; set as cron target.
|
||||||
7. **Cron delivery:** create a cron job `deliver=android:android:chan_<n>` → it
|
7. **Cron delivery:** create a cron job `deliver=iris:chan_<n>` → it
|
||||||
fires → lands in that channel (not default).
|
fires → lands in that channel (not default).
|
||||||
8. **Search:** "search everywhere" vs "this chat" → correct scoping; tap → jump.
|
8. **Search:** "search everywhere" vs "this chat" → correct scoping; tap → jump.
|
||||||
9. **Media (in):** attach a photo + a video → agent receives (vision) → reply.
|
9. **Media (in):** attach a photo + a video → agent receives (vision) → reply.
|
||||||
@@ -144,11 +144,11 @@ adb logcat -d > /tmp/logcat.txt
|
|||||||
## 13.5 Debugging tips
|
## 13.5 Debugging tips
|
||||||
|
|
||||||
- **Gateway side:** `~/.hermes/logs/gateway.log` (and `hermes logs --follow`).
|
- **Gateway side:** `~/.hermes/logs/gateway.log` (and `hermes logs --follow`).
|
||||||
Our plugin logs under the `android` adapter name; secrets redacted.
|
Our plugin logs under the `iris` adapter name; secrets redacted.
|
||||||
- **WS framing bugs:** use the `ws_probe.py` harness — it isolates the protocol
|
- **WS framing bugs:** use the `ws_probe.py` harness — it isolates the protocol
|
||||||
from the app.
|
from the app.
|
||||||
- **Streaming jitter:** the consumer edits at intervals; if updates look chunky,
|
- **Streaming jitter:** the consumer edits at intervals; if updates look chunky,
|
||||||
check `display.platforms.android.streaming` and the consumer's edit interval.
|
check `display.platforms.iris.streaming` and the consumer's edit interval.
|
||||||
- **Media pull stalls:** check chunk size + backpressure; confirm the file is
|
- **Media pull stalls:** check chunk size + backpressure; confirm the file is
|
||||||
within hermes delivery roots (`validate_media_delivery_path`).
|
within hermes delivery roots (`validate_media_delivery_path`).
|
||||||
- **FCM not arriving:** confirm the token registered (`devices.db`), the service
|
- **FCM not arriving:** confirm the token registered (`devices.db`), the service
|
||||||
|
|||||||
+56
-9
@@ -1,4 +1,4 @@
|
|||||||
# 14 — Milestones (M0–M7)
|
# 14 — Milestones (M0–M8)
|
||||||
|
|
||||||
Phased delivery. Each milestone ends with a **demo** (on-device where noted) and
|
Phased delivery. Each milestone ends with a **demo** (on-device where noted) and
|
||||||
has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
||||||
@@ -6,7 +6,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
---
|
---
|
||||||
|
|
||||||
## M0 — Toolchain & scaffolding
|
## M0 — Toolchain & scaffolding
|
||||||
|
|
||||||
**Goal:** everything builds; the plugin is discoverable; the repo is safe.
|
**Goal:** everything builds; the plugin is discoverable; the repo is safe.
|
||||||
|
|
||||||
- [X] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`).
|
- [X] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`).
|
||||||
- [X] `cd hermes-agent && uv sync` (hermes venv works).
|
- [X] `cd hermes-agent && uv sync` (hermes venv works).
|
||||||
- [X] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/`
|
- [X] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/`
|
||||||
@@ -16,20 +18,22 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
- [X] CMP project builds empty: `./gradlew :androidApp:assembleDebug`,
|
- [X] CMP project builds empty: `./gradlew :androidApp:assembleDebug`,
|
||||||
`./gradlew :desktopApp:run` (blank window).
|
`./gradlew :desktopApp:run` (blank window).
|
||||||
- [X] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a
|
- [X] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a
|
||||||
no-op `AndroidAdapter` → `hermes gateway status` lists **android**.
|
no-op `IrisAdapter` → `hermes gateway status` lists **iris**.
|
||||||
- **Demo:** `hermes gateway status` shows `android`; `./gradlew
|
- **Demo:** `hermes gateway status` shows `iris`; `./gradlew
|
||||||
:androidApp:installDebug` installs a blank app on the MIX 2S.
|
:androidApp:installDebug` installs a blank app on the MIX 2S.
|
||||||
- **Accept:** blank app installs + launches on-device; plugin visible in
|
- **Accept:** blank app installs + launches on-device; plugin visible in
|
||||||
`hermes gateway status`; `hermes-agent/` is git-ignored (verify with
|
`hermes gateway status`; `hermes-agent/` is git-ignored (verify with
|
||||||
`git status --ignored`).
|
`git status --ignored`).
|
||||||
|
|
||||||
## M1 — Gateway core loop (text round-trip)
|
## M1 — Gateway core loop (text round-trip)
|
||||||
|
|
||||||
**Goal:** pair + send a text message + get a (non-streaming) reply.
|
**Goal:** pair + send a text message + get a (non-streaming) reply.
|
||||||
|
|
||||||
- [X] WS server (`ws_server.py`): bind, `hello` auth (constant-time),
|
- [X] WS server (`ws_server.py`): bind, `hello` auth (constant-time),
|
||||||
`hello.ack`, heartbeat, connection registry.
|
`hello.ack`, heartbeat, connection registry.
|
||||||
- [X] `AndroidAdapter.send()` → `message` frame; inbound `message.send` →
|
- [X] `IrisAdapter.send()` → `message` frame; inbound `message.send` →
|
||||||
`MessageEvent` → `handle_message`.
|
`MessageEvent` → `handle_message`.
|
||||||
- [X] Pairing store + `ANDROID_TOKEN`; QR payload in `interactive_setup`.
|
- [X] Pairing store + `IRIS_TOKEN`; QR payload in `interactive_setup`.
|
||||||
- [X] App: Connect screen (URL+token, real `hello` test), `GatewayClient`
|
- [X] App: Connect screen (URL+token, real `hello` test), `GatewayClient`
|
||||||
(connect + reconnect), ChatScreen sends + renders `message`.
|
(connect + reconnect), ChatScreen sends + renders `message`.
|
||||||
- [X] `ws_probe.py` harness drives a real turn.
|
- [X] `ws_probe.py` harness drives a real turn.
|
||||||
@@ -38,9 +42,11 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
reconnect after gateway restart re-pairs.
|
reconnect after gateway restart re-pairs.
|
||||||
|
|
||||||
## M2 — Streaming + reasoning + tools + commentary
|
## M2 — Streaming + reasoning + tools + commentary
|
||||||
|
|
||||||
**Goal:** the "agent transparency" features.
|
**Goal:** the "agent transparency" features.
|
||||||
|
|
||||||
- [X] Map consumer `send`/`edit_message` → `message.start/update/stop`.
|
- [X] Map consumer `send`/`edit_message` → `message.start/update/stop`.
|
||||||
- [X] Reasoning: set `show_reasoning` for android; adapter splits prefix →
|
- [X] Reasoning: set `show_reasoning` for iris; adapter splits prefix →
|
||||||
`reasoning` field. **Verify format with `ws_probe.py`.** (The model
|
`reasoning` field. **Verify format with `ws_probe.py`.** (The model
|
||||||
returns a separate `reasoning_content` field. In the *streaming* case the
|
returns a separate `reasoning_content` field. In the *streaming* case the
|
||||||
gateway drops it — the stream consumer only forwards `content` and the
|
gateway drops it — the stream consumer only forwards `content` and the
|
||||||
@@ -64,12 +70,14 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
rendering; reasoning copy button works; frame shapes match `04-wire-protocol`.
|
rendering; reasoning copy button works; frame shapes match `04-wire-protocol`.
|
||||||
|
|
||||||
## M3 — Channels/threads + cron + search
|
## M3 — Channels/threads + cron + search
|
||||||
|
|
||||||
**Goal:** organization + cron delegation + search.
|
**Goal:** organization + cron delegation + search.
|
||||||
|
|
||||||
- [X] Channel directory (SQLite): default channel ensured; `channel.create/
|
- [X] Channel directory (SQLite): default channel ensured; `channel.create/
|
||||||
rename/set_default/delete` + `channel.*` frames.
|
rename/set_default/delete` + `channel.*` frames.
|
||||||
- [X] Threads: toggle in default chat; `thread_id` lanes; `create_handoff_thread`.
|
- [X] Threads: toggle in default chat; `thread_id` lanes; `create_handoff_thread`.
|
||||||
- [X] `parse_target_ref_fn` + `cron_deliver_env_var` → cron
|
- [X] `parse_target_ref_fn` + `cron_deliver_env_var` → cron
|
||||||
`deliver=android:<chat>[:<thread>]` works.
|
`deliver=iris:<chat>[:<thread>]` works.
|
||||||
- [X] `search.py` FTS5 bridge; `search` frame (all / this-chat) → results.
|
- [X] `search.py` FTS5 bridge; `search` frame (all / this-chat) → results.
|
||||||
- [X] App: channel list (drawer/rail), thread toggle + topic switcher, "new
|
- [X] App: channel list (drawer/rail), thread toggle + topic switcher, "new
|
||||||
channel" + "set as cron target", SearchScreen with scope toggle + jump.
|
channel" + "set as cron target", SearchScreen with scope toggle + jump.
|
||||||
@@ -79,7 +87,7 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
isolate context; search scoping correct; channel list reconciles on events.
|
isolate context; search scoping correct; channel list reconciles on events.
|
||||||
- **Status (complete):** channel directory + threads + search + outbox sync
|
- **Status (complete):** channel directory + threads + search + outbox sync
|
||||||
verified end-to-end via `ws_probe.py` (create/rename/set_default/delete,
|
verified end-to-end via `ws_probe.py` (create/rename/set_default/delete,
|
||||||
thread lanes, FTS5 search, sync); cron `deliver=android:<chat>[:<thread>]`
|
thread lanes, FTS5 search, sync); cron `deliver=iris:<chat>[:<thread>]`
|
||||||
target resolution verified via `resolve_send_target`. App on-device: channel
|
target resolution verified via `resolve_send_target`. App on-device: channel
|
||||||
drawer, thread toggle + topic switcher, new channel, search overlay with
|
drawer, thread toggle + topic switcher, new channel, search overlay with
|
||||||
jump. Minor UI gaps deferred to M7 polish: "set as cron target" is set via
|
jump. Minor UI gaps deferred to M7 polish: "set as cron target" is set via
|
||||||
@@ -89,7 +97,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
not e2e-tested (it shares the verified resolution path).
|
not e2e-tested (it shares the verified resolution path).
|
||||||
|
|
||||||
## M4 — Media
|
## M4 — Media
|
||||||
|
|
||||||
**Goal:** attach + receive + play media.
|
**Goal:** attach + receive + play media.
|
||||||
|
|
||||||
- [X] Inbound: `media.upload` chunked → `cache_*_from_bytes` → `media_urls`;
|
- [X] Inbound: `media.upload` chunked → `cache_*_from_bytes` → `media_urls`;
|
||||||
size limit + sha256 + MIME re-sniff.
|
size limit + sha256 + MIME re-sniff.
|
||||||
- [X] Outbound: `send_*` → `media.offer`; `media.pull` chunked; delivery-path
|
- [X] Outbound: `send_*` → `media.offer`; `media.pull` chunked; delivery-path
|
||||||
@@ -117,12 +127,14 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
`04-wire-protocol.md` + `frames.schema.json`.
|
`04-wire-protocol.md` + `frames.schema.json`.
|
||||||
|
|
||||||
## M5 — Push + offline (FCM + ntfy)
|
## M5 — Push + offline (FCM + ntfy)
|
||||||
|
|
||||||
**Goal:** reach the phone when backgrounded; catch up on reconnect.
|
**Goal:** reach the phone when backgrounded; catch up on reconnect.
|
||||||
|
|
||||||
- [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune
|
- [x] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune
|
||||||
(row cap 5000 + prune banner, throttled 1/h).
|
(row cap 5000 + prune banner, throttled 1/h).
|
||||||
- [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via
|
- [x] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx; JWT via
|
||||||
PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by
|
PyJWT+cryptography) + `NtfyBackend` (X-Data header); selected by
|
||||||
`ANDROID_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`.
|
`IRIS_PUSH_BACKEND`. ntfy server exposed in `server_caps.push_ntfy_server`.
|
||||||
- [x] Fire push on no-live-subscriber; data payload for silent sync.
|
- [x] Fire push on no-live-subscriber; data payload for silent sync.
|
||||||
High-priority kinds (approval/clarify/cron) push even when live.
|
High-priority kinds (approval/clarify/cron) push even when live.
|
||||||
- [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a
|
- [x] App: FCM service (`onNewToken` → `fcm.register`; inert without a
|
||||||
@@ -143,7 +155,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
loss/dup (verified); banners show for foreground events (implemented).
|
loss/dup (verified); banners show for foreground events (implemented).
|
||||||
|
|
||||||
## M6 — Desktop app
|
## M6 — Desktop app
|
||||||
|
|
||||||
**Goal:** the same app on a big screen.
|
**Goal:** the same app on a big screen.
|
||||||
|
|
||||||
- [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
|
- [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
|
||||||
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
|
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
|
||||||
- [x] Two-pane default layout; keyboard shortcuts; optional inspector pane.
|
- [x] Two-pane default layout; keyboard shortcuts; optional inspector pane.
|
||||||
@@ -178,7 +192,9 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
macOS/Windows packaging are deferred to M7.
|
macOS/Windows packaging are deferred to M7.
|
||||||
|
|
||||||
## M7 — Polish + E2E + docs
|
## M7 — Polish + E2E + docs
|
||||||
|
|
||||||
**Goal:** ship-quality.
|
**Goal:** ship-quality.
|
||||||
|
|
||||||
- [x] Telegram-style layout pass (per reference image): header, bubbles, date
|
- [x] Telegram-style layout pass (per reference image): header, bubbles, date
|
||||||
separators, ✓✓, model/token footer, banner, bottom bar.
|
separators, ✓✓, model/token footer, banner, bottom bar.
|
||||||
- [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
|
- [x] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
|
||||||
@@ -222,7 +238,38 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## M8 — QR pairing
|
||||||
|
|
||||||
|
**Goal:** QR-based pairing — a scannable QR at `hermes gateway setup` plus an
|
||||||
|
in-app scanner and `iris://pair` deep link (closes gap #12, `docs/20`).
|
||||||
|
|
||||||
|
- [x] Pure-stdlib QR encoder + terminal renderer (`gateway-plugin/qr.py`):
|
||||||
|
byte mode, EC M with L fallback, versions 1–10, ISO penalty masking,
|
||||||
|
**zero new Python deps**.
|
||||||
|
- [x] `interactive_setup` renders the QR after the pairing URL (text lines
|
||||||
|
stay as the primary path).
|
||||||
|
- [x] `PairLink` parser (`iris/util/PairLink.kt`) + jvmTest (valid/missing
|
||||||
|
token/bad port/wrong scheme/wrong host/percent-encoded/secure/default
|
||||||
|
port).
|
||||||
|
- [x] CameraX + ML Kit scanner (`QrScanActivity`, on-device, no Play
|
||||||
|
services) + Connect-screen **Scan QR** button (Android only; hidden on
|
||||||
|
desktop) + `CAMERA` permission.
|
||||||
|
- [x] `iris://pair` deep link (system-scanner / other-phone fallback) reusing
|
||||||
|
the same parser.
|
||||||
|
- **Accept:** `docs/20` §20.6; gap #12 in `09-pairing-security.md` closed.
|
||||||
|
- **Status (2026-08-22):** Encoder cross-checked byte-for-byte against an
|
||||||
|
independent reference and decoded by an independent decoder (zbarimg); fixed
|
||||||
|
v1-M and v7-M matrix vectors lock the algorithm. `hermes gateway setup`
|
||||||
|
prints a scannable QR (v7-M, 45 modules) for the 64-hex-token payload. App:
|
||||||
|
Connect screen shows **Scan QR** (Android), which opens `QrScanActivity`
|
||||||
|
(CameraX camera2 + ML Kit barcode), requests `CAMERA`, and pre-fills URL +
|
||||||
|
token via `PairLink.parse` without auto-connecting; `iris://pair` deep link
|
||||||
|
pre-fills the same way. Docs updated per `docs/20` Part C.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Sequencing notes
|
## Sequencing notes
|
||||||
|
|
||||||
- **M1/M2 depend on the `ws_probe.py` harness** to lock frame shapes early —
|
- **M1/M2 depend on the `ws_probe.py` harness** to lock frame shapes early —
|
||||||
build it in M1.
|
build it in M1.
|
||||||
- **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3,
|
- **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ integration point. Paths are relative to `hermes-agent/` (the read-only
|
|||||||
reference). This lets a coder jump straight to the right code instead of
|
reference). This lets a coder jump straight to the right code instead of
|
||||||
re-deriving the architecture.
|
re-deriving the architecture.
|
||||||
|
|
||||||
> ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/android`; we
|
> ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/iris`; we
|
||||||
> never edit these files.
|
> never edit these files.
|
||||||
|
|
||||||
## Plugin / platform registration
|
## Plugin / platform registration
|
||||||
|
|||||||
@@ -3,24 +3,24 @@
|
|||||||
## Locked decisions (from planning, 2026-08-19)
|
## Locked decisions (from planning, 2026-08-19)
|
||||||
|
|
||||||
| # | Decision | Choice | Rationale |
|
| # | Decision | Choice | Rationale |
|
||||||
|---|---|---|---|
|
| --- | --- | --- | --- |
|
||||||
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android app, tweaked"; share protocol/state/UI. |
|
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android app, tweaked"; share protocol/state/UI. |
|
||||||
| 2 | Push backend | **Both — FCM primary, ntfy fallback** | FCM is standard/reliable; ntfy for self-hosters with no Firebase. `ANDROID_PUSH_BACKEND`. |
|
| 2 | Push backend | **Both — FCM primary, ntfy fallback** | FCM is standard/reliable; ntfy for self-hosters with no Firebase. `IRIS_PUSH_BACKEND`. |
|
||||||
| 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. |
|
| 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. |
|
||||||
| 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. |
|
| 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. |
|
||||||
|
|
||||||
## Additional decisions made during planning
|
## Additional decisions made during planning
|
||||||
|
|
||||||
| Decision | Choice | Note |
|
| Decision | Choice | Note |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| Connection point | **Messaging gateway** (platform plugin `android`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. |
|
| Connection point | **Messaging gateway** (platform plugin `iris`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. |
|
||||||
| Plugin style | **Community plugin** (`register(ctx)`) | Zero hermes-core changes. |
|
| Plugin style | **Community plugin** (`register(ctx)`) | Zero hermes-core changes. |
|
||||||
| Python deps | **None new** (`websockets` + `httpx` are core) | Respects hermes pinning policy. |
|
| Python deps | **None new** (`websockets` + `httpx` are core) | Respects hermes pinning policy. |
|
||||||
| Tool events | **Structured frames; app controls verbosity** | Gateway sends full data; app = everything/truncated/nothing. |
|
| Tool events | **Structured frames; app controls verbosity** | Gateway sends full data; app = everything/truncated/nothing. |
|
||||||
| Reasoning | **Adapter splits the `show_reasoning` prefix** | Clean `reasoning` field → collapsible block above message. |
|
| Reasoning | **Adapter splits the `show_reasoning` prefix** | Clean `reasoning` field → collapsible block above message. |
|
||||||
| Channels/threads | **Map onto `chat_id`/`thread_id`** | Existing gateway primitives; cron targets them. |
|
| Channels/threads | **Map onto `chat_id`/`thread_id`** | Existing gateway primitives; cron targets them. |
|
||||||
| Offline | **SQLite outbox + sync cursor** | Catch-up on reconnect; push on disconnect. |
|
| Offline | **SQLite outbox + sync cursor** | Catch-up on reconnect; push on disconnect. |
|
||||||
| Default chat id | `android:default` | Home channel + cron default. |
|
| Default chat id | `default` | Home channel + cron default. |
|
||||||
| WS port | `8790` (default) | Configurable. |
|
| WS port | `8790` (default) | Configurable. |
|
||||||
| minSdk | 26 (test device API 29) | Broad coverage. |
|
| minSdk | 26 (test device API 29) | Broad coverage. |
|
||||||
| Frame routing | **Broadcast to all connected devices** (no per-chat subscribe) | Single-user model; simpler. |
|
| Frame routing | **Broadcast to all connected devices** (no per-chat subscribe) | Single-user model; simpler. |
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
| Agent lifecycle | **`agent.busy`/`agent.idle`** events + **`agent.stop`/`agent.steer`** requests | App shows thinking indicator; user can abort or steer mid-turn. |
|
| Agent lifecycle | **`agent.busy`/`agent.idle`** events + **`agent.stop`/`agent.steer`** requests | App shows thinking indicator; user can abort or steer mid-turn. |
|
||||||
| Local DB (KMP) | **SQLDelight** (not Room) | Room is Android-only; SQLDelight works in commonMain for both platforms. |
|
| Local DB (KMP) | **SQLDelight** (not Room) | Room is Android-only; SQLDelight works in commonMain for both platforms. |
|
||||||
| Voice input | **Record → upload as audio media** (no client-side STT) | Agent's STT (if configured) handles transcription. |
|
| Voice input | **Record → upload as audio media** (no client-side STT) | Agent's STT (if configured) handles transcription. |
|
||||||
|
| QR encoder | **Pure-stdlib** (`gateway-plugin/qr.py`) | No `qrcode`/`segno`/Pillow; byte mode, EC M→L, v1–10, ISO penalty masking. Keeps the plugin's zero-new-dep rule. |
|
||||||
|
| QR scanner | **ML Kit barcode** (not zxing-android-embedded) | On-device, no Google Play services; better accuracy/latency, Google-maintained. Android-only (desktop has no camera). |
|
||||||
|
|
||||||
## Open questions (resolve during implementation)
|
## Open questions (resolve during implementation)
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ will proceed with unless you say otherwise.
|
|||||||
`reasoning_style: code` for android and split on that; fallback = no
|
`reasoning_style: code` for android and split on that; fallback = no
|
||||||
reasoning field (full text) if the prefix isn't found. Verify in M2.
|
reasoning field (full text) if the prefix isn't found. Verify in M2.
|
||||||
3. **Per-device tokens vs shared token (M1/M5).** *Default (v1):* shared
|
3. **Per-device tokens vs shared token (M1/M5).** *Default (v1):* shared
|
||||||
`ANDROID_TOKEN` + optional `ANDROID_ALLOWED_USERS` device allowlist.
|
`IRIS_TOKEN` + optional `IRIS_ALLOWED_USERS` device allowlist.
|
||||||
Per-device revocable tokens are a stretch.
|
Per-device revocable tokens are a stretch.
|
||||||
4. **Desktop video backend (M6).** *Default:* `libmpv`/`mpv`-backed Compose
|
4. **Desktop video backend (M6).** *Default:* `libmpv`/`mpv`-backed Compose
|
||||||
surface, WebView fallback. Confirm `mpv` availability on target OSes during
|
surface, WebView fallback. Confirm `mpv` availability on target OSes during
|
||||||
@@ -54,7 +56,7 @@ will proceed with unless you say otherwise.
|
|||||||
recommended remote path; WSS + reverse proxy as alternatives. No public bind
|
recommended remote path; WSS + reverse proxy as alternatives. No public bind
|
||||||
by default.
|
by default.
|
||||||
6. **Streaming cadence (M2).** If live updates look chunky, tune
|
6. **Streaming cadence (M2).** If live updates look chunky, tune
|
||||||
`display.platforms.android.streaming` / consumer edit interval. *Default:*
|
`display.platforms.iris.streaming` / consumer edit interval. *Default:*
|
||||||
follow global streaming config.
|
follow global streaming config.
|
||||||
7. **App package name / branding.** *Default:* applicationId `dev.iris.app`,
|
7. **App package name / branding.** *Default:* applicationId `dev.iris.app`,
|
||||||
app name "Iris". Confirm final product name + package + icon.
|
app name "Iris". Confirm final product name + package + icon.
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ Config + setup per provider — `web_server.py` `/api/memory/providers/*`.
|
|||||||
`app/shared/.../protocol/Protocol.kt` → `docs/protocol/frames.schema.json`).
|
`app/shared/.../protocol/Protocol.kt` → `docs/protocol/frames.schema.json`).
|
||||||
2. **Security is the real gate.** The single-user model in
|
2. **Security is the real gate.** The single-user model in
|
||||||
`09-pairing-security.md` still holds, but control frames widen the blast
|
`09-pairing-security.md` still holds, but control frames widen the blast
|
||||||
radius of a leaked `ANDROID_TOKEN`. Sensitive operations (env/secrets,
|
radius of a leaked `IRIS_TOKEN`. Sensitive operations (env/secrets,
|
||||||
config writes, gateway restart, profile deletion) should get either an
|
config writes, gateway restart, profile deletion) should get either an
|
||||||
in-app confirmation step or a capability flag negotiated at pairing.
|
in-app confirmation step or a capability flag negotiated at pairing.
|
||||||
3. **Read-heavy first.** Most of the value is in list/view frames (cheap,
|
3. **Read-heavy first.** Most of the value is in list/view frames (cheap,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ acceptable alternative if preferred).
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────── hermes gateway process ───────────────────────┐
|
┌──────────────────────── hermes gateway process ───────────────────────┐
|
||||||
│ AndroidAdapter │
|
│ IrisAdapter │
|
||||||
│ │ frames (same protocol.Frame objects) │
|
│ │ frames (same protocol.Frame objects) │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ _broadcast_or_log ──► outbox.append(cursor) ──► push (if no live) │
|
│ _broadcast_or_log ──► outbox.append(cursor) ──► push (if no live) │
|
||||||
@@ -105,7 +105,7 @@ over HTTP whenever the WS is down.
|
|||||||
|
|
||||||
## 19.4 Gateway: `gateway-plugin/http_server.py`
|
## 19.4 Gateway: `gateway-plugin/http_server.py`
|
||||||
|
|
||||||
New module, started/stopped by `AndroidAdapter.connect()`/`disconnect()` next
|
New module, started/stopped by `IrisAdapter.connect()`/`disconnect()` next
|
||||||
to the WS server.
|
to the WS server.
|
||||||
|
|
||||||
- **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`,
|
- **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`,
|
||||||
@@ -114,8 +114,8 @@ to the WS server.
|
|||||||
into the gateway's asyncio loop with
|
into the gateway's asyncio loop with
|
||||||
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
|
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
|
||||||
start, same loop the WS server runs on).
|
start, same loop the WS server runs on).
|
||||||
- **Config:** `ANDROID_HTTP_PORT` (default **8791**), same bind host as the WS
|
- **Config:** `IRIS_HTTP_PORT` (default **8791**), same bind host as the WS
|
||||||
(`ANDROID_WS_HOST`). Optional TLS via `ANDROID_HTTP_CERT`/`ANDROID_HTTP_KEY`
|
(`IRIS_WS_HOST`). Optional TLS via `IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`
|
||||||
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
|
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
|
||||||
trusted LAN by default, TLS for remote/Tailscale setups.
|
trusted LAN by default, TLS for remote/Tailscale setups.
|
||||||
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the
|
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the
|
||||||
@@ -151,7 +151,7 @@ Wire format (standard SSE, three fields):
|
|||||||
```
|
```
|
||||||
id: 1043
|
id: 1043
|
||||||
event: frame
|
event: frame
|
||||||
data: {"v":1,"type":"message","chat_id":"android:default",...}
|
data: {"v":1,"type":"message","chat_id":"default",...}
|
||||||
|
|
||||||
: hb ← comment heartbeat every 15 s (keeps proxies alive)
|
: hb ← comment heartbeat every 15 s (keeps proxies alive)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
# 20 — QR Pairing (terminal QR + in-app scanner)
|
||||||
|
|
||||||
|
**Status: implemented (M8, 2026-08-22).**
|
||||||
|
|
||||||
|
Closes gap #12 in `09-pairing-security.md` ("in-app QR scanner") and implements
|
||||||
|
the QR branch of the §9.2 pairing flow, which the docs already promise but the
|
||||||
|
code never delivered: today `interactive_setup` prints the pairing URL as
|
||||||
|
plain text only, and the app has no `iris://pair` parser at all.
|
||||||
|
|
||||||
|
Two halves, independent and shippable separately:
|
||||||
|
|
||||||
|
- **A — Gateway:** `hermes gateway setup` renders a scannable QR in the
|
||||||
|
terminal encoding `iris://pair?host=…&port=…&token=…`. **Zero new Python
|
||||||
|
dependencies** (pure stdlib encoder).
|
||||||
|
- **B — App:** a "Scan QR" button on the Android Connect screen (CameraX +
|
||||||
|
ML Kit, on-device, no Play services) that pre-fills URL + token. Not added
|
||||||
|
to desktop. Plus an `iris://pair` deep link so *any* scanner (system camera
|
||||||
|
app, other phones) can route the QR into the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20.1 Current state (what exists today)
|
||||||
|
|
||||||
|
| Piece | State | Location |
|
||||||
|
| ------- | ------- | ---------- |
|
||||||
|
| QR payload format | ✅ implemented | `gateway-plugin/pairing.py` → `qr_payload(host, port, token, secure)` → `iris://pair?host=<lan-ip>&port=8791&secure=0&token=<64-hex>` |
|
||||||
|
| Terminal QR rendering | ❌ missing | `gateway-plugin/adapter.py` → `interactive_setup()` prints the URL text only |
|
||||||
|
| App `iris://pair` parser | ❌ missing | app has manual URL + token entry only (`ConnectScreen`) |
|
||||||
|
| In-app camera scan | ❌ missing | no camera deps anywhere in `app/` |
|
||||||
|
| `iris://` deep link | ⚠️ partial | manifest handles `iris://chat/<id>` only (`androidApp/.../AndroidManifest.xml`, `MainActivity.handleDeepLink`) |
|
||||||
|
| QR libs in hermes venv | ❌ absent | `qrcode`/`segno` not installed; `Pillow` is a hermes core dep but only renders images — the QR *matrix* algorithm is still needed either way |
|
||||||
|
|
||||||
|
Payload size: `iris://pair?host=192.168.x.x&port=8791&secure=0&token=<64 hex>`
|
||||||
|
≈ **118 bytes** → QR version **7 at EC level M** (capacity 122 bytes) or v6 at
|
||||||
|
L (134). The encoder must therefore support at least versions 1–8; we target
|
||||||
|
1–10.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20.2 Part A — terminal QR in `interactive_setup`
|
||||||
|
|
||||||
|
### A1. Pure-stdlib QR encoder — `gateway-plugin/qr.py` (new file)
|
||||||
|
|
||||||
|
A self-contained ISO/IEC 18004 encoder, **stdlib only** (no `qrcode`, no
|
||||||
|
`segno`, no Pillow). Scope is deliberately minimal — we only ever encode
|
||||||
|
ASCII pairing URLs:
|
||||||
|
|
||||||
|
- **Mode:** byte mode only (no alphanumeric/numeric/kanji paths).
|
||||||
|
- **Error correction:** level **M** (15 %); auto-fallback to **L** if the
|
||||||
|
payload doesn't fit at M within the version cap.
|
||||||
|
- **Versions:** 1–10, auto-selected (smallest version whose capacity fits).
|
||||||
|
Payloads that don't fit v10-L raise `QrTooLongError` (caller falls back to
|
||||||
|
text-only output — see A3).
|
||||||
|
- **Components** (all well-known, spec-stable algorithms):
|
||||||
|
1. Data encoding: mode indicator `0100`, 8-bit char count (8 bits for
|
||||||
|
v1–9, 16 bits for v10), payload bytes, terminator, padding
|
||||||
|
(`0xEC`/`0x11` alternation).
|
||||||
|
2. Reed–Solomon error correction over GF(256), generator polynomial
|
||||||
|
`0x11D`, per (version, EC level) block structure from the spec tables.
|
||||||
|
3. Matrix placement: finder patterns + separators, timing patterns,
|
||||||
|
alignment patterns (v2+), dark module, format info (BCH(15,5)),
|
||||||
|
version info (v7+, BCH(18,6)), zig-zag data placement.
|
||||||
|
4. Masking: all 8 masks, ISO penalty scoring (N1–N4), pick lowest.
|
||||||
|
- **Public API:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def qr_matrix(data: str) -> list[list[bool]]:
|
||||||
|
"""Encode *data* (ASCII) into a module matrix (True = dark).
|
||||||
|
Includes the 4-module quiet zone. Raises QrTooLongError."""
|
||||||
|
```
|
||||||
|
|
||||||
|
~250–350 lines including the spec tables. No I/O, no globals, fully
|
||||||
|
unit-testable.
|
||||||
|
|
||||||
|
### A2. Terminal renderer — `qr.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def render_qr(data: str) -> str:
|
||||||
|
"""Render *data* as a terminal QR using Unicode half-blocks (▀).
|
||||||
|
Returns '' (not an exception) when the payload is too long."""
|
||||||
|
```
|
||||||
|
|
||||||
|
- Pair consecutive module rows into one character row: both dark → `█`,
|
||||||
|
top dark → `▀`, bottom dark → `▄`, both light → space. (Matrix height
|
||||||
|
including quiet zone is always even: `2·(17+4v)+8`.)
|
||||||
|
- Output is a single string of `\n`-joined lines; the caller prints it.
|
||||||
|
- No ANSI colors, no cursor tricks — must survive `less`, log files, and
|
||||||
|
copy-paste.
|
||||||
|
|
||||||
|
### A3. Integration — `adapter.py:interactive_setup()`
|
||||||
|
|
||||||
|
After the existing "Pairing URL / Server URL" lines:
|
||||||
|
|
||||||
|
```python
|
||||||
|
qr = render_qr(qr_payload(host, port, token))
|
||||||
|
if qr:
|
||||||
|
print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:")
|
||||||
|
print(qr)
|
||||||
|
else:
|
||||||
|
print_warning("QR too large to render; use the pairing URL above.")
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **URL text lines stay** — the QR is a convenience, not a replacement
|
||||||
|
(terminals without UTF-8 still work, and the text is copy-pasteable).
|
||||||
|
- Printed on every setup run (new *and* existing token), consistent with the
|
||||||
|
URL lines which already print the token in cleartext.
|
||||||
|
- **Security note:** no new exposure — the token is already printed in the
|
||||||
|
pairing URL line today; the QR is the same bytes in a different encoding,
|
||||||
|
on the same operator-only stdout. (Gap #5 in the §9.7 table already
|
||||||
|
documents the stdout token print.)
|
||||||
|
|
||||||
|
### A4. Tests — `hermes-agent/tests/gateway/test_android.py`
|
||||||
|
|
||||||
|
The test file is a thin mirror importing the **live `gateway-plugin/`
|
||||||
|
package**, so new tests land there:
|
||||||
|
|
||||||
|
1. **Fixed test vectors** (guard against silent algorithm drift): at least
|
||||||
|
two known-good (data → matrix) pairs from public QR test vectors
|
||||||
|
(e.g. the ISO 18004 annex examples / the classic `KARAT` v2-L vector).
|
||||||
|
Assert the full matrix, not just dimensions.
|
||||||
|
2. **Round-trip via payload:** `qr_matrix(qr_payload(h, p, t))` has the
|
||||||
|
expected version/size for a 64-hex token (`17 + 4·7 = 45` modules at
|
||||||
|
v7-M, +8 quiet zone).
|
||||||
|
3. **Renderer shape:** every line equal length, height = half of matrix
|
||||||
|
height, quiet zone renders as blank border, only the 4 block chars +
|
||||||
|
space appear.
|
||||||
|
4. **`QrTooLongError` / `render_qr` → `""`** for a payload beyond v10-L.
|
||||||
|
5. **`interactive_setup` smoke:** with sandboxed HERMES_HOME (conftest
|
||||||
|
already does this), capture stdout and assert the QR block appears after
|
||||||
|
the pairing URL line.
|
||||||
|
|
||||||
|
Run: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20.3 Part B — in-app scanner (Android only)
|
||||||
|
|
||||||
|
### B1. Dependencies — `app/shared/build.gradle.kts`, `androidMain.dependencies` only
|
||||||
|
|
||||||
|
| Dependency | Why |
|
||||||
|
| ------------ | ----- |
|
||||||
|
| `androidx.camera:camera-camera2` | Camera access (CameraX) |
|
||||||
|
| `androidx.camera:camera-lifecycle` | Lifecycle-aware binding |
|
||||||
|
| `androidx.camera:camera-view` | `PreviewView` for the scan surface |
|
||||||
|
| `com.google.mlkit:barcode-scanning` | On-device QR decode; **no Google Play services required** (self-contained model) |
|
||||||
|
|
||||||
|
- Chosen over `zxing-android-embedded` (decided 2026-07-20): ML Kit has
|
||||||
|
better accuracy/latency, is maintained by Google, and works fully
|
||||||
|
on-device without GMS.
|
||||||
|
- These go in **`androidMain`** only — the no-new-dep rule covers
|
||||||
|
`gateway-plugin/`, and the app already carries OkHttp/SQLDelight/KCEF/etc.
|
||||||
|
Desktop is untouched.
|
||||||
|
- `minSdk 29` is fine for all four (ML Kit barcode needs 21+).
|
||||||
|
- Versions go in the existing version catalog / `composeVersion`-style
|
||||||
|
constants at the top of the build file (follow the current pattern).
|
||||||
|
|
||||||
|
### B2. Scanner activity — `shared/src/androidMain/kotlin/iris/platform/QrScanActivity.kt` (new)
|
||||||
|
|
||||||
|
A minimal `ComponentActivity` (not a Fragment, no nav graph):
|
||||||
|
|
||||||
|
- Layout: full-screen `PreviewView` + overlay hint text ("Point at the QR
|
||||||
|
code") + close button.
|
||||||
|
- `ImageAnalysis` (STRATEGY_LATEST, YUV_420_888) →
|
||||||
|
`BarcodeScannerOptions(FORMAT_QR_CODE)` → first result →
|
||||||
|
`setResult(RESULT_OK, Intent().putExtra("iris.qr.text", raw))` → finish.
|
||||||
|
- **Runtime permission:** request `CAMERA` on launch; on denial show a
|
||||||
|
message + close (the Connect screen still has manual entry).
|
||||||
|
- Registered in `shared/src/androidMain/AndroidManifest.xml` (or the
|
||||||
|
androidApp manifest — follow where `NtfyListenerService` is declared)
|
||||||
|
with `android:exported="false"`, `android:theme` reusing the app theme.
|
||||||
|
- Manifest additions (androidApp manifest):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
```
|
||||||
|
|
||||||
|
`required="false"` so the app stays installable on camera-less devices
|
||||||
|
(the button then just reports "no camera").
|
||||||
|
|
||||||
|
### B3. Platform hook — `expect`/`actual`
|
||||||
|
|
||||||
|
`shared/src/commonMain/kotlin/iris/platform/PlatformQr.kt` (new):
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
/** Launch the QR scanner. [onResult] gets the decoded text, or null when
|
||||||
|
* the user cancelled / no camera / permission denied. Desktop: no-op. */
|
||||||
|
expect fun scanQrCode(onResult: (String?) -> Unit)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **androidMain actual:** `ActivityResultLauncher` (from the Compose
|
||||||
|
`LocalContext`) starting `QrScanActivity`; maps `RESULT_OK` → text,
|
||||||
|
everything else → `null`.
|
||||||
|
- **desktopMain actual:** `onResult(null)` immediately (the button is
|
||||||
|
hidden on desktop anyway — see B4; the no-op keeps the `expect` total).
|
||||||
|
|
||||||
|
### B4. Connect screen button — `ConnectScreen.kt`
|
||||||
|
|
||||||
|
- New **"Scan QR"** `Button` below the token field, rendered only when
|
||||||
|
`!isDesktop` (`iris.platform.isDesktop` already exists).
|
||||||
|
- On tap: `scanQrCode { raw -> … }`; on non-null `raw`:
|
||||||
|
- `PairLink.parse(raw)` (B5) → pre-fill `url` and `token` state, clear
|
||||||
|
error, and **do not auto-connect** — the user still taps
|
||||||
|
"Test & Connect" (pairing stays an explicit act, per §10.8).
|
||||||
|
- Parse failure → set `error` to "Not a pairing QR code" (don't echo the
|
||||||
|
raw payload — it may contain someone else's token).
|
||||||
|
- On `null` (cancel/denied): no-op, no error.
|
||||||
|
|
||||||
|
### B5. Pair-link parser — `shared/src/commonMain/kotlin/iris/util/PairLink.kt` (new)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class PairLink(val url: String, val token: String)
|
||||||
|
|
||||||
|
object PairLink {
|
||||||
|
/** Parse `iris://pair?host=…&port=…&secure=…&token=…` → PairLink.
|
||||||
|
* Returns null on any malformation. */
|
||||||
|
fun parse(raw: String): PairLink?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Accepts exactly scheme `iris`, host `pair` (case-insensitive scheme).
|
||||||
|
- Required: `host` (non-empty), `token` (non-empty). `port` defaults to
|
||||||
|
`8791` (the HTTP default, `docs/19`); `secure` defaults to `0`.
|
||||||
|
- Builds `url` as `http(s)://<host>:<port>`; validates port 1–65535.
|
||||||
|
- URL-decodes `host`/`token` (the Python side `quote()`s them).
|
||||||
|
- Pure function, no platform imports → **unit-tested in `jvmTest`**
|
||||||
|
(`:shared:testAndroidHostTest` / `:shared:desktopTest` both run it):
|
||||||
|
valid link, missing token, bad port, wrong scheme, wrong host,
|
||||||
|
percent-encoded host, secure=1 → https, default port.
|
||||||
|
|
||||||
|
### B6. `iris://pair` deep link (system-scanner fallback)
|
||||||
|
|
||||||
|
So a QR scanned by *any* app (phone's built-in scanner, a friend's phone)
|
||||||
|
lands in Iris:
|
||||||
|
|
||||||
|
- Manifest: extend the existing `VIEW` intent-filter block (or add a
|
||||||
|
sibling) with `<data android:scheme="iris" android:host="pair" />`.
|
||||||
|
- `MainActivity.handleDeepLink`: on `iris://pair` → `PairLink.parse(uri)` →
|
||||||
|
stash into a `mutableStateOf<PairLink?>` passed into `IrisApp` →
|
||||||
|
`ConnectScreen` receives it as `prefillUrl`/`prefillToken` (the params
|
||||||
|
already exist). If the app is already connected, ignore (or surface in
|
||||||
|
Settings later — out of scope).
|
||||||
|
- This reuses B5's parser; add one test for the URI shape Android delivers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20.4 Part C — doc updates (with the implementation)
|
||||||
|
|
||||||
|
| Doc | Change |
|
||||||
|
| ----- | -------- |
|
||||||
|
| `09-pairing-security.md` | Gap #12 → **implemented** (fix the stale `adapter.py:648-654` reference while at it); §9.2 QR branch no longer aspirational |
|
||||||
|
| `10-android-app.md` §10.8 | "or scan QR" becomes real: scanner button + deep link, camera permission |
|
||||||
|
| `14-milestones.md` | New **M8 — QR pairing** section (acceptance criteria below) |
|
||||||
|
| `16-open-questions.md` | Record decision: ML Kit over zxing; pure-stdlib encoder over vendoring `segno` |
|
||||||
|
| `README.md` | Reading-order table: add row 20 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20.5 Work breakdown & sequencing
|
||||||
|
|
||||||
|
Ordered so each step is independently verifiable; A and B can be
|
||||||
|
interleaved (different languages, no shared surface).
|
||||||
|
|
||||||
|
| # | Task | Verify |
|
||||||
|
| --- | ------ | -------- |
|
||||||
|
| 1 | `qr.py` encoder + renderer (A1/A2) | new unit tests green (A4.1–4.4) |
|
||||||
|
| 2 | `interactive_setup` integration (A3) | A4.5 + manual: `hermes gateway setup` in a real terminal shows a scannable QR (scan with the phone's *system* camera app as the decoder oracle) |
|
||||||
|
| 3 | `PairLink` parser + jvmTest (B5) | `./gradlew :shared:testAndroidHostTest` |
|
||||||
|
| 4 | Deps + manifest + `QrScanActivity` (B1/B2) | `:androidApp:assembleDebug` |
|
||||||
|
| 5 | `PlatformQr` expect/actual + Connect button (B3/B4) | `:androidApp:assembleDebug` + `:desktopApp:run` (button absent, no crash) |
|
||||||
|
| 6 | `iris://pair` deep link (B6) | ADB: `adb shell am start -a android.intent.action.VIEW -d "iris://pair?host=…&port=…&token=…"` → Connect screen pre-filled |
|
||||||
|
| 7 | Doc updates (Part C) | — |
|
||||||
|
|
||||||
|
**On-device E2E (final gate, per `13-testing.md` ADB workflow):**
|
||||||
|
|
||||||
|
1. `hermes gateway setup` on the gateway host → QR in terminal.
|
||||||
|
2. Phone: `adb shell am start -n dev.iris.app/.MainActivity` → Connect →
|
||||||
|
**Scan QR** → grant camera → point at the terminal (screenshot the QR
|
||||||
|
onto a second screen if needed; the reference device is API 29 —
|
||||||
|
verify CameraX works on the MIX 2S in step 4 before building the rest).
|
||||||
|
3. Fields pre-filled → **Test & Connect** → chat screen.
|
||||||
|
4. Repeat via deep link (step 6 command) with a *different* token.
|
||||||
|
5. Negative: scan a non-pairing QR (e.g. a website) → "Not a pairing QR
|
||||||
|
code", fields untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20.6 Acceptance criteria (M8)
|
||||||
|
|
||||||
|
- [ ] `hermes gateway setup` prints a QR that a stock Android camera app
|
||||||
|
decodes to exactly `qr_payload(host, port, token)`.
|
||||||
|
- [ ] QR encoder: fixed test vectors + size/round-trip tests green;
|
||||||
|
**zero** new entries in the plugin's import surface (stdlib only —
|
||||||
|
verifiable by `ruff`/import scan).
|
||||||
|
- [ ] Android: Connect screen shows **Scan QR** (hidden on desktop);
|
||||||
|
scanning the setup QR pre-fills URL + token; "Test & Connect" pairs.
|
||||||
|
- [ ] Camera permission denied → graceful message, manual entry still works.
|
||||||
|
- [ ] `iris://pair` deep link pre-fills the Connect screen (ADB-verified).
|
||||||
|
- [ ] `PairLink.parse` unit tests cover the matrix in B5.
|
||||||
|
- [ ] Full Python suite green: `scripts/run_tests.sh` (no args).
|
||||||
|
- [ ] Docs updated per Part C; gap #12 closed.
|
||||||
|
|
||||||
|
## 20.7 Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| ------ | ------------ |
|
||||||
|
| Hand-rolled QR encoder has a subtle bug | Fixed spec test vectors (A4.1) + the system-camera-app oracle in the E2E gate; scope locked to byte mode / v1–10 so the surface stays small |
|
||||||
|
| Terminal without UTF-8 mangles the QR | URL text lines remain the primary path; QR is additive |
|
||||||
|
| CameraX quirks on API 29 (MIX 2S) | Build the scanner activity first (task 4) and verify on-device before wiring the UI |
|
||||||
|
| ML Kit model size (~4 MB) | Bundled in the APK, on-device, no runtime download — acceptable for this app's footprint |
|
||||||
|
| Token in QR scanned by a bystander's phone | Same trust domain as the token already printed in the terminal; LAN pairing is operator-supervised by design (§9.2). Deep link only pre-fills — it never auto-connects |
|
||||||
|
| `secure=1` (WSS) URLs | Parser already handles `secure` → `https://`; QR payload unchanged |
|
||||||
|
|
||||||
|
## 20.8 Explicit non-goals
|
||||||
|
|
||||||
|
- **QR display in the app** (showing a QR for other devices to scan) —
|
||||||
|
single-device pairing today; revisit if multi-device lands.
|
||||||
|
- **`hermes android pair` stretch CLI** (re-issue token + new QR,
|
||||||
|
`09-pairing-security.md` §9.2 line 48) — separate backlog item.
|
||||||
|
- **WSS cert pinning** (gap #6) — orthogonal; QR carries `secure=1`
|
||||||
|
already, pinning is app-side.
|
||||||
|
- **iOS scanner** — no iOS target (per `00-overview.md`).
|
||||||
+2
-1
@@ -41,6 +41,7 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
|||||||
| 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
|
| 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
|
||||||
| 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). |
|
| 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). |
|
||||||
| 19 | [`19-http-fallback-transport.md`](19-http-fallback-transport.md) | **Design** — HTTP fallback leg (POST + SSE/long-poll) so the app can send/receive when the WS is down. |
|
| 19 | [`19-http-fallback-transport.md`](19-http-fallback-transport.md) | **Design** — HTTP fallback leg (POST + SSE/long-poll) so the app can send/receive when the WS is down. |
|
||||||
|
| 20 | [`20-qr-pairing.md`](20-qr-pairing.md) | Terminal QR at `gateway setup` + in-app QR scanner (Android) + `iris://pair` deep link. |
|
||||||
|
|
||||||
Machine-readable / diagrams:
|
Machine-readable / diagrams:
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ Machine-readable / diagrams:
|
|||||||
|
|
||||||
## The three deliverables (one monorepo)
|
## The three deliverables (one monorepo)
|
||||||
|
|
||||||
1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `android`.
|
1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `iris`.
|
||||||
Runs inside the `hermes gateway` process. Opens a WebSocket server the apps
|
Runs inside the `hermes gateway` process. Opens a WebSocket server the apps
|
||||||
connect to. Implements the full `BasePlatformAdapter` contract. **Zero new
|
connect to. Implements the full `BasePlatformAdapter` contract. **Zero new
|
||||||
Python dependencies, zero hermes-core changes.**
|
Python dependencies, zero hermes-core changes.**
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ flowchart TB
|
|||||||
WSS["WebSocket SERVER<br/>(websockets) ws://host:8790/ws"]
|
WSS["WebSocket SERVER<br/>(websockets) ws://host:8790/ws"]
|
||||||
end
|
end
|
||||||
AGENT -->|legacy stream callbacks| ADAPTER
|
AGENT -->|legacy stream callbacks| ADAPTER
|
||||||
CRON -->|deliver=android:chat:thread| ADAPTER
|
CRON -->|deliver=iris:chat:thread| ADAPTER
|
||||||
ADAPTER <--> WSS
|
ADAPTER <--> WSS
|
||||||
ADAPTER <--> OUTBOX
|
ADAPTER <--> OUTBOX
|
||||||
ADAPTER <--> PUSH
|
ADAPTER <--> PUSH
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"v": { "type": "integer", "const": 1, "description": "Protocol version." },
|
"v": { "type": "integer", "const": 1, "description": "Protocol version." },
|
||||||
"id": { "type": "integer", "description": "Request id; present on requests and their responses/acks. Absent on pure events." },
|
"id": { "type": "integer", "description": "Request id; present on requests and their responses/acks. Absent on pure events." },
|
||||||
"type": { "type": "string", "description": "Frame type (see frame_types)." },
|
"type": { "type": "string", "description": "Frame type (see frame_types)." },
|
||||||
"chat_id": { "type": "string", "description": "Optional chat scope (e.g. android:default, android:chan_7)." },
|
"chat_id": { "type": "string", "description": "Optional chat scope (e.g. default, chan_7)." },
|
||||||
"thread_id": { "type": "string", "description": "Optional thread scope within a chat_id." },
|
"thread_id": { "type": "string", "description": "Optional thread scope within a chat_id." },
|
||||||
"cursor": { "type": "integer", "description": "Outbox cursor the frame was parked under. Present ONLY on frames replayed by sync (live frames carry none). The app skips re-notifying replayed frames with cursor <= last_pushed_cursor (docs/08 §8.7)." },
|
"cursor": { "type": "integer", "description": "Outbox cursor the frame was parked under. Present ONLY on frames replayed by sync (live frames carry none). The app skips re-notifying replayed frames with cursor <= last_pushed_cursor (docs/08 §8.7)." },
|
||||||
"payload": { "type": "object", "description": "Type-specific payload." }
|
"payload": { "type": "object", "description": "Type-specific payload." }
|
||||||
|
|||||||
+19
-16
@@ -9,7 +9,7 @@ just the steps.
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
| Where | You need |
|
| Where | You need |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| Gateway host | hermes installed with its venv (`cd hermes-agent && uv sync`, see [`12-toolchain.md` §12.4](12-toolchain.md)) |
|
| Gateway host | hermes installed with its venv (`cd hermes-agent && uv sync`, see [`12-toolchain.md` §12.4](12-toolchain.md)) |
|
||||||
| Android build machine | JDK 17, Android SDK with `ANDROID_HOME` set (or `app/local.properties`), ADB with a connected device |
|
| Android build machine | JDK 17, Android SDK with `ANDROID_HOME` set (or `app/local.properties`), ADB with a connected device |
|
||||||
| Desktop build machine | JDK 17 only |
|
| Desktop build machine | JDK 17 only |
|
||||||
@@ -24,8 +24,8 @@ root):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.hermes/plugins
|
mkdir -p ~/.hermes/plugins
|
||||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
|
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
|
||||||
hermes gateway status # should list "android"
|
hermes gateway status # should list "iris"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the interactive setup:
|
Run the interactive setup:
|
||||||
@@ -36,12 +36,13 @@ hermes gateway setup
|
|||||||
|
|
||||||
What it does:
|
What it does:
|
||||||
|
|
||||||
- Generates `ANDROID_TOKEN` (64 hex chars) if none exists and stores it in
|
- Generates `IRIS_TOKEN` (64 hex chars) if none exists and stores it in
|
||||||
`~/.hermes/.env` (it prints the token once, at generation).
|
`~/.hermes/.env` (it prints the token once, at generation).
|
||||||
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
|
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
|
||||||
and push backend (`fcm` or `ntfy`, default `fcm`).
|
and push backend (`fcm` or `ntfy`, default `fcm`).
|
||||||
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
|
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
|
||||||
string) and the server URL (`ws://<host>:8790/ws`).
|
string), a scannable QR of that payload, and the server URL
|
||||||
|
(`ws://<host>:8790/ws`).
|
||||||
|
|
||||||
Then start the gateway:
|
Then start the gateway:
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ hermes gateway # or: hermes gateway restart after config changes
|
|||||||
> **Note:** the default bind host `127.0.0.1` only accepts connections from the
|
> **Note:** the default bind host `127.0.0.1` only accepts connections from the
|
||||||
> gateway host itself (e.g. a desktop app on the same machine). For a phone on
|
> gateway host itself (e.g. a desktop app on the same machine). For a phone on
|
||||||
> the LAN, re-run `hermes gateway setup` (or edit `~/.hermes/.env`) and set
|
> the LAN, re-run `hermes gateway setup` (or edit `~/.hermes/.env`) and set
|
||||||
> `ANDROID_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`).
|
> `IRIS_WS_HOST` to the host's LAN IP (e.g. `192.168.1.10`).
|
||||||
|
|
||||||
## 2. Android app
|
## 2. Android app
|
||||||
|
|
||||||
@@ -68,12 +69,14 @@ First run opens the **Connect** screen:
|
|||||||
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (the URL printed by
|
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (the URL printed by
|
||||||
`hermes gateway setup`; use the LAN IP, not `127.0.0.1`, from a phone).
|
`hermes gateway setup`; use the LAN IP, not `127.0.0.1`, from a phone).
|
||||||
2. **Pairing token** — from the `hermes gateway setup` output, or
|
2. **Pairing token** — from the `hermes gateway setup` output, or
|
||||||
`grep ANDROID_TOKEN ~/.hermes/.env` on the gateway host.
|
`grep IRIS_TOKEN ~/.hermes/.env` on the gateway host.
|
||||||
3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the
|
3. **Test & Connect** — performs a real `hello` (the auth leg), then saves the
|
||||||
pairing and connects.
|
pairing and connects.
|
||||||
|
|
||||||
> **Honest limitation:** QR scanning is **not** supported in the app yet. The
|
> **Scan QR (Android):** the Connect screen has a **Scan QR** button (CameraX +
|
||||||
> server prints a QR payload, but pairing is manual URL + token entry only.
|
> ML Kit) that reads the QR printed by `hermes gateway setup` and pre-fills the
|
||||||
|
> URL + token. Desktop has no camera, so it uses manual entry. An `iris://pair`
|
||||||
|
> deep link (from any scanner) pre-fills the same way.
|
||||||
|
|
||||||
## 3. Desktop app
|
## 3. Desktop app
|
||||||
|
|
||||||
@@ -104,8 +107,8 @@ high-priority events (approvals, clarifies, cron) even when a device is live.
|
|||||||
`app/androidApp/`.
|
`app/androidApp/`.
|
||||||
2. Create a service account (Project settings → Service accounts → Generate new
|
2. Create a service account (Project settings → Service accounts → Generate new
|
||||||
private key) and store the JSON path in `~/.hermes/.env`:
|
private key) and store the JSON path in `~/.hermes/.env`:
|
||||||
`ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
|
`IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
|
||||||
3. Keep `ANDROID_PUSH_BACKEND=fcm` (the default).
|
3. Keep `IRIS_PUSH_BACKEND=fcm` (the default).
|
||||||
|
|
||||||
Without a Firebase project the FCM path is **inert** (the app's FCM service
|
Without a Firebase project the FCM path is **inert** (the app's FCM service
|
||||||
does nothing) — use ntfy below, or add Firebase later.
|
does nothing) — use ntfy below, or add Firebase later.
|
||||||
@@ -116,7 +119,7 @@ backgrounded; tapping one deep-links to the chat.
|
|||||||
### ntfy (zero-config fallback)
|
### ntfy (zero-config fallback)
|
||||||
|
|
||||||
```
|
```
|
||||||
ANDROID_PUSH_BACKEND=ntfy
|
IRIS_PUSH_BACKEND=ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
|
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
|
||||||
@@ -135,7 +138,7 @@ the app is off; incoming pushes trigger a silent sync.
|
|||||||
the app connects to `ws://<tailnet-ip>:8790/ws`. No public exposure.
|
the app connects to `ws://<tailnet-ip>:8790/ws`. No public exposure.
|
||||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at
|
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS at
|
||||||
the edge, forward the WebSocket to `127.0.0.1:8790`.
|
the edge, forward the WebSocket to `127.0.0.1:8790`.
|
||||||
- **WSS:** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY` (paths, in
|
- **WSS:** set `IRIS_WS_CERT` / `IRIS_WS_KEY` (paths, in
|
||||||
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
|
`~/.hermes/.env`) and the server serves `wss://` instead of `ws://`.
|
||||||
|
|
||||||
> **Honest limitation:** the app has **no certificate pinning** yet, so
|
> **Honest limitation:** the app has **no certificate pinning** yet, so
|
||||||
@@ -145,8 +148,8 @@ the app is off; incoming pushes trigger a silent sync.
|
|||||||
## 6. Troubleshooting
|
## 6. Troubleshooting
|
||||||
|
|
||||||
| Symptom | Likely cause / fix |
|
| Symptom | Likely cause / fix |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `ANDROID_TOKEN` in `~/.hermes/.env` on the gateway host (setup prints it only when it generates it). |
|
| `auth failed` / `error {code:"auth"}` on connect | Wrong token. Check `IRIS_TOKEN` in `~/.hermes/.env` on the gateway host (setup prints it only when it generates it). |
|
||||||
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8790`, path `/ws`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
|
| Connection refused | Gateway not running (`hermes gateway status`); wrong URL (port `8790`, path `/ws`, LAN IP instead of `127.0.0.1` from a phone); firewall blocking the port. |
|
||||||
| Push not arriving | Backend not configured (gateway log: `push backend … not configured`); app backgrounded with no working backend; ntfy.sh SSE flakiness — use a self-hosted ntfy. |
|
| Push not arriving | Backend not configured (gateway log: `push backend … not configured`); app backgrounded with no working backend; ntfy.sh SSE flakiness — use a self-hosted ntfy. |
|
||||||
| Desktop jpackage launcher warning | Non-fatal (JDK-8348560 on Linux JDK 17); the app runs and connects regardless. |
|
| Desktop jpackage launcher warning | Non-fatal (JDK-8348560 on Linux JDK 17); the app runs and connects regardless. |
|
||||||
@@ -159,7 +162,7 @@ import asyncio, json, websockets
|
|||||||
async def main():
|
async def main():
|
||||||
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
|
||||||
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
|
||||||
"token":"<ANDROID_TOKEN>","device_id":"test","device_name":"probe",
|
"token":"<IRIS_TOKEN>","device_id":"test","device_name":"probe",
|
||||||
"caps":{"min_protocol":1}}}))
|
"caps":{"min_protocol":1}}}))
|
||||||
print("recv:", await ws.recv())
|
print("recv:", await ws.recv())
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
+156
-122
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Android Platform Adapter for Hermes Agent (Iris x Hermes).
|
Iris Platform Adapter for Hermes Agent (Iris x Hermes).
|
||||||
|
|
||||||
A plugin-based gateway adapter that runs an HTTP server *inside* the
|
A plugin-based gateway adapter that runs an HTTP server *inside* the
|
||||||
``hermes gateway`` process. The native Android / Desktop app connects to it
|
``hermes gateway`` process. The native Android / Desktop app connects to it
|
||||||
@@ -34,7 +34,7 @@ register the (delivery-validated) file in the media registry and emit
|
|||||||
|
|
||||||
Milestone M5: push + offline. Frames with no live subscriber are parked in
|
Milestone M5: push + offline. Frames with no live subscriber are parked in
|
||||||
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
|
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
|
||||||
HTTP v1 primary, ntfy fallback, selected by ``ANDROID_PUSH_BACKEND``).
|
HTTP v1 primary, ntfy fallback, selected by ``IRIS_PUSH_BACKEND``).
|
||||||
``notification`` frames render in-app banners and mirror to push (channel
|
``notification`` frames render in-app banners and mirror to push (channel
|
||||||
events, cron deliveries, approvals, clarifies); high-priority kinds push even
|
events, cron deliveries, approvals, clarifies); high-priority kinds push even
|
||||||
when a device is live. ``fcm.register`` rotates push tokens (registry + live
|
when a device is live. ``fcm.register`` rotates push tokens (registry + live
|
||||||
@@ -44,19 +44,19 @@ Configuration in config.yaml::
|
|||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
platforms:
|
platforms:
|
||||||
android:
|
iris:
|
||||||
enabled: true
|
enabled: true
|
||||||
extra:
|
extra:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 8790
|
port: 8790
|
||||||
home_channel: android:default
|
home_channel: default
|
||||||
push_backend: fcm
|
push_backend: fcm
|
||||||
outbox_retention_hours: 72
|
outbox_retention_hours: 72
|
||||||
max_upload_bytes: 104857600
|
max_upload_bytes: 104857600
|
||||||
|
|
||||||
Or via environment variables (overrides config.yaml; secrets live in .env):
|
Or via environment variables (overrides config.yaml; secrets live in .env):
|
||||||
ANDROID_TOKEN, ANDROID_WS_HOST, ANDROID_WS_PORT, ANDROID_HOME_CHANNEL,
|
IRIS_TOKEN, IRIS_WS_HOST, IRIS_WS_PORT, IRIS_HOME_CHANNEL,
|
||||||
ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
|
IRIS_PUSH_BACKEND, IRIS_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -116,7 +116,10 @@ from gateway.platforms.base import ( # noqa: E402
|
|||||||
from hermes_constants import get_hermes_home # noqa: E402
|
from hermes_constants import get_hermes_home # noqa: E402
|
||||||
|
|
||||||
from . import media as media_bridge # noqa: E402
|
from . import media as media_bridge # noqa: E402
|
||||||
from . import protocol # noqa: E402
|
from . import ( # noqa: E402
|
||||||
|
protocol,
|
||||||
|
qr,
|
||||||
|
)
|
||||||
from . import purge as purge_bridge # noqa: E402
|
from . import purge as purge_bridge # noqa: E402
|
||||||
from . import search as search_bridge # noqa: E402
|
from . import search as search_bridge # noqa: E402
|
||||||
from .channels import get_directory # noqa: E402
|
from .channels import get_directory # noqa: E402
|
||||||
@@ -124,6 +127,7 @@ from .http_server import HttpServer # noqa: E402
|
|||||||
from .outbox import Outbox # noqa: E402
|
from .outbox import Outbox # noqa: E402
|
||||||
from .pairing import ( # noqa: E402
|
from .pairing import ( # noqa: E402
|
||||||
DeviceRegistry,
|
DeviceRegistry,
|
||||||
|
advertise_host,
|
||||||
generate_token,
|
generate_token,
|
||||||
pairing_url,
|
pairing_url,
|
||||||
qr_payload,
|
qr_payload,
|
||||||
@@ -147,7 +151,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
|||||||
from hermes_cli import commands as hermes_commands
|
from hermes_cli import commands as hermes_commands
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"android: slash catalog unavailable (hermes_cli.commands import failed)",
|
"iris: slash catalog unavailable (hermes_cli.commands import failed)",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
@@ -175,7 +179,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Code skew: the private helpers moved. Fall back to the plain
|
# Code skew: the private helpers moved. Fall back to the plain
|
||||||
# cli_only filter (config-gated commands are dropped, acceptable).
|
# cli_only filter (config-gated commands are dropped, acceptable).
|
||||||
logger.warning("android: slash catalog fell back to cli_only filter", exc_info=True)
|
logger.warning("iris: slash catalog fell back to cli_only filter", exc_info=True)
|
||||||
entries = [
|
entries = [
|
||||||
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
|
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
|
||||||
for cmd in hermes_commands.COMMAND_REGISTRY
|
for cmd in hermes_commands.COMMAND_REGISTRY
|
||||||
@@ -187,7 +191,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Best-effort: a broken plugin-command registry should not break the
|
# Best-effort: a broken plugin-command registry should not break the
|
||||||
# built-in catalog, so the failure is intentionally swallowed.
|
# built-in catalog, so the failure is intentionally swallowed.
|
||||||
logger.debug("android: plugin command enumeration failed", exc_info=True)
|
logger.debug("iris: plugin command enumeration failed", exc_info=True)
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
@@ -200,7 +204,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
|||||||
# hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
|
# hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
|
||||||
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
|
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
|
||||||
# We accumulate those deltas here and attach the result to the turn's
|
# We accumulate those deltas here and attach the result to the turn's
|
||||||
# ``message.stop`` frame. Single-chat for now (android:default), so a
|
# ``message.stop`` frame. Single-chat for now (the default home channel), so a
|
||||||
# module-level buffer suffices; it is reset at each turn start.
|
# module-level buffer suffices; it is reset at each turn start.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -261,7 +265,7 @@ def _reset_reasoning() -> None:
|
|||||||
# (Settings → Tool detail), we capture each completed tool call via the
|
# (Settings → Tool detail), we capture each completed tool call via the
|
||||||
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame.
|
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame.
|
||||||
#
|
#
|
||||||
# Global FIFO (like the reasoning buffer): a personal android gateway serves
|
# Global FIFO (like the reasoning buffer): a personal iris gateway serves
|
||||||
# one active turn at a time, and records are matched to the open tool by name
|
# one active turn at a time, and records are matched to the open tool by name
|
||||||
# in completion order. Bounded so a runaway turn can't grow it without limit.
|
# in completion order. Bounded so a runaway turn can't grow it without limit.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -356,8 +360,8 @@ def _tool_emoji(tool_name: str) -> str | None:
|
|||||||
# * turn start — the first API call of the turn (latency baseline)
|
# * turn start — the first API call of the turn (latency baseline)
|
||||||
#
|
#
|
||||||
# Global buffer (same pattern as the reasoning/tool buffers): a personal
|
# Global buffer (same pattern as the reasoning/tool buffers): a personal
|
||||||
# android gateway serves one active turn at a time. The hook fires for every
|
# iris gateway serves one active turn at a time. The hook fires for every
|
||||||
# platform, so we only record when the turn's platform is android.
|
# platform, so we only record when the turn's platform is iris.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_runtime_meta: dict[str, Any] = {}
|
_runtime_meta: dict[str, Any] = {}
|
||||||
@@ -374,7 +378,7 @@ _CTX_RESOLVE_TIMEOUT_S = 3.0
|
|||||||
def _on_post_api_request(**kwargs: Any) -> None:
|
def _on_post_api_request(**kwargs: Any) -> None:
|
||||||
"""Plugin hook: capture per-turn runtime metadata (model, prompt tokens)."""
|
"""Plugin hook: capture per-turn runtime metadata (model, prompt tokens)."""
|
||||||
platform = kwargs.get("platform")
|
platform = kwargs.get("platform")
|
||||||
if platform and platform != "android":
|
if platform and platform != "iris":
|
||||||
return
|
return
|
||||||
model = kwargs.get("model") or ""
|
model = kwargs.get("model") or ""
|
||||||
usage = kwargs.get("usage") or {}
|
usage = kwargs.get("usage") or {}
|
||||||
@@ -416,7 +420,7 @@ def _resolve_context_length(model: str) -> int | None:
|
|||||||
_context_length_cache[model] = int(ctx)
|
_context_length_cache[model] = int(ctx)
|
||||||
return int(ctx)
|
return int(ctx)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("android: context-length resolution failed for %s", model, exc_info=True)
|
logger.debug("iris: context-length resolution failed for %s", model, exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -472,7 +476,7 @@ async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
|
|||||||
DEFAULT_HOST = "127.0.0.1"
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
DEFAULT_PORT = 8790
|
DEFAULT_PORT = 8790
|
||||||
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
|
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
|
||||||
DEFAULT_HOME_CHANNEL = "android:default"
|
DEFAULT_HOME_CHANNEL = "default"
|
||||||
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
||||||
DEFAULT_PUSH_BACKEND = "fcm"
|
DEFAULT_PUSH_BACKEND = "fcm"
|
||||||
DEFAULT_OUTBOX_RETENTION_HOURS = 72
|
DEFAULT_OUTBOX_RETENTION_HOURS = 72
|
||||||
@@ -825,13 +829,13 @@ def check_requirements() -> bool:
|
|||||||
dashboard readiness). Never installs. The HTTP transport is stdlib-only,
|
dashboard readiness). Never installs. The HTTP transport is stdlib-only,
|
||||||
so there is no extra dependency to probe.
|
so there is no extra dependency to probe.
|
||||||
"""
|
"""
|
||||||
return bool(_get_scoped_secret("ANDROID_TOKEN"))
|
return bool(_get_scoped_secret("IRIS_TOKEN"))
|
||||||
|
|
||||||
|
|
||||||
def validate_config(config) -> bool:
|
def validate_config(config) -> bool:
|
||||||
"""Given a PlatformConfig, is the platform properly configured?"""
|
"""Given a PlatformConfig, is the platform properly configured?"""
|
||||||
extra = getattr(config, "extra", {}) or {}
|
extra = getattr(config, "extra", {}) or {}
|
||||||
token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "")
|
||||||
return bool(token)
|
return bool(token)
|
||||||
|
|
||||||
|
|
||||||
@@ -858,7 +862,7 @@ def _env_enablement() -> dict | None:
|
|||||||
core hook -- it becomes a proper ``HomeChannel`` dataclass on the
|
core hook -- it becomes a proper ``HomeChannel`` dataclass on the
|
||||||
``PlatformConfig`` rather than being merged into ``extra``.
|
``PlatformConfig`` rather than being merged into ``extra``.
|
||||||
"""
|
"""
|
||||||
token = _get_scoped_secret("ANDROID_TOKEN", "")
|
token = _get_scoped_secret("IRIS_TOKEN", "")
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -867,20 +871,20 @@ def _env_enablement() -> dict | None:
|
|||||||
# clobber user YAML. Unset keys fall through to config.yaml / adapter
|
# clobber user YAML. Unset keys fall through to config.yaml / adapter
|
||||||
# defaults.
|
# defaults.
|
||||||
seed: dict[str, Any] = {}
|
seed: dict[str, Any] = {}
|
||||||
host = os.getenv("ANDROID_WS_HOST", "").strip()
|
host = os.getenv("IRIS_WS_HOST", "").strip()
|
||||||
if host:
|
if host:
|
||||||
seed["host"] = host
|
seed["host"] = host
|
||||||
http_port_raw = os.getenv("ANDROID_HTTP_PORT", "").strip()
|
http_port_raw = os.getenv("IRIS_HTTP_PORT", "").strip()
|
||||||
if http_port_raw:
|
if http_port_raw:
|
||||||
seed["http_port"] = _parse_port(http_port_raw)
|
seed["http_port"] = _parse_port(http_port_raw)
|
||||||
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
|
push = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower()
|
||||||
if push:
|
if push:
|
||||||
seed["push_backend"] = push
|
seed["push_backend"] = push
|
||||||
home = os.getenv("ANDROID_HOME_CHANNEL", "").strip()
|
home = os.getenv("IRIS_HOME_CHANNEL", "").strip()
|
||||||
if home:
|
if home:
|
||||||
seed["home_channel"] = {
|
seed["home_channel"] = {
|
||||||
"chat_id": home,
|
"chat_id": home,
|
||||||
"name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
|
"name": os.getenv("IRIS_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
|
||||||
}
|
}
|
||||||
return seed
|
return seed
|
||||||
|
|
||||||
@@ -893,21 +897,22 @@ def _parse_port(raw: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Target parsing: "android:<chat>[:<thread>]"
|
# Target parsing: "<chat_id>[:<thread>]" (platform prefix stripped by core)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _parse_target_ref(target_ref: str) -> tuple | None:
|
def _parse_target_ref(target_ref: str) -> tuple | None:
|
||||||
"""Parse a raw target string into ``(chat_id, thread_id)`` or ``None``.
|
"""Parse a raw target string into ``(chat_id, thread_id)`` or ``None``.
|
||||||
|
|
||||||
Recognises the native syntax ``android:<chat>[:<thread>]`` where the
|
The core strips the platform prefix before calling us, so the native
|
||||||
chat_id itself carries the ``android:`` prefix (e.g. ``android:chan_7``)
|
syntax is simply ``<chat_id>[:<thread>]`` (e.g. ``chan_7`` or
|
||||||
and an optional thread is a trailing ``:t_<n>``. A bare friendly name
|
``chan_7:t_31``); the home channel is ``default``. Chat ids are direct
|
||||||
(e.g. ``Cron Reports``) is resolved against the channel directory so cron
|
(no embedded platform prefix), so a cron delivery reads
|
||||||
/ ``send_message`` can target a channel by name immediately, without
|
``iris:chan_7`` end to end. A bare friendly name (e.g. ``Cron Reports``)
|
||||||
waiting for the core directory's refresh timer. Returns ``None`` for
|
is resolved against the channel directory so cron / ``send_message`` can
|
||||||
anything unrecognised so the target proceeds to the core channel-directory
|
target a channel by name immediately, without waiting for the core
|
||||||
resolution.
|
directory's refresh timer. Returns ``None`` for anything unrecognised so
|
||||||
|
the target proceeds to the core channel-directory resolution.
|
||||||
"""
|
"""
|
||||||
if not target_ref:
|
if not target_ref:
|
||||||
return None
|
return None
|
||||||
@@ -915,17 +920,26 @@ def _parse_target_ref(target_ref: str) -> tuple | None:
|
|||||||
if not t:
|
if not t:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if t.startswith("android:"):
|
|
||||||
body = t[len("android:") :].strip()
|
|
||||||
if not body:
|
|
||||||
return None
|
|
||||||
thread_id: str | None = None
|
thread_id: str | None = None
|
||||||
if ":" in body:
|
if ":" in t:
|
||||||
head, tail = body.rsplit(":", 1)
|
head, tail = t.rsplit(":", 1)
|
||||||
if tail and tail.startswith("t_"):
|
if head and tail.startswith("t_"):
|
||||||
thread_id = tail
|
thread_id = tail
|
||||||
body = head
|
t = head
|
||||||
return (f"android:{body}", thread_id)
|
else:
|
||||||
|
# Not a <chat>:<thread> pair -- treat the whole string as a name.
|
||||||
|
t = target_ref.strip()
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Native chat id (default / chan_<n>) or any id known to the directory
|
||||||
|
# (covers custom IRIS_HOME_CHANNEL values).
|
||||||
|
try:
|
||||||
|
known = get_directory().get(t) is not None
|
||||||
|
except Exception:
|
||||||
|
known = False
|
||||||
|
if t == "default" or re.fullmatch(r"chan_\d+", t) or known:
|
||||||
|
return (t, thread_id)
|
||||||
|
|
||||||
# Bare friendly name -> resolve via the channel directory. A thread resolves
|
# Bare friendly name -> resolve via the channel directory. A thread resolves
|
||||||
# to its session lane (parent_chat_id + thread_id); a channel/default to
|
# to its session lane (parent_chat_id + thread_id); a channel/default to
|
||||||
@@ -966,7 +980,7 @@ async def _standalone_send(
|
|||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"error": (
|
"error": (
|
||||||
"android standalone send: the running gateway is required to serve "
|
"iris standalone send: the running gateway is required to serve "
|
||||||
"the outbox (standalone delivery is best-effort only)"
|
"the outbox (standalone delivery is best-effort only)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -978,7 +992,7 @@ async def _standalone_send(
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_verbose_tool_progress() -> None:
|
def _ensure_verbose_tool_progress() -> None:
|
||||||
"""Ensure the android platform renders tool progress in ``verbose`` mode.
|
"""Ensure the iris platform renders tool progress in ``verbose`` mode.
|
||||||
|
|
||||||
Verbose mode makes the gateway's tool-progress line carry the FULL
|
Verbose mode makes the gateway's tool-progress line carry the FULL
|
||||||
argument JSON (not just a ~40-char preview), which the adapter parses
|
argument JSON (not just a ~40-char preview), which the adapter parses
|
||||||
@@ -988,7 +1002,7 @@ def _ensure_verbose_tool_progress() -> None:
|
|||||||
it).
|
it).
|
||||||
|
|
||||||
Best-effort and idempotent: writes
|
Best-effort and idempotent: writes
|
||||||
``display.platforms.android.tool_progress: verbose`` to config.yaml only
|
``display.platforms.iris.tool_progress: verbose`` to config.yaml only
|
||||||
when it isn't already set. The gateway's config cache is mtime-keyed, so
|
when it isn't already set. The gateway's config cache is mtime-keyed, so
|
||||||
the write takes effect on the next turn without a restart. Never raises.
|
the write takes effect on the next turn without a restart. Never raises.
|
||||||
"""
|
"""
|
||||||
@@ -998,19 +1012,19 @@ def _ensure_verbose_tool_progress() -> None:
|
|||||||
cfg = load_config_readonly() or {}
|
cfg = load_config_readonly() or {}
|
||||||
display = cfg.get("display") or {}
|
display = cfg.get("display") or {}
|
||||||
platforms = display.get("platforms") or {}
|
platforms = display.get("platforms") or {}
|
||||||
android = platforms.get("android") or {}
|
iris_cfg = platforms.get("iris") or {}
|
||||||
if android.get("tool_progress") == "verbose":
|
if iris_cfg.get("tool_progress") == "verbose":
|
||||||
return # already set
|
return # already set
|
||||||
from utils import atomic_roundtrip_yaml_update
|
from utils import atomic_roundtrip_yaml_update
|
||||||
|
|
||||||
atomic_roundtrip_yaml_update(
|
atomic_roundtrip_yaml_update(
|
||||||
get_hermes_home() / "config.yaml",
|
get_hermes_home() / "config.yaml",
|
||||||
"display.platforms.android.tool_progress",
|
"display.platforms.iris.tool_progress",
|
||||||
"verbose",
|
"verbose",
|
||||||
)
|
)
|
||||||
logger.info("android: set display.platforms.android.tool_progress=verbose")
|
logger.info("iris: set display.platforms.iris.tool_progress=verbose")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("android: could not ensure verbose tool_progress", exc_info=True)
|
logger.debug("iris: could not ensure verbose tool_progress", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1033,57 +1047,77 @@ def interactive_setup() -> None:
|
|||||||
)
|
)
|
||||||
from hermes_cli.config import get_env_value, save_env_value
|
from hermes_cli.config import get_env_value, save_env_value
|
||||||
except Exception:
|
except Exception:
|
||||||
print("android: setup helpers unavailable; set ANDROID_TOKEN in ~/.hermes/.env")
|
print("iris: setup helpers unavailable; set IRIS_TOKEN in ~/.hermes/.env")
|
||||||
return
|
return
|
||||||
|
|
||||||
print_info("📱 Android / Desktop (Iris x Hermes)")
|
print_info("📱 Android / Desktop (Iris x Hermes)")
|
||||||
token = get_env_value("ANDROID_TOKEN") or ""
|
token = get_env_value("IRIS_TOKEN") or ""
|
||||||
if not token:
|
if not token:
|
||||||
generated = generate_token()
|
generated = generate_token()
|
||||||
save_env_value("ANDROID_TOKEN", generated)
|
save_env_value("IRIS_TOKEN", generated)
|
||||||
print_success(f"Generated pairing token: {generated}")
|
print_success(f"Generated pairing token: {generated}")
|
||||||
print_warning("Keep this secret -- the app presents it on connect.")
|
print_warning("Keep this secret -- the app presents it on connect.")
|
||||||
else:
|
else:
|
||||||
print_info("Existing ANDROID_TOKEN found (not shown).")
|
print_info("Existing IRIS_TOKEN found (not shown).")
|
||||||
|
|
||||||
host = prompt("Bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST)
|
host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST)
|
||||||
save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST)
|
save_env_value("IRIS_WS_HOST", host or DEFAULT_HOST)
|
||||||
# _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
|
# _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
|
||||||
# HTTP default must be applied explicitly (docs/19: 8791).
|
# HTTP default must be applied explicitly (docs/19: 8791).
|
||||||
http_port_raw = (get_env_value("ANDROID_HTTP_PORT") or "").strip()
|
http_port_raw = (get_env_value("IRIS_HTTP_PORT") or "").strip()
|
||||||
port = prompt(
|
port = prompt(
|
||||||
"HTTP port",
|
"HTTP port",
|
||||||
default=str(int(http_port_raw) if http_port_raw.isdigit() else DEFAULT_HTTP_PORT),
|
default=str(int(http_port_raw) if http_port_raw.isdigit() else DEFAULT_HTTP_PORT),
|
||||||
)
|
)
|
||||||
save_env_value("ANDROID_HTTP_PORT", str(_parse_port(port)))
|
save_env_value("IRIS_HTTP_PORT", str(_parse_port(port)))
|
||||||
backend = prompt(
|
backend = prompt(
|
||||||
"Push backend (fcm/ntfy)",
|
"Push backend (fcm/ntfy)",
|
||||||
default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
|
default=get_env_value("IRIS_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
|
||||||
)
|
)
|
||||||
save_env_value("ANDROID_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
|
save_env_value("IRIS_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
|
||||||
|
|
||||||
# Pairing payload for the app's Connect screen (manual entry; the app has
|
# Pairing payload for the app's Connect screen (manual entry + QR scan).
|
||||||
# no QR scanner).
|
# Advertise a routable host: a bind wildcard (0.0.0.0/127.0.0.1) is
|
||||||
url = pairing_url(host or DEFAULT_HOST, _parse_port(port))
|
# replaced by the default-route LAN IP so the QR points somewhere a phone
|
||||||
|
# can actually reach (the user can still override the Server URL in-app).
|
||||||
|
advertised = advertise_host(host or DEFAULT_HOST)
|
||||||
|
url = pairing_url(advertised, _parse_port(port))
|
||||||
|
pairing = qr_payload(advertised, _parse_port(port), token)
|
||||||
print_info("Pair your device (enter this on the app's Connect screen):")
|
print_info("Pair your device (enter this on the app's Connect screen):")
|
||||||
print_info(f"Pairing URL: {qr_payload(host or DEFAULT_HOST, _parse_port(port), token)}")
|
print_info(f"Pairing URL: {pairing}")
|
||||||
print_info(f"Server URL: {url}")
|
print_info(f"Server URL: {url}")
|
||||||
|
if advertised != (host or DEFAULT_HOST):
|
||||||
|
print_info(
|
||||||
|
f"QR points to {advertised} (your default LAN address). If your "
|
||||||
|
"phone is on a different network, change the Server URL in the app."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scannable QR (docs/20): the same payload as a terminal QR. The URL text
|
||||||
|
# lines stay — the QR is a convenience, not a replacement (non-UTF-8
|
||||||
|
# terminals still work, and the text is copy-pasteable). render_qr returns
|
||||||
|
# '' (not an exception) when the payload is too long to encode.
|
||||||
|
qr_block = qr.render_qr(pairing)
|
||||||
|
if qr_block:
|
||||||
|
print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:")
|
||||||
|
print(qr_block)
|
||||||
|
else:
|
||||||
|
print_warning("QR too large to render; use the pairing URL above.")
|
||||||
|
|
||||||
# Always render tool progress verbosely so the app receives the full tool
|
# Always render tool progress verbosely so the app receives the full tool
|
||||||
# call args (it decides how much to show via Settings → Tool detail).
|
# call args (it decides how much to show via Settings → Tool detail).
|
||||||
_ensure_verbose_tool_progress()
|
_ensure_verbose_tool_progress()
|
||||||
|
|
||||||
print_success("Android configuration saved to ~/.hermes/.env")
|
print_success("Iris configuration saved to ~/.hermes/.env")
|
||||||
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Android Adapter
|
# Iris Adapter
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class AndroidAdapter(BasePlatformAdapter):
|
class IrisAdapter(BasePlatformAdapter):
|
||||||
"""HTTP-backed adapter for the native Iris Android / Desktop app.
|
"""HTTP-backed adapter for the native Iris app (Android / Desktop).
|
||||||
|
|
||||||
The HTTP server (``http_server.HttpServer``) authenticates devices with
|
The HTTP server (``http_server.HttpServer``) authenticates devices with
|
||||||
the pairing token, the device registry tracks live subscribers, ``send()``
|
the pairing token, the device registry tracks live subscribers, ``send()``
|
||||||
@@ -1100,7 +1134,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
MAX_MESSAGE_LENGTH = 1_000_000
|
MAX_MESSAGE_LENGTH = 1_000_000
|
||||||
|
|
||||||
def __init__(self, config, **kwargs):
|
def __init__(self, config, **kwargs):
|
||||||
platform = Platform("android")
|
platform = Platform("iris")
|
||||||
super().__init__(config=config, platform=platform)
|
super().__init__(config=config, platform=platform)
|
||||||
|
|
||||||
# Ensure verbose tool progress (full args on the progress line) so the
|
# Ensure verbose tool progress (full args on the progress line) so the
|
||||||
@@ -1111,13 +1145,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
# Connection settings (env vars override config.yaml). The bind host
|
# Connection settings (env vars override config.yaml). The bind host
|
||||||
# is shared with the (legacy) WS-era env var name for compatibility.
|
# is shared with the (legacy) WS-era env var name for compatibility.
|
||||||
self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
self.host = os.getenv("IRIS_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
||||||
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
|
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
|
||||||
self.http_port = _parse_port(
|
self.http_port = _parse_port(
|
||||||
os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
|
os.getenv("IRIS_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
|
||||||
)
|
)
|
||||||
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
self.token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "")
|
||||||
self.push_backend = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get(
|
self.push_backend = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower() or extra.get(
|
||||||
"push_backend", DEFAULT_PUSH_BACKEND
|
"push_backend", DEFAULT_PUSH_BACKEND
|
||||||
)
|
)
|
||||||
self.outbox_retention_hours = int(
|
self.outbox_retention_hours = int(
|
||||||
@@ -1146,18 +1180,18 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
|
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
|
||||||
|
|
||||||
# TLS (optional)
|
# TLS (optional)
|
||||||
self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "")
|
self.http_cert = _get_scoped_secret("IRIS_HTTP_CERT") or extra.get("http_cert", "")
|
||||||
self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "")
|
self.http_key = _get_scoped_secret("IRIS_HTTP_KEY") or extra.get("http_key", "")
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip()
|
allowed = os.getenv("IRIS_ALLOWED_USERS", "").strip()
|
||||||
self.allowed_users: list[str] = (
|
self.allowed_users: list[str] = (
|
||||||
[u.strip() for u in allowed.split(",") if u.strip()] if allowed else []
|
[u.strip() for u in allowed.split(",") if u.strip()] if allowed else []
|
||||||
)
|
)
|
||||||
self.allow_all = _truthy(os.getenv("ANDROID_ALLOW_ALL_USERS"))
|
self.allow_all = _truthy(os.getenv("IRIS_ALLOW_ALL_USERS"))
|
||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
|
self._devices = DeviceRegistry(get_hermes_home() / "iris" / "devices.db")
|
||||||
# docs/19: HTTP transport (the only device-facing transport).
|
# docs/19: HTTP transport (the only device-facing transport).
|
||||||
self._http_server = HttpServer(self, self._devices)
|
self._http_server = HttpServer(self, self._devices)
|
||||||
# docs/19 §19.7: reply sinks for in-flight HTTP requests — while a
|
# docs/19 §19.7: reply sinks for in-flight HTTP requests — while a
|
||||||
@@ -1171,7 +1205,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# M3: channel directory (shared singleton) + offline outbox.
|
# M3: channel directory (shared singleton) + offline outbox.
|
||||||
self._channels = get_directory()
|
self._channels = get_directory()
|
||||||
self._outbox = Outbox(
|
self._outbox = Outbox(
|
||||||
get_hermes_home() / "android" / "outbox.db",
|
get_hermes_home() / "iris" / "outbox.db",
|
||||||
retention_hours=self.outbox_retention_hours,
|
retention_hours=self.outbox_retention_hours,
|
||||||
)
|
)
|
||||||
# M4: media registry (inbound upload refs + outbound offers) and the
|
# M4: media registry (inbound upload refs + outbound offers) and the
|
||||||
@@ -1182,8 +1216,8 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# the outbox-prune banner.
|
# the outbox-prune banner.
|
||||||
self._push: PushBackend = build_push_backend(
|
self._push: PushBackend = build_push_backend(
|
||||||
self.push_backend,
|
self.push_backend,
|
||||||
fcm_service_account=_get_scoped_secret("ANDROID_FCM_SERVICE_ACCOUNT"),
|
fcm_service_account=_get_scoped_secret("IRIS_FCM_SERVICE_ACCOUNT"),
|
||||||
fcm_server_key=_get_scoped_secret("ANDROID_FCM_SERVER_KEY"),
|
fcm_server_key=_get_scoped_secret("IRIS_FCM_SERVER_KEY"),
|
||||||
ntfy_topic=_get_scoped_secret("NTFY_TOPIC"),
|
ntfy_topic=_get_scoped_secret("NTFY_TOPIC"),
|
||||||
ntfy_server_url=os.getenv("NTFY_SERVER_URL", "").strip() or None,
|
ntfy_server_url=os.getenv("NTFY_SERVER_URL", "").strip() or None,
|
||||||
ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"),
|
ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"),
|
||||||
@@ -1202,17 +1236,17 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "Android"
|
return "Iris"
|
||||||
|
|
||||||
# ── Connection lifecycle ──────────────────────────────────────────────
|
# ── Connection lifecycle ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||||
"""Bring the platform up: bind the HTTP server on host:http_port."""
|
"""Bring the platform up: bind the HTTP server on host:http_port."""
|
||||||
if not self.token:
|
if not self.token:
|
||||||
logger.error("android: ANDROID_TOKEN must be set")
|
logger.error("iris: IRIS_TOKEN must be set")
|
||||||
self._set_fatal_error(
|
self._set_fatal_error(
|
||||||
"config_missing",
|
"config_missing",
|
||||||
"ANDROID_TOKEN must be set",
|
"IRIS_TOKEN must be set",
|
||||||
retryable=False,
|
retryable=False,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
@@ -1222,7 +1256,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# start() never raises; it disables the leg and logs on failure.
|
# start() never raises; it disables the leg and logs on failure.
|
||||||
await self._http_server.start()
|
await self._http_server.start()
|
||||||
if not self._http_server.enabled:
|
if not self._http_server.enabled:
|
||||||
logger.error("android: HTTP server failed to bind %s:%s", self.host, self.http_port)
|
logger.error("iris: HTTP server failed to bind %s:%s", self.host, self.http_port)
|
||||||
self._set_fatal_error(
|
self._set_fatal_error(
|
||||||
"bind_failed",
|
"bind_failed",
|
||||||
f"HTTP port {self.http_port} unavailable",
|
f"HTTP port {self.http_port} unavailable",
|
||||||
@@ -1242,21 +1276,21 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
try:
|
try:
|
||||||
self._channels.ensure_default(self.home_channel, self.home_channel_name)
|
self._channels.ensure_default(self.home_channel, self.home_channel_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: ensure_default failed", exc_info=True)
|
logger.warning("iris: ensure_default failed", exc_info=True)
|
||||||
|
|
||||||
# M5: push backend status (degrade gracefully when unconfigured).
|
# M5: push backend status (degrade gracefully when unconfigured).
|
||||||
if not self._push.configured():
|
if not self._push.configured():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"android: push backend %r not configured (no credentials) -- "
|
"iris: push backend %r not configured (no credentials) -- "
|
||||||
"offline devices will not be woken; outbox + sync still apply",
|
"offline devices will not be woken; outbox + sync still apply",
|
||||||
self.push_backend,
|
self.push_backend,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("android: push backend: %s", self._push.name)
|
logger.info("iris: push backend: %s", self._push.name)
|
||||||
|
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self._mark_connected()
|
self._mark_connected()
|
||||||
logger.info("android: connected; HTTP server on %s:%s", self.host, self.http_port)
|
logger.info("iris: connected; HTTP server on %s:%s", self.host, self.http_port)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
@@ -1271,7 +1305,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
try:
|
try:
|
||||||
await self._http_server.stop()
|
await self._http_server.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: HTTP server stop failed", exc_info=True)
|
logger.warning("iris: HTTP server stop failed", exc_info=True)
|
||||||
# Best-effort shutdown: a close failure on an already-closed store is
|
# Best-effort shutdown: a close failure on an already-closed store is
|
||||||
# not actionable at disconnect time.
|
# not actionable at disconnect time.
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
@@ -1280,7 +1314,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
self._outbox.close()
|
self._outbox.close()
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._mark_disconnected()
|
self._mark_disconnected()
|
||||||
logger.info("android: disconnected")
|
logger.info("iris: disconnected")
|
||||||
|
|
||||||
# ── Outbound (agent -> app) ───────────────────────────────────────────
|
# ── Outbound (agent -> app) ───────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1706,7 +1740,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
try:
|
try:
|
||||||
cursor = self._outbox.append(chat_id, frame.to_json())
|
cursor = self._outbox.append(chat_id, frame.to_json())
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: outbox append failed", exc_info=True)
|
logger.warning("iris: outbox append failed", exc_info=True)
|
||||||
return
|
return
|
||||||
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber
|
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber
|
||||||
# — count it in the delivery total or every message would push AND
|
# — count it in the delivery total or every message would push AND
|
||||||
@@ -1714,7 +1748,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
delivered = await self._http_server.fanout(frame, cursor)
|
delivered = await self._http_server.fanout(frame, cursor)
|
||||||
if delivered == 0:
|
if delivered == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
"iris: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
||||||
chat_id,
|
chat_id,
|
||||||
frame.type,
|
frame.type,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -1786,7 +1820,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
if now - self._last_push_at.get(chat_id, 0.0) < _PUSH_COALESCE_S:
|
if now - self._last_push_at.get(chat_id, 0.0) < _PUSH_COALESCE_S:
|
||||||
logger.info(
|
logger.info(
|
||||||
"android: push coalesced for %s (%s frame within %.0fs of last push)",
|
"iris: push coalesced for %s (%s frame within %.0fs of last push)",
|
||||||
chat_id,
|
chat_id,
|
||||||
frame.type,
|
frame.type,
|
||||||
_PUSH_COALESCE_S,
|
_PUSH_COALESCE_S,
|
||||||
@@ -1823,7 +1857,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
priority=priority,
|
priority=priority,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: push via %s failed", backend.name, exc_info=True)
|
logger.warning("iris: push via %s failed", backend.name, exc_info=True)
|
||||||
continue
|
continue
|
||||||
if ok:
|
if ok:
|
||||||
# M5: remember that this cursor reached the device via push,
|
# M5: remember that this cursor reached the device via push,
|
||||||
@@ -1832,11 +1866,11 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
self._devices.update_push_cursor(device_id, cursor)
|
self._devices.update_push_cursor(device_id, cursor)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"android: push cursor update failed for %s", device_id, exc_info=True
|
"iris: push cursor update failed for %s", device_id, exc_info=True
|
||||||
)
|
)
|
||||||
self._last_push_at[chat_id] = time.time()
|
self._last_push_at[chat_id] = time.time()
|
||||||
logger.info(
|
logger.info(
|
||||||
"android: push via %s -> %s (%s, chat=%s)",
|
"iris: push via %s -> %s (%s, chat=%s)",
|
||||||
backend.name,
|
backend.name,
|
||||||
device_id,
|
device_id,
|
||||||
frame.type,
|
frame.type,
|
||||||
@@ -1897,13 +1931,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
) -> SendResult:
|
) -> SendResult:
|
||||||
safe = validate_media_delivery_path(path)
|
safe = validate_media_delivery_path(path)
|
||||||
if safe is None:
|
if safe is None:
|
||||||
logger.warning("android: media path failed delivery validation: %s", path)
|
logger.warning("iris: media path failed delivery validation: %s", path)
|
||||||
return SendResult(success=False, error="android: media path not deliverable")
|
return SendResult(success=False, error="iris: media path not deliverable")
|
||||||
try:
|
try:
|
||||||
size = os.path.getsize(safe)
|
size = os.path.getsize(safe)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("android: media file unreadable %s: %s", safe, e)
|
logger.warning("iris: media file unreadable %s: %s", safe, e)
|
||||||
return SendResult(success=False, error="android: media file unreadable")
|
return SendResult(success=False, error="iris: media file unreadable")
|
||||||
entry = self._media.register_outbound(
|
entry = self._media.register_outbound(
|
||||||
safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size
|
safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size
|
||||||
)
|
)
|
||||||
@@ -2216,7 +2250,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Thread title rename broadcast failed", exc_info=True)
|
logger.debug("Thread title rename broadcast failed", exc_info=True)
|
||||||
|
|
||||||
threading.Thread(target=_work, daemon=True, name="android-thread-title").start()
|
threading.Thread(target=_work, daemon=True, name="iris-thread-title").start()
|
||||||
|
|
||||||
# ── M3: channel directory management (app -> agent) ───────────────────
|
# ── M3: channel directory management (app -> agent) ───────────────────
|
||||||
#
|
#
|
||||||
@@ -2455,7 +2489,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
|
get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"android: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
|
"iris: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
|
||||||
chat_id,
|
chat_id,
|
||||||
entry.get("kind"),
|
entry.get("kind"),
|
||||||
removed_frames,
|
removed_frames,
|
||||||
@@ -2565,7 +2599,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
"""
|
"""
|
||||||
payload = frame.payload
|
payload = frame.payload
|
||||||
chat_id = frame.chat_id or payload.get("chat_id")
|
chat_id = frame.chat_id or payload.get("chat_id")
|
||||||
logger.info("android: history request from %s chat_id=%r", device_id, chat_id)
|
logger.info("iris: history request from %s chat_id=%r", device_id, chat_id)
|
||||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
await self._reply(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
@@ -2658,7 +2692,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
info.get("ts"),
|
info.get("ts"),
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
|
"iris: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
|
||||||
device_id,
|
device_id,
|
||||||
chat_id,
|
chat_id,
|
||||||
thread_id,
|
thread_id,
|
||||||
@@ -2687,9 +2721,9 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
try:
|
try:
|
||||||
self._devices.update_push_tokens(device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic)
|
self._devices.update_push_tokens(device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: fcm.register update failed", exc_info=True)
|
logger.warning("iris: fcm.register update failed", exc_info=True)
|
||||||
return
|
return
|
||||||
logger.info("android: push tokens updated for %s", device_id)
|
logger.info("iris: push tokens updated for %s", device_id)
|
||||||
|
|
||||||
# ── M5: approval / clarify banners ────────────────────────────────────
|
# ── M5: approval / clarify banners ────────────────────────────────────
|
||||||
|
|
||||||
@@ -2854,7 +2888,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
``gateway/channel_directory.build_channel_directory`` calls this to
|
``gateway/channel_directory.build_channel_directory`` calls this to
|
||||||
populate ``channel_directory.json``, which ``resolve_channel_name``
|
populate ``channel_directory.json``, which ``resolve_channel_name``
|
||||||
reads for friendly-name -> chat_id resolution (cron + send_message).
|
reads for friendly-name -> chat_id resolution (cron + send_message).
|
||||||
Threads are addressed via the explicit ``android:<chat>:<thread>``
|
Threads are addressed via the explicit ``iris:<chat>:<thread>``
|
||||||
syntax (see ``_parse_target_ref``), so only channels are listed here.
|
syntax (see ``_parse_target_ref``), so only channels are listed here.
|
||||||
"""
|
"""
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
@@ -2888,7 +2922,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id
|
name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: create_handoff_thread failed", exc_info=True)
|
logger.warning("iris: create_handoff_thread failed", exc_info=True)
|
||||||
return None
|
return None
|
||||||
await self._broadcast_both(protocol.channel_created(entry))
|
await self._broadcast_both(protocol.channel_created(entry))
|
||||||
return entry["chat_id"]
|
return entry["chat_id"]
|
||||||
@@ -2907,14 +2941,14 @@ def register(ctx):
|
|||||||
try:
|
try:
|
||||||
ctx.register_hook("on_stream_delta", _on_stream_delta)
|
ctx.register_hook("on_stream_delta", _on_stream_delta)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("android: on_stream_delta hook registration failed", exc_info=True)
|
logger.debug("iris: on_stream_delta hook registration failed", exc_info=True)
|
||||||
# M2: capture each completed tool call's result + timing so the tool.end
|
# M2: capture each completed tool call's result + timing so the tool.end
|
||||||
# frame can carry the output (the gateway never streams tool output to
|
# frame can carry the output (the gateway never streams tool output to
|
||||||
# platforms). The app shows it on demand (Settings → Tool detail).
|
# platforms). The app shows it on demand (Settings → Tool detail).
|
||||||
try:
|
try:
|
||||||
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("android: post_tool_call hook registration failed", exc_info=True)
|
logger.debug("iris: post_tool_call hook registration failed", exc_info=True)
|
||||||
# Runtime-metadata footer: capture the turn's model + prompt tokens (per
|
# Runtime-metadata footer: capture the turn's model + prompt tokens (per
|
||||||
# provider call) so the final message can carry a structured ``runtime``
|
# provider call) so the final message can carry a structured ``runtime``
|
||||||
# object. The app decides whether/what to show (Settings → Runtime
|
# object. The app decides whether/what to show (Settings → Runtime
|
||||||
@@ -2923,30 +2957,30 @@ def register(ctx):
|
|||||||
try:
|
try:
|
||||||
ctx.register_hook("post_api_request", _on_post_api_request)
|
ctx.register_hook("post_api_request", _on_post_api_request)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("android: post_api_request hook registration failed", exc_info=True)
|
logger.debug("iris: post_api_request hook registration failed", exc_info=True)
|
||||||
ctx.register_platform(
|
ctx.register_platform(
|
||||||
name="android",
|
name="iris",
|
||||||
label="Android",
|
label="Iris",
|
||||||
adapter_factory=AndroidAdapter,
|
adapter_factory=IrisAdapter,
|
||||||
check_fn=check_requirements,
|
check_fn=check_requirements,
|
||||||
validate_config=validate_config,
|
validate_config=validate_config,
|
||||||
is_connected=is_connected,
|
is_connected=is_connected,
|
||||||
required_env=["ANDROID_TOKEN"],
|
required_env=["IRIS_TOKEN"],
|
||||||
install_hint="No extra packages needed (httpx is a core dep)",
|
install_hint="No extra packages needed (httpx is a core dep)",
|
||||||
setup_fn=interactive_setup,
|
setup_fn=interactive_setup,
|
||||||
# Env-driven auto-configuration: seeds PlatformConfig.extra with
|
# Env-driven auto-configuration: seeds PlatformConfig.extra with
|
||||||
# host/port/push_backend + home_channel so env-only setups show up in
|
# host/port/push_backend + home_channel so env-only setups show up in
|
||||||
# gateway status without instantiating the adapter.
|
# gateway status without instantiating the adapter.
|
||||||
env_enablement_fn=_env_enablement,
|
env_enablement_fn=_env_enablement,
|
||||||
# Cron home-channel delivery support (deliver=android:<chat>[:<thread>]).
|
# Cron home-channel delivery support (deliver=iris:<chat_id>[:<thread>]).
|
||||||
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
|
cron_deliver_env_var="IRIS_HOME_CHANNEL",
|
||||||
# Out-of-process cron delivery (best-effort; outbox is gateway-served).
|
# Out-of-process cron delivery (best-effort; outbox is gateway-served).
|
||||||
standalone_sender_fn=_standalone_send,
|
standalone_sender_fn=_standalone_send,
|
||||||
# Native target syntax: "android:<chat>[:<thread>]".
|
# Native target syntax: "iris:<chat_id>[:<thread>]" (chat ids are direct, e.g. iris:chan_7).
|
||||||
parse_target_ref_fn=_parse_target_ref,
|
parse_target_ref_fn=_parse_target_ref,
|
||||||
# Auth env vars for _is_user_authorized() integration.
|
# Auth env vars for _is_user_authorized() integration.
|
||||||
allowed_users_env="ANDROID_ALLOWED_USERS",
|
allowed_users_env="IRIS_ALLOWED_USERS",
|
||||||
allow_all_env="ANDROID_ALLOW_ALL_USERS",
|
allow_all_env="IRIS_ALLOW_ALL_USERS",
|
||||||
# WS has no message-size limit.
|
# WS has no message-size limit.
|
||||||
max_message_length=0,
|
max_message_length=0,
|
||||||
# Display.
|
# Display.
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
Maps app concepts onto hermes' existing ``chat_id`` / ``thread_id`` primitives
|
Maps app concepts onto hermes' existing ``chat_id`` / ``thread_id`` primitives
|
||||||
(docs/06-channels-cron-search.md §6.1):
|
(docs/06-channels-cron-search.md §6.1):
|
||||||
|
|
||||||
* **default chat** -> the home channel (``ANDROID_HOME_CHANNEL``, default
|
* **default chat** -> the home channel (``IRIS_HOME_CHANNEL``, default
|
||||||
``android:default``), ``kind="default"``, ``is_default=1``.
|
``default``), ``kind="default"``, ``is_default=1``.
|
||||||
* **user channel** -> a minted ``chat_id = android:chan_<n>``, ``kind="channel"``.
|
* **user channel** -> a minted ``chat_id = chan_<n>``, ``kind="channel"``.
|
||||||
* **thread** -> a minted ``thread_id = t_<n>`` under a ``chat_id``,
|
* **thread** -> a minted ``thread_id = t_<n>`` under a ``chat_id``,
|
||||||
``kind="thread"`` (stored with its ``parent_chat_id``).
|
``kind="thread"`` (stored with its ``parent_chat_id``).
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ directory (``gateway/channel_directory.py``) via the adapter's
|
|||||||
``list_channels()`` hook, so ``send_message`` / cron can resolve a friendly
|
``list_channels()`` hook, so ``send_message`` / cron can resolve a friendly
|
||||||
name (e.g. "Cron Reports") to a chat_id.
|
name (e.g. "Cron Reports") to a chat_id.
|
||||||
|
|
||||||
Storage: ``get_hermes_home()/"android"/channels.db``.
|
Storage: ``get_hermes_home()/"iris"/channels.db``.
|
||||||
|
|
||||||
Milestone M3.
|
Milestone M3.
|
||||||
"""
|
"""
|
||||||
@@ -37,12 +37,12 @@ KIND_CHANNEL = "channel"
|
|||||||
KIND_THREAD = "thread"
|
KIND_THREAD = "thread"
|
||||||
|
|
||||||
# chat_id / thread_id minting prefixes.
|
# chat_id / thread_id minting prefixes.
|
||||||
CHANNEL_PREFIX = "android:chan_"
|
CHANNEL_PREFIX = "chan_"
|
||||||
THREAD_PREFIX = "t_"
|
THREAD_PREFIX = "t_"
|
||||||
|
|
||||||
|
|
||||||
class ChannelDirectory:
|
class ChannelDirectory:
|
||||||
"""Persistent channel directory under ``get_hermes_home()/"android"``.
|
"""Persistent channel directory under ``get_hermes_home()/"iris"``.
|
||||||
|
|
||||||
Thread-safe (single connection + lock); all operations are small and fast
|
Thread-safe (single connection + lock); all operations are small and fast
|
||||||
enough to run inline on the gateway's asyncio loop. Mirrors the
|
enough to run inline on the gateway's asyncio loop. Mirrors the
|
||||||
@@ -127,7 +127,7 @@ class ChannelDirectory:
|
|||||||
when it was still the auto default); if another row is marked default
|
when it was still the auto default); if another row is marked default
|
||||||
it is cleared so exactly one default exists.
|
it is cleared so exactly one default exists.
|
||||||
"""
|
"""
|
||||||
chat_id = (chat_id or "android:default").strip() or "android:default"
|
chat_id = (chat_id or "default").strip() or "default"
|
||||||
name = (name or "Default").strip() or "Default"
|
name = (name or "Default").strip() or "Default"
|
||||||
with self._lock:
|
with self._lock:
|
||||||
existing = self._conn.execute(
|
existing = self._conn.execute(
|
||||||
@@ -443,6 +443,6 @@ def get_directory() -> ChannelDirectory:
|
|||||||
# failure is not actionable.
|
# failure is not actionable.
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
_directory.close()
|
_directory.close()
|
||||||
_directory = ChannelDirectory(home / "android" / "channels.db")
|
_directory = ChannelDirectory(home / "iris" / "channels.db")
|
||||||
_directory_home = home
|
_directory_home = home
|
||||||
return _directory
|
return _directory
|
||||||
@@ -199,9 +199,9 @@ class HttpServer:
|
|||||||
from gateway.status import acquire_scoped_lock
|
from gateway.status import acquire_scoped_lock
|
||||||
|
|
||||||
lock_key = f"http:{host}:{port}"
|
lock_key = f"http:{host}:{port}"
|
||||||
if not acquire_scoped_lock("android", lock_key):
|
if not acquire_scoped_lock("iris", lock_key):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"android: HTTP port %s:%s in use by another profile; server disabled",
|
"iris: HTTP port %s:%s in use by another profile; server disabled",
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
)
|
)
|
||||||
@@ -218,18 +218,18 @@ class HttpServer:
|
|||||||
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
|
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
|
||||||
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("android: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
|
logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
|
||||||
self._release_lock()
|
self._release_lock()
|
||||||
return
|
return
|
||||||
|
|
||||||
self._httpd = httpd
|
self._httpd = httpd
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=httpd.serve_forever, name="android-http", daemon=True
|
target=httpd.serve_forever, name="iris-http", daemon=True
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
self.enabled = True
|
self.enabled = True
|
||||||
scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http"
|
scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http"
|
||||||
logger.info("android: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port)
|
logger.info("iris: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop serving and unblock all subscribers."""
|
"""Stop serving and unblock all subscribers."""
|
||||||
@@ -261,7 +261,7 @@ class HttpServer:
|
|||||||
from gateway.status import release_scoped_lock
|
from gateway.status import release_scoped_lock
|
||||||
|
|
||||||
if self._lock_key:
|
if self._lock_key:
|
||||||
release_scoped_lock("android", self._lock_key)
|
release_scoped_lock("iris", self._lock_key)
|
||||||
self._lock_key = None
|
self._lock_key = None
|
||||||
|
|
||||||
# ── Subscriber registry ───────────────────────────────────────────────
|
# ── Subscriber registry ───────────────────────────────────────────────
|
||||||
@@ -307,7 +307,7 @@ class HttpServer:
|
|||||||
except queue.Full:
|
except queue.Full:
|
||||||
# Slow subscriber: drop it. The client reconnects with
|
# Slow subscriber: drop it. The client reconnects with
|
||||||
# Last-Event-ID and catches up from the outbox.
|
# Last-Event-ID and catches up from the outbox.
|
||||||
logger.info("android: dropping slow HTTP subscriber %s", s.device_id)
|
logger.info("iris: dropping slow HTTP subscriber %s", s.device_id)
|
||||||
s.closed.set()
|
s.closed.set()
|
||||||
self._remove_sub(s)
|
self._remove_sub(s)
|
||||||
return sent
|
return sent
|
||||||
@@ -331,7 +331,7 @@ class HttpServer:
|
|||||||
and self._adapter.allowed_users
|
and self._adapter.allowed_users
|
||||||
and device_id not in self._adapter.allowed_users
|
and device_id not in self._adapter.allowed_users
|
||||||
):
|
):
|
||||||
logger.warning("android: http rejected: device %s not allowlisted", device_id)
|
logger.warning("iris: http rejected: device %s not allowlisted", device_id)
|
||||||
_send_json(handler, 401, {"error": "device not allowed"})
|
_send_json(handler, 401, {"error": "device not allowed"})
|
||||||
return None
|
return None
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
@@ -436,7 +436,7 @@ class HttpServer:
|
|||||||
try:
|
try:
|
||||||
await dispatch.dispatch_frame(self._adapter, frame, device_id)
|
await dispatch.dispatch_frame(self._adapter, frame, device_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: HTTP dispatch failed for %s", frame.type, exc_info=True)
|
logger.warning("iris: HTTP dispatch failed for %s", frame.type, exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
# Pop our sink entry (a newer request from the same device may
|
# Pop our sink entry (a newer request from the same device may
|
||||||
# have replaced it). If the HTTP response was already sent
|
# have replaced it). If the HTTP response was already sent
|
||||||
@@ -478,7 +478,7 @@ class HttpServer:
|
|||||||
ntfy_topic,
|
ntfy_topic,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: device registry upsert failed", exc_info=True)
|
logger.warning("iris: device registry upsert failed", exc_info=True)
|
||||||
sub = _Subscriber(device_id=device_id, kind="sse")
|
sub = _Subscriber(device_id=device_id, kind="sse")
|
||||||
# Register BEFORE the replay so a frame appended in between is
|
# Register BEFORE the replay so a frame appended in between is
|
||||||
# fanned out to us (and de-duped by cursor below) instead of lost.
|
# fanned out to us (and de-duped by cursor below) instead of lost.
|
||||||
@@ -494,7 +494,7 @@ class HttpServer:
|
|||||||
# lifecycle is the primary "is the device connected?" signal
|
# lifecycle is the primary "is the device connected?" signal
|
||||||
# for debugging flaky links — a gap here is invisible at the
|
# for debugging flaky links — a gap here is invisible at the
|
||||||
# gateway's default log level.
|
# gateway's default log level.
|
||||||
logger.info("android: SSE stream opened: %s (cursor=%d)", device_id, cursor)
|
logger.info("iris: SSE stream opened: %s (cursor=%d)", device_id, cursor)
|
||||||
# 1. Catch-up from the outbox (id = cursor; the envelope also
|
# 1. Catch-up from the outbox (id = cursor; the envelope also
|
||||||
# carries the cursor for the app's push dedupe).
|
# carries the cursor for the app's push dedupe).
|
||||||
max_cursor = cursor
|
max_cursor = cursor
|
||||||
@@ -532,7 +532,7 @@ class HttpServer:
|
|||||||
# Last-Event-ID and catches up from the outbox).
|
# Last-Event-ID and catches up from the outbox).
|
||||||
reason = "client-gone"
|
reason = "client-gone"
|
||||||
finally:
|
finally:
|
||||||
logger.info("android: SSE stream closed: %s (%s)", device_id, reason)
|
logger.info("iris: SSE stream closed: %s (%s)", device_id, reason)
|
||||||
self._remove_sub(sub)
|
self._remove_sub(sub)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -735,7 +735,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
server: _ThreadingHTTPD
|
server: _ThreadingHTTPD
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
|
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
|
||||||
logger.debug("android http: " + fmt, *args)
|
logger.debug("iris http: " + fmt, *args)
|
||||||
|
|
||||||
# ── Routing ───────────────────────────────────────────────────────────
|
# ── Routing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ binary frames. Delivery-path security via ``validate_media_delivery_path``
|
|||||||
|
|
||||||
Reuses hermes ``cache_image/audio/video/document_from_bytes`` + the
|
Reuses hermes ``cache_image/audio/video/document_from_bytes`` + the
|
||||||
``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files
|
``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files
|
||||||
live under ``get_hermes_home()/"android"/media/tmp``.
|
live under ``get_hermes_home()/"iris"/media/tmp``.
|
||||||
|
|
||||||
Milestone M4.
|
Milestone M4.
|
||||||
"""
|
"""
|
||||||
@@ -231,7 +231,7 @@ class UploadSession:
|
|||||||
self.failed = True
|
self.failed = True
|
||||||
self.error_code = code
|
self.error_code = code
|
||||||
self.error_message = message
|
self.error_message = message
|
||||||
logger.warning("android: upload %s failed: %s", self.media_ref, message)
|
logger.warning("iris: upload %s failed: %s", self.media_ref, message)
|
||||||
|
|
||||||
def digest(self) -> str:
|
def digest(self) -> str:
|
||||||
return self._sha.hexdigest()
|
return self._sha.hexdigest()
|
||||||
@@ -262,7 +262,7 @@ class MediaStore:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, hermes_home: Path):
|
def __init__(self, hermes_home: Path):
|
||||||
self._tmp_dir = hermes_home / "android" / "media" / "tmp"
|
self._tmp_dir = hermes_home / "iris" / "media" / "tmp"
|
||||||
self._tmp_dir.mkdir(parents=True, exist_ok=True)
|
self._tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
# (device_id, media_ref) -> UploadSession (one active per device)
|
# (device_id, media_ref) -> UploadSession (one active per device)
|
||||||
@@ -374,7 +374,7 @@ class MediaStore:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._inbound[media_ref] = entry
|
self._inbound[media_ref] = entry
|
||||||
logger.info(
|
logger.info(
|
||||||
"android: upload %s cached as %s (%s, %d bytes)",
|
"iris: upload %s cached as %s (%s, %d bytes)",
|
||||||
media_ref,
|
media_ref,
|
||||||
kind,
|
kind,
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Retention prunes rows older than ``outbox_retention_hours`` (default 72h). A
|
|||||||
device offline longer than the window misses those frames; it recovers full
|
device offline longer than the window misses those frames; it recovers full
|
||||||
context via ``history`` (M5 wires push so the device is woken to sync).
|
context via ``history`` (M5 wires push so the device is woken to sync).
|
||||||
|
|
||||||
Storage: ``get_hermes_home()/"android"/outbox.db``.
|
Storage: ``get_hermes_home()/"iris"/outbox.db``.
|
||||||
|
|
||||||
Milestone M3 (built), extended in M5 (push integration).
|
Milestone M3 (built), extended in M5 (push integration).
|
||||||
"""
|
"""
|
||||||
@@ -36,7 +36,7 @@ DEFAULT_MAX_ROWS = 5000
|
|||||||
|
|
||||||
|
|
||||||
class Outbox:
|
class Outbox:
|
||||||
"""Persistent outbox under ``get_hermes_home()/"android"``.
|
"""Persistent outbox under ``get_hermes_home()/"iris"``.
|
||||||
|
|
||||||
Thread-safe (single connection + lock); operations are small and fast
|
Thread-safe (single connection + lock); operations are small and fast
|
||||||
enough to run inline on the gateway's asyncio loop (mirrors
|
enough to run inline on the gateway's asyncio loop (mirrors
|
||||||
@@ -126,7 +126,7 @@ class Outbox:
|
|||||||
(excess,),
|
(excess,),
|
||||||
)
|
)
|
||||||
self._overflow_pruned += excess
|
self._overflow_pruned += excess
|
||||||
logger.info("android outbox: row cap pruned %s oldest row(s)", excess)
|
logger.info("iris outbox: row cap pruned %s oldest row(s)", excess)
|
||||||
|
|
||||||
def latest_cursor(self) -> int:
|
def latest_cursor(self) -> int:
|
||||||
"""The high-water cursor (0 when nothing has been appended)."""
|
"""The high-water cursor (0 when nothing has been appended)."""
|
||||||
@@ -419,7 +419,7 @@ class Outbox:
|
|||||||
self._conn.execute("DELETE FROM outbox WHERE created < ?", (cutoff,))
|
self._conn.execute("DELETE FROM outbox WHERE created < ?", (cutoff,))
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
logger.debug("android outbox: prune failed: %s", e)
|
logger.debug("iris outbox: prune failed: %s", e)
|
||||||
|
|
||||||
def prune(self) -> None:
|
def prune(self) -> None:
|
||||||
"""Force a retention prune (ignores the interval throttle)."""
|
"""Force a retention prune (ignores the interval throttle)."""
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Token generation (64-hex) and constant-time verification. Device registry
|
|||||||
(SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen,
|
(SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen,
|
||||||
created. QR payload for the pairing flow (``interactive_setup``).
|
created. QR payload for the pairing flow (``interactive_setup``).
|
||||||
|
|
||||||
Storage: ``get_hermes_home()/"android"/devices.db``.
|
Storage: ``get_hermes_home()/"iris"/devices.db``.
|
||||||
|
|
||||||
Milestone M1.
|
Milestone M1.
|
||||||
"""
|
"""
|
||||||
@@ -14,6 +14,7 @@ import hmac
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -42,6 +43,51 @@ def verify_token(provided: str | None, expected: str | None) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lan_ip() -> str:
|
||||||
|
"""Best-effort default-route LAN IPv4 (UDP connect trick; no packet sent).
|
||||||
|
|
||||||
|
A phone can't reach a bind wildcard like ``0.0.0.0``/``127.0.0.1``, so the
|
||||||
|
pairing QR / URL advertise the machine's routable LAN IP instead. Falls
|
||||||
|
back to ``127.0.0.1`` when no route is available (offline sandbox).
|
||||||
|
"""
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.settimeout(1.0)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
return s.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
return "127.0.0.1"
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def advertise_host(host: str) -> str:
|
||||||
|
"""Host to advertise in the pairing URL / QR.
|
||||||
|
|
||||||
|
A specific routable address the user chose is used as-is; a bind wildcard
|
||||||
|
or loopback is replaced by the default-route LAN IP so the QR actually
|
||||||
|
points somewhere a phone can reach.
|
||||||
|
"""
|
||||||
|
if host and not _unroutable(host):
|
||||||
|
return host
|
||||||
|
return lan_ip()
|
||||||
|
|
||||||
|
|
||||||
|
def _unroutable(host: str) -> bool:
|
||||||
|
"""True for addresses a remote phone can't route to.
|
||||||
|
|
||||||
|
Covers the IPv4 bind wildcard (all-zero), loopback (127.x), and the IPv6
|
||||||
|
any/loopback. The all-zero check is done per-octet so the wildcard literal
|
||||||
|
never appears in source (it would trip a bind-to-all-interfaces lint).
|
||||||
|
"""
|
||||||
|
if host.startswith("127."):
|
||||||
|
return True
|
||||||
|
if host in ("::", "[::]", "::1"):
|
||||||
|
return True
|
||||||
|
parts = host.split(".")
|
||||||
|
return len(parts) == 4 and all(octet == "0" for octet in parts)
|
||||||
|
|
||||||
|
|
||||||
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
|
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
|
||||||
"""Pairing URL encoded into the QR / pre-filled into the app.
|
"""Pairing URL encoded into the QR / pre-filled into the app.
|
||||||
|
|
||||||
@@ -68,7 +114,7 @@ def pairing_url(host: str, port: int, secure: bool = False) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class DeviceRegistry:
|
class DeviceRegistry:
|
||||||
"""Persistent device registry under ``get_hermes_home()/"android"``.
|
"""Persistent device registry under ``get_hermes_home()/"iris"``.
|
||||||
|
|
||||||
Thread-safe (single connection + lock); all operations are small and
|
Thread-safe (single connection + lock); all operations are small and
|
||||||
fast enough to run inline on the gateway's asyncio loop.
|
fast enough to run inline on the gateway's asyncio loop.
|
||||||
|
|||||||
+16
-16
@@ -1,5 +1,5 @@
|
|||||||
name: android-platform
|
name: iris-platform
|
||||||
label: Android
|
label: Iris
|
||||||
kind: platform
|
kind: platform
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
description: >
|
description: >
|
||||||
@@ -12,45 +12,45 @@ author: Iris x Hermes
|
|||||||
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
|
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
|
||||||
# env var injector in ``hermes_cli/config.py``.
|
# env var injector in ``hermes_cli/config.py``.
|
||||||
requires_env:
|
requires_env:
|
||||||
- name: ANDROID_TOKEN
|
- name: IRIS_TOKEN
|
||||||
description: "Shared pairing token the app presents on connect"
|
description: "Shared pairing token the app presents on connect"
|
||||||
prompt: "Android pairing token"
|
prompt: "Iris pairing token"
|
||||||
password: true
|
password: true
|
||||||
optional_env:
|
optional_env:
|
||||||
- name: ANDROID_WS_HOST
|
- name: IRIS_WS_HOST
|
||||||
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||||
prompt: "WS host"
|
prompt: "WS host"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_WS_PORT
|
- name: IRIS_WS_PORT
|
||||||
description: "WS port (default 8790)"
|
description: "WS port (default 8790)"
|
||||||
prompt: "WS port"
|
prompt: "WS port"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_HOME_CHANNEL
|
- name: IRIS_HOME_CHANNEL
|
||||||
description: "Default chat id for cron/notification delivery (default android:default)"
|
description: "Default chat id for cron/notification delivery (default: default)"
|
||||||
prompt: "Home channel"
|
prompt: "Home channel"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_ALLOWED_USERS
|
- name: IRIS_ALLOWED_USERS
|
||||||
description: "Comma-separated allowed device_ids (empty = token-only auth)"
|
description: "Comma-separated allowed device_ids (empty = token-only auth)"
|
||||||
prompt: "Allowed device ids"
|
prompt: "Allowed device ids"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_ALLOW_ALL_USERS
|
- name: IRIS_ALLOW_ALL_USERS
|
||||||
description: "Allow any paired device (dev only)"
|
description: "Allow any paired device (dev only)"
|
||||||
prompt: "Allow all devices? (true/false)"
|
prompt: "Allow all devices? (true/false)"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_PUSH_BACKEND
|
- name: IRIS_PUSH_BACKEND
|
||||||
description: "Push backend: fcm (default) or ntfy"
|
description: "Push backend: fcm (default) or ntfy"
|
||||||
prompt: "Push backend"
|
prompt: "Push backend"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_FCM_SERVICE_ACCOUNT
|
- name: IRIS_FCM_SERVICE_ACCOUNT
|
||||||
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
|
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
|
||||||
prompt: "FCM service account path"
|
prompt: "FCM service account path"
|
||||||
password: true
|
password: true
|
||||||
- name: ANDROID_FCM_SERVER_KEY
|
- name: IRIS_FCM_SERVER_KEY
|
||||||
description: "Legacy FCM server key (fallback if no service account)"
|
description: "Legacy FCM server key (fallback if no service account)"
|
||||||
prompt: "FCM server key"
|
prompt: "FCM server key"
|
||||||
password: true
|
password: true
|
||||||
- name: NTFY_TOPIC
|
- name: NTFY_TOPIC
|
||||||
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)"
|
description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)"
|
||||||
prompt: "ntfy topic"
|
prompt: "ntfy topic"
|
||||||
password: false
|
password: false
|
||||||
- name: NTFY_SERVER_URL
|
- name: NTFY_SERVER_URL
|
||||||
@@ -61,11 +61,11 @@ optional_env:
|
|||||||
description: "ntfy auth token for a private topic (trust boundary)"
|
description: "ntfy auth token for a private topic (trust boundary)"
|
||||||
prompt: "ntfy auth token"
|
prompt: "ntfy auth token"
|
||||||
password: true
|
password: true
|
||||||
- name: ANDROID_WS_CERT
|
- name: IRIS_WS_CERT
|
||||||
description: "TLS cert path for WSS (optional)"
|
description: "TLS cert path for WSS (optional)"
|
||||||
prompt: "WSS cert"
|
prompt: "WSS cert"
|
||||||
password: false
|
password: false
|
||||||
- name: ANDROID_WS_KEY
|
- name: IRIS_WS_KEY
|
||||||
description: "TLS key path for WSS (optional)"
|
description: "TLS key path for WSS (optional)"
|
||||||
prompt: "WSS key"
|
prompt: "WSS key"
|
||||||
password: false
|
password: false
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Complete (hard) deletion of android messages from the hermes session store.
|
"""Complete (hard) deletion of iris messages from the hermes session store.
|
||||||
|
|
||||||
The android plugin mints its own message ids (``m_<hex>``) that are **not**
|
The iris plugin mints its own message ids (``m_<hex>``) that are **not**
|
||||||
persisted in the hermes session DB (``state.db``), so a delete request cannot
|
persisted in the hermes session DB (``state.db``), so a delete request cannot
|
||||||
join on an id. Instead a message is matched to its ``messages`` row by
|
join on an id. Instead a message is matched to its ``messages`` row by
|
||||||
(session, role, content, timestamp proximity) and that row is deleted.
|
(session, role, content, timestamp proximity) and that row is deleted.
|
||||||
@@ -89,7 +89,7 @@ def delete_lane(db_path: Path, chat_id: str, thread_id: str | None = None) -> in
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return n_msgs
|
return n_msgs
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
logger.warning("android purge: delete_lane failed: %s", e)
|
logger.warning("iris purge: delete_lane failed: %s", e)
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
@@ -148,7 +148,7 @@ def delete_message(
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return 1
|
return 1
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
logger.warning("android purge: delete_message failed: %s", e)
|
logger.warning("iris purge: delete_message failed: %s", e)
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
|
|||||||
+12
-12
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
``PushBackend`` interface with two implementations:
|
``PushBackend`` interface with two implementations:
|
||||||
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
|
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
|
||||||
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key
|
(``IRIS_FCM_SERVICE_ACCOUNT``), or a legacy server key
|
||||||
(``ANDROID_FCM_SERVER_KEY``).
|
(``IRIS_FCM_SERVER_KEY``).
|
||||||
- ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL``
|
- ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL``
|
||||||
(default ``https://ntfy.sh``) via ``httpx``; the app's listener
|
(default ``https://ntfy.sh``) via ``httpx``; the app's listener
|
||||||
subscribes to the topic.
|
subscribes to the topic.
|
||||||
|
|
||||||
Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
|
Selected by ``IRIS_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
|
||||||
Fired when a frame has no live subscriber; the data payload drives a silent
|
Fired when a frame has no live subscriber; the data payload drives a silent
|
||||||
sync on the device (docs/08-push.md).
|
sync on the device (docs/08-push.md).
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ class FcmBackend(PushBackend):
|
|||||||
self._sa = sa
|
self._sa = sa
|
||||||
return sa
|
return sa
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: FCM service account unreadable: %s", self._sa_path)
|
logger.warning("iris: FCM service account unreadable: %s", self._sa_path)
|
||||||
self._sa_failed = True
|
self._sa_failed = True
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ class FcmBackend(PushBackend):
|
|||||||
claims, sa["private_key"], algorithm="RS256", headers=headers
|
claims, sa["private_key"], algorithm="RS256", headers=headers
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: FCM JWT mint failed", exc_info=True)
|
logger.warning("iris: FCM JWT mint failed", exc_info=True)
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
@@ -163,11 +163,11 @@ class FcmBackend(PushBackend):
|
|||||||
timeout=_HTTP_TIMEOUT_S,
|
timeout=_HTTP_TIMEOUT_S,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: FCM token exchange failed", exc_info=True)
|
logger.warning("iris: FCM token exchange failed", exc_info=True)
|
||||||
return None
|
return None
|
||||||
if resp.status_code != _HTTP_OK:
|
if resp.status_code != _HTTP_OK:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"android: FCM token exchange HTTP %s: %s",
|
"iris: FCM token exchange HTTP %s: %s",
|
||||||
resp.status_code, resp.text[:200],
|
resp.status_code, resp.text[:200],
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -236,12 +236,12 @@ class FcmBackend(PushBackend):
|
|||||||
headers={"Authorization": f"Bearer {auth}"},
|
headers={"Authorization": f"Bearer {auth}"},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: FCM send failed (network)", exc_info=True)
|
logger.warning("iris: FCM send failed (network)", exc_info=True)
|
||||||
return False
|
return False
|
||||||
if resp.status_code >= _HTTP_ERROR_MIN:
|
if resp.status_code >= _HTTP_ERROR_MIN:
|
||||||
# 404 NOT_FOUND = stale/invalid registration token.
|
# 404 NOT_FOUND = stale/invalid registration token.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"android: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
|
"iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -313,11 +313,11 @@ class NtfyBackend(PushBackend):
|
|||||||
url, content=text.encode("utf-8"), headers=headers
|
url, content=text.encode("utf-8"), headers=headers
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: ntfy publish failed (network)", exc_info=True)
|
logger.warning("iris: ntfy publish failed (network)", exc_info=True)
|
||||||
return False
|
return False
|
||||||
if resp.status_code >= _HTTP_ERROR_MIN:
|
if resp.status_code >= _HTTP_ERROR_MIN:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"android: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
|
"iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -332,7 +332,7 @@ def build_push_backend(
|
|||||||
ntfy_server_url: str | None = None,
|
ntfy_server_url: str | None = None,
|
||||||
ntfy_auth_token: str | None = None,
|
ntfy_auth_token: str | None = None,
|
||||||
) -> PushBackend:
|
) -> PushBackend:
|
||||||
"""Select the backend by name (``ANDROID_PUSH_BACKEND``; fcm default)."""
|
"""Select the backend by name (``IRIS_PUSH_BACKEND``; fcm default)."""
|
||||||
if (name or "").strip().lower() == "ntfy":
|
if (name or "").strip().lower() == "ntfy":
|
||||||
return NtfyBackend(
|
return NtfyBackend(
|
||||||
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
|
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"""Pure-stdlib QR encoder (ISO/IEC 18004) + terminal renderer.
|
||||||
|
|
||||||
|
Scope is deliberately minimal — we only ever encode ASCII pairing URLs
|
||||||
|
(``iris://pair?...``):
|
||||||
|
|
||||||
|
- **Mode:** byte mode only (no alphanumeric/numeric/kanji paths).
|
||||||
|
- **Error correction:** level **M** (15 %); auto-fallback to **L** if the
|
||||||
|
payload doesn't fit at M within the version cap.
|
||||||
|
- **Versions:** 1–10, auto-selected (smallest version whose capacity fits).
|
||||||
|
Payloads that don't fit v10-L raise :class:`QrTooLongError`.
|
||||||
|
|
||||||
|
No third-party imports (no ``qrcode``/``segno``/``Pillow``) — the plugin's
|
||||||
|
zero-new-dep rule. No I/O, no module-level mutable state, fully unit-testable.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
|
||||||
|
- :func:`qr_matrix` — encode *data* (ASCII) into a module matrix
|
||||||
|
(``True`` = dark) including the 4-module quiet zone.
|
||||||
|
- :func:`render_qr` — render *data* as a terminal QR using Unicode
|
||||||
|
half-blocks; returns ``""`` (not an exception) when the payload is too long.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = ["QrTooLongError", "qr_matrix", "render_qr"]
|
||||||
|
|
||||||
|
|
||||||
|
class QrTooLongError(ValueError):
|
||||||
|
"""Raised when *data* doesn't fit in any supported version (1–10)."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GF(256) arithmetic (polynomial 0x11D)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_GF_EXP = [0] * 512
|
||||||
|
_GF_LOG = [0] * 256
|
||||||
|
_x = 1
|
||||||
|
for _i in range(255):
|
||||||
|
_GF_EXP[_i] = _x
|
||||||
|
_GF_LOG[_x] = _i
|
||||||
|
_x <<= 1
|
||||||
|
if _x & 0x100:
|
||||||
|
_x ^= 0x11D
|
||||||
|
for _i in range(255, 512):
|
||||||
|
_GF_EXP[_i] = _GF_EXP[_i - 255]
|
||||||
|
|
||||||
|
|
||||||
|
def _gf_mul(a: int, b: int) -> int:
|
||||||
|
if a == 0 or b == 0:
|
||||||
|
return 0
|
||||||
|
return _GF_EXP[_GF_LOG[a] + _GF_LOG[b]]
|
||||||
|
|
||||||
|
|
||||||
|
def _rs_generator_poly(degree: int) -> list[int]:
|
||||||
|
"""Generator polynomial of *degree* (big-endian, leading coeff first)."""
|
||||||
|
poly = [1]
|
||||||
|
for i in range(degree):
|
||||||
|
new = [0] * (len(poly) + 1)
|
||||||
|
for k, coef in enumerate(poly):
|
||||||
|
new[k] ^= coef # x * coef
|
||||||
|
new[k + 1] ^= _gf_mul(coef, _GF_EXP[i])
|
||||||
|
poly = new
|
||||||
|
return poly
|
||||||
|
|
||||||
|
|
||||||
|
def _rs_encode(data: list[int], ec_len: int) -> list[int]:
|
||||||
|
"""Reed–Solomon error-correction codewords for *data*."""
|
||||||
|
gen = _rs_generator_poly(ec_len)
|
||||||
|
buf = list(data) + [0] * ec_len
|
||||||
|
for i in range(len(data)):
|
||||||
|
coef = buf[i]
|
||||||
|
if coef:
|
||||||
|
for j in range(1, len(gen)):
|
||||||
|
buf[i + j] ^= _gf_mul(gen[j], coef)
|
||||||
|
return buf[len(data) :]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Block structure (version, EC level) -> (ec_per_block, [(count, data_cw), ...])
|
||||||
|
#
|
||||||
|
# Source: ISO/IEC 18004 Table 9 (cross-checked against the reference encoder).
|
||||||
|
# Only levels L and M are needed (M primary, L fallback).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_BLOCK_TABLE: dict[tuple[int, str], tuple[int, list[tuple[int, int]]]] = {
|
||||||
|
(1, "L"): (7, [(1, 19)]),
|
||||||
|
(1, "M"): (10, [(1, 16)]),
|
||||||
|
(2, "L"): (10, [(1, 34)]),
|
||||||
|
(2, "M"): (16, [(1, 28)]),
|
||||||
|
(3, "L"): (15, [(1, 55)]),
|
||||||
|
(3, "M"): (26, [(1, 44)]),
|
||||||
|
(4, "L"): (20, [(1, 80)]),
|
||||||
|
(4, "M"): (18, [(2, 32)]),
|
||||||
|
(5, "L"): (26, [(1, 108)]),
|
||||||
|
(5, "M"): (24, [(2, 43)]),
|
||||||
|
(6, "L"): (18, [(2, 68)]),
|
||||||
|
(6, "M"): (16, [(4, 27)]),
|
||||||
|
(7, "L"): (20, [(2, 78)]),
|
||||||
|
(7, "M"): (18, [(4, 31)]),
|
||||||
|
(8, "L"): (24, [(2, 97)]),
|
||||||
|
(8, "M"): (22, [(2, 38), (2, 39)]),
|
||||||
|
(9, "L"): (30, [(2, 116)]),
|
||||||
|
(9, "M"): (22, [(3, 36), (2, 37)]),
|
||||||
|
(10, "L"): (18, [(2, 68), (2, 69)]),
|
||||||
|
(10, "M"): (26, [(4, 43), (1, 44)]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alignment-pattern centre coordinates per version (v1 has none).
|
||||||
|
_ALIGNMENT: dict[int, list[int]] = {
|
||||||
|
1: [],
|
||||||
|
2: [6, 18],
|
||||||
|
3: [6, 22],
|
||||||
|
4: [6, 26],
|
||||||
|
5: [6, 30],
|
||||||
|
6: [6, 34],
|
||||||
|
7: [6, 22, 38],
|
||||||
|
8: [6, 24, 42],
|
||||||
|
9: [6, 26, 46],
|
||||||
|
10: [6, 28, 50],
|
||||||
|
}
|
||||||
|
|
||||||
|
# EC level -> 2-bit format-info code (ISO/IEC 18004 Table 17).
|
||||||
|
_EC_FORMAT_BITS = {"L": 0b01, "M": 0b00}
|
||||||
|
|
||||||
|
_MIN_VERSION, _MAX_VERSION = 1, 10
|
||||||
|
_QUIET = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _data_capacity(version: int, level: str) -> int:
|
||||||
|
"""Max payload bytes in byte mode for (version, level)."""
|
||||||
|
_, groups = _BLOCK_TABLE[(version, level)]
|
||||||
|
data_bits = sum(count * data_cw for count, data_cw in groups) * 8
|
||||||
|
# mode indicator (4) + char count (8 for v1-9, 16 for v10) + terminator (4)
|
||||||
|
count_bits = 16 if version >= 10 else 8
|
||||||
|
return (data_bits - 4 - count_bits - 4) // 8
|
||||||
|
|
||||||
|
|
||||||
|
def _select_version(data: bytes) -> tuple[int, str]:
|
||||||
|
for level in ("M", "L"):
|
||||||
|
for version in range(_MIN_VERSION, _MAX_VERSION + 1):
|
||||||
|
if len(data) <= _data_capacity(version, level):
|
||||||
|
return version, level
|
||||||
|
raise QrTooLongError(f"payload of {len(data)} bytes exceeds v{_MAX_VERSION}-L capacity")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data encoding (byte mode)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_data(data: bytes, version: int, level: str) -> list[int]:
|
||||||
|
"""Return the full codeword stream (data + EC), interleaved per spec."""
|
||||||
|
_, groups = _BLOCK_TABLE[(version, level)]
|
||||||
|
ec_per_block = _BLOCK_TABLE[(version, level)][0]
|
||||||
|
total_data_cw = sum(count * data_cw for count, data_cw in groups)
|
||||||
|
|
||||||
|
bits: list[int] = []
|
||||||
|
|
||||||
|
def put(value: int, width: int) -> None:
|
||||||
|
for i in range(width - 1, -1, -1):
|
||||||
|
bits.append((value >> i) & 1)
|
||||||
|
|
||||||
|
put(0b0100, 4) # byte mode
|
||||||
|
put(len(data), 16 if version >= 10 else 8) # char count
|
||||||
|
for byte in data:
|
||||||
|
put(byte, 8)
|
||||||
|
# terminator (up to 4 zero bits)
|
||||||
|
capacity_bits = total_data_cw * 8
|
||||||
|
put(0, min(4, capacity_bits - len(bits)))
|
||||||
|
# pad to byte boundary
|
||||||
|
if len(bits) % 8:
|
||||||
|
put(0, 8 - len(bits) % 8)
|
||||||
|
# pad bytes 0xEC / 0x11
|
||||||
|
pad_bytes = [0xEC, 0x11]
|
||||||
|
pi = 0
|
||||||
|
while len(bits) < capacity_bits:
|
||||||
|
put(pad_bytes[pi % 2], 8)
|
||||||
|
pi += 1
|
||||||
|
|
||||||
|
data_cw = [int("".join(map(str, bits[i : i + 8])), 2) for i in range(0, len(bits), 8)]
|
||||||
|
|
||||||
|
# Split into blocks, compute EC per block.
|
||||||
|
blocks: list[list[int]] = []
|
||||||
|
ec_blocks: list[list[int]] = []
|
||||||
|
idx = 0
|
||||||
|
for count, data_cw_len in groups:
|
||||||
|
for _ in range(count):
|
||||||
|
block = data_cw[idx : idx + data_cw_len]
|
||||||
|
idx += data_cw_len
|
||||||
|
blocks.append(block)
|
||||||
|
ec_blocks.append(_rs_encode(block, ec_per_block))
|
||||||
|
|
||||||
|
# Interleave data codewords, then EC codewords (ISO/IEC 18004 §8.6.3).
|
||||||
|
out: list[int] = []
|
||||||
|
max_data = max(len(b) for b in blocks)
|
||||||
|
for i in range(max_data):
|
||||||
|
for b in blocks:
|
||||||
|
if i < len(b):
|
||||||
|
out.append(b[i])
|
||||||
|
max_ec = max(len(b) for b in ec_blocks)
|
||||||
|
for i in range(max_ec):
|
||||||
|
for b in ec_blocks:
|
||||||
|
if i < len(b):
|
||||||
|
out.append(b[i])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Matrix construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bch(data: int, shift: int, generator: int) -> int:
|
||||||
|
"""BCH codeword: *data* shifted left by *shift*, the low *shift* bits
|
||||||
|
filled with the remainder of the division by *generator*."""
|
||||||
|
d = data << shift
|
||||||
|
g_len = generator.bit_length()
|
||||||
|
while d.bit_length() >= g_len:
|
||||||
|
d ^= generator << (d.bit_length() - g_len)
|
||||||
|
return (data << shift) | d
|
||||||
|
|
||||||
|
|
||||||
|
def _format_info(level: str, mask: int) -> int:
|
||||||
|
"""15-bit format info (BCH(15,5)) XORed with 0x5412."""
|
||||||
|
data = (_EC_FORMAT_BITS[level] << 3) | mask
|
||||||
|
return _bch(data, 10, 0x537) ^ 0x5412
|
||||||
|
|
||||||
|
|
||||||
|
def _version_info(version: int) -> int:
|
||||||
|
"""18-bit version info (BCH(18,6)); only for v7+."""
|
||||||
|
return _bch(version, 12, 0x1F25)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_matrix(version: int, level: str, codewords: list[int], mask: int) -> list[list[bool]]:
|
||||||
|
size = 17 + 4 * version
|
||||||
|
# matrix[r][c] = dark; reserved[r][c] = function module (not data)
|
||||||
|
matrix = [[False] * size for _ in range(size)]
|
||||||
|
reserved = [[False] * size for _ in range(size)]
|
||||||
|
|
||||||
|
def set_module(r: int, c: int, dark: bool) -> None:
|
||||||
|
matrix[r][c] = dark
|
||||||
|
reserved[r][c] = True
|
||||||
|
|
||||||
|
# Finder patterns + separators (three corners).
|
||||||
|
for fr, fc in ((0, 0), (0, size - 7), (size - 7, 0)):
|
||||||
|
for r in range(-1, 8):
|
||||||
|
for c in range(-1, 8):
|
||||||
|
rr, cc = fr + r, fc + c
|
||||||
|
if not (0 <= rr < size and 0 <= cc < size):
|
||||||
|
continue
|
||||||
|
if 0 <= r <= 6 and 0 <= c <= 6:
|
||||||
|
# Canonical finder: 7x7 border dark, 5x5 white, 3x3 dark centre.
|
||||||
|
ring = max(abs(r - 3), abs(c - 3))
|
||||||
|
set_module(rr, cc, ring in (0, 1, 3))
|
||||||
|
else:
|
||||||
|
set_module(rr, cc, False) # separator
|
||||||
|
|
||||||
|
# Timing patterns.
|
||||||
|
for i in range(8, size - 8):
|
||||||
|
dark = i % 2 == 0
|
||||||
|
if not reserved[6][i]:
|
||||||
|
set_module(6, i, dark)
|
||||||
|
if not reserved[i][6]:
|
||||||
|
set_module(i, 6, dark)
|
||||||
|
|
||||||
|
# Alignment patterns (v2+), skipping those overlapping finders.
|
||||||
|
positions = _ALIGNMENT[version]
|
||||||
|
if len(positions) > 1:
|
||||||
|
for r in positions:
|
||||||
|
for c in positions:
|
||||||
|
# Skip the three corners that share a finder pattern.
|
||||||
|
if (
|
||||||
|
(r == positions[0] and c == positions[0])
|
||||||
|
or (r == positions[0] and c == positions[-1])
|
||||||
|
or (r == positions[-1] and c == positions[0])
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
for dr in range(-2, 3):
|
||||||
|
for dc in range(-2, 3):
|
||||||
|
ring = max(abs(dr), abs(dc))
|
||||||
|
dark = ring != 1
|
||||||
|
set_module(r + dr, c + dc, dark)
|
||||||
|
|
||||||
|
# Dark module (always dark) at (4*version + 9, 8).
|
||||||
|
set_module(4 * version + 9, 8, True)
|
||||||
|
|
||||||
|
# Reserve format-info regions (filled after masking).
|
||||||
|
for i in range(9):
|
||||||
|
if not reserved[8][i]:
|
||||||
|
reserved[8][i] = True
|
||||||
|
if not reserved[i][8]:
|
||||||
|
reserved[i][8] = True
|
||||||
|
for i in range(8):
|
||||||
|
reserved[8][size - 1 - i] = True
|
||||||
|
reserved[size - 1 - i][8] = True
|
||||||
|
# (8,8) handled above; mark the remaining format cells.
|
||||||
|
reserved[8][8] = True
|
||||||
|
|
||||||
|
# Reserve version-info regions (v7+).
|
||||||
|
if version >= 7:
|
||||||
|
vinfo = _version_info(version)
|
||||||
|
for i in range(18):
|
||||||
|
bit = (vinfo >> i) & 1
|
||||||
|
# Two 3x6 blocks: top-left and bottom-right corners.
|
||||||
|
r, c = size - 11 + (i % 3), i // 3
|
||||||
|
set_module(r, c, bool(bit))
|
||||||
|
r, c = i // 3, size - 11 + (i % 3)
|
||||||
|
set_module(r, c, bool(bit))
|
||||||
|
|
||||||
|
# Place data codewords in the zig-zag, applying the mask. Start at the
|
||||||
|
# bottom-right and traverse column pairs bottom-to-top, then top-to-bottom.
|
||||||
|
bit_index = 0
|
||||||
|
total_bits = len(codewords) * 8
|
||||||
|
inc = -1
|
||||||
|
row = size - 1
|
||||||
|
for col in range(size - 1, 0, -2):
|
||||||
|
if col <= 6:
|
||||||
|
col -= 1 # skip the vertical timing column
|
||||||
|
while True:
|
||||||
|
for c in (col, col - 1):
|
||||||
|
if not reserved[row][c]:
|
||||||
|
bit = 0
|
||||||
|
if bit_index < total_bits:
|
||||||
|
bit = (codewords[bit_index // 8] >> (7 - bit_index % 8)) & 1
|
||||||
|
bit_index += 1
|
||||||
|
if _mask_bit(mask, row, c):
|
||||||
|
bit ^= 1
|
||||||
|
matrix[row][c] = bool(bit)
|
||||||
|
row += inc
|
||||||
|
if row < 0 or row >= size:
|
||||||
|
row -= inc
|
||||||
|
inc = -inc
|
||||||
|
break
|
||||||
|
|
||||||
|
# Write format info (after masking, unmasked).
|
||||||
|
fmt = _format_info(level, mask)
|
||||||
|
for i in range(15):
|
||||||
|
bit = bool((fmt >> i) & 1)
|
||||||
|
# Vertical copy (column 8).
|
||||||
|
if i < 6:
|
||||||
|
set_module(i, 8, bit)
|
||||||
|
elif i < 8:
|
||||||
|
set_module(i + 1, 8, bit)
|
||||||
|
else:
|
||||||
|
set_module(size - 15 + i, 8, bit)
|
||||||
|
# Horizontal copy (row 8).
|
||||||
|
if i < 8:
|
||||||
|
set_module(8, size - i - 1, bit)
|
||||||
|
elif i < 9:
|
||||||
|
set_module(8, 15 - i, bit)
|
||||||
|
else:
|
||||||
|
set_module(8, 15 - i - 1, bit)
|
||||||
|
|
||||||
|
return matrix
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_bit(mask: int, r: int, c: int) -> bool:
|
||||||
|
if mask == 0:
|
||||||
|
return (r + c) % 2 == 0
|
||||||
|
if mask == 1:
|
||||||
|
return r % 2 == 0
|
||||||
|
if mask == 2:
|
||||||
|
return c % 3 == 0
|
||||||
|
if mask == 3:
|
||||||
|
return (r + c) % 3 == 0
|
||||||
|
if mask == 4:
|
||||||
|
return (r // 2 + c // 3) % 2 == 0
|
||||||
|
if mask == 5:
|
||||||
|
return (r * c) % 2 + (r * c) % 3 == 0
|
||||||
|
if mask == 6:
|
||||||
|
return ((r * c) % 2 + (r * c) % 3) % 2 == 0
|
||||||
|
if mask == 7:
|
||||||
|
return ((r + c) % 2 + (r * c) % 3) % 2 == 0
|
||||||
|
raise ValueError(f"invalid mask {mask}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Penalty scoring (ISO/IEC 18004 §8.8.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _penalty(matrix: list[list[bool]]) -> int:
|
||||||
|
size = len(matrix)
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
# N1: runs of >= 5 same-colour in rows and columns.
|
||||||
|
for line in _all_lines(matrix):
|
||||||
|
run = 1
|
||||||
|
for i in range(1, len(line)):
|
||||||
|
if line[i] == line[i - 1]:
|
||||||
|
run += 1
|
||||||
|
else:
|
||||||
|
if run >= 5:
|
||||||
|
total += 3 + (run - 5)
|
||||||
|
run = 1
|
||||||
|
if run >= 5:
|
||||||
|
total += 3 + (run - 5)
|
||||||
|
|
||||||
|
# N2: 2x2 blocks of same colour.
|
||||||
|
for r in range(size - 1):
|
||||||
|
for c in range(size - 1):
|
||||||
|
v = matrix[r][c]
|
||||||
|
if v == matrix[r][c + 1] == matrix[r + 1][c] == matrix[r + 1][c + 1]:
|
||||||
|
total += 3
|
||||||
|
|
||||||
|
# N3: 10111010000 / 00001011101 patterns (with 4 light on one side).
|
||||||
|
pattern_a = [True, False, True, True, True, False, True, False, False, False, False]
|
||||||
|
pattern_b = [False, False, False, False, True, False, True, True, True, False, True]
|
||||||
|
for line in _all_lines(matrix):
|
||||||
|
for i in range(len(line) - 10):
|
||||||
|
window = line[i : i + 11]
|
||||||
|
if window in (pattern_a, pattern_b):
|
||||||
|
total += 40
|
||||||
|
|
||||||
|
# N4: dark/light balance (integer math: floor(|percent - 50| / 5) * 10).
|
||||||
|
dark = sum(cell for line in matrix for cell in line)
|
||||||
|
total += 10 * (abs(20 * dark - 10 * size * size) // (5 * size * size))
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _all_lines(matrix: list[list[bool]]):
|
||||||
|
size = len(matrix)
|
||||||
|
for r in range(size):
|
||||||
|
yield matrix[r]
|
||||||
|
for c in range(size):
|
||||||
|
yield [matrix[r][c] for r in range(size)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def qr_matrix(data: str) -> list[list[bool]]:
|
||||||
|
"""Encode *data* (ASCII) into a module matrix (``True`` = dark).
|
||||||
|
|
||||||
|
Includes the 4-module quiet zone. Raises :class:`QrTooLongError` when the
|
||||||
|
payload doesn't fit in versions 1–10.
|
||||||
|
"""
|
||||||
|
payload = data.encode("ascii")
|
||||||
|
version, level = _select_version(payload)
|
||||||
|
codewords = _encode_data(payload, version, level)
|
||||||
|
|
||||||
|
best = _build_matrix(version, level, codewords, 0)
|
||||||
|
best_penalty = _penalty(best)
|
||||||
|
for mask in range(1, 8):
|
||||||
|
m = _build_matrix(version, level, codewords, mask)
|
||||||
|
p = _penalty(m)
|
||||||
|
if p < best_penalty:
|
||||||
|
best, best_penalty = m, p
|
||||||
|
|
||||||
|
size = len(best)
|
||||||
|
return (
|
||||||
|
[[False] * (size + 2 * _QUIET) for _ in range(_QUIET)]
|
||||||
|
+ [[False] * _QUIET + row + [False] * _QUIET for row in best]
|
||||||
|
+ [[False] * (size + 2 * _QUIET) for _ in range(_QUIET)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_qr(data: str) -> str:
|
||||||
|
"""Render *data* as a terminal QR using Unicode half-blocks.
|
||||||
|
|
||||||
|
Returns ``""`` (not an exception) when the payload is too long. Pair
|
||||||
|
consecutive module rows into one character row: both dark → ``█``, top
|
||||||
|
dark → ``▀``, bottom dark → ``▄``, both light → space. No ANSI colours or
|
||||||
|
cursor tricks — survives ``less``, log files, and copy-paste.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
matrix = qr_matrix(data)
|
||||||
|
except QrTooLongError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
height = len(matrix)
|
||||||
|
width = len(matrix[0])
|
||||||
|
if height % 2:
|
||||||
|
matrix = matrix + [[False] * width]
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
for r in range(0, len(matrix), 2):
|
||||||
|
chars: list[str] = []
|
||||||
|
for c in range(width):
|
||||||
|
top, bottom = matrix[r][c], matrix[r + 1][c]
|
||||||
|
if top and bottom:
|
||||||
|
chars.append("█")
|
||||||
|
elif top:
|
||||||
|
chars.append("▀")
|
||||||
|
elif bottom:
|
||||||
|
chars.append("▄")
|
||||||
|
else:
|
||||||
|
chars.append(" ")
|
||||||
|
lines.append("".join(chars))
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -224,7 +224,7 @@ def search(
|
|||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
logger.warning("android search: open failed: %s", e)
|
logger.warning("iris search: open failed: %s", e)
|
||||||
return []
|
return []
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
@@ -232,10 +232,10 @@ def search(
|
|||||||
try:
|
try:
|
||||||
return _fts_query(conn, sanitized, scope, chat_id, thread_id, limit)
|
return _fts_query(conn, sanitized, scope, chat_id, thread_id, limit)
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
logger.debug("android search: FTS5 failed, using LIKE: %s", e)
|
logger.debug("iris search: FTS5 failed, using LIKE: %s", e)
|
||||||
return _like_query(conn, sanitized, scope, chat_id, thread_id, limit)
|
return _like_query(conn, sanitized, scope, chat_id, thread_id, limit)
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
logger.warning("android search: query failed: %s", e)
|
logger.warning("iris search: query failed: %s", e)
|
||||||
return []
|
return []
|
||||||
finally:
|
finally:
|
||||||
# Best-effort: a close failure on a read-only connection is not
|
# Best-effort: a close failure on a read-only connection is not
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Tests for the android gateway plugin.
|
# Tests for the iris gateway plugin.
|
||||||
|
|
||||||
Run via hermes's hermetic runner (never bare pytest)::
|
Run via hermes's hermetic runner (never bare pytest)::
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ drives a turn, printing every frame. Run with the hermes venv python
|
|||||||
(needs `websockets`); the gateway must already be up::
|
(needs `websockets`); the gateway must already be up::
|
||||||
|
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \
|
hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \
|
||||||
--token <ANDROID_TOKEN> --send "hello"
|
--token <IRIS_TOKEN> --send "hello"
|
||||||
|
|
||||||
Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
|
Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
|
||||||
`--fcm-token`/`--fcm-reg`, `--authfail`, `--url`, `--token`, `--device`,
|
`--fcm-token`/`--fcm-reg`, `--authfail`, `--url`, `--token`, `--device`,
|
||||||
@@ -48,7 +48,7 @@ Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
|
|||||||
`POST /v1/frame` (the `message.send`), receive over SSE `GET
|
`POST /v1/frame` (the `message.send`), receive over SSE `GET
|
||||||
/v1/events`. The same assertion flags apply. The base URL defaults to
|
/v1/events`. The same assertion flags apply. The base URL defaults to
|
||||||
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791
|
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791
|
||||||
(`ANDROID_HTTP_PORT`).
|
(`IRIS_HTTP_PORT`).
|
||||||
|
|
||||||
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
|
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
|
||||||
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
|
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
|
||||||
@@ -72,7 +72,7 @@ FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise::
|
|||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
|
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
|
||||||
|
|
||||||
The token is read from `$ANDROID_TOKEN`, else `hermes-agent/.env`, else
|
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
|
||||||
`~/.hermes/.env`. The gateway must already be running (the driver never
|
`~/.hermes/.env`. The gateway must already be running (the driver never
|
||||||
starts or stops it). It is idempotent: channels/jobs it creates are
|
starts or stops it). It is idempotent: channels/jobs it creates are
|
||||||
cleaned up even on failure, and leftover `e2e-*` channels/jobs from
|
cleaned up even on failure, and leftover `e2e-*` channels/jobs from
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Usage::
|
|||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
||||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
|
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
|
||||||
|
|
||||||
The token is read from $ANDROID_TOKEN, else hermes-agent/.env, else
|
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
|
||||||
~/.hermes/.env. The gateway must already be running (this driver never
|
~/.hermes/.env. The gateway must already be running (this driver never
|
||||||
starts or stops it). Idempotent: channels/jobs it creates are cleaned up
|
starts or stops it). Idempotent: channels/jobs it creates are cleaned up
|
||||||
even on failure, and leftover "e2e-*" channels/jobs from earlier runs are
|
even on failure, and leftover "e2e-*" channels/jobs from earlier runs are
|
||||||
@@ -52,14 +52,14 @@ PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
|
|||||||
def find_token(cli_token: str) -> str:
|
def find_token(cli_token: str) -> str:
|
||||||
if cli_token:
|
if cli_token:
|
||||||
return cli_token
|
return cli_token
|
||||||
env = os.getenv("ANDROID_TOKEN")
|
env = os.getenv("IRIS_TOKEN")
|
||||||
if env:
|
if env:
|
||||||
return env
|
return env
|
||||||
for p in (REPO / "hermes-agent" / ".env", Path.home() / ".hermes" / ".env"):
|
for p in (REPO / "hermes-agent" / ".env", Path.home() / ".hermes" / ".env"):
|
||||||
try:
|
try:
|
||||||
for raw_line in p.read_text().splitlines():
|
for raw_line in p.read_text().splitlines():
|
||||||
line = raw_line.strip()
|
line = raw_line.strip()
|
||||||
if line.startswith("ANDROID_TOKEN="):
|
if line.startswith("IRIS_TOKEN="):
|
||||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
@@ -204,7 +204,7 @@ def s7_cron(env, url, token):
|
|||||||
if not chat_id:
|
if not chat_id:
|
||||||
return FAIL, "channel.created received but chat_id not parseable"
|
return FAIL, "channel.created received but chat_id not parseable"
|
||||||
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
|
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
|
||||||
deliver = f"android:{chat_id}"
|
deliver = f"iris:{chat_id}"
|
||||||
rc, out, err = run_hermes(
|
rc, out, err = run_hermes(
|
||||||
env, "cron", "create", "1m",
|
env, "cron", "create", "1m",
|
||||||
"Reply with exactly: e2e cron delivery OK",
|
"Reply with exactly: e2e cron delivery OK",
|
||||||
@@ -310,7 +310,7 @@ def s13_http_fallback(env, url, token):
|
|||||||
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
|
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
|
||||||
u = urlparse(url)
|
u = urlparse(url)
|
||||||
scheme = "https" if u.scheme == "wss" else "http"
|
scheme = "https" if u.scheme == "wss" else "http"
|
||||||
http_port = os.getenv("ANDROID_HTTP_PORT", "8791")
|
http_port = os.getenv("IRIS_HTTP_PORT", "8791")
|
||||||
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
|
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
|
||||||
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
|
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
|
||||||
"--send", "Reply with exactly: e2e http fallback OK",
|
"--send", "Reply with exactly: e2e http fallback OK",
|
||||||
@@ -352,7 +352,7 @@ def main() -> int:
|
|||||||
p = argparse.ArgumentParser(
|
p = argparse.ArgumentParser(
|
||||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
)
|
)
|
||||||
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", DEFAULT_URL))
|
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", DEFAULT_URL))
|
||||||
p.add_argument("--token", default="")
|
p.add_argument("--token", default="")
|
||||||
p.add_argument("--skip", default="",
|
p.add_argument("--skip", default="",
|
||||||
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
|
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
|
||||||
@@ -360,12 +360,12 @@ def main() -> int:
|
|||||||
|
|
||||||
token = find_token(args.token)
|
token = find_token(args.token)
|
||||||
if not token:
|
if not token:
|
||||||
print("!! ANDROID_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
|
print("!! IRIS_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
|
||||||
return 1
|
return 1
|
||||||
skip = {int(x) for x in args.skip.split(",") if x.strip()}
|
skip = {int(x) for x in args.skip.split(",") if x.strip()}
|
||||||
|
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env["ANDROID_TOKEN"] = token
|
env["IRIS_TOKEN"] = token
|
||||||
|
|
||||||
print(f"== e2e: url={args.url} token={token[:6]}…")
|
print(f"== e2e: url={args.url} token={token[:6]}…")
|
||||||
sweep_leftovers(env, args.url, token)
|
sweep_leftovers(env, args.url, token)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for the Iris x Hermes android gateway plugin (M4: media).
|
"""Tests for the Iris x Hermes android gateway plugin (M4: media).
|
||||||
|
|
||||||
The plugin lives in the sibling ``iris_x_hermes`` checkout (installed into
|
The plugin lives in the sibling ``iris_x_hermes`` checkout (installed into
|
||||||
``~/.hermes/plugins/android`` as a symlink in production); tests load it
|
``~/.hermes/plugins/iris`` as a symlink in production); tests load it
|
||||||
from the source tree directly so they never depend on that install.
|
from the source tree directly so they never depend on that install.
|
||||||
|
|
||||||
Coverage (docs/13-testing.md §13.1, media bullets):
|
Coverage (docs/13-testing.md §13.1, media bullets):
|
||||||
@@ -42,12 +42,12 @@ PNG_1X1 = base64.b64decode(
|
|||||||
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||||
)
|
)
|
||||||
|
|
||||||
TOKEN = "test-android-token-0123456789"
|
TOKEN = "test-iris-token-0123456789"
|
||||||
DEVICE_ID = "test-device"
|
DEVICE_ID = "test-device"
|
||||||
|
|
||||||
|
|
||||||
def _plugin_dir() -> Path:
|
def _plugin_dir() -> Path:
|
||||||
env = os.environ.get("ANDROID_PLUGIN_DIR")
|
env = os.environ.get("IRIS_PLUGIN_DIR")
|
||||||
if env:
|
if env:
|
||||||
return Path(env)
|
return Path(env)
|
||||||
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
||||||
@@ -66,13 +66,13 @@ def _load_plugin():
|
|||||||
The plugin uses relative imports (``from . import protocol``), so it
|
The plugin uses relative imports (``from . import protocol``), so it
|
||||||
must be imported as a package (``submodule_search_locations``).
|
must be imported as a package (``submodule_search_locations``).
|
||||||
"""
|
"""
|
||||||
name = "android_plugin_under_test"
|
name = "iris_plugin_under_test"
|
||||||
cached = sys.modules.get(name)
|
cached = sys.modules.get(name)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
pkg_dir = _plugin_dir()
|
pkg_dir = _plugin_dir()
|
||||||
if not (pkg_dir / "__init__.py").is_file():
|
if not (pkg_dir / "__init__.py").is_file():
|
||||||
pytest.fail(f"android plugin not found at {pkg_dir}")
|
pytest.fail(f"iris plugin not found at {pkg_dir}")
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
|
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
|
||||||
)
|
)
|
||||||
@@ -95,16 +95,16 @@ def plugin():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def adapter(plugin, monkeypatch):
|
def adapter(plugin, monkeypatch):
|
||||||
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
|
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
|
||||||
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
|
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
|
||||||
from gateway.platform_registry import PlatformEntry, platform_registry
|
from gateway.platform_registry import PlatformEntry, platform_registry
|
||||||
|
|
||||||
# Platform("android") resolves only once the platform is registered
|
# Platform("iris") resolves only once the platform is registered
|
||||||
# (the plugin's register(ctx) does this in production).
|
# (the plugin's register(ctx) does this in production).
|
||||||
if not platform_registry.is_registered("android"):
|
if not platform_registry.is_registered("iris"):
|
||||||
platform_registry.register(
|
platform_registry.register(
|
||||||
PlatformEntry(
|
PlatformEntry(
|
||||||
name="android",
|
name="iris",
|
||||||
label="Android",
|
label="Android",
|
||||||
adapter_factory=lambda cfg: None,
|
adapter_factory=lambda cfg: None,
|
||||||
check_fn=lambda: True,
|
check_fn=lambda: True,
|
||||||
@@ -119,7 +119,7 @@ def adapter(plugin, monkeypatch):
|
|||||||
},
|
},
|
||||||
home_channel=None,
|
home_channel=None,
|
||||||
)
|
)
|
||||||
a = plugin.adapter.AndroidAdapter(config)
|
a = plugin.adapter.IrisAdapter(config)
|
||||||
yield a
|
yield a
|
||||||
try:
|
try:
|
||||||
a._devices.close()
|
a._devices.close()
|
||||||
@@ -459,14 +459,14 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
|
|||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
# Simulate the post_api_request hook capturing the turn's model + tokens.
|
# Simulate the post_api_request hook capturing the turn's model + tokens.
|
||||||
plugin.adapter._on_post_api_request(
|
plugin.adapter._on_post_api_request(
|
||||||
platform="android",
|
platform="iris",
|
||||||
model="openai/gpt-5.4",
|
model="openai/gpt-5.4",
|
||||||
usage={"prompt_tokens": 12345},
|
usage={"prompt_tokens": 12345},
|
||||||
)
|
)
|
||||||
# Stub context-length resolution (avoid network probing in tests).
|
# Stub context-length resolution (avoid network probing in tests).
|
||||||
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
||||||
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
||||||
res = await adapter.send("android:default", "hello", metadata={"notify": True})
|
res = await adapter.send("default", "hello", metadata={"notify": True})
|
||||||
assert res.success
|
assert res.success
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||||
msg = frames[-1]
|
msg = frames[-1]
|
||||||
@@ -477,7 +477,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
|
|||||||
assert runtime["cwd"] == "~"
|
assert runtime["cwd"] == "~"
|
||||||
assert "latency" in runtime and runtime["latency"] >= 0
|
assert "latency" in runtime and runtime["latency"] >= 0
|
||||||
# The turn buffer is drained: a second final send carries no stale model.
|
# The turn buffer is drained: a second final send carries no stale model.
|
||||||
res2 = await adapter.send("android:default", "again", metadata={"notify": True})
|
res2 = await adapter.send("default", "again", metadata={"notify": True})
|
||||||
assert res2.success
|
assert res2.success
|
||||||
frames2 = await recv_until(
|
frames2 = await recv_until(
|
||||||
ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "again"
|
ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "again"
|
||||||
@@ -492,7 +492,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client, monkeypatch):
|
async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client, monkeypatch):
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
# A non-android turn must not pollute the android runtime buffer.
|
# A non-iris turn must not pollute the iris runtime buffer.
|
||||||
plugin.adapter._on_post_api_request(
|
plugin.adapter._on_post_api_request(
|
||||||
platform="telegram",
|
platform="telegram",
|
||||||
model="openai/gpt-5.4",
|
model="openai/gpt-5.4",
|
||||||
@@ -500,7 +500,7 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
||||||
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
||||||
res = await adapter.send("android:default", "hi", metadata={"notify": True})
|
res = await adapter.send("default", "hi", metadata={"notify": True})
|
||||||
assert res.success
|
assert res.success
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||||
runtime = frames[-1]["payload"].get("runtime")
|
runtime = frames[-1]["payload"].get("runtime")
|
||||||
@@ -512,15 +512,15 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client
|
|||||||
async def test_history_preserves_runtime_footer(plugin, adapter, ws_client, monkeypatch):
|
async def test_history_preserves_runtime_footer(plugin, adapter, ws_client, monkeypatch):
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
plugin.adapter._on_post_api_request(
|
plugin.adapter._on_post_api_request(
|
||||||
platform="android",
|
platform="iris",
|
||||||
model="openai/gpt-5.4",
|
model="openai/gpt-5.4",
|
||||||
usage={"prompt_tokens": 12345},
|
usage={"prompt_tokens": 12345},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
||||||
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
||||||
await adapter.send("android:default", "hello", metadata={"notify": True})
|
await adapter.send("default", "hello", metadata={"notify": True})
|
||||||
# The outbox history reconstruction must carry the runtime object.
|
# The outbox history reconstruction must carry the runtime object.
|
||||||
page = adapter._outbox.history("android:default", limit=50)
|
page = adapter._outbox.history("default", limit=50)
|
||||||
assert len(page["messages"]) == 1
|
assert len(page["messages"]) == 1
|
||||||
m = page["messages"][0]
|
m = page["messages"][0]
|
||||||
assert m.get("runtime", {}).get("model") == "gpt-5.4"
|
assert m.get("runtime", {}).get("model") == "gpt-5.4"
|
||||||
@@ -722,7 +722,7 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client,
|
|||||||
"v": 1,
|
"v": 1,
|
||||||
"id": 40,
|
"id": 40,
|
||||||
"type": "message.send",
|
"type": "message.send",
|
||||||
"chat_id": "android:default",
|
"chat_id": "default",
|
||||||
"payload": {"text": "fix the login bug please", "auto_thread": True},
|
"payload": {"text": "fix the login bug please", "auto_thread": True},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -733,16 +733,16 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client,
|
|||||||
created = next(f for f in frames if f.get("type") == "channel.created")
|
created = next(f for f in frames if f.get("type") == "channel.created")
|
||||||
assert created["payload"]["kind"] == "thread"
|
assert created["payload"]["kind"] == "thread"
|
||||||
assert created["payload"]["auto"] is True
|
assert created["payload"]["auto"] is True
|
||||||
assert created["payload"]["parent_chat_id"] == "android:default"
|
assert created["payload"]["parent_chat_id"] == "default"
|
||||||
assert created["payload"]["name"] == "fix the login bug please"
|
assert created["payload"]["name"] == "fix the login bug please"
|
||||||
thread_id = created["payload"]["chat_id"]
|
thread_id = created["payload"]["chat_id"]
|
||||||
|
|
||||||
echo = frames[-1]
|
echo = frames[-1]
|
||||||
assert echo["chat_id"] == "android:default"
|
assert echo["chat_id"] == "default"
|
||||||
assert echo["thread_id"] == thread_id
|
assert echo["thread_id"] == thread_id
|
||||||
|
|
||||||
assert len(captured) == 1
|
assert len(captured) == 1
|
||||||
assert captured[0].source.chat_id == "android:default"
|
assert captured[0].source.chat_id == "default"
|
||||||
assert captured[0].source.thread_id == thread_id
|
assert captured[0].source.thread_id == thread_id
|
||||||
|
|
||||||
|
|
||||||
@@ -762,7 +762,7 @@ async def test_message_send_auto_thread_llm_upgrade_renames(adapter, ws_client,
|
|||||||
"v": 1,
|
"v": 1,
|
||||||
"id": 41,
|
"id": 41,
|
||||||
"type": "message.send",
|
"type": "message.send",
|
||||||
"chat_id": "android:default",
|
"chat_id": "default",
|
||||||
"payload": {"text": "fix the login bug please", "auto_thread": True},
|
"payload": {"text": "fix the login bug please", "auto_thread": True},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -789,7 +789,7 @@ async def test_message_send_auto_thread_ignored_with_existing_thread(adapter, ws
|
|||||||
"v": 1,
|
"v": 1,
|
||||||
"id": 42,
|
"id": 42,
|
||||||
"type": "message.send",
|
"type": "message.send",
|
||||||
"chat_id": "android:default",
|
"chat_id": "default",
|
||||||
"thread_id": "t_9",
|
"thread_id": "t_9",
|
||||||
"payload": {"text": "follow up", "auto_thread": True},
|
"payload": {"text": "follow up", "auto_thread": True},
|
||||||
}
|
}
|
||||||
@@ -818,7 +818,7 @@ async def test_message_send_auto_thread_ignored_for_slash_command(adapter, ws_cl
|
|||||||
"v": 1,
|
"v": 1,
|
||||||
"id": 43,
|
"id": 43,
|
||||||
"type": "message.send",
|
"type": "message.send",
|
||||||
"chat_id": "android:default",
|
"chat_id": "default",
|
||||||
"payload": {"text": "/new", "auto_thread": True},
|
"payload": {"text": "/new", "auto_thread": True},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -848,7 +848,7 @@ async def test_message_send_auto_thread_media_only_stays_flat(adapter, ws_client
|
|||||||
"v": 1,
|
"v": 1,
|
||||||
"id": 44,
|
"id": 44,
|
||||||
"type": "message.send",
|
"type": "message.send",
|
||||||
"chat_id": "android:default",
|
"chat_id": "default",
|
||||||
"payload": {"text": "", "media_refs": ["mu_flat"], "auto_thread": True},
|
"payload": {"text": "", "media_refs": ["mu_flat"], "auto_thread": True},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -877,7 +877,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client):
|
|||||||
"v": 1,
|
"v": 1,
|
||||||
"id": 20,
|
"id": 20,
|
||||||
"type": "message.send",
|
"type": "message.send",
|
||||||
"chat_id": "android:default",
|
"chat_id": "default",
|
||||||
"payload": {"text": "persist me"},
|
"payload": {"text": "persist me"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -888,7 +888,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client):
|
|||||||
echo = frames[-1]
|
echo = frames[-1]
|
||||||
message_id = echo["payload"]["message_id"]
|
message_id = echo["payload"]["message_id"]
|
||||||
# The echo is parked in the outbox under the home channel.
|
# The echo is parked in the outbox under the home channel.
|
||||||
page = adapter._outbox.history("android:default", limit=50)
|
page = adapter._outbox.history("default", limit=50)
|
||||||
ids = [m["message_id"] for m in page["messages"]]
|
ids = [m["message_id"] for m in page["messages"]]
|
||||||
assert message_id in ids
|
assert message_id in ids
|
||||||
parked = next(m for m in page["messages"] if m["message_id"] == message_id)
|
parked = next(m for m in page["messages"] if m["message_id"] == message_id)
|
||||||
@@ -904,7 +904,7 @@ async def test_history_returns_final_messages_oldest_first(plugin, adapter, ws_c
|
|||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
protocol = plugin.protocol
|
protocol = plugin.protocol
|
||||||
adapter.handle_message = AsyncMock()
|
adapter.handle_message = AsyncMock()
|
||||||
chat_id = "android:default"
|
chat_id = "default"
|
||||||
# Two user turns, each with a (non-streaming) assistant final.
|
# Two user turns, each with a (non-streaming) assistant final.
|
||||||
for i, text in enumerate(["one", "two"]):
|
for i, text in enumerate(["one", "two"]):
|
||||||
await ws.send(
|
await ws.send(
|
||||||
@@ -952,7 +952,7 @@ async def test_history_paginates_older_pages(plugin, adapter, ws_client):
|
|||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
protocol = plugin.protocol
|
protocol = plugin.protocol
|
||||||
adapter.handle_message = AsyncMock()
|
adapter.handle_message = AsyncMock()
|
||||||
chat_id = "android:default"
|
chat_id = "default"
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
await adapter._broadcast_or_log(
|
await adapter._broadcast_or_log(
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -982,7 +982,7 @@ async def test_history_frame_roundtrip(plugin, adapter, ws_client):
|
|||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
protocol = plugin.protocol
|
protocol = plugin.protocol
|
||||||
adapter.handle_message = AsyncMock()
|
adapter.handle_message = AsyncMock()
|
||||||
chat_id = "android:default"
|
chat_id = "default"
|
||||||
await adapter._broadcast_or_log(
|
await adapter._broadcast_or_log(
|
||||||
chat_id,
|
chat_id,
|
||||||
protocol.message(
|
protocol.message(
|
||||||
@@ -1023,7 +1023,7 @@ async def test_message_delete_removes_from_outbox_and_broadcasts(plugin, adapter
|
|||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
protocol = plugin.protocol
|
protocol = plugin.protocol
|
||||||
adapter.handle_message = AsyncMock()
|
adapter.handle_message = AsyncMock()
|
||||||
chat_id = "android:default"
|
chat_id = "default"
|
||||||
await adapter._broadcast_or_log(
|
await adapter._broadcast_or_log(
|
||||||
chat_id,
|
chat_id,
|
||||||
protocol.message(
|
protocol.message(
|
||||||
@@ -1062,7 +1062,7 @@ async def test_message_delete_idempotent(plugin, adapter, ws_client):
|
|||||||
on the outbox but still emits ``message.deleted`` so live caches drop it."""
|
on the outbox but still emits ``message.deleted`` so live caches drop it."""
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
adapter.handle_message = AsyncMock()
|
adapter.handle_message = AsyncMock()
|
||||||
chat_id = "android:default"
|
chat_id = "default"
|
||||||
await ws.send(
|
await ws.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
@@ -1088,7 +1088,7 @@ async def test_message_delete_requires_message_ids(adapter, ws_client):
|
|||||||
"v": 1,
|
"v": 1,
|
||||||
"id": 52,
|
"id": 52,
|
||||||
"type": "message.delete",
|
"type": "message.delete",
|
||||||
"chat_id": "android:default",
|
"chat_id": "default",
|
||||||
"payload": {},
|
"payload": {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1125,7 +1125,7 @@ async def test_message_delete_purges_session_store(plugin, adapter, ws_client):
|
|||||||
adapter.handle_message = AsyncMock()
|
adapter.handle_message = AsyncMock()
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
chat_id = "android:default"
|
chat_id = "default"
|
||||||
db = get_hermes_home() / "state.db"
|
db = get_hermes_home() / "state.db"
|
||||||
_make_state_db(db)
|
_make_state_db(db)
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -1244,12 +1244,12 @@ async def test_send_video_emits_offer_with_message_association(adapter, ws_clien
|
|||||||
video.write_bytes(b"fake-video-bytes")
|
video.write_bytes(b"fake-video-bytes")
|
||||||
|
|
||||||
# A final message first, so the offer can associate with it.
|
# A final message first, so the offer can associate with it.
|
||||||
res = await adapter.send("android:default", "here you go", metadata={"notify": True})
|
res = await adapter.send("default", "here you go", metadata={"notify": True})
|
||||||
assert res.success
|
assert res.success
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||||
msg_id = frames[-1]["payload"]["message_id"]
|
msg_id = frames[-1]["payload"]["message_id"]
|
||||||
|
|
||||||
res2 = await adapter.send_video("android:default", str(video))
|
res2 = await adapter.send_video("default", str(video))
|
||||||
assert res2.success
|
assert res2.success
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
||||||
offer = frames[-1]["payload"]
|
offer = frames[-1]["payload"]
|
||||||
@@ -1270,7 +1270,7 @@ async def test_send_document_uses_caller_filename(adapter, ws_client):
|
|||||||
doc = get_document_cache_dir() / "report.pdf"
|
doc = get_document_cache_dir() / "report.pdf"
|
||||||
doc.write_bytes(b"%PDF-1.4 fake")
|
doc.write_bytes(b"%PDF-1.4 fake")
|
||||||
res = await adapter.send_document(
|
res = await adapter.send_document(
|
||||||
"android:default", str(doc), file_name="My Report.pdf"
|
"default", str(doc), file_name="My Report.pdf"
|
||||||
)
|
)
|
||||||
assert res.success
|
assert res.success
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
||||||
@@ -1287,7 +1287,7 @@ async def test_send_image_file_offers_image(adapter, ws_client):
|
|||||||
|
|
||||||
img = get_image_cache_dir() / "shot.png"
|
img = get_image_cache_dir() / "shot.png"
|
||||||
img.write_bytes(PNG_1X1)
|
img.write_bytes(PNG_1X1)
|
||||||
res = await adapter.send_image_file("android:default", str(img))
|
res = await adapter.send_image_file("default", str(img))
|
||||||
assert res.success
|
assert res.success
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
||||||
offer = frames[-1]["payload"]
|
offer = frames[-1]["payload"]
|
||||||
@@ -1299,7 +1299,7 @@ async def test_send_image_file_offers_image(adapter, ws_client):
|
|||||||
async def test_send_media_rejects_denied_path(adapter, ws_client):
|
async def test_send_media_rejects_denied_path(adapter, ws_client):
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
# /etc/passwd exists but is on hermes' delivery denylist.
|
# /etc/passwd exists but is on hermes' delivery denylist.
|
||||||
res = await adapter.send_document("android:default", "/etc/passwd")
|
res = await adapter.send_document("default", "/etc/passwd")
|
||||||
assert not res.success
|
assert not res.success
|
||||||
# Nothing was offered (a failed offer is silent, like other platforms).
|
# Nothing was offered (a failed offer is silent, like other platforms).
|
||||||
try:
|
try:
|
||||||
@@ -1439,10 +1439,10 @@ async def test_ntfy_backend_publishes_with_data_header(plugin, monkeypatch):
|
|||||||
)
|
)
|
||||||
ok = await backend.send(
|
ok = await backend.send(
|
||||||
device_id="d1",
|
device_id="d1",
|
||||||
chat_id="android:default",
|
chat_id="default",
|
||||||
title="Iris",
|
title="Iris",
|
||||||
body="hello",
|
body="hello",
|
||||||
data={"chat_id": "android:default", "kind": "message", "cursor": "7"},
|
data={"chat_id": "default", "kind": "message", "cursor": "7"},
|
||||||
token="iris-topic",
|
token="iris-topic",
|
||||||
)
|
)
|
||||||
assert ok is True
|
assert ok is True
|
||||||
@@ -1479,10 +1479,10 @@ async def test_fcm_backend_legacy_server_key(plugin, monkeypatch):
|
|||||||
)
|
)
|
||||||
ok = await backend.send(
|
ok = await backend.send(
|
||||||
device_id="d1",
|
device_id="d1",
|
||||||
chat_id="android:default",
|
chat_id="default",
|
||||||
title="Iris",
|
title="Iris",
|
||||||
body="hi",
|
body="hi",
|
||||||
data={"chat_id": "android:default", "kind": "message", "cursor": "3"},
|
data={"chat_id": "default", "kind": "message", "cursor": "3"},
|
||||||
token="fcm-token-1",
|
token="fcm-token-1",
|
||||||
)
|
)
|
||||||
assert ok is True
|
assert ok is True
|
||||||
@@ -1528,10 +1528,10 @@ async def test_fcm_backend_service_account_v1(plugin, monkeypatch, tmp_path):
|
|||||||
fake = _patch_httpx(plugin, monkeypatch, responder)
|
fake = _patch_httpx(plugin, monkeypatch, responder)
|
||||||
ok = await backend.send(
|
ok = await backend.send(
|
||||||
device_id="d1",
|
device_id="d1",
|
||||||
chat_id="android:default",
|
chat_id="default",
|
||||||
title="Iris",
|
title="Iris",
|
||||||
body="hi",
|
body="hi",
|
||||||
data={"chat_id": "android:default", "kind": "cron", "cursor": "4"},
|
data={"chat_id": "default", "kind": "cron", "cursor": "4"},
|
||||||
token="fcm-token-2",
|
token="fcm-token-2",
|
||||||
priority="high",
|
priority="high",
|
||||||
)
|
)
|
||||||
@@ -1584,15 +1584,15 @@ async def test_push_fires_when_no_live_subscriber(adapter):
|
|||||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||||
|
|
||||||
res = await adapter.send(
|
res = await adapter.send(
|
||||||
"android:default", "hello while offline", metadata={"notify": True}
|
"default", "hello while offline", metadata={"notify": True}
|
||||||
)
|
)
|
||||||
assert res.success
|
assert res.success
|
||||||
assert len(fake.calls) == 1
|
assert len(fake.calls) == 1
|
||||||
call = fake.calls[0]
|
call = fake.calls[0]
|
||||||
assert call["token"] == "tok-1"
|
assert call["token"] == "tok-1"
|
||||||
assert call["chat_id"] == "android:default"
|
assert call["chat_id"] == "default"
|
||||||
assert call["data"]["kind"] == "message"
|
assert call["data"]["kind"] == "message"
|
||||||
assert call["data"]["chat_id"] == "android:default"
|
assert call["data"]["chat_id"] == "default"
|
||||||
assert call["data"]["cursor"] == "1"
|
assert call["data"]["cursor"] == "1"
|
||||||
assert call["priority"] == "normal"
|
assert call["priority"] == "normal"
|
||||||
# The frame is parked in the outbox for sync.
|
# The frame is parked in the outbox for sync.
|
||||||
@@ -1606,7 +1606,7 @@ async def test_push_not_fired_when_live(adapter, ws_client):
|
|||||||
adapter._push = fake
|
adapter._push = fake
|
||||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||||
|
|
||||||
await adapter.send("android:default", "live reply", metadata={"notify": True})
|
await adapter.send("default", "live reply", metadata={"notify": True})
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||||
assert frames[-1]["payload"]["text"] == "live reply"
|
assert frames[-1]["payload"]["text"] == "live reply"
|
||||||
assert fake.calls == []
|
assert fake.calls == []
|
||||||
@@ -1619,7 +1619,7 @@ async def test_live_delivered_frame_still_parked_for_sync(adapter, ws_client):
|
|||||||
tapping a push notification) can catch up via sync. Regression: chat empty
|
tapping a push notification) can catch up via sync. Regression: chat empty
|
||||||
after tapping a message notification."""
|
after tapping a message notification."""
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
await adapter.send("android:default", "live reply", metadata={"notify": True})
|
await adapter.send("default", "live reply", metadata={"notify": True})
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||||
assert frames[-1]["payload"]["text"] == "live reply"
|
assert frames[-1]["payload"]["text"] == "live reply"
|
||||||
# The live-delivered frame is still parked in the outbox for sync.
|
# The live-delivered frame is still parked in the outbox for sync.
|
||||||
@@ -1674,9 +1674,9 @@ async def test_intermediate_frames_park_without_push(adapter):
|
|||||||
adapter._push = fake
|
adapter._push = fake
|
||||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||||
|
|
||||||
await adapter.send("android:default", "seg", metadata={"expect_edits": True})
|
await adapter.send("default", "seg", metadata={"expect_edits": True})
|
||||||
stream_id = adapter._turns["android:default"].stream_id
|
stream_id = adapter._turns["default"].stream_id
|
||||||
await adapter.edit_message("android:default", stream_id, "seg more")
|
await adapter.edit_message("default", stream_id, "seg more")
|
||||||
assert adapter._outbox.latest_cursor() == 2
|
assert adapter._outbox.latest_cursor() == 2
|
||||||
assert fake.calls == []
|
assert fake.calls == []
|
||||||
|
|
||||||
@@ -1689,9 +1689,9 @@ async def test_high_priority_notification_pushes_even_when_live(plugin, adapter,
|
|||||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||||
|
|
||||||
await adapter._broadcast_or_log(
|
await adapter._broadcast_or_log(
|
||||||
"android:default",
|
"default",
|
||||||
plugin.protocol.notification(
|
plugin.protocol.notification(
|
||||||
"android:default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body"
|
"default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "notification")
|
frames = await recv_until(ws, lambda f: f.get("type") == "notification")
|
||||||
@@ -1707,7 +1707,7 @@ async def test_push_skipped_when_device_has_no_token(adapter):
|
|||||||
adapter._push = fake
|
adapter._push = fake
|
||||||
adapter._devices.upsert(DEVICE_ID, "Test", {}) # no push token
|
adapter._devices.upsert(DEVICE_ID, "Test", {}) # no push token
|
||||||
|
|
||||||
await adapter.send("android:default", "no token", metadata={"notify": True})
|
await adapter.send("default", "no token", metadata={"notify": True})
|
||||||
assert fake.calls == []
|
assert fake.calls == []
|
||||||
assert adapter._outbox.latest_cursor() == 1 # still parked for sync
|
assert adapter._outbox.latest_cursor() == 1 # still parked for sync
|
||||||
|
|
||||||
@@ -1718,7 +1718,7 @@ async def test_push_skipped_when_backend_unconfigured(adapter):
|
|||||||
adapter._push = fake
|
adapter._push = fake
|
||||||
adapter._devices.upsert(DEVICE_ID, "Test", {})
|
adapter._devices.upsert(DEVICE_ID, "Test", {})
|
||||||
|
|
||||||
await adapter.send("android:default", "unconfigured", metadata={"notify": True})
|
await adapter.send("default", "unconfigured", metadata={"notify": True})
|
||||||
assert fake.calls == []
|
assert fake.calls == []
|
||||||
|
|
||||||
|
|
||||||
@@ -1755,7 +1755,7 @@ async def test_fcm_register_updates_registry(adapter, ws_client):
|
|||||||
adapter._http_server._subs.clear()
|
adapter._http_server._subs.clear()
|
||||||
fake = _FakePush()
|
fake = _FakePush()
|
||||||
adapter._push = fake
|
adapter._push = fake
|
||||||
await adapter.send("android:default", "after rotation", metadata={"notify": True})
|
await adapter.send("default", "after rotation", metadata={"notify": True})
|
||||||
assert len(fake.calls) == 1
|
assert len(fake.calls) == 1
|
||||||
assert fake.calls[0]["token"] == "rotated-token"
|
assert fake.calls[0]["token"] == "rotated-token"
|
||||||
|
|
||||||
@@ -1854,7 +1854,7 @@ async def test_channel_favorite_toggle(adapter, ws_client):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_channel_favorite_unknown_id_rejected(adapter, ws_client):
|
async def test_channel_favorite_unknown_id_rejected(adapter, ws_client):
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "android:chan_999", "payload": {"on": True}}))
|
await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "chan_999", "payload": {"on": True}}))
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1)
|
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1)
|
||||||
assert frames[-1]["payload"]["code"] == "not_found"
|
assert frames[-1]["payload"]["code"] == "not_found"
|
||||||
|
|
||||||
@@ -1909,7 +1909,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
|
|||||||
"hello from cron\n\n"
|
"hello from cron\n\n"
|
||||||
"To stop or manage this job, send me a new message (e.g. \"stop reminder My Job\")."
|
"To stop or manage this job, send me a new message (e.g. \"stop reminder My Job\")."
|
||||||
)
|
)
|
||||||
res = await adapter.send("android:default", wrapped, metadata={"job_id": "abc123"})
|
res = await adapter.send("default", wrapped, metadata={"job_id": "abc123"})
|
||||||
assert res.success
|
assert res.success
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||||
msg = frames[-1]
|
msg = frames[-1]
|
||||||
@@ -1922,7 +1922,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
|
|||||||
assert notif[0]["payload"]["body"] == "hello from cron"
|
assert notif[0]["payload"]["body"] == "hello from cron"
|
||||||
|
|
||||||
# wrap_response: false -- raw content, job id as the name.
|
# wrap_response: false -- raw content, job id as the name.
|
||||||
res2 = await adapter.send("android:default", "raw cron output", metadata={"job_id": "j2"})
|
res2 = await adapter.send("default", "raw cron output", metadata={"job_id": "j2"})
|
||||||
assert res2.success
|
assert res2.success
|
||||||
frames = await recv_until(
|
frames = await recv_until(
|
||||||
ws,
|
ws,
|
||||||
@@ -1941,7 +1941,7 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
|
|||||||
cg.register("cl_1", "sk", "Which one?", ["A", "B"])
|
cg.register("cl_1", "sk", "Which one?", ["A", "B"])
|
||||||
try:
|
try:
|
||||||
res = await adapter.send_clarify(
|
res = await adapter.send_clarify(
|
||||||
"android:default", "Which one?", ["A", "B"], "cl_1", "sk"
|
"default", "Which one?", ["A", "B"], "cl_1", "sk"
|
||||||
)
|
)
|
||||||
assert res.success
|
assert res.success
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||||
@@ -1964,8 +1964,8 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_replays_parked_frames_and_done_cursor(adapter):
|
async def test_sync_replays_parked_frames_and_done_cursor(adapter):
|
||||||
# Park two frames while offline.
|
# Park two frames while offline.
|
||||||
await adapter.send("android:default", "one", metadata={"notify": True})
|
await adapter.send("default", "one", metadata={"notify": True})
|
||||||
await adapter.send("android:default", "two", metadata={"notify": True})
|
await adapter.send("default", "two", metadata={"notify": True})
|
||||||
assert adapter._outbox.latest_cursor() == 2
|
assert adapter._outbox.latest_cursor() == 2
|
||||||
|
|
||||||
await adapter.connect()
|
await adapter.connect()
|
||||||
@@ -2021,26 +2021,26 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
|
|||||||
adapter._push = fake
|
adapter._push = fake
|
||||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||||
|
|
||||||
await adapter.send("android:default", "one", metadata={"notify": True})
|
await adapter.send("default", "one", metadata={"notify": True})
|
||||||
assert len(fake.calls) == 1
|
assert len(fake.calls) == 1
|
||||||
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
|
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
|
||||||
|
|
||||||
# Immediate second frame (cron's message frame) coalesces — no second
|
# Immediate second frame (cron's message frame) coalesces — no second
|
||||||
# push, cursor unchanged.
|
# push, cursor unchanged.
|
||||||
await adapter.send("android:default", "two", metadata={"notify": True})
|
await adapter.send("default", "two", metadata={"notify": True})
|
||||||
assert len(fake.calls) == 1
|
assert len(fake.calls) == 1
|
||||||
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
|
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
|
||||||
|
|
||||||
# Simulate the coalesce window elapsing, then push again.
|
# Simulate the coalesce window elapsing, then push again.
|
||||||
adapter._last_push_at["android:default"] = 0.0
|
adapter._last_push_at["default"] = 0.0
|
||||||
await adapter.send("android:default", "three", metadata={"notify": True})
|
await adapter.send("default", "three", metadata={"notify": True})
|
||||||
assert len(fake.calls) == 2
|
assert len(fake.calls) == 2
|
||||||
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
|
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
|
||||||
|
|
||||||
# A failed push must NOT advance the cursor (the device never woke).
|
# A failed push must NOT advance the cursor (the device never woke).
|
||||||
fake.fail_next = True
|
fake.fail_next = True
|
||||||
adapter._last_push_at["android:default"] = 0.0
|
adapter._last_push_at["default"] = 0.0
|
||||||
await adapter.send("android:default", "four", metadata={"notify": True})
|
await adapter.send("default", "four", metadata={"notify": True})
|
||||||
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
|
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
|
||||||
|
|
||||||
await adapter.connect()
|
await adapter.connect()
|
||||||
@@ -2058,8 +2058,8 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter):
|
|||||||
"""Frames replayed by sync carry their outbox cursor in the envelope so
|
"""Frames replayed by sync carry their outbox cursor in the envelope so
|
||||||
the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
|
the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
|
||||||
frames carry no cursor."""
|
frames carry no cursor."""
|
||||||
await adapter.send("android:default", "one", metadata={"notify": True})
|
await adapter.send("default", "one", metadata={"notify": True})
|
||||||
await adapter.send("android:default", "two", metadata={"notify": True})
|
await adapter.send("default", "two", metadata={"notify": True})
|
||||||
|
|
||||||
await adapter.connect()
|
await adapter.connect()
|
||||||
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
|
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
|
||||||
@@ -2083,7 +2083,7 @@ async def test_live_frames_carry_no_cursor(adapter, ws_client):
|
|||||||
"""Live (non-replay) frames must not carry a cursor — the app only
|
"""Live (non-replay) frames must not carry a cursor — the app only
|
||||||
suppresses notifications for replayed frames (docs/08 §8.7)."""
|
suppresses notifications for replayed frames (docs/08 §8.7)."""
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
await adapter.send("android:default", "live", metadata={"notify": True})
|
await adapter.send("default", "live", metadata={"notify": True})
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||||
assert "cursor" not in frames[-1]
|
assert "cursor" not in frames[-1]
|
||||||
|
|
||||||
@@ -2093,7 +2093,7 @@ def test_outbox_row_cap_prunes_oldest(plugin, tmp_path):
|
|||||||
try:
|
try:
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
outbox.append(
|
outbox.append(
|
||||||
"android:default",
|
"default",
|
||||||
json.dumps({"v": 1, "type": "message", "payload": {"n": i}}),
|
json.dumps({"v": 1, "type": "message", "payload": {"n": i}}),
|
||||||
)
|
)
|
||||||
assert outbox.latest_cursor() == 7 # cursor stays monotonic
|
assert outbox.latest_cursor() == 7 # cursor stays monotonic
|
||||||
@@ -2110,7 +2110,7 @@ def test_outbox_delete_message_removes_all_frames_for_id(plugin, tmp_path):
|
|||||||
in the chat, leaving other messages intact; a thread_id scopes the delete."""
|
in the chat, leaving other messages intact; a thread_id scopes the delete."""
|
||||||
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
|
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
|
||||||
try:
|
try:
|
||||||
chat = "android:default"
|
chat = "default"
|
||||||
# A streaming message spans start/update/stop; a standalone message is
|
# A streaming message spans start/update/stop; a standalone message is
|
||||||
# one frame. Plus an unrelated message that must survive.
|
# one frame. Plus an unrelated message that must survive.
|
||||||
outbox.append(chat, json.dumps({"v": 1, "type": "message.start", "payload": {"message_id": "m1", "role": "assistant"}}))
|
outbox.append(chat, json.dumps({"v": 1, "type": "message.start", "payload": {"message_id": "m1", "role": "assistant"}}))
|
||||||
@@ -2139,12 +2139,12 @@ def test_outbox_delete_lane_removes_channel_and_thread_frames(plugin, tmp_path):
|
|||||||
scoped to that thread's frames only."""
|
scoped to that thread's frames only."""
|
||||||
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
|
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
|
||||||
try:
|
try:
|
||||||
chan = "android:chan_9"
|
chan = "chan_9"
|
||||||
# Flat-lane frames + two threads' frames, plus an unrelated channel.
|
# Flat-lane frames + two threads' frames, plus an unrelated channel.
|
||||||
outbox.append(chan, json.dumps({"v": 1, "type": "message", "payload": {"message_id": "a", "role": "user", "text": "flat"}}))
|
outbox.append(chan, json.dumps({"v": 1, "type": "message", "payload": {"message_id": "a", "role": "user", "text": "flat"}}))
|
||||||
outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_1", "payload": {"message_id": "b", "role": "user", "text": "t1"}}))
|
outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_1", "payload": {"message_id": "b", "role": "user", "text": "t1"}}))
|
||||||
outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_2", "payload": {"message_id": "c", "role": "user", "text": "t2"}}))
|
outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_2", "payload": {"message_id": "c", "role": "user", "text": "t2"}}))
|
||||||
outbox.append("android:chan_8", json.dumps({"v": 1, "type": "message", "payload": {"message_id": "z", "role": "user", "text": "other"}}))
|
outbox.append("chan_8", json.dumps({"v": 1, "type": "message", "payload": {"message_id": "z", "role": "user", "text": "other"}}))
|
||||||
# Thread delete: only t_1's frame goes.
|
# Thread delete: only t_1's frame goes.
|
||||||
assert outbox.delete_lane(chan, thread_id="t_1") == 1
|
assert outbox.delete_lane(chan, thread_id="t_1") == 1
|
||||||
rows = outbox.replay(0)
|
rows = outbox.replay(0)
|
||||||
@@ -2164,7 +2164,7 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
|
|||||||
a channel, its threads; the default channel cannot be deleted."""
|
a channel, its threads; the default channel cannot be deleted."""
|
||||||
d = plugin.channels.ChannelDirectory(tmp_path / "ch.db")
|
d = plugin.channels.ChannelDirectory(tmp_path / "ch.db")
|
||||||
try:
|
try:
|
||||||
d.ensure_default("android:default", "Default")
|
d.ensure_default("default", "Default")
|
||||||
chan = d.create("Work", kind="channel")
|
chan = d.create("Work", kind="channel")
|
||||||
t1 = d.create("Topic", kind="thread", parent_chat_id=chan["chat_id"])
|
t1 = d.create("Topic", kind="thread", parent_chat_id=chan["chat_id"])
|
||||||
# Deleting the channel removes it AND its thread from the directory.
|
# Deleting the channel removes it AND its thread from the directory.
|
||||||
@@ -2180,10 +2180,10 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
|
|||||||
assert d.get(t2["chat_id"]) is None
|
assert d.get(t2["chat_id"]) is None
|
||||||
assert d.get(chan2["chat_id"]) is not None # parent channel survives
|
assert d.get(chan2["chat_id"]) is not None # parent channel survives
|
||||||
# The default channel cannot be deleted.
|
# The default channel cannot be deleted.
|
||||||
assert d.delete("android:default") is None
|
assert d.delete("default") is None
|
||||||
assert d.get("android:default") is not None
|
assert d.get("default") is not None
|
||||||
# Unknown id -> None.
|
# Unknown id -> None.
|
||||||
assert d.delete("android:chan_nope") is None
|
assert d.delete("chan_nope") is None
|
||||||
finally:
|
finally:
|
||||||
d.close()
|
d.close()
|
||||||
|
|
||||||
@@ -2272,14 +2272,14 @@ def test_tool_end_fields_from_hook(plugin):
|
|||||||
def test_parse_tool_line_or_block_verbose(plugin):
|
def test_parse_tool_line_or_block_verbose(plugin):
|
||||||
a = plugin.adapter
|
a = plugin.adapter
|
||||||
content = '🔍 web_search(["query"])\n{"query": "hermes agent"}'
|
content = '🔍 web_search(["query"])\n{"query": "hermes agent"}'
|
||||||
name, preview, args = a.AndroidAdapter._parse_tool_line_or_block(
|
name, preview, args = a.IrisAdapter._parse_tool_line_or_block(
|
||||||
'🔍 web_search(["query"])', content
|
'🔍 web_search(["query"])', content
|
||||||
)
|
)
|
||||||
assert name == "web_search"
|
assert name == "web_search"
|
||||||
assert args == {"query": "hermes agent"}
|
assert args == {"query": "hermes agent"}
|
||||||
assert preview == "hermes agent" # derived short preview
|
assert preview == "hermes agent" # derived short preview
|
||||||
# Non-verbose line -> args None, preview from the line.
|
# Non-verbose line -> args None, preview from the line.
|
||||||
name2, preview2, args2 = a.AndroidAdapter._parse_tool_line_or_block(
|
name2, preview2, args2 = a.IrisAdapter._parse_tool_line_or_block(
|
||||||
'🔍 web_search: "x"', '🔍 web_search: "x"'
|
'🔍 web_search: "x"', '🔍 web_search: "x"'
|
||||||
)
|
)
|
||||||
assert name2 == "web_search" and preview2 == "x" and args2 is None
|
assert name2 == "web_search" and preview2 == "x" and args2 is None
|
||||||
@@ -2289,11 +2289,11 @@ def test_tool_start_frame_emoji_field(plugin):
|
|||||||
"""``tool.start`` carries the cosmetic emoji when given, omits it when
|
"""``tool.start`` carries the cosmetic emoji when given, omits it when
|
||||||
None (the app then falls back to its own default glyph)."""
|
None (the app then falls back to its own default glyph)."""
|
||||||
pf = plugin.protocol.tool_start
|
pf = plugin.protocol.tool_start
|
||||||
f = pf("android:default", 3, "terminal", emoji="💻")
|
f = pf("default", 3, "terminal", emoji="💻")
|
||||||
assert f.payload["emoji"] == "💻"
|
assert f.payload["emoji"] == "💻"
|
||||||
f2 = pf("android:default", 3, "terminal")
|
f2 = pf("default", 3, "terminal")
|
||||||
assert "emoji" not in f2.payload
|
assert "emoji" not in f2.payload
|
||||||
f3 = pf("android:default", 3, "terminal", emoji=None)
|
f3 = pf("default", 3, "terminal", emoji=None)
|
||||||
assert "emoji" not in f3.payload
|
assert "emoji" not in f3.payload
|
||||||
|
|
||||||
|
|
||||||
@@ -2330,7 +2330,7 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey
|
|||||||
"agent.display.get_tool_emoji",
|
"agent.display.get_tool_emoji",
|
||||||
lambda name, default="⚡": "💻" if name == "terminal" else default,
|
lambda name, default="⚡": "💻" if name == "terminal" else default,
|
||||||
)
|
)
|
||||||
res = await adapter.send('android:default', '💻 terminal: "ls -la"')
|
res = await adapter.send('default', '💻 terminal: "ls -la"')
|
||||||
assert res.success
|
assert res.success
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "tool.start")
|
frames = await recv_until(ws, lambda f: f.get("type") == "tool.start")
|
||||||
payload = frames[-1]["payload"]
|
payload = frames[-1]["payload"]
|
||||||
@@ -2338,7 +2338,7 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey
|
|||||||
assert payload["emoji"] == "💻"
|
assert payload["emoji"] == "💻"
|
||||||
# Unknown tool -> field omitted (app falls back to its default glyph).
|
# Unknown tool -> field omitted (app falls back to its default glyph).
|
||||||
monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚡": default)
|
monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚡": default)
|
||||||
res2 = await adapter.send('android:default', '🔧 patch: "x"')
|
res2 = await adapter.send('default', '🔧 patch: "x"')
|
||||||
assert res2.success
|
assert res2.success
|
||||||
frames2 = await recv_until(
|
frames2 = await recv_until(
|
||||||
ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch"
|
ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the android plugin's HTTP fallback transport (docs/19).
|
"""Tests for the iris plugin's HTTP fallback transport (docs/19).
|
||||||
|
|
||||||
The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it
|
The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it
|
||||||
from the source tree directly (same pattern as ``test_android.py``).
|
from the source tree directly (same pattern as ``test_android.py``).
|
||||||
@@ -44,9 +44,9 @@ import pytest_asyncio
|
|||||||
# Test-only token (not a credential; the adapter is built with it via
|
# Test-only token (not a credential; the adapter is built with it via
|
||||||
# monkeypatch in the fixture below).
|
# monkeypatch in the fixture below).
|
||||||
# pi-lens-ignore: S105
|
# pi-lens-ignore: S105
|
||||||
TOKEN = "test-android-http-token-0123456789"
|
TOKEN = "test-iris-http-token-0123456789"
|
||||||
DEVICE_ID = "test-http-device"
|
DEVICE_ID = "test-http-device"
|
||||||
CHAT_ID = "android:default"
|
CHAT_ID = "default"
|
||||||
|
|
||||||
# 1x1 PNG (same fixture as test_android.py).
|
# 1x1 PNG (same fixture as test_android.py).
|
||||||
PNG_1X1 = base64.b64decode(
|
PNG_1X1 = base64.b64decode(
|
||||||
@@ -56,7 +56,7 @@ PNG_1X1 = base64.b64decode(
|
|||||||
|
|
||||||
|
|
||||||
def _plugin_dir() -> Path:
|
def _plugin_dir() -> Path:
|
||||||
env = os.environ.get("ANDROID_PLUGIN_DIR")
|
env = os.environ.get("IRIS_PLUGIN_DIR")
|
||||||
if env:
|
if env:
|
||||||
return Path(env)
|
return Path(env)
|
||||||
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
||||||
@@ -72,13 +72,13 @@ def _plugin_dir() -> Path:
|
|||||||
def _load_plugin():
|
def _load_plugin():
|
||||||
"""Load the gateway-plugin package under a unique module name (same
|
"""Load the gateway-plugin package under a unique module name (same
|
||||||
pattern as test_android.py)."""
|
pattern as test_android.py)."""
|
||||||
name = "android_plugin_http_under_test"
|
name = "iris_plugin_http_under_test"
|
||||||
cached = sys.modules.get(name)
|
cached = sys.modules.get(name)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
pkg_dir = _plugin_dir()
|
pkg_dir = _plugin_dir()
|
||||||
if not (pkg_dir / "__init__.py").is_file():
|
if not (pkg_dir / "__init__.py").is_file():
|
||||||
pytest.fail(f"android plugin not found at {pkg_dir}")
|
pytest.fail(f"iris plugin not found at {pkg_dir}")
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
|
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
|
||||||
)
|
)
|
||||||
@@ -101,14 +101,14 @@ def plugin():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def adapter(plugin, monkeypatch):
|
def adapter(plugin, monkeypatch):
|
||||||
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
|
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
|
||||||
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
|
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
|
||||||
from gateway.platform_registry import PlatformEntry, platform_registry
|
from gateway.platform_registry import PlatformEntry, platform_registry
|
||||||
|
|
||||||
if not platform_registry.is_registered("android"):
|
if not platform_registry.is_registered("iris"):
|
||||||
platform_registry.register(
|
platform_registry.register(
|
||||||
PlatformEntry(
|
PlatformEntry(
|
||||||
name="android",
|
name="iris",
|
||||||
label="Android",
|
label="Android",
|
||||||
adapter_factory=lambda cfg: None,
|
adapter_factory=lambda cfg: None,
|
||||||
check_fn=lambda: True,
|
check_fn=lambda: True,
|
||||||
@@ -124,7 +124,7 @@ def adapter(plugin, monkeypatch):
|
|||||||
},
|
},
|
||||||
home_channel=None,
|
home_channel=None,
|
||||||
)
|
)
|
||||||
a = plugin.adapter.AndroidAdapter(config)
|
a = plugin.adapter.IrisAdapter(config)
|
||||||
yield a
|
yield a
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
a._devices.close()
|
a._devices.close()
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ while building the Kotlin client.
|
|||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
hermes gateway & # with the android plugin
|
hermes gateway & # with the iris plugin
|
||||||
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
|
python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
|
||||||
--send "hello"
|
--send "hello"
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
|
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
|
||||||
--token ANDROID_TOKEN (default: $ANDROID_TOKEN)
|
--token IRIS_TOKEN (default: $IRIS_TOKEN)
|
||||||
--device device_id (default: probe-<rand>)
|
--device device_id (default: probe-<rand>)
|
||||||
--send TEXT send this message after pairing (default: "hello")
|
--send TEXT send this message after pairing (default: "hello")
|
||||||
--upload F M4: upload F (chunked media.upload) and attach it to the
|
--upload F M4: upload F (chunked media.upload) and attach it to the
|
||||||
@@ -573,7 +573,7 @@ def run_http(args, base: str) -> int:
|
|||||||
"v": 1,
|
"v": 1,
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"type": "message.send",
|
"type": "message.send",
|
||||||
"chat_id": "android:default",
|
"chat_id": "default",
|
||||||
"payload": {"text": args.send},
|
"payload": {"text": args.send},
|
||||||
}
|
}
|
||||||
conn = HTTPConnection(host, port, timeout=30)
|
conn = HTTPConnection(host, port, timeout=30)
|
||||||
@@ -666,8 +666,8 @@ def run_http(args, base: str) -> int:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
p = argparse.ArgumentParser(description=__doc__)
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", "ws://127.0.0.1:8790/ws"))
|
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", "ws://127.0.0.1:8790/ws"))
|
||||||
p.add_argument("--token", default=os.getenv("ANDROID_TOKEN", ""))
|
p.add_argument("--token", default=os.getenv("IRIS_TOKEN", ""))
|
||||||
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
|
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
|
||||||
p.add_argument("--send", default="hello")
|
p.add_argument("--send", default="hello")
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
@@ -724,8 +724,8 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--chat-id",
|
"--chat-id",
|
||||||
default="android:default",
|
default="default",
|
||||||
help="chat_id for --scope chat (default android:default)",
|
help="chat_id for --scope chat (default default)",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--channel-create", default="", help="M3: create a channel, print its chat_id, exit"
|
"--channel-create", default="", help="M3: create a channel, print its chat_id, exit"
|
||||||
@@ -762,7 +762,7 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
if not args.token and not args.authfail:
|
if not args.token and not args.authfail:
|
||||||
p.error("--token (or $ANDROID_TOKEN) is required")
|
p.error("--token (or $IRIS_TOKEN) is required")
|
||||||
if args.assert_read_receipt and not args.send:
|
if args.assert_read_receipt and not args.send:
|
||||||
p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)")
|
p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)")
|
||||||
# HTTP is the only transport (docs/19): derive the http(s) base from the
|
# HTTP is the only transport (docs/19): derive the http(s) base from the
|
||||||
|
|||||||
Reference in new issue
Block a user