@glassly/engine 0.3.8-dev.998 → 0.4.0-dev.1017

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/build/engine.d.ts +11 -0
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/facades/ai.d.ts +6 -0
  4. package/build/facades/ai.d.ts.map +1 -1
  5. package/build/facades/ai.js +6 -0
  6. package/build/facades/ai.js.map +1 -1
  7. package/build/facades/glasses.d.ts +9 -1
  8. package/build/facades/glasses.d.ts.map +1 -1
  9. package/build/facades/glasses.js +13 -0
  10. package/build/facades/glasses.js.map +1 -1
  11. package/build/generated/releaseMetadata.js +4 -4
  12. package/build/generated/releaseMetadata.js.map +1 -1
  13. package/build/index.d.ts +1 -1
  14. package/build/index.d.ts.map +1 -1
  15. package/build/index.js.map +1 -1
  16. package/build/services/CloudClientService.d.ts +2 -1
  17. package/build/services/CloudClientService.d.ts.map +1 -1
  18. package/build/services/CloudClientService.js +8 -0
  19. package/build/services/CloudClientService.js.map +1 -1
  20. package/build/services/DeviceEventRouter.d.ts.map +1 -1
  21. package/build/services/DeviceEventRouter.js +1 -0
  22. package/build/services/DeviceEventRouter.js.map +1 -1
  23. package/build/services/GlassesSensorService.js +1 -1
  24. package/build/services/GlassesSensorService.js.map +1 -1
  25. package/build/services/LocalLlmModelManager.d.ts +22 -2
  26. package/build/services/LocalLlmModelManager.d.ts.map +1 -1
  27. package/build/services/LocalLlmModelManager.js +104 -2
  28. package/build/services/LocalLlmModelManager.js.map +1 -1
  29. package/build/services/LocalLlmProvider.d.ts.map +1 -1
  30. package/build/services/LocalLlmProvider.js +11 -0
  31. package/build/services/LocalLlmProvider.js.map +1 -1
  32. package/build/services/LocalMiniappRuntime.d.ts +13 -0
  33. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  34. package/build/services/LocalMiniappRuntime.js +57 -1
  35. package/build/services/LocalMiniappRuntime.js.map +1 -1
  36. package/build/services/controllers.d.ts.map +1 -1
  37. package/build/services/controllers.js +2 -0
  38. package/build/services/controllers.js.map +1 -1
  39. package/build/services/neuralBand/HandwritingGate.d.ts +38 -0
  40. package/build/services/neuralBand/HandwritingGate.d.ts.map +1 -0
  41. package/build/services/neuralBand/HandwritingGate.js +89 -0
  42. package/build/services/neuralBand/HandwritingGate.js.map +1 -0
  43. package/build/services/neuralBand/NeuralBandInput.d.ts +12 -1
  44. package/build/services/neuralBand/NeuralBandInput.d.ts.map +1 -1
  45. package/build/services/neuralBand/NeuralBandInput.js +27 -0
  46. package/build/services/neuralBand/NeuralBandInput.js.map +1 -1
  47. package/build/services/neuralBand/NeuralBandSettingsSync.d.ts.map +1 -1
  48. package/build/services/neuralBand/NeuralBandSettingsSync.js +36 -3
  49. package/build/services/neuralBand/NeuralBandSettingsSync.js.map +1 -1
  50. package/build/stores/appVisibility.d.ts.map +1 -1
  51. package/build/stores/appVisibility.js +0 -1
  52. package/build/stores/appVisibility.js.map +1 -1
  53. package/build/stores/settings.d.ts.map +1 -1
  54. package/build/stores/settings.js +18 -0
  55. package/build/stores/settings.js.map +1 -1
  56. package/build/types/enums.d.ts +3 -1
  57. package/build/types/enums.d.ts.map +1 -1
  58. package/build/types/enums.js +2 -0
  59. package/build/types/enums.js.map +1 -1
  60. package/package.json +7 -7
  61. package/src/facades/ai.ts +6 -0
  62. package/src/facades/glasses.ts +17 -0
  63. package/src/generated/releaseMetadata.ts +4 -4
  64. package/src/index.ts +1 -0
  65. package/src/services/CloudClientService.ts +8 -0
  66. package/src/services/DeviceEventRouter.ts +1 -0
  67. package/src/services/GlassesSensorService.ts +1 -1
  68. package/src/services/LocalLlmModelManager.ts +110 -3
  69. package/src/services/LocalLlmProvider.ts +8 -0
  70. package/src/services/LocalMiniappRuntime.ts +62 -1
  71. package/src/services/controllers.ts +2 -0
  72. package/src/services/neuralBand/HandwritingGate.ts +111 -0
  73. package/src/services/neuralBand/NeuralBandInput.ts +37 -1
  74. package/src/services/neuralBand/NeuralBandSettingsSync.ts +46 -3
  75. package/src/stores/appVisibility.ts +0 -1
  76. package/src/stores/settings.ts +18 -0
  77. package/src/types/enums.ts +2 -0
package/src/facades/ai.ts CHANGED
@@ -21,6 +21,12 @@ export const ai = {
21
21
  /** The Settings > Offline Models pick. */
22
22
  selectedModelId: (): LocalLlmModelId => localLlmModelManager.selectedModelId(),
23
23
  select: (id: LocalLlmModelId): Promise<void> => localLlmModelManager.select(id),
24
+ /** Whether the model fits this phone's memory at all. */
25
+ fitsDevice: (id: LocalLlmModelId): boolean => localLlmModelManager.fitsDevice(id),
26
+ /** The OS killed the app while this model loaded; picking it again retries it. */
27
+ isBlocked: (id: LocalLlmModelId): boolean => localLlmModelManager.isBlocked(id),
28
+ /** Why the model will not load on this phone, or null when it can. */
29
+ unusableReason: (id: LocalLlmModelId): string | null => localLlmModelManager.unusableReason(id),
24
30
  isDownloaded: (id?: LocalLlmModelId): Promise<boolean> => localLlmModelManager.isModelAvailable(id),
25
31
  downloadedModelIds: (): Promise<LocalLlmModelId[]> => localLlmModelManager.downloadedModelIds(),
26
32
  download: (id?: LocalLlmModelId): Promise<void> => localLlmModelManager.downloadModel(id),
@@ -19,6 +19,7 @@ import type {
19
19
  Device,
20
20
  KeyboardAdvertEvent,
21
21
  KeyboardPairingEvent,
22
+ NeuralBandFirmwareEvent,
22
23
  PairedController,
23
24
  TouchEvent,
24
25
  } from "@glassly/bluetooth-sdk"
@@ -39,6 +40,7 @@ import {pushAllBluetoothSettings} from "../services/GlassesSettingsSync"
39
40
  import {isControllerReady, pairedControllersFrom, readyControllers} from "../services/controllers"
40
41
  import neuralBandInput, {
41
42
  type NeuralBandClaimStatus,
43
+ type NeuralBandFirmwareStatus,
42
44
  type NeuralBandHandwritingStatus,
43
45
  type NeuralBandHandwritingUpdate,
44
46
  } from "../services/neuralBand/NeuralBandInput"
@@ -344,6 +346,21 @@ export const glasses = {
344
346
  /** The band's air mouse; movement reaches miniapps on the pointer stream. */
345
347
  setPointer: (id: string, enabled: boolean): Promise<void> => BluetoothSdk.neuralBandSetPointer(id, enabled),
346
348
  setHand: (id: string, hand: "left" | "right"): Promise<void> => BluetoothSdk.neuralBandSetHand(id, hand),
349
+ firmwareStatus: (id: string): NeuralBandFirmwareStatus => ({...neuralBandInput.firmwareStatus(id)}),
350
+ onFirmwareStatus: (cb: (update: {id: string; status: NeuralBandFirmwareStatus}) => void): (() => void) =>
351
+ neuralBandInput.onFirmwareStatus(cb),
352
+ refreshFirmware: async (id: string): Promise<NeuralBandFirmwareStatus> => {
353
+ const info = await BluetoothSdk.neuralBandFirmwareInfo(id)
354
+ neuralBandInput.handleFirmware({
355
+ ...(info as object),
356
+ type: "neural_band_firmware",
357
+ controllerId: id,
358
+ } as NeuralBandFirmwareEvent)
359
+ return neuralBandInput.firmwareStatus(id)
360
+ },
361
+ checkFirmware: (id: string): Promise<void> => BluetoothSdk.neuralBandCheckFirmware(id),
362
+ updateFirmware: (id: string, mode: "update" | "cancel" | "key" | "send" | "trial" | "install"): Promise<void> =>
363
+ BluetoothSdk.neuralBandUpdateFirmware(id, mode),
347
364
  },
348
365
 
349
366
  // --- watch: an Apple Watch over WatchConnectivity (iOS), a Wear OS watch over the Data Layer (Android) ---
@@ -11,10 +11,10 @@ export interface EngineReleaseMetadata {
11
11
 
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
- "familyBaseVersion": "0.3.8",
15
- "releaseIdentity": "0.3.8-dev.998",
16
- "releaseSetId": "glassly-0.3.8-dev.998",
17
- "sourceCommit": "56392e6a51a274b34157ef34792183652af28ebe",
14
+ "familyBaseVersion": "0.4.0",
15
+ "releaseIdentity": "0.4.0-dev.1017",
16
+ "releaseSetId": "glassly-0.4.0-dev.1017",
17
+ "sourceCommit": "2d95873e90b2de55f29df33e0e361469fc2b58f8",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })
package/src/index.ts CHANGED
@@ -239,6 +239,7 @@ export {
239
239
  } from "./services/neuralBand/MetaAuth"
240
240
  export type {
241
241
  NeuralBandClaimStatus,
242
+ NeuralBandFirmwareStatus,
242
243
  NeuralBandHandwritingStatus,
243
244
  NeuralBandHandwritingUpdate,
244
245
  } from "./services/neuralBand/NeuralBandInput"
@@ -19,6 +19,7 @@ import type {
19
19
  RuntimeSnapshot,
20
20
  StoreCatalogResponse,
21
21
  StoreListing,
22
+ StoreReleasesResponse,
22
23
  } from "@glassly/cloud-client/react-native"
23
24
  import type {SubjectTokenType} from "@glassly/cloud-client"
24
25
  import {AppState, Platform} from "react-native"
@@ -778,6 +779,13 @@ export const cloudClientService = {
778
779
  return c.core.store.get(packageName)
779
780
  },
780
781
 
782
+ async getStoreReleases(packageName: string): Promise<StoreReleasesResponse> {
783
+ if (!client) this.init()
784
+ const c = client
785
+ if (!c?.core) throw new Error("cloud client core is unavailable")
786
+ return c.core.store.releases(packageName)
787
+ },
788
+
781
789
  /** Bytes of a store icon or screenshot; these routes need the user Bearer. */
782
790
  async fetchStoreAsset(url: string): Promise<Uint8Array> {
783
791
  if (!client) this.init()
@@ -245,6 +245,7 @@ export function startDeviceEventRouter(): void {
245
245
  subs.push(BluetoothSdk.addListener("neural_band_claim", (event) => neuralBandInput.handleClaim(event)))
246
246
  subs.push(BluetoothSdk.addListener("neural_band_handwriting", (event) => neuralBandInput.handleHandwriting(event)))
247
247
  subs.push(BluetoothSdk.addListener("neural_band_pointer", (event) => neuralBandInput.handlePointer(event)))
248
+ subs.push(BluetoothSdk.addListener("neural_band_firmware", (event) => neuralBandInput.handleFirmware(event)))
248
249
  subs.push({
249
250
  remove: useGlassesStore.subscribe(
250
251
  (s) => s.keyboardConnected === true,
@@ -110,7 +110,7 @@ export class GlassesSensorService {
110
110
  private commandFailed(): never {
111
111
  fail(
112
112
  MiniappErrorCode.INTERNAL,
113
- "Sensor command failed: connect G2 custom firmware and keep its miniapp display active",
113
+ "Sensor command failed: the glasses need Glassly custom firmware with this sensor, and on the G2 an active miniapp display",
114
114
  )
115
115
  }
116
116
 
@@ -15,6 +15,7 @@ import {Platform} from "react-native"
15
15
  import * as RNFS from "@dr.pogodin/react-native-fs"
16
16
 
17
17
  import {SETTINGS, useSettingsStore} from "../stores/settings"
18
+ import {storage} from "../utils/storage"
18
19
 
19
20
  export type LocalLlmModelId = "qwen3-0.6b" | "qwen3-1.7b" | "qwen3-4b"
20
21
 
@@ -28,7 +29,7 @@ export interface LocalLlmModelConfig {
28
29
  sizeBytes: number
29
30
  /** Exact size of the published .pte; the availability check compares against it. */
30
31
  modelBytes: number
31
- /** Rough resident memory while loaded; the Settings copy warns above ~half of device RAM. */
32
+ /** Rough resident memory while loaded; `fitsDevice` holds it against the phone's RAM. */
32
33
  ramBytes: number
33
34
  urls: {
34
35
  model: string
@@ -106,6 +107,36 @@ export function isLocalLlmModelId(value: unknown): value is LocalLlmModelId {
106
107
  return typeof value === "string" && value in MODELS
107
108
  }
108
109
 
110
+ // Without the increased-memory-limit entitlement iOS kills a foreground app
111
+ // at roughly 60% of physical RAM (Android's low-memory killer is no kinder),
112
+ // and the host holds about 1 GB besides the model: JS runtimes, WebViews,
113
+ // the speech model. A 4 GB phone fits the 0.6B model and nothing larger.
114
+ const APP_MEMORY_SHARE = 0.6
115
+ const HOST_BASELINE_BYTES = 1 * GB
116
+
117
+ export function modelFitsMemory(model: LocalLlmModelConfig, deviceMemoryBytes: number | null): boolean {
118
+ if (!deviceMemoryBytes) return true
119
+ return model.ramBytes + HOST_BASELINE_BYTES <= deviceMemoryBytes * APP_MEMORY_SHARE
120
+ }
121
+
122
+ let deviceMemory: number | null | undefined
123
+ function deviceMemoryBytes(): number | null {
124
+ if (deviceMemory === undefined) {
125
+ try {
126
+ deviceMemory = (require("expo-device") as {totalMemory?: number | null}).totalMemory ?? null
127
+ } catch {
128
+ deviceMemory = null
129
+ }
130
+ }
131
+ return deviceMemory
132
+ }
133
+
134
+ // Written before a model's first load and generation, cleared after either
135
+ // ends. A marker still there at the next launch means the OS killed the app
136
+ // mid-load (out of memory), so that model stays off until picked again.
137
+ const COLD_LOAD_KEY = "local_llm_cold_load"
138
+ const BLOCKED_MODELS_KEY = "local_llm_blocked_models"
139
+
109
140
  type StatusListener = (status: LocalLlmDownloadStatus | null) => void
110
141
 
111
142
  class LocalLlmModelManager {
@@ -114,6 +145,7 @@ class LocalLlmModelManager {
114
145
  private downloadJobId?: number
115
146
  private inFlight: {modelId: LocalLlmModelId; promise: Promise<void>} | null = null
116
147
  private cancelled = false
148
+ private coldLoadRecovered = false
117
149
  private currentStatus: LocalLlmDownloadStatus | null = null
118
150
  private listeners = new Set<StatusListener>()
119
151
  private modelReadyListeners = new Set<(modelId: LocalLlmModelId) => void>()
@@ -135,16 +167,89 @@ class LocalLlmModelManager {
135
167
  return MODELS[id]
136
168
  }
137
169
 
138
- /** The Settings > AI Models pick; garbage in the store resolves to the default. */
170
+ /**
171
+ * The model on-device requests use: the Settings > Offline Models pick
172
+ * (garbage resolves to the default) while it is usable, else the largest
173
+ * model that is. With nothing usable it stays the pick, and loading it
174
+ * fails with `unusableReason`.
175
+ */
139
176
  selectedModelId(): LocalLlmModelId {
140
177
  const raw = useSettingsStore.getState().getSetting(SETTINGS.local_llm_model.key)
141
- return isLocalLlmModelId(raw) ? raw : DEFAULT_LOCAL_LLM_MODEL
178
+ const picked = isLocalLlmModelId(raw) ? raw : DEFAULT_LOCAL_LLM_MODEL
179
+ if (this.isUsable(picked)) return picked
180
+ const fallback = this.models()
181
+ .filter((m) => this.isUsable(m.id))
182
+ .sort((a, b) => b.ramBytes - a.ramBytes)[0]
183
+ return fallback?.id ?? picked
142
184
  }
143
185
 
186
+ /** Picking a model in Settings is also the way to retry one that crashed the app. */
144
187
  async select(id: LocalLlmModelId): Promise<void> {
188
+ this.unblock(id)
145
189
  await useSettingsStore.getState().setSetting(SETTINGS.local_llm_model.key, id)
146
190
  }
147
191
 
192
+ fitsDevice(id: LocalLlmModelId): boolean {
193
+ return modelFitsMemory(MODELS[id], deviceMemoryBytes())
194
+ }
195
+
196
+ /** The OS killed the app while this model loaded; it stays off until picked again. */
197
+ isBlocked(id: LocalLlmModelId): boolean {
198
+ return this.blockedModelIds().includes(id)
199
+ }
200
+
201
+ isUsable(id: LocalLlmModelId): boolean {
202
+ return this.fitsDevice(id) && !this.isBlocked(id)
203
+ }
204
+
205
+ /** Why `id` will not load on this phone, or null when it can. */
206
+ unusableReason(id: LocalLlmModelId): string | null {
207
+ const label = MODELS[id].label
208
+ if (!this.fitsDevice(id)) {
209
+ return `${label} needs more memory than this phone has. Pick a smaller model in Settings > Offline Models.`
210
+ }
211
+ if (this.isBlocked(id)) {
212
+ return `${label} closed the app the last time it loaded. Pick it again in Settings > Offline Models to retry, or pick a smaller model.`
213
+ }
214
+ return null
215
+ }
216
+
217
+ /** Mark the start of a model's first load and generation in this process. */
218
+ beginColdLoad(id: LocalLlmModelId): void {
219
+ this.recoverColdLoad()
220
+ storage.save(COLD_LOAD_KEY, id)
221
+ }
222
+
223
+ endColdLoad(): void {
224
+ storage.removeMultiple([COLD_LOAD_KEY])
225
+ }
226
+
227
+ private blockedModelIds(): LocalLlmModelId[] {
228
+ this.recoverColdLoad()
229
+ const stored = storage.load<unknown>(BLOCKED_MODELS_KEY)
230
+ const ids = stored.is_ok() ? stored.value : []
231
+ return Array.isArray(ids) ? ids.filter(isLocalLlmModelId) : []
232
+ }
233
+
234
+ private unblock(id: LocalLlmModelId): void {
235
+ const remaining = this.blockedModelIds().filter((blocked) => blocked !== id)
236
+ storage.save(BLOCKED_MODELS_KEY, remaining)
237
+ }
238
+
239
+ // A cold-load marker left by an earlier process: that load never finished.
240
+ private recoverColdLoad(): void {
241
+ if (this.coldLoadRecovered) return
242
+ this.coldLoadRecovered = true
243
+ const marker = storage.load<unknown>(COLD_LOAD_KEY)
244
+ if (!marker.is_ok() || !isLocalLlmModelId(marker.value)) return
245
+ const id = marker.value
246
+ console.warn(`LOCAL_LLM: the app was killed while ${id} loaded; keeping it off until it is picked again`)
247
+ const stored = storage.load<unknown>(BLOCKED_MODELS_KEY)
248
+ const blocked = stored.is_ok() && Array.isArray(stored.value) ? stored.value.filter(isLocalLlmModelId) : []
249
+ if (!blocked.includes(id)) storage.save(BLOCKED_MODELS_KEY, [...blocked, id])
250
+ storage.removeMultiple([COLD_LOAD_KEY])
251
+ }
252
+
148
253
  getModelDirectory(): string {
149
254
  return `${RNFS.DocumentDirectoryPath}/llm_models`
150
255
  }
@@ -210,6 +315,7 @@ class LocalLlmModelManager {
210
315
  async ensureSelectedModel(): Promise<boolean> {
211
316
  const id = this.selectedModelId()
212
317
  if (await this.isModelAvailable(id)) return true
318
+ if (!this.fitsDevice(id)) return false
213
319
  void this.downloadModel(id).catch((error) => {
214
320
  console.warn(`LOCAL_LLM: background download of ${id} failed:`, error)
215
321
  })
@@ -222,6 +328,7 @@ class LocalLlmModelManager {
222
328
  * the running one — the RNFS job slot is singular.
223
329
  */
224
330
  async downloadModel(id: LocalLlmModelId = this.selectedModelId()): Promise<void> {
331
+ if (!this.fitsDevice(id)) throw new Error(this.unusableReason(id) ?? `${MODELS[id].label} does not fit this phone.`)
225
332
  if (this.inFlight) {
226
333
  if (this.inFlight.modelId === id) return this.inFlight.promise
227
334
  await this.inFlight.promise.catch(() => undefined)
@@ -187,6 +187,11 @@ class LocalLlmProvider {
187
187
 
188
188
  private async completeInner(request: LlmCompleteRequest): Promise<LocalLlmCompleteResult> {
189
189
  this.clearIdleTimer()
190
+ // Loading and the first generation are where memory peaks; if the OS kills
191
+ // the app in between, the marker keeps the next launch from looping on it.
192
+ const selectedId = localLlmModelManager.selectedModelId()
193
+ const coldModelId = this.loaded?.modelId === selectedId ? null : selectedId
194
+ if (coldModelId) localLlmModelManager.beginColdLoad(coldModelId)
190
195
  try {
191
196
  const model = await this.ensureLoaded()
192
197
  const jsonMode = request.jsonMode === true
@@ -248,6 +253,7 @@ class LocalLlmProvider {
248
253
  )
249
254
  return {...completion, modelId: model.modelId}
250
255
  } finally {
256
+ if (coldModelId) localLlmModelManager.endColdLoad()
251
257
  this.armIdleTimer()
252
258
  }
253
259
  }
@@ -272,6 +278,8 @@ class LocalLlmProvider {
272
278
  // Runs inside the request queue, so nothing is generating; unload() would
273
279
  // wait on that same queue.
274
280
  this.releaseRunner()
281
+ const unusable = localLlmModelManager.unusableReason(modelId)
282
+ if (unusable) throw new Error(unusable)
275
283
  if (!(await localLlmModelManager.isModelAvailable(modelId))) {
276
284
  throw new Error(`on-device model ${modelId} is not downloaded`)
277
285
  }
@@ -673,6 +673,9 @@ class LocalMiniappRuntime {
673
673
  private connectedApps: Map<string, ConnectedMiniapp> = new Map()
674
674
  /** Claimed gestures (CLAIM_GESTURES): a stack per gesture, the most recent claimer holding it. */
675
675
  private gestureClaims: GestureClaimState = emptyGestureClaims()
676
+ /** Apps with a text field open on the glasses (TEXT_FIELD). */
677
+ private textFieldApps = new Set<string>()
678
+ private textFieldListeners = new Set<(open: boolean) => void>()
676
679
  // The last main-view frame each app asked to render. Widget mode refuses
677
680
  // frames and demotion clears them, and an app that has drawn once may not
678
681
  // draw again on its own, so promotion replays this.
@@ -1295,6 +1298,7 @@ class LocalMiniappRuntime {
1295
1298
  }
1296
1299
  // Its gesture claims go back to whoever claimed them before.
1297
1300
  this.applyGestureClaimChanges(dropGestureClaims(this.gestureClaims, packageName))
1301
+ this.setTextFieldOpen(packageName, false)
1298
1302
  this.retainedRenders.delete(packageName)
1299
1303
 
1300
1304
  // Warm-up is a request-owned camera lease. Cancel it even if the app record was already
@@ -1478,6 +1482,9 @@ class LocalMiniappRuntime {
1478
1482
  case MiniappRequestType.CLAIM_GESTURES:
1479
1483
  this.handleClaimGestures(packageName, payload, requestId)
1480
1484
  break
1485
+ case MiniappRequestType.TEXT_FIELD:
1486
+ this.handleTextField(packageName, payload, requestId)
1487
+ break
1481
1488
  case MiniappRequestType.DISPLAY:
1482
1489
  this.handleDisplay(packageName, payload, requestId)
1483
1490
  break
@@ -2214,6 +2221,48 @@ class LocalMiniappRuntime {
2214
2221
  }
2215
2222
  }
2216
2223
 
2224
+ /**
2225
+ * TEXT_FIELD (session.input.setTextFieldOpen): the app has a text field on
2226
+ * the glasses, or no longer does. Text entry that costs power to leave on
2227
+ * (Neural Band handwriting) runs while any app has one. Cleared on app stop.
2228
+ */
2229
+ private handleTextField(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
2230
+ if (typeof payload.open !== "boolean") {
2231
+ this.sendResult(packageName, requestId, false, undefined, {
2232
+ code: MiniappErrorCode.INVALID_ARGUMENT,
2233
+ message: "Text field open must be a boolean",
2234
+ })
2235
+ return
2236
+ }
2237
+ if (this.connectedApps.has(packageName)) this.setTextFieldOpen(packageName, payload.open)
2238
+ this.sendResult(packageName, requestId, true)
2239
+ }
2240
+
2241
+ private setTextFieldOpen(packageName: string, open: boolean): void {
2242
+ const before = this.textFieldApps.size > 0
2243
+ if (open) this.textFieldApps.add(packageName)
2244
+ else this.textFieldApps.delete(packageName)
2245
+ const after = this.textFieldApps.size > 0
2246
+ if (before === after) return
2247
+ for (const cb of this.textFieldListeners) {
2248
+ try {
2249
+ cb(after)
2250
+ } catch (err) {
2251
+ console.warn(`${LOG_TAG}: text field listener failed:`, err)
2252
+ }
2253
+ }
2254
+ }
2255
+
2256
+ /** True while any running miniapp has a text field open on the glasses. */
2257
+ public hasOpenTextField(): boolean {
2258
+ return this.textFieldApps.size > 0
2259
+ }
2260
+
2261
+ public onTextFieldChange(cb: (open: boolean) => void): () => void {
2262
+ this.textFieldListeners.add(cb)
2263
+ return () => this.textFieldListeners.delete(cb)
2264
+ }
2265
+
2217
2266
  /** The app holding `gesture` (the most recent claimer), or null when it is unclaimed. */
2218
2267
  public gestureClaimant(gesture: string): string | null {
2219
2268
  const claimant = gestureClaimant(this.gestureClaims, gesture)
@@ -4322,6 +4371,18 @@ class LocalMiniappRuntime {
4322
4371
  primaryError: {code: MiniappErrorCode; message: string} | null,
4323
4372
  ): Promise<void> {
4324
4373
  const modelId = localLlmModelManager.selectedModelId()
4374
+ const unusable = localLlmModelManager.unusableReason(modelId)
4375
+ if (unusable) {
4376
+ console.warn(`${LOG_TAG}: ai.complete on-device for ${packageName} (${reason}): ${unusable}`)
4377
+ this.sendResult(
4378
+ packageName,
4379
+ requestId,
4380
+ false,
4381
+ undefined,
4382
+ primaryError ?? {code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE, message: unusable},
4383
+ )
4384
+ return
4385
+ }
4325
4386
  if (!(await localLlmModelManager.ensureSelectedModel())) {
4326
4387
  const status = localLlmModelManager.getStatus()
4327
4388
  const state =
@@ -4338,7 +4399,7 @@ class LocalMiniappRuntime {
4338
4399
  undefined,
4339
4400
  primaryError ?? {
4340
4401
  code: MiniappErrorCode.AI_LOCAL_UNAVAILABLE,
4341
- message: `The on-device model (${localLlmModelManager.model(modelId).label}) ${state}. Manage it in Settings > AI Models.`,
4402
+ message: `The on-device model (${localLlmModelManager.model(modelId).label}) ${state}. Manage it in Settings > Offline Models.`,
4342
4403
  },
4343
4404
  )
4344
4405
  return
@@ -55,6 +55,8 @@ const INPUT_SURFACES: Readonly<Record<string, readonly InputSurface[]>> = {
55
55
  [ControllerTypes.WEAR_OS]: [WATCH_SURFACE],
56
56
  [ControllerTypes.GAMEBRICK]: [GAMEBRICK_SURFACE],
57
57
  [ControllerTypes.IINE_ANANKE]: [GAMEBRICK_SURFACE],
58
+ [ControllerTypes.ZERO2]: [GAMEBRICK_SURFACE],
59
+ [ControllerTypes.MICRO]: [GAMEBRICK_SURFACE],
58
60
  [ControllerTypes.R1]: [],
59
61
  }
60
62
 
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Decides when each connected Meta Neural Band runs its handwriting model. The
3
+ * model streams to the phone for as long as it runs, written on or not, so it
4
+ * runs only while it can type somewhere: the wearer has handwriting enabled
5
+ * and a miniapp has a text field open on the glasses. It outlives the last
6
+ * field by OFF_DELAY_MS, so moving between fields doesn't restart it. A session
7
+ * the band ends on its own (timeout, refused setting, silence) isn't retried
8
+ * until no field is open, so a band that can't run the model isn't asked again
9
+ * and again.
10
+ */
11
+ import type {NeuralBandHandwritingStatus} from "./NeuralBandInput"
12
+
13
+ type Phase = NeuralBandHandwritingStatus["phase"]
14
+
15
+ export interface HandwritingGateDeps {
16
+ enabled: () => boolean
17
+ textFieldOpen: () => boolean
18
+ setHandwriting: (id: string, enabled: boolean) => Promise<void>
19
+ setTimeout: (fn: () => void, ms: number) => unknown
20
+ clearTimeout: (handle: unknown) => void
21
+ }
22
+
23
+ interface Band {
24
+ /** What we last asked this band for. */
25
+ requested: boolean
26
+ failed: boolean
27
+ phase: Phase
28
+ }
29
+
30
+ export const OFF_DELAY_MS = 5_000
31
+ const ACTIVE = new Set<Phase>(["preparing", "ready", "restoring"])
32
+
33
+ export class HandwritingGate {
34
+ private bands = new Map<string, Band>()
35
+ private offTimer: unknown = null
36
+
37
+ constructor(private readonly deps: HandwritingGateDeps) {}
38
+
39
+ /** The connected bands. A band keeps nothing across connections, so a reconnect starts fresh. */
40
+ setBands(ids: string[]): void {
41
+ for (const id of [...this.bands.keys()]) if (!ids.includes(id)) this.bands.delete(id)
42
+ for (const id of ids) if (!this.bands.has(id)) this.bands.set(id, {requested: false, failed: false, phase: "idle"})
43
+ this.update()
44
+ }
45
+
46
+ /** A band reported its handwriting phase. */
47
+ status(id: string, phase: Phase): void {
48
+ const band = this.bands.get(id)
49
+ if (!band) return
50
+ band.phase = phase
51
+ if (phase === "finished" && band.requested) {
52
+ band.requested = false
53
+ band.failed = true
54
+ }
55
+ this.update()
56
+ }
57
+
58
+ /** Re-evaluate after the setting or the open text fields changed. */
59
+ update(): void {
60
+ const enabled = this.deps.enabled()
61
+ if (enabled && this.deps.textFieldOpen()) {
62
+ this.clearOffTimer()
63
+ for (const [id, band] of this.bands) {
64
+ // A session still winding down (or one we didn't start) has to finish first.
65
+ if (band.requested || band.failed || ACTIVE.has(band.phase)) continue
66
+ this.request(id, band, true)
67
+ }
68
+ return
69
+ }
70
+ for (const band of this.bands.values()) band.failed = false
71
+ if (![...this.bands.values()].some((band) => band.requested)) {
72
+ this.clearOffTimer()
73
+ return
74
+ }
75
+ if (!enabled) {
76
+ this.clearOffTimer()
77
+ this.stopAll()
78
+ } else if (this.offTimer === null) {
79
+ this.offTimer = this.deps.setTimeout(() => {
80
+ this.offTimer = null
81
+ if (!this.deps.textFieldOpen() || !this.deps.enabled()) this.stopAll()
82
+ }, OFF_DELAY_MS)
83
+ }
84
+ }
85
+
86
+ dispose(): void {
87
+ this.clearOffTimer()
88
+ this.bands.clear()
89
+ }
90
+
91
+ private stopAll(): void {
92
+ for (const [id, band] of this.bands) if (band.requested) this.request(id, band, false)
93
+ }
94
+
95
+ private request(id: string, band: Band, enabled: boolean): void {
96
+ band.requested = enabled
97
+ this.deps.setHandwriting(id, enabled).catch((err) => {
98
+ console.warn(`HandwritingGate: turning handwriting ${enabled ? "on" : "off"} failed:`, err)
99
+ if (enabled && this.bands.get(id) === band && band.requested) {
100
+ band.requested = false
101
+ band.failed = true
102
+ }
103
+ })
104
+ }
105
+
106
+ private clearOffTimer(): void {
107
+ if (this.offTimer === null) return
108
+ this.deps.clearTimeout(this.offTimer)
109
+ this.offTimer = null
110
+ }
111
+ }
@@ -6,7 +6,12 @@
6
6
  * latest claim is also kept on its own, since the pairing screen runs before
7
7
  * the band's id reaches JS.
8
8
  */
9
- import type {NeuralBandClaimEvent, NeuralBandHandwritingEvent, NeuralBandPointerEvent} from "@glassly/bluetooth-sdk"
9
+ import type {
10
+ NeuralBandClaimEvent,
11
+ NeuralBandFirmwareEvent,
12
+ NeuralBandHandwritingEvent,
13
+ NeuralBandPointerEvent,
14
+ } from "@glassly/bluetooth-sdk"
10
15
  import {MiniappStreamType} from "@glassly/miniapp"
11
16
 
12
17
  import type {KeyboardEventPayload} from "../GlassesKeyboardInput"
@@ -34,6 +39,8 @@ export interface NeuralBandHandwritingUpdate {
34
39
  status: NeuralBandHandwritingStatus
35
40
  }
36
41
 
42
+ export type NeuralBandFirmwareStatus = Omit<NeuralBandFirmwareEvent, "type" | "controllerId"> & {known: boolean}
43
+
37
44
  export interface NeuralBandInputOptions {
38
45
  forward?: (stream: string, payload: unknown) => void
39
46
  now?: () => number
@@ -67,13 +74,25 @@ function codeFor(char: string): string {
67
74
 
68
75
  const IDLE_CLAIM: NeuralBandClaimStatus = {stage: "idle", sessionExpired: false}
69
76
  const IDLE_HANDWRITING: NeuralBandHandwritingStatus = {phase: "idle", message: "", text: ""}
77
+ const IDLE_FIRMWARE: NeuralBandFirmwareStatus = {
78
+ known: false,
79
+ connected: false,
80
+ hasMetaSession: false,
81
+ checked: false,
82
+ updateAvailable: false,
83
+ phase: "idle",
84
+ progress: 0,
85
+ message: "",
86
+ }
70
87
 
71
88
  export class NeuralBandInput {
72
89
  private latestClaim: NeuralBandClaimStatus = IDLE_CLAIM
73
90
  private claims = new Map<string, NeuralBandClaimStatus>()
74
91
  private handwriting = new Map<string, NeuralBandHandwritingStatus>()
92
+ private firmware = new Map<string, NeuralBandFirmwareStatus>()
75
93
  private claimListeners = new Set<(status: NeuralBandClaimStatus) => void>()
76
94
  private handwritingListeners = new Set<(update: NeuralBandHandwritingUpdate) => void>()
95
+ private firmwareListeners = new Set<(update: {id: string; status: NeuralBandFirmwareStatus}) => void>()
77
96
  private readonly forward: (stream: string, payload: unknown) => void
78
97
  private readonly now: () => number
79
98
 
@@ -102,6 +121,15 @@ export class NeuralBandInput {
102
121
  return () => this.handwritingListeners.delete(cb)
103
122
  }
104
123
 
124
+ firmwareStatus(controllerId: string): NeuralBandFirmwareStatus {
125
+ return this.firmware.get(controllerId) ?? IDLE_FIRMWARE
126
+ }
127
+
128
+ onFirmwareStatus(cb: (update: {id: string; status: NeuralBandFirmwareStatus}) => void): () => void {
129
+ this.firmwareListeners.add(cb)
130
+ return () => this.firmwareListeners.delete(cb)
131
+ }
132
+
105
133
  /** A new pairing attempt starts from a clean slate. */
106
134
  resetClaim(): void {
107
135
  this.latestClaim = IDLE_CLAIM
@@ -112,6 +140,14 @@ export class NeuralBandInput {
112
140
  forget(controllerId: string): void {
113
141
  this.claims.delete(controllerId)
114
142
  this.handwriting.delete(controllerId)
143
+ this.firmware.delete(controllerId)
144
+ }
145
+
146
+ handleFirmware(event: NeuralBandFirmwareEvent): void {
147
+ const {type: _type, controllerId, ...rest} = event
148
+ const status: NeuralBandFirmwareStatus = {...rest, known: true}
149
+ this.firmware.set(controllerId, status)
150
+ for (const cb of this.firmwareListeners) cb({id: controllerId, status})
115
151
  }
116
152
 
117
153
  handleClaim(event: NeuralBandClaimEvent): void {