@glassly/engine 0.3.2-dev.617 → 0.3.3-beta.630

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 (50) hide show
  1. package/build/facades/glasses.d.ts +1 -0
  2. package/build/facades/glasses.d.ts.map +1 -1
  3. package/build/facades/glasses.js +1 -0
  4. package/build/facades/glasses.js.map +1 -1
  5. package/build/generated/releaseMetadata.js +4 -4
  6. package/build/generated/releaseMetadata.js.map +1 -1
  7. package/build/index.d.ts +1 -0
  8. package/build/index.d.ts.map +1 -1
  9. package/build/index.js +1 -0
  10. package/build/index.js.map +1 -1
  11. package/build/services/GestureDotsService.d.ts +2 -0
  12. package/build/services/GestureDotsService.d.ts.map +1 -1
  13. package/build/services/GestureDotsService.js +25 -9
  14. package/build/services/GestureDotsService.js.map +1 -1
  15. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  16. package/build/services/GlassesTouchInput.js +4 -2
  17. package/build/services/GlassesTouchInput.js.map +1 -1
  18. package/build/services/LocalMiniappRuntime.d.ts +1 -0
  19. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  20. package/build/services/LocalMiniappRuntime.js +14 -8
  21. package/build/services/LocalMiniappRuntime.js.map +1 -1
  22. package/build/services/MiniappIcons.d.ts +16 -0
  23. package/build/services/MiniappIcons.d.ts.map +1 -0
  24. package/build/services/MiniappIcons.js +54 -0
  25. package/build/services/MiniappIcons.js.map +1 -0
  26. package/build/services/effectiveCapabilities.d.ts +2 -0
  27. package/build/services/effectiveCapabilities.d.ts.map +1 -1
  28. package/build/services/effectiveCapabilities.js +54 -6
  29. package/build/services/effectiveCapabilities.js.map +1 -1
  30. package/build/stores/glasses.d.ts.map +1 -1
  31. package/build/stores/glasses.js +1 -0
  32. package/build/stores/glasses.js.map +1 -1
  33. package/build/stores/settings.d.ts.map +1 -1
  34. package/build/stores/settings.js +38 -0
  35. package/build/stores/settings.js.map +1 -1
  36. package/build/types/hardware.d.ts +1 -0
  37. package/build/types/hardware.d.ts.map +1 -1
  38. package/build/types/hardware.js.map +1 -1
  39. package/package.json +8 -7
  40. package/src/facades/glasses.ts +1 -0
  41. package/src/generated/releaseMetadata.ts +4 -4
  42. package/src/index.ts +1 -0
  43. package/src/services/GestureDotsService.ts +20 -7
  44. package/src/services/GlassesTouchInput.ts +4 -2
  45. package/src/services/LocalMiniappRuntime.ts +26 -13
  46. package/src/services/MiniappIcons.ts +50 -0
  47. package/src/services/effectiveCapabilities.ts +64 -5
  48. package/src/stores/glasses.ts +1 -0
  49. package/src/stores/settings.ts +38 -0
  50. package/src/types/hardware.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/engine",
3
- "version": "0.3.2-dev.617",
3
+ "version": "0.3.3-beta.630",
4
4
  "description": "Glassly Engine — miniapp registry, WebView bridge, and miniapp store",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -97,17 +97,17 @@
97
97
  "registry": "https://registry.npmjs.org/"
98
98
  },
99
99
  "dependencies": {
100
- "@glassly/bluetooth-sdk": "0.3.2-dev.617",
101
- "@glassly/cloud-client": "0.3.2-dev.617",
102
- "@glassly/cloud-protocol": "0.3.2-dev.617",
103
- "@glassly/crust": "0.3.2-dev.617",
104
- "@glassly/miniapp": "0.3.2-dev.617",
100
+ "@glassly/bluetooth-sdk": "0.3.3-beta.630",
101
+ "@glassly/cloud-client": "0.3.3-beta.630",
102
+ "@glassly/cloud-protocol": "0.3.3-beta.630",
103
+ "@glassly/crust": "0.3.3-beta.630",
104
+ "@glassly/miniapp": "0.3.3-beta.630",
105
105
  "buffer": "^6.0.3",
106
106
  "events": "^3.3.0",
107
107
  "react-native-marked": "8.1.1",
108
108
  "semver": "^7.7.4",
109
109
  "typesafe-ts": "^1.7.1",
110
- "@glassly/llm-providers": "0.3.2-dev.617"
110
+ "@glassly/llm-providers": "0.3.3-beta.630"
111
111
  },
112
112
  "devDependencies": {
113
113
  "@glassly/llm-providers": "workspace:*",
@@ -132,6 +132,7 @@
132
132
  "react-native-reanimated": ">=4.2.1 <5",
133
133
  "react-native-worklets": ">=0.7.4 <1",
134
134
  "expo-file-system": ">=55.0.0",
135
+ "expo-image-manipulator": ">=55.0.0",
135
136
  "react-native-zip-archive": "7.1.1",
136
137
  "zustand": "*",
137
138
  "expo-battery": ">=55.0.0",
@@ -95,6 +95,7 @@ function projectInfo() {
95
95
  rightMac: s.rightMacAddress,
96
96
  sceneCanvasWidth: s.sceneCanvasWidth,
97
97
  sceneCanvasHeight: s.sceneCanvasHeight,
98
+ volumeGate: s.volumeGate === true,
98
99
  // Copy: the snapshot must not hand callers a mutable reference into the store.
99
100
  wifi: {...s.wifi},
100
101
  }
@@ -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.2",
15
- "releaseIdentity": "0.3.2-dev.617",
16
- "releaseSetId": "glassly-0.3.2-dev.617",
17
- "sourceCommit": "28231d2694923368e949dded3f7e8c3e015af932",
14
+ "familyBaseVersion": "0.3.3",
15
+ "releaseIdentity": "0.3.3-beta.630",
16
+ "releaseSetId": "glassly-0.3.3-beta.630",
17
+ "sourceCommit": "5d41a8af4bb8c4353bb00670eb4913a05bd9e3c9",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ export type {
45
45
  // imperative access goes through engine.settings.
46
46
  export {SETTINGS, useSetting, AI_DATA_CONSENT_VERSION} from "./stores/settings"
47
47
  export {isCfwFirmwareVersion, isCustomDashboardEligible} from "./services/pages/pageEligibility"
48
+ export {getEffectiveCapabilities} from "./services/effectiveCapabilities"
48
49
 
49
50
  // AI tier registry: the curated per-tier model dropdown options and helpers
50
51
  // the Settings > AI Models screen renders. Resolution itself runs inside
@@ -3,7 +3,9 @@
3
3
  * glasses when a touch gesture arrives, while nothing else is on the display:
4
4
  * a filled circle for a single tap, two for a double tap, an arrow for a swipe
5
5
  * (cleared after a short pulse), a bullseye for a recognized long press.
6
- * The Neural Band's middle finger (index 1) draws the same shapes hollow. Extracted from
6
+ * Controllers that report press edges (touch_down … tap_end) show the press
7
+ * itself instead: a dot while pressed, the bullseye once held, cleared at
8
+ * release. The Neural Band's middle finger (index 1) draws the same shapes hollow. Extracted from
7
9
  * the CFW dashboard service; it renders through
8
10
  * the ordinary scene pipeline, so it works on any positioning device (stock
9
11
  * G2 included), and is gated by the gesture_dots debug setting.
@@ -24,6 +26,8 @@ import sceneRenderer from "./SceneRenderer"
24
26
  const DOTS_APP_ID = "system.gesture-dots"
25
27
  /** How long a gesture stays on the indicator. */
26
28
  const GESTURE_FLASH_MS = 700
29
+ /** A press-edge device's tap and hold reports trail its last edge by its double-tap window. */
30
+ const EDGE_REPORT_GRACE_MS = 1000
27
31
  const MARGIN_X = 16
28
32
  const MARGIN_TOP = 10
29
33
  /** Gestures that flash a glyph. */
@@ -45,6 +49,8 @@ class GestureDotsService {
45
49
  private flashTimer: number | null = null
46
50
  /** Raw controller holds survive hold recognition/dial events until their own release. */
47
51
  private presses = new Map<number, {raw: boolean; glyph: GestureDotGlyph}>()
52
+ /** When each finger last sent a press edge (touch_down, or tap_end after one). */
53
+ private lastEdgeAt = new Map<number, number>()
48
54
  /** Avoid sending identical images for successive phases of the same press. */
49
55
  private renderedGlyph: GestureDotGlyph | null = null
50
56
  /** Whether the indicator scene currently has anything on it (needs a clear when the flash ends). */
@@ -76,6 +82,7 @@ class GestureDotsService {
76
82
  for (const unsub of this.unsubs) unsub()
77
83
  this.unsubs = []
78
84
  this.relinquish()
85
+ this.lastEdgeAt.clear()
79
86
  this.started = false
80
87
  }
81
88
 
@@ -124,9 +131,12 @@ class GestureDotsService {
124
131
  const finger = fingerIndex ?? 0
125
132
  const press = this.presses.get(finger)
126
133
  if (gestureName === "touch_down" || gestureName === "tap_start") {
134
+ if (gestureName === "touch_down") this.lastEdgeAt.set(finger, Date.now())
127
135
  if (press) {
128
- // Both reports describe one press. Never restart its visual feedback.
136
+ // Both reports describe one press: after touch_down, tap_start means it is held.
129
137
  if (gestureName === "touch_down") press.raw = true
138
+ else if (press.raw) press.glyph = "hold"
139
+ this.render()
130
140
  return
131
141
  }
132
142
  this.clearFlash()
@@ -137,21 +147,24 @@ class GestureDotsService {
137
147
  if (gestureName === "tap_end") {
138
148
  if (!press) return
139
149
  this.presses.delete(finger)
140
- // The band's resolved tap arrives after release (double-tap window).
141
- // Keep the glyph lit across that gap so one pinch never flashes twice.
142
- if (press.raw && this.presses.size === 0) this.startFlash(press.glyph)
150
+ if (press.raw) this.lastEdgeAt.set(finger, Date.now())
143
151
  this.render()
144
152
  return
145
153
  }
146
154
  const shape = FLASH_GLYPHS[gestureName]
147
155
  const glyph = shape && finger === 1 ? MIDDLE_FINGER_GLYPHS[shape] ?? shape : shape
148
- if (press?.raw && (!glyph || gestureName === "long_press")) {
156
+ // The press already showed its own tap and hold; the device's report of
157
+ // them follows after its double-tap window, often after release.
158
+ const shownByPress = gestureName === "single_tap" || gestureName === "long_press"
159
+ if (press?.raw && (!glyph || shownByPress)) {
149
160
  // Pinch-and-turn reports do not release a finger. A recognized hold
150
161
  // stays visible until tap_end instead of expiring halfway through it.
151
- if (glyph) press.glyph = glyph
162
+ if (gestureName === "long_press") press.glyph = "hold"
152
163
  this.render()
153
164
  return
154
165
  }
166
+ const lastEdgeAt = this.lastEdgeAt.get(finger)
167
+ if (!press && shownByPress && lastEdgeAt !== undefined && Date.now() - lastEdgeAt < EDGE_REPORT_GRACE_MS) return
155
168
  // Completed taps/swipes become one continuous pulse, even if the device
156
169
  // omits tap_end. Older tap_start-only devices retain the same fallback.
157
170
  this.presses.delete(finger)
@@ -7,6 +7,7 @@ import {useGlassesStore} from "../stores/glasses"
7
7
  import {BgTimer} from "../utils/timers"
8
8
  import dashboardOpenTrace from "./debug/DashboardOpenTrace"
9
9
  import gestureDotsService from "./GestureDotsService"
10
+ import {isGlassesConnected} from "./GlassesReadiness"
10
11
  import localDisplayManager from "./LocalDisplayManager"
11
12
  import localMiniappRuntime from "./LocalMiniappRuntime"
12
13
  import {MiniappExitGesture} from "./MiniappExitGesture"
@@ -92,8 +93,9 @@ export function dispatchTouchEvent(event: TouchEvent): void {
92
93
  // Nobody claimed a double tap: the glasses' own dashboard. On eligible
93
94
  // Glassly CFW glasses the dashboard miniapp claims it while it runs
94
95
  // (ADR-0006); stock firmware keeps its native dashboard (ADR-0002). The
95
- // modal nav UI owns the screen; showDashboard would fight it.
96
- if (osDoubleTap && !nativeNavigationService.isActive) {
96
+ // modal nav UI owns the screen; showDashboard would fight it. Without
97
+ // glasses there is no dashboard to open, and the mirror keeps its frame.
98
+ if (osDoubleTap && !nativeNavigationService.isActive && isGlassesConnected(useGlassesStore.getState().connection)) {
97
99
  useDisplayStore.getState().setDisplayEvent({layout: {layoutType: "unavailable", reason: "firmware-dashboard"}})
98
100
  void Promise.resolve(BluetoothSdk.showDashboard()).catch((err) => {
99
101
  console.error("DeviceEventRouter: showDashboard failed:", err)
@@ -72,6 +72,7 @@ import overlayPages from "./pages/OverlayPages"
72
72
  import dashboardOpenTrace from "./debug/DashboardOpenTrace"
73
73
  import {isAppForegrounded, isPhoneActive, onAppForegroundChange, onPhoneActivityChange} from "./PhoneActivity"
74
74
  import {installedNotificationApps} from "./PhoneNotificationsSync"
75
+ import {MiniappIcons} from "./MiniappIcons"
75
76
  import {
76
77
  DERIVED_SETTINGS_KEYS,
77
78
  SETTINGS_BRIDGE_KEYS,
@@ -1719,7 +1720,7 @@ class LocalMiniappRuntime {
1719
1720
  // Inter-miniapp interop (lifecycle is SYSTEM-only; actions are open,
1720
1721
  // gated per-action by the target's declared visibility)
1721
1722
  case MiniappRequestType.MINIAPPS_LIST:
1722
- this.handleMiniappsList(packageName, payload, requestId)
1723
+ void this.handleMiniappsList(packageName, payload, requestId)
1723
1724
  break
1724
1725
  case MiniappRequestType.MINIAPPS_START:
1725
1726
  void this.handleMiniappsStart(packageName, payload, requestId)
@@ -5330,12 +5331,14 @@ class LocalMiniappRuntime {
5330
5331
  if (this.capabilitiesWired) return
5331
5332
  this.capabilitiesWired = true
5332
5333
  const broadcast = () => this.broadcastCapabilities()
5333
- // Connection, model and firmware also decide the dashboard view's eligibility.
5334
+ // Connection, model and firmware also decide the dashboard view's eligibility;
5335
+ // a connected Neural Band adds its swipe2d surface to `button`.
5334
5336
  useGlassesStore.subscribe(
5335
5337
  (s) =>
5336
- `${s.sceneCanvasWidth ?? 0}x${s.sceneCanvasHeight ?? 0}:${s.sceneVector === true}:${s.firmwareVersion}:${s.connection.state}:${s.deviceModel}`,
5338
+ `${s.sceneCanvasWidth ?? 0}x${s.sceneCanvasHeight ?? 0}:${s.sceneVector === true}:${s.firmwareVersion}:${s.connection.state}:${s.deviceModel}:${s.controllerConnected}`,
5337
5339
  broadcast,
5338
5340
  )
5341
+ useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.default_controller.key), broadcast)
5339
5342
  // The debug knob flips hasNativeNavigation on custom firmware.
5340
5343
  useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.cfw_native_navigation.key), broadcast)
5341
5344
  // The dashboard master switch flips the dashboard view.
@@ -5902,18 +5905,28 @@ class LocalMiniappRuntime {
5902
5905
  }
5903
5906
  }
5904
5907
 
5905
- private handleMiniappsList(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
5908
+ private miniappIcons = new MiniappIcons()
5909
+
5910
+ private async handleMiniappsList(
5911
+ packageName: string,
5912
+ payload: Record<string, unknown>,
5913
+ requestId?: string,
5914
+ ): Promise<void> {
5906
5915
  if (!this.requireMiniappsPermission(packageName, requestId, "list")) return
5907
5916
  const includeIncompatible = payload.includeIncompatible === true
5908
- const infos = this.interopApps()
5909
- .filter(
5910
- (a) =>
5911
- a.packageName &&
5912
- a.packageName !== packageName &&
5913
- !a.packageName.includes("@empty") &&
5914
- (includeIncompatible || a.compatibility?.isCompatible !== false),
5915
- )
5916
- .map((a) => this.buildMiniappInfo(a))
5917
+ const apps = this.interopApps().filter(
5918
+ (a) =>
5919
+ a.packageName &&
5920
+ a.packageName !== packageName &&
5921
+ !a.packageName.includes("@empty") &&
5922
+ (includeIncompatible || a.compatibility?.isCompatible !== false),
5923
+ )
5924
+ const infos = await Promise.all(
5925
+ apps.map(async (app) => ({
5926
+ ...this.buildMiniappInfo(app),
5927
+ ...(payload.includeIcons === true ? {icon: await this.miniappIcons.read(app)} : {}),
5928
+ })),
5929
+ )
5917
5930
  this.sendResult(packageName, requestId, true, infos)
5918
5931
  this.auditInterop({caller: packageName, op: "list", ok: true})
5919
5932
  }
@@ -0,0 +1,50 @@
1
+ import {File} from "expo-file-system"
2
+ import {ImageManipulator, SaveFormat} from "expo-image-manipulator"
3
+
4
+ type IconApp = {packageName: string; version?: string; logoUrl: string}
5
+
6
+ /** Small thumbnails keep the inter-miniapp bridge and every grid frame lightweight. */
7
+ async function thumbnail(uri: string): Promise<string | undefined> {
8
+ const context = ImageManipulator.manipulate(uri)
9
+ let image: Awaited<ReturnType<typeof context.renderAsync>> | undefined
10
+ let savedUri: string | undefined
11
+ try {
12
+ context.resize({width: 32, height: 32})
13
+ image = await context.renderAsync()
14
+ const saved = await image.saveAsync({format: SaveFormat.PNG, base64: true})
15
+ savedUri = saved.uri
16
+ return saved.base64
17
+ } finally {
18
+ image?.release()
19
+ context.release()
20
+ if (savedUri) {
21
+ try {
22
+ new File(savedUri).delete()
23
+ } catch {
24
+ /* Already removed by cache cleanup. */
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ /** Cache by installed version and icon URL; failed icons never prevent listing apps. */
31
+ export class MiniappIcons {
32
+ private cache = new Map<string, {key: string; value: Promise<string | undefined>}>()
33
+
34
+ constructor(private readonly load = thumbnail) {}
35
+
36
+ read(app: IconApp): Promise<string | undefined> {
37
+ if (!app.logoUrl) return Promise.resolve(undefined)
38
+ const key = JSON.stringify([app.version, app.logoUrl])
39
+ const cached = this.cache.get(app.packageName)
40
+ if (cached?.key === key) return cached.value
41
+ const value = this.load(app.logoUrl).catch(() => {
42
+ if (this.cache.get(app.packageName)?.value === value) this.cache.delete(app.packageName)
43
+ return undefined
44
+ })
45
+ this.cache.set(app.packageName, {key, value})
46
+ // Bound memory even if apps are repeatedly installed and removed.
47
+ if (this.cache.size > 64) this.cache.delete(this.cache.keys().next().value!)
48
+ return value
49
+ }
50
+ }
@@ -11,11 +11,22 @@
11
11
  * lifted alongside the canvas, bounded only to keep frames sane. Firmware
12
12
  * that draws vector shapes keeps 128 scene slots, so shape-heavy frames
13
13
  * (a game board, a chart) get a larger shared text/shape budget.
14
+ *
15
+ * A connected controller that swipes in two dimensions (the Meta Neural Band)
16
+ * adds its surface to `button.buttons` as a `swipe2d` entry, so a miniapp can
17
+ * tell four-way swipes from the temples' and the R1 ring's up/down.
14
18
  */
15
19
  import {useGlassesStore} from "../stores/glasses"
16
20
  import {SETTINGS, useSettingsStore} from "../stores/settings"
17
- import {ALL_DISPLAY_SHAPES, DeviceTypes, getModelCapabilities, type Capabilities} from "../types"
21
+ import {
22
+ ALL_DISPLAY_SHAPES,
23
+ DeviceTypes,
24
+ getModelCapabilities,
25
+ type ButtonCapabilities,
26
+ type Capabilities,
27
+ } from "../types"
18
28
  import {resolveNativeNavigation} from "./nativeNavigationGate"
29
+ import {input2dFrom} from "./settingsBridge"
19
30
 
20
31
  const CFW_MAX_TEXT_ELEMENTS = 24
21
32
  const CFW_MAX_IMAGE_ELEMENTS = 16
@@ -36,17 +47,28 @@ function forceNativeNavigationOnCfw(): boolean {
36
47
  return useSettingsStore.getState().getSetting(SETTINGS.cfw_native_navigation.key) === true
37
48
  }
38
49
 
50
+ /** The connected firmware gates microphone streaming on volume (G2 Glassly CFW revision 40+). */
51
+ export function runtimeVolumeGate(): boolean {
52
+ return useGlassesStore.getState().volumeGate === true
53
+ }
54
+
39
55
  export function getEffectiveCapabilities(model: DeviceTypes): Capabilities {
56
+ return withControllerInput(glassesCapabilities(model))
57
+ }
58
+
59
+ function glassesCapabilities(model: DeviceTypes): Capabilities {
40
60
  const base = getModelCapabilities(model)
41
- // Only models with a Glassly CFW publish a canvas; never let a stale value
42
- // reshape another model.
61
+ // Only models with a Glassly CFW publish a canvas or a volume gate; never
62
+ // let a stale value reshape another model.
43
63
  const canvas = base.hasCustomFirmware ? runtimeSceneCanvas() : null
44
- if (!base.display || !canvas) return base
64
+ const volumeGate = base.hasCustomFirmware && base.hasMicrophone && runtimeVolumeGate()
65
+ if (!canvas) return volumeGate ? withVolumeGate(base) : base
66
+ if (!base.display) return volumeGate ? withVolumeGate(base) : base
45
67
  // Glassly CFW supports the complete vector contract, including SVG and rotation.
46
68
  // Upstream g2flash publishes a raster canvas with sceneVector=false.
47
69
  const vector = useGlassesStore.getState().sceneVector === true
48
70
  return {
49
- ...base,
71
+ ...(volumeGate ? withVolumeGate(base) : base),
50
72
  hasNativeNavigation: resolveNativeNavigation(base.hasNativeNavigation, true, forceNativeNavigationOnCfw()),
51
73
  display: {
52
74
  ...base.display,
@@ -67,3 +89,40 @@ export function getEffectiveCapabilities(model: DeviceTypes): Capabilities {
67
89
  },
68
90
  }
69
91
  }
92
+
93
+ type InputSurface = NonNullable<ButtonCapabilities["buttons"]>[number]
94
+
95
+ /** The Neural Band's thumb swipes, pinches and dial. */
96
+ const NEURAL_BAND_SURFACE: InputSurface = {
97
+ type: "swipe2d",
98
+ events: [
99
+ "TAP",
100
+ "DOUBLE_TAP",
101
+ "PRESS_HOLD",
102
+ "SWIPE_UP",
103
+ "SWIPE_DOWN",
104
+ "SWIPE_LEFT",
105
+ "SWIPE_RIGHT",
106
+ "DIAL_UP",
107
+ "DIAL_DOWN",
108
+ ],
109
+ }
110
+
111
+ function withControllerInput(caps: Capabilities): Capabilities {
112
+ const controller = useSettingsStore.getState().getSetting(SETTINGS.default_controller.key)
113
+ if (!input2dFrom(useGlassesStore.getState(), controller)) return caps
114
+ const buttons = caps.button?.buttons ?? []
115
+ return {
116
+ ...caps,
117
+ hasButton: true,
118
+ button: {
119
+ ...caps.button,
120
+ count: (caps.button?.count ?? buttons.length) + 1,
121
+ buttons: [...buttons, NEURAL_BAND_SURFACE],
122
+ },
123
+ }
124
+ }
125
+
126
+ function withVolumeGate(base: Capabilities): Capabilities {
127
+ return {...base, microphone: {...(base.microphone ?? {}), volumeGate: true}}
128
+ }
@@ -145,6 +145,7 @@ const initialState: GlassesStore = {
145
145
  sceneCanvasHeight: 0,
146
146
  sceneVector: false,
147
147
  sceneSettle: false,
148
+ volumeGate: false,
148
149
  // wifi info
149
150
  wifi: {state: "disconnected"},
150
151
  wifiStatusKnown: false,
@@ -611,6 +611,40 @@ export const SETTINGS: Record<string, Setting> = {
611
611
  saveOnServer: true,
612
612
  persist: true,
613
613
  },
614
+ // G2 Glassly CFW microphone volume gate (revision 40+): the temples stop
615
+ // streaming audio while nothing reaches the threshold. Off until the user
616
+ // turns it on; shown only when the connected firmware reports the capability.
617
+ volume_gate_enabled: {
618
+ key: "volume_gate_enabled",
619
+ defaultValue: () => false,
620
+ writable: true,
621
+ saveOnServer: true,
622
+ persist: true,
623
+ },
624
+ // dBFS RMS a 50 ms chunk must reach to count as sound.
625
+ volume_gate_threshold_db: {
626
+ key: "volume_gate_threshold_db",
627
+ defaultValue: () => -45,
628
+ writable: true,
629
+ saveOnServer: true,
630
+ persist: true,
631
+ },
632
+ // Quiet time before the gate closes.
633
+ volume_gate_hold_ms: {
634
+ key: "volume_gate_hold_ms",
635
+ defaultValue: () => 3000,
636
+ writable: true,
637
+ saveOnServer: true,
638
+ persist: true,
639
+ },
640
+ // Minimum time the gate stays open once sound opens it.
641
+ volume_gate_min_open_ms: {
642
+ key: "volume_gate_min_open_ms",
643
+ defaultValue: () => 2000,
644
+ writable: true,
645
+ saveOnServer: true,
646
+ persist: true,
647
+ },
614
648
  always_on_status_bar: {
615
649
  key: "always_on_status_bar",
616
650
  defaultValue: () => false,
@@ -1108,6 +1142,10 @@ export const BLUETOOTH_SETTING_KEYS: string[] = [
1108
1142
  SETTINGS.power_saving_mode.key,
1109
1143
  SETTINGS.voice_activity_detection_enabled.key,
1110
1144
  SETTINGS.loudness_gate_enabled.key,
1145
+ SETTINGS.volume_gate_enabled.key,
1146
+ SETTINGS.volume_gate_threshold_db.key,
1147
+ SETTINGS.volume_gate_hold_ms.key,
1148
+ SETTINGS.volume_gate_min_open_ms.key,
1111
1149
  SETTINGS.lc3_frame_size.key,
1112
1150
  SETTINGS.preferred_mic.key,
1113
1151
  SETTINGS.screen_disabled.key,
@@ -95,6 +95,10 @@ export const ALL_DISPLAY_SHAPES: DisplayShape[] = ["rect", "circle", "arc", "pie
95
95
  export interface MicrophoneCapabilities {
96
96
  count?: number
97
97
  hasVAD?: boolean // Voice Activity Detection
98
+ // The connected firmware can stop streaming audio below a volume floor
99
+ // (G2 Glassly CFW revision 40+). Never set on a model profile: it is
100
+ // reported at runtime and layered on by getEffectiveCapabilities.
101
+ volumeGate?: boolean
98
102
  }
99
103
 
100
104
  /**