OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / d85fb4c

android: find lamps automatically (NsdManager) -- step 3
LampFinderModule.kt browses _cthuloid._tcp with Android's own mDNS (no multicast lock: the system daemon does it). Android 14+ resolves with registerServiceInfoCallback (address changes + loss reported); below that, resolveService queued one at a time (concurrent resolves fail). Lamps go to JS as unit-up/unit-down by IPv4 address, then over IPC into the worklet's IpcDiscovery, which emits the same events avahi does on Linux. Browsing runs while the app is on screen. Worklet IPC input splits on the newline byte before decoding; a busy-port fallback closes the half-started app. With webui's remembered lastAddr, a known lamp is dialled at once. Verified on the SM-S906U (Android 16): studiolight reported ~200 ms after start (192.168.50.89, port 1337, proto 3) and listed on the page by itself. Connecting from the phone still needs the WiFi's client isolation off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d85fb4cef74bc918e1b42ceac73c79dc29462d4f
by plankatron <fleeky@gmail.com> · 2026-10-02T16:35:06.000Z

Changed files (7) +308 -8

android/android/app/src/main/java/org/circusautomatic/cthuloid/CthuloidPackage.kt +15 -0
……@@ -0,0 +1,15 @@
1package org.circusautomatic.cthuloid
2
3import com.facebook.react.ReactPackage
4import com.facebook.react.bridge.NativeModule
5import com.facebook.react.bridge.ReactApplicationContext
6import com.facebook.react.uimanager.ViewManager
7
8/** The app's own native modules. Autolinking cannot find these: they are not a node_modules package. */
9class CthuloidPackage : ReactPackage {
10 override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
11 listOf(LampFinderModule(reactContext))
12
13 override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
14 emptyList()
15}
android/android/app/src/main/java/org/circusautomatic/cthuloid/LampFinderModule.kt +158 -0
……@@ -0,0 +1,158 @@
1package org.circusautomatic.cthuloid
2
3import android.content.Context
4import android.net.nsd.NsdManager
5import android.net.nsd.NsdServiceInfo
6import android.os.Build
7import com.facebook.react.bridge.Arguments
8import com.facebook.react.bridge.ReactApplicationContext
9import com.facebook.react.bridge.ReactContextBaseJavaModule
10import com.facebook.react.bridge.ReactMethod
11import com.facebook.react.bridge.WritableMap
12import com.facebook.react.modules.core.DeviceEventManagerModule
13import java.net.Inet4Address
14import java.util.ArrayDeque
15import java.util.concurrent.Executors
16
17/**
18 * Finds light units: Android's own mDNS (NsdManager) browsing _cthuloid._tcp, which every unit on
19 * firmware proto >= 2 advertises on its command port with TXT proto=<n>. Results go to JS as
20 * "cthuloid-unit" events shaped like the worklet's IPC messages (lib/ipc-discovery.js):
21 * { t: 'unit-up', name, host, addr, port, proto } { t: 'unit-down', name }
22 * { t: 'discovery', available, reason }
23 *
24 * No multicast lock: NsdManager does the multicast in the system's mDNS daemon, not in this app.
25 * There is no DNS on Android for apps (favela memory, 2026-08-24), so units are reported by ADDRESS.
26 *
27 * Resolving differs by version. Android 14+ (API 34): registerServiceInfoCallback, which also reports
28 * address changes and the unit going away. Below 34: resolveService, which fails with
29 * FAILURE_ALREADY_ACTIVE if a second resolve starts before the first finishes -- so those are queued.
30 *
31 * A plain legacy module, as favela-surface-android's FavelaNodeModule: it works under the new
32 * architecture's interop layer and needs no codegen spec.
33 */
34class LampFinderModule(private val ctx: ReactApplicationContext) : ReactContextBaseJavaModule(ctx) {
35
36 companion object {
37 const val NAME = "CthuloidLampFinder"
38 const val EVENT = "cthuloid-unit"
39 const val SERVICE = "_cthuloid._tcp"
40 }
41
42 override fun getName(): String = NAME
43
44 private val nsd by lazy { ctx.getSystemService(Context.NSD_SERVICE) as NsdManager }
45 private val executor = Executors.newSingleThreadExecutor()
46 private var discovery: NsdManager.DiscoveryListener? = null
47 private val callbacks = HashMap<String, NsdManager.ServiceInfoCallback>()
48 private val resolveQueue = ArrayDeque<NsdServiceInfo>()
49 private var resolving = false
50
51 private fun emit(map: WritableMap) {
52 ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java).emit(EVENT, map)
53 }
54
55 private fun available(ok: Boolean, reason: String = "") {
56 emit(Arguments.createMap().apply {
57 putString("t", "discovery"); putBoolean("available", ok); putString("reason", reason)
58 })
59 }
60
61 private fun up(info: NsdServiceInfo, addr: String) {
62 val proto = info.attributes["proto"]?.let { String(it, Charsets.UTF_8).toIntOrNull() } ?: 0
63 emit(Arguments.createMap().apply {
64 putString("t", "unit-up")
65 putString("name", info.serviceName)
66 putString("host", info.serviceName + ".local")
67 putString("addr", addr)
68 putInt("port", info.port)
69 putInt("proto", proto)
70 })
71 }
72
73 private fun down(name: String) {
74 emit(Arguments.createMap().apply { putString("t", "unit-down"); putString("name", name) })
75 }
76
77 private fun ipv4Of(info: NsdServiceInfo): String? {
78 val addrs = if (Build.VERSION.SDK_INT >= 34) info.hostAddresses else listOfNotNull(@Suppress("DEPRECATION") info.host)
79 return (addrs.firstOrNull { it is Inet4Address } ?: addrs.firstOrNull())?.hostAddress
80 }
81
82 @ReactMethod
83 fun start() {
84 if (discovery != null) return
85 val listener = object : NsdManager.DiscoveryListener {
86 override fun onDiscoveryStarted(serviceType: String) = available(true)
87 override fun onDiscoveryStopped(serviceType: String) {}
88 override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
89 discovery = null
90 available(false, "lamp discovery failed to start (NsdManager error $errorCode)")
91 }
92 override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
93 override fun onServiceFound(info: NsdServiceInfo) = resolve(info)
94 override fun onServiceLost(info: NsdServiceInfo) {
95 if (Build.VERSION.SDK_INT < 34) down(info.serviceName) // 34+: the info callback says so
96 }
97 }
98 discovery = listener
99 nsd.discoverServices(SERVICE, NsdManager.PROTOCOL_DNS_SD, listener)
100 }
101
102 @ReactMethod
103 fun stop() {
104 discovery?.let { try { nsd.stopServiceDiscovery(it) } catch (_: IllegalArgumentException) {} }
105 discovery = null
106 if (Build.VERSION.SDK_INT >= 34) {
107 for (cb in callbacks.values) try { nsd.unregisterServiceInfoCallback(cb) } catch (_: IllegalArgumentException) {}
108 }
109 callbacks.clear()
110 synchronized(resolveQueue) { resolveQueue.clear() }
111 }
112
113 // Required by RN's event emitter contract when JS subscribes through NativeEventEmitter.
114 @ReactMethod fun addListener(eventName: String) {}
115 @ReactMethod fun removeListeners(count: Int) {}
116
117 private fun resolve(info: NsdServiceInfo) {
118 if (Build.VERSION.SDK_INT >= 34) {
119 if (callbacks.containsKey(info.serviceName)) return
120 val cb = object : NsdManager.ServiceInfoCallback {
121 override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
122 callbacks.remove(info.serviceName)
123 }
124 override fun onServiceUpdated(updated: NsdServiceInfo) {
125 ipv4Of(updated)?.let { up(updated, it) }
126 }
127 override fun onServiceLost() = down(info.serviceName)
128 override fun onServiceInfoCallbackUnregistered() {}
129 }
130 callbacks[info.serviceName] = cb
131 nsd.registerServiceInfoCallback(info, executor, cb)
132 return
133 }
134 synchronized(resolveQueue) { resolveQueue.add(info) }
135 nextResolve()
136 }
137
138 @Suppress("DEPRECATION")
139 private fun nextResolve() {
140 val info = synchronized(resolveQueue) {
141 if (resolving) return
142 val next = resolveQueue.poll() ?: return
143 resolving = true
144 next
145 }
146 nsd.resolveService(info, object : NsdManager.ResolveListener {
147 override fun onResolveFailed(si: NsdServiceInfo, errorCode: Int) {
148 synchronized(resolveQueue) { resolving = false }
149 nextResolve()
150 }
151 override fun onServiceResolved(si: NsdServiceInfo) {
152 ipv4Of(si)?.let { up(si, it) }
153 synchronized(resolveQueue) { resolving = false }
154 nextResolve()
155 }
156 })
157 }
158}
android/android/app/src/main/java/org/circusautomatic/cthuloid/MainApplication.kt +3 -2
……@@ -17,8 +17,9 @@ class MainApplication : Application(), ReactApplication {
1717
1818 override val reactNativeHost: ReactNativeHost =
1919 object : DefaultReactNativeHost(this) {
20 // react-native-bare-kit, -fs and -webview arrive by autolinking.
21 override fun getPackages(): List<ReactPackage> = PackageList(this).packages
20 // react-native-bare-kit, -fs and -webview arrive by autolinking; CthuloidPackage (the lamp
21 // finder) is ours and lives here, so it is appended by hand.
22 override fun getPackages(): List<ReactPackage> = PackageList(this).packages + CthuloidPackage()
2223 override fun getJSMainModuleName(): String = "app/index"
2324 override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
2425 override val isHermesEnabled: Boolean = true
android/app/App.jsx +18 -2
……@@ -2,10 +2,26 @@
22// serves on stortron, served here by the same controller running in this app's
33// worklet, shown in a WebView. Nothing about lamps lives in this file.
44import React, { useEffect, useState, useCallback, useRef } from 'react'
5import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable } from 'react-native'
5import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable, AppState, DeviceEventEmitter, NativeModules } from 'react-native'
66import { WebView } from 'react-native-webview'
77import { startWorklet } from './worklet-host'
88
9// The lamp finder (LampFinderModule.kt, NsdManager) -> the worklet's discovery.
10// Browses only while the app is on screen: in the background the worklet is
11// suspended anyway, and on resume a fresh browse re-reports every lamp.
12let finding = false
13function startFinder (host) {
14 const finder = NativeModules.CthuloidLampFinder
15 if (!finder || finding) return
16 finding = true
17 DeviceEventEmitter.addListener('cthuloid-unit', (m) => host.send(m))
18 finder.start()
19 AppState.addEventListener('change', (state) => {
20 if (state === 'active') finder.start()
21 else finder.stop()
22 })
23}
24
925function Center ({ children }) {
1026 return <View style={s.center}>{children}</View>
1127}
……@@ -21,7 +37,7 @@ export default function App () {
2137 let answered = false
2238 host.onMessage((m) => {
2339 if (m.t === 'log') setStage(m.msg)
24 else if (m.t === 'ready') { answered = true; setUrl(m.url) }
40 else if (m.t === 'ready') { answered = true; setUrl(m.url); startFinder(host) }
2541 else if (m.t === 'fatal') { answered = true; setError(m.error) }
2642 })
2743 // A worklet has no error or exit event: if it never speaks, say so rather
android/lib/ipc-discovery.js +48 -0
……@@ -0,0 +1,48 @@
1// Unit discovery fed by the app shell over IPC. On Android the shell's Kotlin
2// lamp finder (NsdManager, browsing _cthuloid._tcp) does the mDNS work and sends
3// { t: 'unit-up', name, host, addr, port, proto }
4// { t: 'unit-down', name }
5// { t: 'discovery', available, reason }
6// which this turns into the same events avahi produces on a desktop
7// (webui/lib/discovery.js), so the rig cannot tell them apart.
8import EventEmitter from 'bare-events'
9
10export class IpcDiscovery extends EventEmitter {
11 constructor () {
12 super()
13 this.available = true
14 this.reason = ''
15 }
16
17 start () {}
18 stop () {}
19
20 // Returns true if the message was a discovery message.
21 handle (m) {
22 if (!m || typeof m !== 'object') return false
23 if (m.t === 'unit-up') {
24 const port = Number(m.port)
25 if (!m.name || !m.addr || !Number.isInteger(port)) return true
26 this.emit('up', {
27 name: String(m.name),
28 host: String(m.host || m.name + '.local'),
29 addr: String(m.addr),
30 port,
31 proto: Number(m.proto) || 0
32 })
33 return true
34 }
35 if (m.t === 'unit-down') {
36 if (m.name) this.emit('down', { name: String(m.name) })
37 return true
38 }
39 if (m.t === 'discovery') {
40 const was = this.available
41 this.available = !!m.available
42 this.reason = this.available ? '' : String(m.reason || 'lamp discovery is not running')
43 if (was !== this.available) this.emit(this.available ? 'available' : 'unavailable', this.reason)
44 return true
45 }
46 return false
47 }
48}
android/lib/worklet.js +29 -4
……@@ -3,11 +3,12 @@
33// packed bundle has no files on disk, and tells the React Native side where the
44// page is.
55//
6// IPC is newline-delimited JSON over BareKit.IPC (bytes both ways). This side
7// only ever SENDS in v1:
6// IPC is newline-delimited JSON over BareKit.IPC (bytes both ways).
7// Worklet -> shell:
88// { t: 'log', msg } progress, also printed to the console (logcat)
99// { t: 'ready', url } the page is being served -- point the WebView here
1010// { t: 'fatal', error } the controller could not start
11// Shell -> worklet: the lamp finder's events (lib/ipc-discovery.js).
1112//
1213// Storage arrives as the ONE argv entry. Nothing else is ever positional: in a
1314// later version the APK and the bundle can differ, and a new positional argument
……@@ -19,9 +20,30 @@ import path from 'bare-path'
1920import { createApp } from 'cthuloid-web/lib/app.js'
2021import assets from 'cthuloid-web/lib/assets.generated.js'
2122import { storageFromArgv } from './argv.js'
23import { IpcDiscovery } from './ipc-discovery.js'
2224
2325const IPC = typeof BareKit !== 'undefined' ? BareKit.IPC : null
2426
27// Lamps found by the shell's NsdManager arrive here over IPC.
28export const discovery = new IpcDiscovery()
29
30if (IPC) {
31 let buf = Buffer.alloc(0)
32 // 'data', not read(cb): BareKit.IPC is a streamx Duplex (favela-surface-android).
33 // Split on the newline BYTE, then decode: a chunk can end mid-character.
34 IPC.on('data', (data) => {
35 buf = Buffer.concat([buf, Buffer.from(data)])
36 let i
37 while ((i = buf.indexOf(10)) !== -1) {
38 const line = buf.subarray(0, i).toString()
39 buf = buf.subarray(i + 1)
40 let m
41 try { m = JSON.parse(line) } catch { continue }
42 if (discovery.handle(m)) console.log('[cthuloid] ' + line)
43 }
44 })
45}
46
2547function send (msg) {
2648 const line = JSON.stringify(msg)
2749 console.log('[cthuloid] ' + line)
……@@ -42,13 +64,16 @@ function lastPort (storage) {
4264
4365async function listenPreferring (storage, port) {
4466 const configDir = path.join(storage, 'cthuloid-web')
67 let app = null
4568 try {
46 const app = createApp({ configDir, port, assets })
69 app = createApp({ configDir, port, assets, discovery })
4770 return { app, ...(await app.listen()) }
4871 } catch (err) {
4972 if (port === 0) throw err
73 // Stop the half-started one, or its rig stays subscribed to discovery.
74 try { await app.close() } catch {}
5075 send({ t: 'log', msg: 'port ' + port + ' busy (' + err.message + '), taking any' })
51 const app = createApp({ configDir, port: 0, assets })
76 app = createApp({ configDir, port: 0, assets, discovery })
5277 return { app, ...(await app.listen()) }
5378 }
5479}
android/test/ipc-discovery.test.js +37 -0
……@@ -0,0 +1,37 @@
1// The shell's NSD messages become the rig's discovery events.
2import test from 'brittle'
3import { IpcDiscovery } from '../lib/ipc-discovery.js'
4
5test('unit-up / unit-down become up / down', (t) => {
6 const d = new IpcDiscovery()
7 const ev = []
8 d.on('up', (u) => ev.push(['up', u]))
9 d.on('down', (u) => ev.push(['down', u]))
10 t.ok(d.handle({ t: 'unit-up', name: 'studiolight', host: 'studiolight.local', addr: '192.168.50.89', port: 1337, proto: '3' }))
11 t.ok(d.handle({ t: 'unit-down', name: 'studiolight' }))
12 t.alike(ev, [
13 ['up', { name: 'studiolight', host: 'studiolight.local', addr: '192.168.50.89', port: 1337, proto: 3 }],
14 ['down', { name: 'studiolight' }]
15 ])
16})
17
18test('incomplete unit-up is ignored, unrelated messages are not ours', (t) => {
19 const d = new IpcDiscovery()
20 let n = 0
21 d.on('up', () => n++)
22 t.ok(d.handle({ t: 'unit-up', name: 'x', port: 1337 }), 'consumed')
23 t.is(n, 0, 'but no event without an address')
24 t.absent(d.handle({ t: 'ping' }))
25})
26
27test('availability changes are reported once', (t) => {
28 const d = new IpcDiscovery()
29 const ev = []
30 d.on('unavailable', (r) => ev.push('unavailable:' + r))
31 d.on('available', () => ev.push('available'))
32 d.handle({ t: 'discovery', available: false, reason: 'wifi off' })
33 d.handle({ t: 'discovery', available: false, reason: 'wifi off' })
34 d.handle({ t: 'discovery', available: true })
35 t.alike(ev, ['unavailable:wifi off', 'available'])
36 t.is(d.reason, '')
37})