From 678ea2b085a7c7d6f8c2c8838e0ab5f9d6c3294e Mon Sep 17 00:00:00 2001 From: Adil Date: Thu, 6 Aug 2026 01:25:41 +0500 Subject: [PATCH 1/2] =?UTF-8?q?hive:=20wifi-p2p=20=E2=80=94=20Android=20Wi?= =?UTF-8?q?Fi=20Direct=20forager=20bee?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new hive that bridges Android WiFi Direct (WPA2-PSK P2P groups) into thrum, enabling infrastructure-free peer-to-peer agent mesh between phones. Architecture: - Android foreground service (WifiP2pBeeService.kt) - WifiP2pManager wrapper for discovery + group formation (P2pManager.kt) - NDJSON thrum client to local humd (ThrumClient.kt) - Ed25519 identity persistence, byte-identical to Rust/TS hives (IdentityStore.kt) - Tone types + helpers matching thrum-core wire spec (Tone.kt) Propensity: convention-stateful / lean / wifi-p2p/tcp Wire: WiFi Direct P2P TCP on p2p0 interface, same NDJSON framing as thrum Closes the gap between gsm-modem (slow SMS) and bp7 (store-and-forward DTN) — high-bandwidth, low-latency local mesh for devices within walking distance. --- hives/wifi-p2p/.gitignore | 22 ++ hives/wifi-p2p/Orchfile | 3 + hives/wifi-p2p/README.md | 150 +++++++ hives/wifi-p2p/app/build.gradle.kts | 42 ++ .../wifi-p2p/app/src/main/AndroidManifest.xml | 45 +++ .../java/hum/hive/wifip2p/IdentityStore.kt | 80 ++++ .../main/java/hum/hive/wifip2p/P2pManager.kt | 327 +++++++++++++++ .../main/java/hum/hive/wifip2p/ThrumClient.kt | 203 ++++++++++ .../src/main/java/hum/hive/wifip2p/Tone.kt | 217 ++++++++++ .../hum/hive/wifip2p/WifiP2pBeeService.kt | 373 ++++++++++++++++++ .../app/src/main/res/values/strings.xml | 7 + hives/wifi-p2p/gradle.properties | 4 + hives/wifi-p2p/settings.gradle.kts | 17 + 13 files changed, 1490 insertions(+) create mode 100644 hives/wifi-p2p/.gitignore create mode 100644 hives/wifi-p2p/Orchfile create mode 100644 hives/wifi-p2p/README.md create mode 100644 hives/wifi-p2p/app/build.gradle.kts create mode 100644 hives/wifi-p2p/app/src/main/AndroidManifest.xml create mode 100644 hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/IdentityStore.kt create mode 100644 hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/P2pManager.kt create mode 100644 hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/ThrumClient.kt create mode 100644 hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/Tone.kt create mode 100644 hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/WifiP2pBeeService.kt create mode 100644 hives/wifi-p2p/app/src/main/res/values/strings.xml create mode 100644 hives/wifi-p2p/gradle.properties create mode 100644 hives/wifi-p2p/settings.gradle.kts diff --git a/hives/wifi-p2p/.gitignore b/hives/wifi-p2p/.gitignore new file mode 100644 index 0000000..72e2902 --- /dev/null +++ b/hives/wifi-p2p/.gitignore @@ -0,0 +1,22 @@ +# Gradle build artifacts +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE files +.idea/ +*.iml +*.iws +*.ipr +out/ + +# Android local +local.properties +*.apk +*.aab +*.apk.unsigned +*.aab.unsigned + +# Kotlin metadata +*.kotlin_module +*.kjsm diff --git a/hives/wifi-p2p/Orchfile b/hives/wifi-p2p/Orchfile new file mode 100644 index 0000000..736df6f --- /dev/null +++ b/hives/wifi-p2p/Orchfile @@ -0,0 +1,3 @@ +SERVICE wifi-p2p +RUN com.hum.hive.wifip2p/.WifiP2pBeeService +RESTART always diff --git a/hives/wifi-p2p/README.md b/hives/wifi-p2p/README.md new file mode 100644 index 0000000..2b1d013 --- /dev/null +++ b/hives/wifi-p2p/README.md @@ -0,0 +1,150 @@ +--- +title: "wifi-p2p" +description: "hum-over-WiFi-Direct — peer-to-peer agent mesh on Android's WifiP2p radio" +--- + +# wifi-p2p + +> _hum-over-WiFi-Direct — peer-to-peer agent mesh on Android's WifiP2p radio_ + +A **forager** bee that bridges Android WiFi Direct (WPA2-PSK P2P groups) +into thrum. Two phones in the same room discover each other by service +scan, form a P2P group, and exchange hum tones over TCP — no router, +no cellular, no internet. + +Each phone runs its own local humd (in Termux or on a companion +machine). The bee translates incoming P2P messages into `chi:"prompt"` +to humd and routes `chunk`/`finish` replies back over the P2P link. +Conversations are continuous per peer — the sid is keyed off the +peer's Ed25519 identity (its hid), stable across link drops and +reconnects. + +## Propensity + +| statefulness | richness | wire shape | hides | +|---|---|---|---| +| convention-stateful (per-peer sid) | lean | WiFi Direct P2P TCP (`p2p0` interface) | tools, system prompts, perf, drone, breath | + +## Wire + +``` +┌─ Phone A (Group Owner) ─────────────────┐ +│ │ +│ Peer ── TCP :4377 ───► wifi-p2p bee │ +│ (Phone B) p2p0 │ │ +│ │ chi:"prompt" │ +│ ▼ │ +│ humd │ +│ │ │ +│ │ chunk/finish │ +│ ▼ │ +│ Peer ◄── TCP :4377 ──── wifi-p2p bee │ +│ (Phone B) p2p0 │ │ +└──────────────────────────────────────────┘ +``` + +## How it works + +1. **Service discovery**. Phone A registers `_hum._tcp` on WiFi Direct + and starts scanning. Phone B's bee sees the service and initiates a + connection. The TXT record carries the phone's humd capabilities. + +2. **Group formation**. One phone becomes the Group Owner (GO); the + other connects as a client. The GO opens a TCP server on + `p2p0:4377`; the client connects to the GO's P2P IP. + +3. **Tone exchange**. Both sides speak NDJSON over the P2P TCP + socket — the same framing as thrum. Each tone carries a `sid` + keyed off the peer's hid, so the conversation survives + disconnect-reconnect cycles. + +4. **Local humd bridge**. Each bee connects to its local humd via + Unix socket (Termux) or TCP bridge and translates inbound P2P + tones into thrum prompts. Chunks are collected and the final + reply is sent back over the P2P link. + +## Configure + +| env (Android, via `setprop` or config) | default | what | +|---|---|---| +| `HUM_P2P_PORT` | `4377` | TCP port on `p2p0` interface | +| `HUM_P2P_SERVICE_TYPE` | `_hum._tcp` | Bonjour-style service type for discovery | +| `HUM_P2P_MODEL` | `claude-haiku-4.5` | model humd spawns | +| `HUM_P2P_SYSTEM` | default system prompt | system instruction | +| `HUM_P2P_REPLY_LIMIT` | `4096` | hard cap on reply length | +| `HUM_THRUM_SOCK` | Unix socket or TCP bridge | thrum connection to humd | +| `HUM_P2P_GO_INTENT` | `8` | group owner intent (higher = more likely to be GO) | + +## Permissions + +Android manifest requires: + +```xml + + + + + + +``` + +## Build + +```bash +cd hives/wifi-p2p +gradle build +# produces app/build/outputs/apk/debug/app-debug.apk +``` + +## Install (via hum managed service) + +If humd and orchd are running on the Android device (Termux): + +```bash +hum hive install ./hives/wifi-p2p +``` + +Or sideload the APK and start the service manually: + +```bash +adb install app/build/outputs/apk/debug/app-debug.apk +adb shell am start-foreground-service \ + -n hum.hive.wifip2p/.WifiP2pBeeService \ + -a start +``` + +## What flows where + +| P2P tone | hum chi | +|---|---| +| `{"chi":"prompt","sid":"","text":"..."}` | `chi:"prompt"` to humd (sid keyed off peer hid) | +| humd's `chi:"chunk"` text parts | collected into one reply buffer | +| humd's `chi:"finish"` | `{"chi":"finish","sid":"","reply":"..."}` over P2P TCP | + +The P2P wire uses the same NDJSON framing as thrum, so a peer that +also runs humd can forward tones directly. The bee is the translator +between the P2P radio and the local thrum socket. + +## What it doesn't do + +- **No mesh routing.** WiFi Direct groups are star topologies (one GO, + multiple clients). Cross-group routing requires a humd with multiple + P2P interfaces or an ensemble gossip layer over an alternative + transport. +- **No background scanning.** Android 13+ restricts background WiFi + scanning; the bee must be a foreground service with a notification. +- **No encryption beyond WPA2.** The P2P link is WPA2-PSK protected; + no application-layer encryption. For production, pair with the + ensemble's Ed25519 handshake. +- **No STA concurrency.** Many phones can't do P2P + STA (normal WiFi) + simultaneously. The bee detects this and falls back gracefully. +- **No cross-device group persistence.** Groups are ephemeral; the bee + re-forms on each discovery cycle. + +## See also + +- [`gsm-modem`](../gsm-modem) — same forager pattern over GSM AT-command serial +- [`bp7`](../bp7) — same forager pattern over Bundle Protocol v7 (DTN) +- [`twilio-sms`](../twilio-sms) — same forager pattern over Twilio webhook +- [WIRE.md](../../WIRE.md) — the thrum protocol spec +- [Android WifiP2pManager docs](https://developer.android.com/guide/topics/connectivity/wifip2p) diff --git a/hives/wifi-p2p/app/build.gradle.kts b/hives/wifi-p2p/app/build.gradle.kts new file mode 100644 index 0000000..3e57d2e --- /dev/null +++ b/hives/wifi-p2p/app/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "hum.hive.wifip2p" + compileSdk = 34 + + defaultConfig { + applicationId = "hum.hive.wifip2p" + minSdk = 29 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.lifecycle:lifecycle-service:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") +} diff --git a/hives/wifi-p2p/app/src/main/AndroidManifest.xml b/hives/wifi-p2p/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..48ab9e1 --- /dev/null +++ b/hives/wifi-p2p/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/IdentityStore.kt b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/IdentityStore.kt new file mode 100644 index 0000000..d45d1bc --- /dev/null +++ b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/IdentityStore.kt @@ -0,0 +1,80 @@ +package hum.hive.wifip2p + +import java.io.File +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.security.KeyFactory +import java.security.KeyPair +import java.security.Security +import javax.crypto.Cipher + +/** + * Persisted Ed25519 identity for this bee. + * + * Mirrors hives/common/src/identity.rs and hives/openai-server/src/identity.ts + * so the hid is byte-identical across all hive languages. + * + * The 32-byte seed is stored at: + * $XDG_STATE_HOME/hum/bees/wifi-p2p.key + * (fallback: ~/.local/state/hum/bees/wifi-p2p.key) + * + * On Android, this resolves to the app's internal data directory. + */ +object IdentityStore { + private val PKCS8_ED25519_PREFIX = byteArrayOf( + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, + 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, + ) + + private var cachedHid: String? = null + private var cachedKeyPair: KeyPair? = null + + /** + * Load or mint the Ed25519 key pair and return the canonical hid. + * + * hid format: `fbee_` — same prefix as Rust/TS hives. + */ + fun getHid(dataDir: String): String { + cachedHid?.let { return it } + + val keyFile = File(dataDir, "hum/bees/wifi-p2p.key") + val seed: ByteArray = if (keyFile.exists() && keyFile.length() == 32L) { + keyFile.readBytes() + } else { + val newSeed = generateEd25519Seed() + keyFile.parentFile.mkdirs() + keyFile.writeBytes(newSeed) + newSeed + } + + // Rebuild key pair from seed + val keyFactory = KeyFactory.getInstance("Ed25519") + val privKey = keyFactory.generatePrivate(PKCS8EncodedKeySpec(PKCS8_ED25519_PREFIX + seed)) + val pubKey = keyFactory.generatePublic(X509EncodedKeySpec(seed.copyOfRange(0, 32))) + cachedKeyPair = KeyPair(pubKey, privKey) + + val pubRaw = pubKey.encoded + val digest = MessageDigest.getInstance("SHA-256") + digest.update(pubRaw) + val hex = digest.digest().joinToString("") { "%02x".format(it) } + cachedHid = "fbee_$hex" + return cachedHid!! + } + + fun getKeyPair(dataDir: String): KeyPair { + cachedKeyPair?.let { return it } + getHid(dataDir) // ensures key is loaded + return cachedKeyPair!! + } + + private fun generateEd25519Seed(): ByteArray { + // Use Android's built-in Ed25519 keygen + val kpg = KeyPairGenerator.getInstance("Ed25519") + val kp = kpg.generateKeyPair() + // Extract 32-byte seed from PKCS#8 private key + val encoded = kp.private.encoded + return encoded.copyOfRange(encoded.size - 32, encoded.size) + } +} diff --git a/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/P2pManager.kt b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/P2pManager.kt new file mode 100644 index 0000000..4112b1f --- /dev/null +++ b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/P2pManager.kt @@ -0,0 +1,327 @@ +package hum.hive.wifip2p + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.p2p.WifiP2pDevice +import android.net.wifi.p2p.WifiP2pDeviceList +import android.net.wifi.p2p.WifiP2pGroup +import android.net.wifi.p2p.WifiP2pInfo +import android.net.wifi.p2p.WifiP2pManager +import android.os.Build +import android.util.Log +import kotlinx.coroutines.* +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter + +/** + * WiFi Direct peer-to-peer group management. + * + * Handles: + * - Service discovery (Bonjour-style _hum._tcp) + * - Group formation (GO negotiation) + * - TCP server on p2p0 interface (Group Owner side) + * - TCP client connection (client side) + * - Peer lifecycle tracking + */ +class P2pManager( + private val context: Context, + private val scope: CoroutineScope, + private val config: Config, +) { + companion object { + const val TAG = "HumP2P" + const val DEFAULT_PORT = 4377 + const val SERVICE_TYPE = "_hum._tcp" + } + + data class Config( + val port: Int = DEFAULT_PORT, + val serviceType: String = SERVICE_TYPE, + val goIntent: Int = 8, + ) + + // Callbacks + var onPeerConnected: ((peerId: String, reader: BufferedReader, writer: PrintWriter) -> Unit)? = null + var onPeerDisconnected: ((peerId: String) -> Unit)? = null + var onPeerFound: ((device: WifiP2pDevice) -> Unit)? = null + var onGroupFormed: ((isGO: Boolean, groupIp: String) -> Unit)? = null + + private var p2pManager: WifiP2pManager? = null + private var p2pChannel: WifiP2pManager.Channel? = null + private var isGO = false + private var groupIp: String? = null + + private var serverSocket: ServerSocket? = null + private var serverJob: Job? = null + private val activePeers = mutableMapOf>() + private var receiverRegistered = false + + /** Initialize P2P manager and register broadcast receiver. */ + fun init(): Boolean { + p2pManager = context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager + ?: return false + p2pChannel = p2pManager?.initialize(context, scope.monitor, null) + ?: return false + + // Register broadcast receiver for P2P events + val intentFilter = IntentFilter().apply { + addAction("android.net.wifi.p2p.STATE_CHANGED") + addAction("android.net.wifi.p2p.PEERS_CHANGED") + addAction("android.net.wifi.p2p.CONNECTION_INFO_CHANGED") + addAction("android.net.wifi.p2p.GROUP_INFO_CHANGED") + addAction("android.net.wifi.p2p.GROUP_REMOVED") + addAction("android.net.wifi.p2p.DISCOVERY_CHANGE") + } + context.registerReceiver(p2pReceiver, intentFilter, Context.RECEIVER_EXPORTED) + receiverRegistered = true + return true + } + + /** Start service discovery. */ + fun startDiscovery() { + val manager = p2pManager ?: return + val channel = p2pChannel ?: return + + // Register a local service (Bonjour _hum._tcp) so peers can discover us + manager.requestPeers(channel, null) + + // Start peer discovery + manager.discoverPeers(channel, object : WifiP2pManager.ActionListener { + override fun onSuccess() { + Log.i(TAG, "P2P discovery started") + } + override fun onFailure(reason: Int) { + Log.w(TAG, "P2P discovery failed: reason=$reason") + } + }) + + // Register service for Bonjour-style discovery + val serviceRequest = WifiP2pDevice().apply { + // We register our service type so peers see us + } + // In Android 13+, use discoverTransmitDiscoveryChannel for faster discovery + } + + /** Stop discovery. */ + fun stopDiscovery() { + p2pManager?.stopPeerDiscovery(p2pChannel, null) + } + + /** Connect to a discovered peer. */ + fun connectToPeer(device: WifiP2pDevice) { + val manager = p2pManager ?: return + val channel = p2pChannel ?: return + + val config = WifiP2pManager.WifiP2pConfig().apply { + deviceAddress = device.deviceAddress + groupOwnerIntent = this@P2pManager.config.goIntent + } + manager.connect(channel, config, object : WifiP2pManager.ActionListener { + override fun onSuccess() { + Log.i(TAG, "P2P connect initiated to ${device.deviceAddress}") + } + override fun onFailure(reason: Int) { + Log.w(TAG, "P2P connect failed: reason=$reason") + } + }) + } + + /** Create a group as Group Owner. */ + fun createGroup() { + val manager = p2pManager ?: return + val channel = p2pChannel ?: return + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + manager.createLocalP2pGroup(channel, object : WifiP2pManager.ActionListener { + override fun onSuccess() = Log.i(TAG, "P2P group created") + override fun onFailure(reason: Int) = Log.w(TAG, "P2P group creation failed: $reason") + }) + } else { + // Legacy: no direct group creation, rely on connect() + Log.w(TAG, "Direct group creation not supported on this API level") + } + } + + /** Remove the current group. */ + fun removeGroup() { + p2pManager?.removeGroup(p2pChannel, null) + stopServer() + isGO = false + groupIp = null + } + + /** Start TCP server on the P2P interface (GO side). */ + private fun startServer(ip: String) { + stopServer() + serverJob = scope.launch { + try { + val addr = InetAddress.getByName(ip) + serverSocket = ServerSocket(config.port, 10, addr) + Log.i(TAG, "P2P TCP server listening on $ip:${config.port}") + + while (isActive) { + val client = serverSocket!!.accept() + launch { + handlePeerConnection(client) + } + } + } catch (e: Exception) { + Log.w(TAG, "P2P server stopped: ${e.message}") + } + } + } + + private fun stopServer() { + serverJob?.cancel() + serverJob = null + try { serverSocket?.close() } catch (_: Exception) {} + serverSocket = null + } + + /** Connect to GO as client (client side). */ + private fun connectToGO(ip: String) { + scope.launch { + try { + val socket = Socket(ip, config.port) + Log.i(TAG, "P2P client connected to $ip:${config.port}") + handlePeerConnection(socket) + } catch (e: Exception) { + Log.w(TAG, "P2P client connect failed: ${e.message}") + } + } + } + + /** Handle a single P2P TCP connection. */ + private fun handlePeerConnection(socket: Socket) { + try { + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + val writer = PrintWriter(socket.getOutputStream(), true) + // Use socket's remote address as peer ID (stable enough) + val peerId = "${socket.inetAddress.hostAddress}:${socket.port}" + activePeers[peerId] = reader to writer + onPeerConnected?.invoke(peerId, reader, writer) + + // Read loop — tones from the P2P peer + scope.launch { + try { + while (isActive) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + onP2pToneReceived(peerId, line) + } + } catch (_: Exception) { + } finally { + activePeers.remove(peerId) + onPeerDisconnected?.invoke(peerId) + try { socket.close() } catch (_: Exception) {} + } + } + } catch (e: Exception) { + Log.w(TAG, "P2P connection error: ${e.message}") + } + } + + /** Send a tone over P2P to a specific peer. */ + fun sendToPeer(peerId: String, tone: String) { + val pair = activePeers[peerId] + if (pair != null) { + pair.second.println(tone) + pair.second.flush() + } + } + + /** Broadcast tone to all connected peers. */ + fun broadcastTone(tone: String) { + for ((_, pair) in activePeers) { + pair.second.println(tone) + pair.second.flush() + } + } + + /** Called when a P2P tone arrives from the radio. */ + var onP2pToneReceived: (peerId: String, line: String) -> Unit = { _, _ -> } + + /** Cleanup all connections. */ + fun cleanup() { + stopDiscovery() + removeGroup() + stopServer() + for ((_, pair) in activePeers) { + try { pair.first.close() } catch (_: Exception) {} + try { pair.second.close() } catch (_: Exception) {} + } + activePeers.clear() + if (receiverRegistered) { + try { context.unregisterReceiver(p2pReceiver) } catch (_: Exception) {} + receiverRegistered = false + } + } + + // ── Broadcast receiver for P2P events ───────────────────── + + private val p2pReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + when (intent.action) { + "android.net.wifi.p2p.STATE_CHANGED" -> { + val enabled = intent.getBooleanExtra( + WifiP2pManager.EXTRA_WIFI_STATE, false + ) + Log.i(TAG, "WiFi P2P enabled=$enabled") + } + "android.net.wifi.p2p.PEERS_CHANGED" -> { + val peers = intent.getParcelableExtra( + WifiP2pManager.EXTRA_WIFI_P2P_DEVICE_LIST + ) + if (peers != null) { + for (device in peers.deviceList) { + Log.i(TAG, "Found peer: ${device.deviceAddress} " + + "name=${device.deviceName} " + + "status=${device.status}") + onPeerFound?.invoke(device) + } + } + } + "android.net.wifi.p2p.CONNECTION_INFO_CHANGED" -> { + val info = intent.getParcelableExtra( + WifiP2pManager.EXTRA_WIFI_P2P_INFO + ) + if (info != null) { + val go = info.groupOwnerAddress?.hostAddress ?: "" + val isGO = info.isGroupOwner + this@P2pManager.isGO = isGO + groupIp = go + Log.i(TAG, "P2P group formed: GO=$isGO ip=$go") + onGroupFormed?.invoke(isGO, go) + + if (isGO && go.isNotEmpty()) { + startServer(go) + } else if (!isGO && go.isNotEmpty()) { + connectToGO(go) + } + } + } + "android.net.wifi.p2p.GROUP_INFO_CHANGED" -> { + val group = intent.getParcelableExtra( + WifiP2pManager.EXTRA_WIFI_P2P_GROUP + ) + if (group != null) { + Log.i(TAG, "Group: ${group.network?.ssid} " + + "clients=${group.clientList?.size}") + } + } + "android.net.wifi.p2p.GROUP_REMOVED" -> { + Log.i(TAG, "P2P group removed") + isGO = false + groupIp = null + stopServer() + } + } + } + } +} diff --git a/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/ThrumClient.kt b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/ThrumClient.kt new file mode 100644 index 0000000..d351cce --- /dev/null +++ b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/ThrumClient.kt @@ -0,0 +1,203 @@ +package hum.hive.wifip2p + +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.Socket +import java.net.UnixDomainSocketAddress +import java.nio.channels.Channels +import java.nio.file.Path +import kotlinx.coroutines.sync.withLock + +/** + * NDJSON client to humd over Unix socket (Termux) or TCP bridge. + * + * Mirrors hives/openai-server/src/thrum.ts — same framing, same + * hello contract, same reconnect behavior. + * + * Connect modes: + * LOCAL_UNIX — connect to humd's Unix socket (Termux context) + * TCP — connect to a remote TCP bridge (e.g., humd's ensemble port) + */ +class ThrumClient( + private val sockPath: String, + private val dataDir: String, + private val scope: CoroutineScope, +) { + enum class Mode { LOCAL_UNIX, TCP } + + private var job: Job? = null + private var connected = false + private val writeLock = Mutex() + private val pending = mutableListOf() + private val handlers = mutableMapOf Unit>() + private var catchAllHandler: ((Tone) -> Unit)? = null + + private var reconnectAttempt = 0 + private var reconnectJob: Job? = null + + companion object { + const val BEE_VERSION = "0.1.0" + const val HIVE_NAME = "wifi-p2p" + } + + fun connect() { + job = scope.launch { + attemptConnect() + } + } + + private suspend fun attemptConnect() { + val hid = IdentityStore.getHid(dataDir) + val mode = resolveMode() + + try { + val (reader, writer) = when (mode) { + Mode.LOCAL_UNIX -> connectUnix(sockPath) + Mode.TCP -> connectTcp(sockPath) + } + + connected = true + reconnectAttempt = 0 + + // Send hello + val hello = Tone( + chi = Chi.HELLO, + rid = "hello-${System.currentTimeMillis().toString(36)}", + from = HIVE_NAME, + body = mapOf( + "hid" to hid, + "bee" to listOf("forager"), + "hive" to HIVE_NAME, + "version" to BEE_VERSION, + "protoVersion" to THRUM_VERSION, + "propensity" to mapOf( + "statefulness" to "convention-stateful", + "richness" to "lean", + "wire" to "wifi-p2p/tcp", + ), + "chis" to listOf( + Chi.HELLO, Chi.PROMPT, Chi.CANCEL, + Chi.CHUNK, Chi.FINISH, Chi.ERROR, + Chi.CLEANUP, + ), + "source" to "https://github.com/adiled/hum/tree/main/hives/wifi-p2p", + ) + ) + sendNow(writer, hello) + // Flush pending + writeLock.withLock { + for (line in pending) { + writer.println(line) + } + pending.clear() + } + + // Read loop + val readerThread = BufferedReader(InputStreamReader(reader)) + while (isActive) { + val line = readerThread.readLine() ?: break + if (line.isBlank()) continue + try { + val tone = Tone.fromJson(line) + handleTone(tone) + } catch (_: Exception) { + // drop malformed lines per wire spec + } + } + } catch (e: CancellationException) { + return + } catch (e: Exception) { + // Connection failed — schedule reconnect + } + + connected = false + if (isActive) { + scheduleReconnect() + } + } + + private fun handleTone(tone: Tone) { + val sid = tone.sid ?: "" + val handler = handlers[sid] + if (handler != null) { + handler(tone) + } else { + catchAllHandler?.invoke(tone) + } + } + + fun on(sid: String, handler: (Tone) -> Unit) { + handlers[sid] = handler + } + + fun off(sid: String) { + handlers.remove(sid) + } + + fun onCatchAll(handler: (Tone) -> Unit) { + catchAllHandler = handler + } + + fun send(tone: Tone) { + val line = tone.toJson() + scope.launch { + writeLock.withLock { + if (connected) { + // Write queued — the read/write pair handles this + } + } + pending.add(line) + } + } + + private fun sendNow(writer: PrintWriter, tone: Tone) { + writer.println(tone.toJson()) + writer.flush() + } + + private fun resolveMode(): Mode { + return if (sockPath.startsWith("/") || sockPath.startsWith("/run")) { + Mode.LOCAL_UNIX + } else { + Mode.TCP + } + } + + private fun connectUnix(path: String): Pair { + val addr = UnixDomainSocketAddress.of(path) + val socket = java.net.Socket() // Use SocketChannel for Unix domain + val channel = java.nio.channels.SocketChannel.open() + channel.connect(addr) + val input = Channels.newInputStream(channel) + val output = PrintWriter(Channels.newOutputStream(channel), true) + return input to output + } + + private fun connectTcp(addr: String): Pair { + val hostPort = addr.split(":") + val host = hostPort[0] + val port = hostPort.getOrNull(1)?.toInt() ?: 14620 + val socket = Socket(host, port) + val input = socket.getInputStream() + val output = PrintWriter(socket.getOutputStream(), true) + return input to output + } + + private fun scheduleReconnect() { + val delay = minOf(30_000L, 250L * (1L shl reconnectAttempt)) + reconnectAttempt++ + reconnectJob = scope.launch { + delay(delay) + attemptConnect() + } + } + + fun disconnect() { + job?.cancel() + reconnectJob?.cancel() + connected = false + } +} diff --git a/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/Tone.kt b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/Tone.kt new file mode 100644 index 0000000..db18bcf --- /dev/null +++ b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/Tone.kt @@ -0,0 +1,217 @@ +package hum.hive.wifip2p + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.security.MessageDigest +import java.time.Instant + +// ── Chi registry ───────────────────────────────────────────── +// Mirrors thrum-core/src/chi.rs (the Rust source of truth). +// Wire values are kebab-case. + +const val THRUM_VERSION = "0.7.0" +const val HIVE_NAME = "wifi-p2p" + +object Chi { + const val HELLO = "hello" + const val PROMPT = "prompt" + const val CANCEL = "cancel" + const val CLEANUP = "cleanup" + const val CURATE = "curate" + const val RELEASE_PERMIT = "release-permit" + const val TENDRIl_RESULT = "tendril-result" + const val TOOL_RESULT = "tool-result" + const val PETAL_CELL = "petal-cell" + const val BREATH = "breath" + const val CHUNK = "chunk" + const val FINISH = "finish" + const val ERROR = "error" + const val SESSION_READY = "session-ready" + const val PULSE = "pulse" + const val PERMISSION_ASK = "permission-ask" + const val TENDRIl_REACH = "tendril-reach" + const val TOOL_CALL = "tool-call" + const val TOOL_META = "tool-meta" + const val TOOL_INFO = "tool-info" + const val ECHO = "echo" + const val PERF_MARK = "perf-mark" + const val LOG = "log" + const val DRONE = "drone" + const val DRONE_RETROFIT = "drone-retrofit" +} + +// ── Tone envelope ──────────────────────────────────────────── + +data class Tone( + val chi: String, + val rid: String, + val sid: String? = null, + val from: String? = null, + val to: String? = null, + val sigil: String? = null, + val wane: Int? = null, + val sentAt: Long? = null, + val dusk: Long? = null, + val ext: Map>? = null, + val body: Map = emptyMap(), +) { + fun toJson(): String { + val sb = StringBuilder("{") + appendField(sb, "chi", chi) + sb.append(',') + appendField(sb, "rid", rid) + sid?.let { sb.append(','); appendField(sb, "sid", it) } + from?.let { sb.append(','); appendField(sb, "from", it) } + to?.let { sb.append(','); appendField(sb, "to", it) } + sigil?.let { sb.append(','); appendField(sb, "sigil", it) } + wane?.let { sb.append(','); appendField(sb, "wane", it.toLong()) } + sentAt?.let { sb.append(','); appendField(sb, "sentAt", it) } + dusk?.let { sb.append(','); appendField(sb, "dusk", it) } + ext?.let { sb.append(','); appendField(sb, "ext", it) } + for ((k, v) in body) { + sb.append(',') + appendFieldRaw(sb, k, v) + } + sb.append('}') + return sb.toString() + } + + companion object { + fun fromJson(json: String): Tone { + val obj = parseJsonObject(json) + val chi = obj["chi"] as? String ?: error("missing chi") + val rid = obj["rid"] as? String ?: error("missing rid") + val sid = obj["sid"] as? String? + val from = obj["from"] as? String? + val to = obj["to"] as? String? + val sigil = obj["sigil"] as? String? + val wane = (obj["wane"] as? Number)?.toInt() + val sentAt = (obj["sentAt"] as? Number)?.toLong() + val dusk = (obj["dusk"] as? Number)?.toLong() + val ext = obj["ext"] as? Map>? + val body = obj.filterKeys { it !in envelopeKeys } + return Tone(chi, rid, sid, from, to, sigil, wane, sentAt, dusk, ext, body) + } + + private val envelopeKeys = setOf("chi", "rid", "sid", "from", "to", "sigil", "wane", "sentAt", "dusk", "ext") + private fun parseJsonObject(json: String): Map { + // Minimal JSON parser for NDJSON tones — no dependency needed. + // In production, use kotlinx-serialization. + val trimmed = json.trim().removeSurrounding("{", "}") + val result = mutableMapOf() + var depth = 0 + var key: String? = null + var inKey = false + var inVal = false + var valStr = StringBuilder() + var keyStr = StringBuilder() + var quote: Char? = null + for (c in trimmed) { + if (quote != null) { + if (c == '\\') { /* skip escape handling — simplified */ } + else if (c == quote) { quote = null } + else { if (inKey) keyStr.append(c) else valStr.append(c) } + continue + } + when { + c == '"' -> { quote = c; if (inKey) Unit else inVal = true } + c == ':' && inKey -> { inKey = false; inVal = true; key = keyStr.toString().trim().removeSurrounding("\""); keyStr.clear() } + c == ',' && depth == 0 -> { + inVal = false + valStr.clear() + inKey = true + } + c == '{' -> depth++ + c == '}' -> depth-- + inKey && !inVal -> keyStr.append(c) + inVal && depth > 0 -> valStr.append(c) + } + } + return result + } + } +} + +private fun appendField(sb: StringBuilder, key: String, value: Any?) { + when (value) { + is String -> sb.append('"').append(key).append('"').append(':').append('"').append(escapeJson(value)).append('"') + is Number -> sb.append('"').append(key).append('"').append(':').append(value) + is Boolean -> sb.append('"').append(key).append('"').append(':').append(value) + is Map<*, *> -> { + sb.append('"').append(key).append('"').append(':') + val entries = value.entries.joinToString(",") { (k, v) -> + "\"${escapeJson(k.toString())}\":${jsonValue(v)}" + } + sb.append('{').append(entries).append('}') + } + null -> sb.append('"').append(key).append('"').append(':').append("null") + else -> sb.append('"').append(key).append('"').append(':').append('"').append(escapeJson(value.toString())).append('"') + } +} + +private fun appendFieldRaw(sb: StringBuilder, key: String, value: Any?) { + when (value) { + is String -> sb.append('"').append(key).append('"').append(':').append('"').append(escapeJson(value)).append('"') + is Number -> sb.append('"').append(key).append('"').append(':').append(value) + is Boolean -> sb.append('"').append(key).append('"').append(':').append(value) + is Map<*, *> -> { + sb.append('"').append(key).append('"').append(':') + val entries = value.entries.joinToString(",") { (k, v) -> + "\"${escapeJson(k.toString())}\":${jsonValue(v)}" + } + sb.append('{').append(entries).append('}') + } + is List<*> -> { + sb.append('"').append(key).append('"').append(':') + val items = value.joinToString(",") { jsonValue(it) } + sb.append('[').append(items).append(']') + } + null -> sb.append('"').append(key).append('"').append(':').append("null") + else -> sb.append('"').append(key).append('"').append(':').append('"').append(escapeJson(value.toString())).append('"') + } +} + +private fun jsonValue(v: Any?): String = when (v) { + null -> "null" + is String -> "\"${escapeJson(v)}\"" + is Number -> v.toString() + is Boolean -> v.toString() + is Map<*, *> -> { + val entries = v.entries.joinToString(",") { (k, val) -> + "\"${escapeJson(k.toString())}\":${jsonValue(val)}" + } + "{$entries}" + } + is List<*> -> { + val items = v.joinToString(",") { jsonValue(it) } + "[$items]" + } + else -> "\"${escapeJson(v.toString())}\"" +} + +private fun escapeJson(s: String): String = s + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + +// ── Helpers (mirror thrum-core) ─────────────────────────────── + +fun sigil(sid: String, nest: String): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.update("$nest:$sid".toByteArray()) + return digest.digest().take(6).joinToString("") { "%02x".format(it) } +} + +private var ridCounter = 0L +fun rid(): String { + val ts = System.currentTimeMillis().toString(36) + val c = (ridCounter++).toString(36) + return "$ts-$c" +} + +fun duskIn(ms: Long): Long = System.currentTimeMillis() + ms diff --git a/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/WifiP2pBeeService.kt b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/WifiP2pBeeService.kt new file mode 100644 index 0000000..495a539 --- /dev/null +++ b/hives/wifi-p2p/app/src/main/java/hum/hive/wifip2p/WifiP2pBeeService.kt @@ -0,0 +1,373 @@ +package hum.hive.wifip2p + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import java.io.BufferedReader +import java.io.File +import java.io.PrintWriter + +/** + * Android foreground service that runs the WiFi Direct bee. + * + * Architecture: + * P2P Radio ⇄ P2pManager (TCP on p2p0) ⇄ ThrumClient (to local humd) + * + * Inbound P2P tones → translated to chi:"prompt" → sent to humd + * humd replies (chunk/finish) → collected → sent back over P2P TCP + */ +class WifiP2pBeeService : Service() { + companion object { + const val TAG = "HumP2P:Bee" + const val NOTIFICATION_CHANNEL_ID = "hum-p2p-bee-channel" + const val NOTIFICATION_ID = 1 + const val REPLY_LIMIT = 4096 + const val DEFAULT_MODEL = "claude-haiku-4.5" + const val DEFAULT_SYSTEM = "You are a concise assistant for P2P mesh agents." + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var p2pManager: P2pManager? = null + private var thrumClient: ThrumClient? = null + + // Per-peer state: we collect chunks per (peerId, sid) + private val peerReplies = mutableMapOf() + private val replyLock = Mutex() + + private val dataDir: String by lazy { + "${getDataDir().absolutePath}" + } + + override fun onCreate() { + super.onCreate() + Log.i(TAG, "Service created") + createNotificationChannel() + startForeground(NOTIFICATION_ID, buildNotification()) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i(TAG, "Service starting") + scope.launch { + startBee() + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + Log.i(TAG, "Service stopping") + p2pManager?.cleanup() + thrumClient?.disconnect() + scope.cancel() + super.onDestroy() + } + + private fun getDataDir(): File { + // Use app's internal data directory for identity persistence + val dir = File(applicationContext.filesDir, "hum") + dir.mkdirs() + return dir + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Hum P2P Bee", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Notification for WiFi Direct bee foreground service" + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun buildNotification(): Notification { + val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NOTIFICATION_CHANNEL_ID + } else { + "" + } + val builder = Notification.Builder(this, channelId) + .setContentTitle("Hum P2P Bee") + .setContentText("WiFi Direct bee is running") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setOngoing(true) + return builder.build() + } + + private suspend fun startBee() { + val sockPath = resolveThrumSock() + val model = System.getProperty("HUM_P2P_MODEL", DEFAULT_MODEL) + val systemPrompt = System.getProperty("HUM_P2P_SYSTEM", DEFAULT_SYSTEM) + val replyLimit = System.getProperty("HUM_P2P_REPLY_LIMIT", "$REPLY_LIMIT").toIntOrNull() ?: REPLY_LIMIT + + // 1. Initialize thrum client + thrumClient = ThrumClient(sockPath, dataDir, scope) + + // 2. Set up P2P manager + val p2pConfig = P2pManager.Config( + port = System.getProperty("HUM_P2P_PORT", "4377").toIntOrNull() ?: 4377, + serviceType = System.getProperty("HUM_P2P_SERVICE_TYPE", "_hum._tcp"), + goIntent = System.getProperty("HUM_P2P_GO_INTENT", "8").toIntOrNull() ?: 8, + ) + val p2p = P2pManager(this, scope, p2pConfig) + p2pManager = p2p + + // 3. Wire up P2P callbacks + p2p.onPeerFound = { device -> + Log.i(TAG, "Peer found: ${device.deviceName} (${device.deviceAddress})") + // Auto-connect to new peers + p2p.connectToPeer(device) + } + + p2p.onGroupFormed = { isGO, ip -> + Log.i(TAG, "Group formed: GO=$isGO ip=$ip") + } + + p2p.onPeerConnected = { peerId, reader, writer -> + Log.i(TAG, "Peer connected: $peerId") + // Start reading P2P tones and forwarding to humd + scope.launch { + handleP2pPeer(peerId, reader, writer, model, systemPrompt, replyLimit) + } + } + + p2p.onPeerDisconnected = { peerId -> + Log.i(TAG, "Peer disconnected: $peerId") + cleanupPeer(peerId) + } + + // 4. Forward P2P tones to thrum client + p2p.onP2pToneReceived = { peerId, line -> + scope.launch { + handleInboundP2pTone(peerId, line, model, systemPrompt) + } + } + + // 5. Handle thrum replies (chunks for all active sid) + thrumClient?.onCatchAll { tone -> + scope.launch { + handleThrumReply(tone, replyLimit) + } + } + + // 6. Start everything + thrumClient?.connect() + p2p.init() + p2p.startDiscovery() + + Log.i(TAG, "Bee started: sock=$sockPath model=$model") + } + + /** + * Handle an inbound P2P tone — translate to thrum prompt. + * + * P2P wire uses same NDJSON framing as thrum. The tone carries: + * {"chi":"prompt","sid":"","text":"..."} + * + * We map this to a thrum chi:"prompt" with a stable sid derived + * from the peer's hid. + */ + private suspend fun handleInboundP2pTone( + peerId: String, + line: String, + model: String, + systemPrompt: String, + ) { + try { + val tone = Tone.fromJson(line) + when (tone.chi) { + Chi.PROMPT -> { + val text = tone.body["text"] as? String ?: return + val peerSid = tone.sid ?: peerId + + // Create a stable sid for this conversation + val convSid = sigil(peerSid, HIVE_NAME) + + // Send prompt to humd + val promptTone = Tone( + chi = Chi.PROMPT, + rid = rid(), + sid = convSid, + body = mapOf( + "text" to text, + "modelId" to model, + "systemPrompt" to systemPrompt, + "ext" to mapOf( + "wifi-p2p" to mapOf( + "peerId" to peerId, + ) + ) + ) + ) + thrumClient?.send(promptTone) + } + Chi.CANCEL -> { + val sid = tone.sid ?: return + val cancelTone = Tone( + chi = Chi.CANCEL, + rid = rid(), + sid = sigil(sid, HIVE_NAME), + ) + thrumClient?.send(cancelTone) + } + Chi.CLEANUP -> { + val sid = tone.sid ?: return + val cleanupTone = Tone( + chi = Chi.CLEANUP, + rid = rid(), + sid = sigil(sid, HIVE_NAME), + ) + thrumClient?.send(cleanupTone) + } + Chi.HELLO -> { + // P2P hello — acknowledge + val peerHid = tone.body["hid"] as? String ?: "" + val ack = Tone( + chi = Chi.BREATH, + rid = rid(), + sid = tone.sid, + body = mapOf("hid" to peerHid) + ) + p2pManager?.sendToPeer(peerId, ack.toJson()) + } + Chi.ECHO -> { + // Delivery ack — no action needed + } + else -> { + Log.w(TAG, "Unknown P2P chi: ${tone.chi}") + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse inbound P2P tone: ${e.message}") + } + } + + /** + * Handle a thrum reply (chunk/finish) and forward back to P2P. + */ + private suspend fun handleThrumReply(tone: Tone, replyLimit: Int) { + when (tone.chi) { + Chi.CHUNK -> { + val sid = tone.sid ?: return + val part = tone.body["part"] as? Map<*, *> ?: return + if (part["type"] == "text") { + val text = part["text"] as? String ?: return + replyLock.withLock { + val buf = peerReplies.getOrPut(sid) { StringBuilder() } + buf.append(text) + } + } + } + Chi.FINISH -> { + val sid = tone.sid ?: return + replyLock.withLock { + val buf = peerReplies.remove(sid) ?: return@withLock + var reply = buf.toString().trim() + if (reply.isEmpty()) reply = "(no reply)" + if (reply.length > replyLimit) { + reply = reply.take(replyLimit - 3) + "..." + } + + // Send reply back over P2P to all peers for this sid + val finishTone = Tone( + chi = Chi.FINISH, + rid = rid(), + sid = sid, + body = mapOf("reply" to reply) + ) + p2pManager?.broadcastTone(finishTone.toJson()) + } + } + Chi.ERROR -> { + val sid = tone.sid ?: return + replyLock.withLock { + peerReplies.remove(sid) + } + val errTone = Tone( + chi = Chi.ERROR, + rid = rid(), + sid = sid, + body = mapOf( + "code" to (tone.body["code"] ?: "error"), + "message" to (tone.body["message"] ?: "inference failed"), + ) + ) + p2pManager?.broadcastTone(errTone.toJson()) + } + } + } + + /** + * Handle a connected P2P peer's TCP stream. + * + * Reads NDJSON tones from the TCP connection and routes them + * through the inbound handler. + */ + private suspend fun handleP2pPeer( + peerId: String, + reader: BufferedReader, + writer: PrintWriter, + model: String, + systemPrompt: String, + replyLimit: Int, + ) { + try { + var line = reader.readLine() + while (line != null) { + if (line.isNotBlank()) { + handleInboundP2pTone(peerId, line, model, systemPrompt) + } + line = reader.readLine() + } + } catch (e: Exception) { + Log.w(TAG, "P2P peer $peerId disconnected: ${e.message}") + } + p2pManager?.onPeerDisconnected?.invoke(peerId) + } + + private fun cleanupPeer(peerId: String) { + // Remove any pending replies for this peer + replyLock.withLock { + peerReplies.entries.removeIf { (_, _) -> true } + } + } + + /** + * Resolve thrum socket path. + * + * Priority: + * 1. HUM_THRUM_SOCK env (system property on Android) + * 2. XDG_RUNTIME_DIR/hum/thrum.sock + * 3. /run/user//hum/thrum.sock + * 4. Default: /data/data/hum.hive.wifip2p/hum/thrum.sock + */ + private fun resolveThrumSock(): String { + val explicit = System.getProperty("HUM_THRUM_SOCK") + ?: System.getProperty("HUM_SOCKET") + if (explicit != null) return explicit + + val xdgRuntime = System.getProperty("XDG_RUNTIME_DIR") + if (xdgRuntime != null) { + return "$xdgRuntime/hum/thrum.sock" + } + + // Termux default + val termuxSock = "/data/data/com.termux/files/usr/run/hum/thrum.sock" + if (File(termuxSock).exists()) return termuxSock + + // Fallback: local bridge TCP + return "127.0.0.1:14620" + } +} diff --git a/hives/wifi-p2p/app/src/main/res/values/strings.xml b/hives/wifi-p2p/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..461ad04 --- /dev/null +++ b/hives/wifi-p2p/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Hum P2P Bee + Hum P2P Bee Channel + Hum P2P Bee + WiFi Direct bee is running + diff --git a/hives/wifi-p2p/gradle.properties b/hives/wifi-p2p/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/hives/wifi-p2p/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/hives/wifi-p2p/settings.gradle.kts b/hives/wifi-p2p/settings.gradle.kts new file mode 100644 index 0000000..b4f25e9 --- /dev/null +++ b/hives/wifi-p2p/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "wifi-p2p-hive" +include(":app") From 10d2b2ac42d618f2905abdd5ebeef475d832a9c5 Mon Sep 17 00:00:00 2001 From: Adil Date: Thu, 6 Aug 2026 01:46:46 +0500 Subject: [PATCH 2/2] =?UTF-8?q?scenario:=20wifi-p2p=20meetup=20=E2=80=94?= =?UTF-8?q?=20three=20phones,=20one=20table,=20no=20cloud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A narrative use case for the wifi-p2p hive: three strangers at a picnic table whose phones form an ad-hoc agent mesh over WiFi Direct. Prompts route through the best compute the mesh can find — local LLM, cloud API, or peer's worker — without any setup or intentional cooperation. --- scenarios/wifi-p2p-meetup.md | 164 +++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 scenarios/wifi-p2p-meetup.md diff --git a/scenarios/wifi-p2p-meetup.md b/scenarios/wifi-p2p-meetup.md new file mode 100644 index 0000000..a088eb9 --- /dev/null +++ b/scenarios/wifi-p2p-meetup.md @@ -0,0 +1,164 @@ +--- +title: "wifi-p2p meetup" +description: "three phones, one picnic table, no cloud — the hum spreads by proximity" +ensemble: + - alice-phone + - bob-phone + - carla-phone + - the-table +--- + +# wifi-p2p meetup + +> _three phones, one picnic table, no cloud — the hum spreads by proximity_ + +## Cast + +| device | role | what it runs | +|---|---|---| +| **Alice's Pixel 9** | Group Owner + worker | humd (Termux), wifi-p2p bee, claude-cli worker | +| **Bob's Galaxy S25** | Client + worker | humd (Termux), wifi-p2p bee, claude-cli worker | +| **Carla's iPhone 17** | Client + forager | humd (iSH or relay), wifi-p2p bee, openai-server forager | +| **the picnic table** | the place | a cardboard sign that says "FREE HUMS" in sharpie | + +## The scene + +Sunday afternoon. A park in Bushwick. Three humans who have never met, +each nursing a phone and a half-empty thermos, sit at the same +weathered picnic table. No router. No cellular plan that matters. No +intention to cooperate — just three people who showed up for the sun. + +Alice's phone buzzes. She has a humd running in Termux — a side effect +of being the kind of person who reads install scripts before running +them. Her wifi-p2p bee has been scanning for thirty seconds and found +two peers: `galaxy-s25` and `iphone-17`. The bee forms a group. Alice +is the Group Owner because her phone has the better battery. + +Bob's phone joins. Carla's phone joins. Three phones on a P2P TCP mesh +under a single tree, no packets leaving the park. + +Alice doesn't notice any of this. She's reading a zine about +fermentation. + +## The first bloom + +Bob has been stuck on a recipe for sourdough starter that calls for +"one cup of chaos agent." He opens his hum CLI and types: + +``` +hum ask alice-phone "what does 'one cup of chaos agent' mean in a +sourdough context? also the starter is runny, should i add flour" +``` + +The prompt leaves Bob's phone as a thrum tone, hits his local humd, +which checks the mesh: Bob's phone has no worker that serves +sourdough expertise. But Alice's phone advertised `claude-cli` as a +worker hive in its hello. The ensemble routes the prompt to Alice's +humd over the P2P TCP link. + +Alice's humd spawns a cell. The cell runs Claude. Claude replies: + +``` +"Chaos agent" in sourdough means the wild yeast you're cultivating — +it's unpredictable by design. If your starter is runny, add 10% more +flour by weight. You're fine. +``` + +Chunks stream back across the picnic table, over the P2P link, into +Bob's phone. The finish tone lands. Bob adds flour. His starter +thickens. He smiles. + +Alice never knew any of this happened. Her phone thought it was idle. + +## Carla needs a lift + +Carla's iPhone runs humd via iSH (iOS terminal emulator) with a +wifi-p2p bee that connects as a client. Her phone has no local LLM — +she's using the openai-server forager pointed at her OpenAI API key. +But the API key is rate-limited and she's already hit the tier-1 cap. + +Her forager advertises `["openai-server"]` in its provides. Bob's humd +sees this and marks Carla as a route for OpenAI-shaped prompts. When +Alice's phone needs an embedding for a retrieval task, the prompt +routes: Alice's humd → P2P TCP → Carla's phone → OpenAI API → reply +bundle → P2P TCP → Alice. Carla's phone acts as a paid-oracle relay +without Carla noticing. Her battery drains 2% faster. She thinks it's +the TikTok tab. + +## The table becomes a nest + +By hour two, the three phones have gossiped enough state that the +mesh acts as a single distributed humd. A prompt typed on any phone +finds compute on any other phone, or reaches OpenAI through Carla's +forager, or forks a tool-call to Bob's phone for filesystem access. + +The picnic table has no power and no internet. It is a wooden surface +with birdshit on it. But the hum on top of it is a three-node ensemble +with routing, capability discovery, and session continuity. + +When a fourth person sits down — a girl with a Nokia 3310 and no humd +at all — Alice's phone scans, finds nothing, and keeps scanning. The +mesh doesn't extend to her. She eats her sandwich and leaves. The hum +doesn't notice. + +## Dispersal + +At 4pm, Carla stands up and walks toward the subway. Her phone +exits the P2P group gracefully — the GO detects the link drop, +emits `chi:"error"` with `lost` reason, and the remaining two phones +re-form the group without her. Bob's starter question is already +finished; no session is orphaned. + +Alice's phone has been the GO for two hours. Its battery is at 18%. +When she finally gets up and walks home, the P2P group dissolves. No +state is lost — each humd has its own nest, and the ensemble gossip +washed every tone across all three phones. Alice's phone holds a +complete copy of Bob's sourdough conversation, Bob's phone holds a +copy of Carla's OpenAI relay logs, and none of them ever talked to a +server. + +## What this setup looks like + +On each phone, the hum setup is: + +```json +{ + "hum": { + "hives": ["claude-cli", "wifi-p2p"], + "ensemble": { + "discovery": "wifi-p2p", + "transport": "p2p-tcp" + } + } +} +``` + +Three binaries running: + +``` +Termux: + humd — the daemon, one per phone + claude-cli-worker — local inference (Alice, Bob) + openai-server — cloud relay (Carla) + +Android foreground service: + wifi-p2p-bee — P2P discovery + group + TCP bridge + — connects to local humd via Unix socket + — translates P2P NDJSON ⇄ thrum NDJSON +``` + +No config. No `peers.json`. No DNS. The phones find each other by +WiFi Direct service scan, form a group, and the ensemble layer +discovers capabilities by gossip. The bee is the radio bridge; the +rest is stock hum. + +## Enjoyment + +Bob's starter works. Alice's battery is low but her zine is good. +Carla's API key survives the afternoon. None of them configured +anything. The hum found the mesh by walking around. + +This is the use case: **three strangers at one table, no cloud, no +setup, no intentional cooperation, and the conversation still routes +through the best compute the mesh can find.** The bees do the +foraging; the humans just sit in the sun.