From cab1e94588355c8079c0d6aa2ff158fcc758431d Mon Sep 17 00:00:00 2001 From: mannes Date: Thu, 24 Sep 2026 09:11:15 +0200 Subject: [PATCH] Invite device reports via pre-filled GitHub issues A dismissible toast, never in the way: - a meter goes live on a driver that isn't live-tested: asks once per driver whether it reads right and opens the device-report issue form pre-filled with the advertised name, driver and verification tier, the GATT layout and any Device Information strings. No readings, no raw frames. - the chooser is dismissed or connecting fails: offers the connection-problem form. The app sends nothing itself; the user reviews and submits on GitHub. MeterSession gains describe() and a cancelled flag; MeterChannel mirrors driverId and cancelled. --- .github/ISSUE_TEMPLATE/connection-problem.yml | 44 +++++++ .github/ISSUE_TEMPLATE/device-report.yml | 46 +++++++ README.md | 7 + apps/web/src/App.tsx | 3 + apps/web/src/components/ReportToast.test.tsx | 83 ++++++++++++ apps/web/src/components/ReportToast.tsx | 120 ++++++++++++++++++ apps/web/src/lib/report.test.ts | 39 ++++++ apps/web/src/lib/report.ts | 67 ++++++++++ packages/react/src/index.ts | 1 + packages/react/src/useMeters.ts | 1 + packages/web-bluetooth/src/index.ts | 2 +- packages/web-bluetooth/src/meters.ts | 4 + packages/web-bluetooth/src/session.test.ts | 2 + packages/web-bluetooth/src/session.ts | 13 +- packages/web-bluetooth/src/transport.test.ts | 24 ++++ packages/web-bluetooth/src/transport.ts | 71 ++++++++++- 16 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/connection-problem.yml create mode 100644 .github/ISSUE_TEMPLATE/device-report.yml create mode 100644 apps/web/src/components/ReportToast.test.tsx create mode 100644 apps/web/src/components/ReportToast.tsx create mode 100644 apps/web/src/lib/report.test.ts create mode 100644 apps/web/src/lib/report.ts diff --git a/.github/ISSUE_TEMPLATE/connection-problem.yml b/.github/ISSUE_TEMPLATE/connection-problem.yml new file mode 100644 index 0000000..87d631f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/connection-problem.yml @@ -0,0 +1,44 @@ +name: Connection problem +description: Your meter isn't in the list, or it won't connect. +title: 'Connection problem: ' +labels: ['connection problem'] +body: + - type: markdown + attributes: + value: | + Sorry it didn't work. Tell us what you have — a meter that doesn't show up in the list + usually just isn't supported *yet*, and knowing it exists is the first step. + - type: input + id: model + attributes: + label: Meter model + description: Brand and model as printed on the meter. + placeholder: UNI-T UT60BT + validations: + required: true + - type: dropdown + id: problem + attributes: + label: What happened? + options: + - My meter isn't in the list + - It's in the list, but won't connect + - It connects, then drops + validations: + required: true + - type: input + id: vendor_app + attributes: + label: Vendor app + description: If the meter works with a phone app from its maker, which one? + - type: textarea + id: details + attributes: + label: Anything else? + description: Is Bluetooth switched on on the meter? Did it show up under a different name? + - type: textarea + id: environment + attributes: + label: Environment + description: Filled in by the app. Review it before submitting. + render: text diff --git a/.github/ISSUE_TEMPLATE/device-report.yml b/.github/ISSUE_TEMPLATE/device-report.yml new file mode 100644 index 0000000..5a1a0b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/device-report.yml @@ -0,0 +1,46 @@ +name: Device report +description: Tell us how your meter works with the app — this is how drivers get confirmed. +title: 'Device report: ' +labels: ['device report'] +body: + - type: markdown + attributes: + value: | + Thanks! Many drivers are built from protocol notes and haven't met a real meter yet — + your report is how they get confirmed (or fixed). + + If you opened this from the app, the **Connection** box below is already filled in with + your meter's advertised name and Bluetooth layout. Nothing else was sent. If we need a raw + capture to fix something, we'll ask in the thread. + - type: input + id: model + attributes: + label: Meter model + description: Brand and model as printed on the meter. + placeholder: UNI-T UT60BT + validations: + required: true + - type: dropdown + id: works + attributes: + label: Does it work? + options: + - Yes — readings match the meter's display + - Mostly — some modes or values are wrong + - It connects, but the readings are wrong + - It connects, but no readings appear + validations: + required: true + - type: textarea + id: details + attributes: + label: What did you try? + description: >- + Which modes did you check (V, A, Ω, °C…)? Where they differ, what does the meter show and + what does the app show? + - type: textarea + id: connection + attributes: + label: Connection + description: Filled in by the app. Review it before submitting. + render: text diff --git a/README.md b/README.md index 65d954f..2a3e6da 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,13 @@ UNI-T, Aneng / BSIDE / ZOYI, Owon, Voltcraft, and AICARE. See the **[hardware support list](docs/HARDWARE.md)** for every model and its verification state, and **[docs/protocols/](docs/protocols/README.md)** for a per-driver protocol spec. +Most drivers haven't met a real meter yet. When yours connects, the app asks whether it reads +right and opens a pre-filled [device report](https://github.com/libreble/multimeter/issues/new?template=device-report.yml) +— the meter's advertised name and Bluetooth layout, no readings. If your meter isn't in the list +or won't connect, dismissing the chooser offers a +[connection-problem report](https://github.com/libreble/multimeter/issues/new?template=connection-problem.yml). +Nothing is sent from the app; you review and submit the issue on GitHub. + ## Packages The app is a thin shell over framework-agnostic packages, so you can build your own diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f27e5a1..560e7c2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -26,6 +26,7 @@ import { ThemeToggle } from './components/ThemeToggle'; import { ChartColorPicker } from './components/ChartColorPicker'; import { ShortcutsHelp } from './components/ShortcutsHelp'; import { UnsupportedBrowser } from './components/UnsupportedBrowser'; +import { ReportToast } from './components/ReportToast'; import { exportCsv, exportPng } from './lib/exporters'; // Code-split the uPlot-heavy chart + the whole Recordings view so the initial bundle stays lean. @@ -376,6 +377,8 @@ export default function App() { {announcement} + + setHelpOpen(false)} /> ); diff --git a/apps/web/src/components/ReportToast.test.tsx b/apps/web/src/components/ReportToast.test.tsx new file mode 100644 index 0000000..0722bc7 --- /dev/null +++ b/apps/web/src/components/ReportToast.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import type { MeterChannel, Meters } from '@libreble/multimeter-react'; +import { ReportToast } from './ReportToast'; + +function channel(over: Partial = {}): MeterChannel { + return { + id: 'm-1', + kind: 'meter', + label: 'Meter', + role: 'Meter', + state: 'idle', + reading: null, + deviceName: null, + error: null, + controls: [], + driverId: null, + cancelled: false, + ...over, + }; +} + +function meters( + list: MeterChannel[], + opts: { isDemo?: boolean; describe?: ReturnType } = {}, +): Meters { + const describe = opts.describe ?? vi.fn().mockResolvedValue(null); + return { + meters: list, + meterSession: () => ({ isDemo: opts.isDemo ?? false, describe }), + } as unknown as Meters; +} + +beforeEach(() => localStorage.clear()); + +describe('ReportToast', () => { + it('stays hidden for an idle meter and for confirmed drivers', () => { + const { container, rerender } = render(); + expect(container.firstChild).toBeNull(); + rerender(); + expect(container.firstChild).toBeNull(); + }); + + it('offers a connection-problem report after the chooser is dismissed, until dismissed', () => { + const { container } = render(); + const link = screen.getByRole('link', { name: /tell us which one/i }); + expect(link.getAttribute('href')).toContain('template=connection-problem.yml'); + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); + expect(container.firstChild).toBeNull(); + }); + + it('asks once about an unconfirmed driver and opens a pre-filled device report', async () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null); + const describe = vi.fn().mockResolvedValue({ + name: 'UT181A', + service: 's', + characteristics: [], + deviceInfo: {}, + }); + const live = [channel({ state: 'live', driverId: 'ut181a', deviceName: 'UT181A' })]; + const { container, unmount } = render(); + expect(screen.getByText(/not yet confirmed on real hardware/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /report it/i })); + await waitFor(() => expect(open).toHaveBeenCalled()); + expect(open.mock.calls[0]![0]).toContain('template=device-report.yml'); + await waitFor(() => expect(container.firstChild).toBeNull()); + open.mockRestore(); + unmount(); + + // Remembered per driver: a later session doesn't ask again. + const again = render(); + expect(again.container.firstChild).toBeNull(); + }); + + it('never asks about demo meters', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ReportToast.tsx b/apps/web/src/components/ReportToast.tsx new file mode 100644 index 0000000..c790217 --- /dev/null +++ b/apps/web/src/components/ReportToast.tsx @@ -0,0 +1,120 @@ +// A dismissible, non-blocking toast inviting a device report (see lib/report.ts). Two cases: +// * a real meter is live on a driver not yet confirmed on hardware → "does it work?". Dismissing +// or reporting is remembered per driver, so it asks once. +// * the chooser was dismissed or connecting failed → "not in the list, or won't connect?". +// Dismissing hides it until the next cancel/failure. +// Both open a pre-filled GitHub issue form in a new tab; the user reviews and submits it there. + +import { useEffect, useState } from 'react'; +import type { MeterChannel, Meters } from '@libreble/multimeter-react'; +import { connectionProblemUrl, deviceReportUrl, verification } from '../lib/report'; + +const DONE_KEY = (driverId: string) => `multimeter.reportDone.${driverId}`; + +function isDone(driverId: string): boolean { + try { + return localStorage.getItem(DONE_KEY(driverId)) !== null; + } catch { + return false; + } +} + +function markDone(driverId: string): void { + try { + localStorage.setItem(DONE_KEY(driverId), '1'); + } catch { + /* storage unavailable — it may ask again next time */ + } +} + +export function ReportToast({ meters }: { meters: Meters }) { + const [done, setDone] = useState>(() => new Set()); + const [problemDismissed, setProblemDismissed] = useState(false); + + const real = meters.meters.filter(c => !meters.meterSession(c.id)?.isDemo); + const unconfirmed = real.find( + c => + c.state === 'live' && + c.driverId !== null && + verification(c.driverId)?.tier !== 'live-tested' && + !done.has(c.driverId) && + !isDone(c.driverId), + ); + const problem = real.find(c => c.cancelled || c.state === 'error'); + + // A new cancel/failure after a successful attempt shows the toast again. + useEffect(() => { + if (!problem) setProblemDismissed(false); + }, [problem]); + + if (unconfirmed) { + const driverId = unconfirmed.driverId!; + const finish = () => { + markDone(driverId); + setDone(d => new Set(d).add(driverId)); + }; + // Read the GATT description only on click: a few best-effort Device Information reads that + // shouldn't sit in the connect path. Transient user activation outlives them, so the new tab + // isn't popup-blocked. + const report = async () => { + const g = (await meters.meterSession(unconfirmed.id)?.describe()) ?? null; + window.open(deviceReportUrl(driverId, g), '_blank', 'noopener,noreferrer'); + finish(); + }; + return ( + +

+ {name(unconfirmed)} is{' '} + {verification(driverId)?.text}. Does it read right? +

+ +
+ ); + } + + if (problem && !problemDismissed) { + return ( + setProblemDismissed(true)}> +

Meter not in the list, or won't connect?

+ setProblemDismissed(true)} + className={ACTION} + > + Tell us which one + +
+ ); + } + + return null; +} + +const name = (c: MeterChannel) => c.deviceName ?? 'This meter'; + +const ACTION = + 'mt-2 inline-block rounded-md bg-emerald-500 px-3 py-1 text-sm font-semibold text-emerald-950 hover:bg-emerald-400'; + +function Toast({ children, onDismiss }: { children: React.ReactNode; onDismiss: () => void }) { + return ( +
+ {children} + +
+ ); +} diff --git a/apps/web/src/lib/report.test.ts b/apps/web/src/lib/report.test.ts new file mode 100644 index 0000000..de82c4f --- /dev/null +++ b/apps/web/src/lib/report.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { connectionProblemUrl, deviceReportUrl, verification } from './report'; + +const params = (url: string) => new URL(url).searchParams; + +describe('device report URLs', () => { + it('pre-fills the device-report form with identifiers and GATT layout only', () => { + const url = deviceReportUrl('uni-t', { + name: 'UT60BT_AB', + service: '49535343-fe7d-4ae5-8fa9-9fafd205e455', + characteristics: [{ uuid: '49535343-1e4d-4bd9-ba61-23c647249616', properties: ['notify'] }], + deviceInfo: { firmware: '1.2' }, + }); + expect(url.startsWith('https://github.com/libreble/multimeter/issues/new?')).toBe(true); + const p = params(url); + expect(p.get('template')).toBe('device-report.yml'); + expect(p.get('title')).toBe('Device report: UT60BT_AB'); + const c = p.get('connection')!; + expect(c).toContain('advertised name: UT60BT_AB'); + expect(c).toContain('driver: uni-t'); + expect(c).toContain('49535343-1e4d-4bd9-ba61-23c647249616 [notify]'); + expect(c).toContain('firmware: 1.2'); + // Short enough for GitHub's URL limit. + expect(url.length).toBeLessThan(4000); + }); + + it('pre-fills the connection-problem form with the error, if any', () => { + const p = params(connectionProblemUrl('NetworkError: GATT server disconnected')); + expect(p.get('template')).toBe('connection-problem.yml'); + expect(p.get('environment')).toContain('error: NetworkError: GATT server disconnected'); + expect(params(connectionProblemUrl(null)).get('environment')).not.toContain('error:'); + }); + + it('maps drivers to their verification tier', () => { + expect(verification('uni-t')?.tier).toBe('live-tested'); + expect(verification('ut181a')?.text).toBe('not yet confirmed on real hardware'); + expect(verification(null)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/report.ts b/apps/web/src/lib/report.ts new file mode 100644 index 0000000..68ea91d --- /dev/null +++ b/apps/web/src/lib/report.ts @@ -0,0 +1,67 @@ +// Device reports: pre-filled GitHub issue forms (.github/ISSUE_TEMPLATE/) the user reviews and +// submits themselves. Nothing is sent from the app — it only opens a URL. The summary carries +// identifiers and GATT layout, never readings or raw frames (we ask for a capture in the thread +// if we need one). + +import { driverById } from '@libreble/multimeter-protocol'; +import type { GattDescription } from '@libreble/multimeter-react'; + +const ISSUES = 'https://github.com/libreble/multimeter/issues/new'; + +const VERIFICATION_TEXT = { + 'live-tested': 'confirmed on real hardware', + 'app-verified': 'checked against the vendor app, not yet on a real meter', + 'ported-unverified': 'not yet confirmed on real hardware', +} as const; + +function browser(): string { + return typeof navigator === 'undefined' ? 'unknown' : navigator.userAgent; +} + +function issueUrl(template: string, title: string, fields: Record): string { + const q = new URLSearchParams({ template, title, ...fields }); + return `${ISSUES}?${q}`; +} + +/** A driver's verification tier plus a plain-language phrase for the report prompt. */ +export function verification(driverId: string | null) { + const d = driverId ? driverById(driverId) : undefined; + return d ? { tier: d.verification, text: VERIFICATION_TEXT[d.verification] } : null; +} + +export function connectedSummary(driverId: string | null, g: GattDescription | null): string { + const d = driverId ? driverById(driverId) : undefined; + const lines = [ + `app: Multimeter`, + `browser: ${browser()}`, + `advertised name: ${g?.name ?? 'unknown'}`, + `driver: ${d ? `${d.id} (${d.label}) · ${d.verification}` : 'none matched'}`, + `service: ${g?.service ?? 'unknown'}`, + ]; + if (g?.characteristics.length) { + lines.push('characteristics:'); + for (const c of g.characteristics) lines.push(` ${c.uuid} [${c.properties.join(',')}]`); + } + const info = Object.entries(g?.deviceInfo ?? {}); + if (info.length) { + lines.push('device information:'); + for (const [k, v] of info) lines.push(` ${k}: ${v}`); + } + return lines.join('\n'); +} + +/** Issue form for a meter that connected: does it read right? */ +export function deviceReportUrl(driverId: string | null, g: GattDescription | null): string { + return issueUrl('device-report.yml', `Device report: ${g?.name ?? 'my meter'}`, { + connection: connectedSummary(driverId, g), + }); +} + +/** Issue form for a meter that isn't listed in the chooser or won't connect. */ +export function connectionProblemUrl(error: string | null): string { + const lines = [`app: Multimeter`, `browser: ${browser()}`]; + if (error) lines.push(`error: ${error}`); + return issueUrl('connection-problem.yml', 'Connection problem: ', { + environment: lines.join('\n'), + }); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a11af2e..90aa23f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -12,6 +12,7 @@ export { type MeterChannel, type DerivedChannel, type DerivedConfig, + type GattDescription, } from './useMeters'; export { useRecorder, type Recorder, type RecState, type SegmentInfo } from './useRecorder'; export { useSessions, type Sessions, type OpenedSession, type OpenedChannel } from './useSessions'; diff --git a/packages/react/src/useMeters.ts b/packages/react/src/useMeters.ts index 2c65707..2675937 100644 --- a/packages/react/src/useMeters.ts +++ b/packages/react/src/useMeters.ts @@ -18,6 +18,7 @@ export type { MeterChannel, DerivedChannel, DerivedConfig, + GattDescription, } from '@libreble/multimeter-web-bluetooth'; export interface Meters extends MetersSnapshot { diff --git a/packages/web-bluetooth/src/index.ts b/packages/web-bluetooth/src/index.ts index 22af1c2..1b9f5e8 100644 --- a/packages/web-bluetooth/src/index.ts +++ b/packages/web-bluetooth/src/index.ts @@ -2,7 +2,7 @@ // MeterSession engine. Depends on @libreble/multimeter-protocol; consumed by the React/Vue // bindings (and any custom UI). Web Bluetooth is Chromium-only and needs a secure context. -export { Transport, type TransportProfile } from './transport'; +export { Transport, type TransportProfile, type GattDescription } from './transport'; export { MeterSession, type MeterState, type MeterSnapshot } from './session'; export { MetersSession, diff --git a/packages/web-bluetooth/src/meters.ts b/packages/web-bluetooth/src/meters.ts index 354635d..33716f5 100644 --- a/packages/web-bluetooth/src/meters.ts +++ b/packages/web-bluetooth/src/meters.ts @@ -40,6 +40,8 @@ export interface MeterChannel { deviceName: string | null; error: string | null; controls: MeterControl[]; // front-panel controls the connected meter exposes (empty when idle) + driverId: string | null; // matched driver (null until identified) + cancelled: boolean; // last connect ended with the chooser dismissed } export interface DerivedChannel { @@ -263,6 +265,8 @@ export class MetersSession { deviceName: s.deviceName, error: s.error, controls: s.controls, + driverId: s.driverId, + cancelled: s.cancelled, }; }); const derived: DerivedChannel[] = this.derivedList.map(d => ({ diff --git a/packages/web-bluetooth/src/session.test.ts b/packages/web-bluetooth/src/session.test.ts index 68a9ac4..486b424 100644 --- a/packages/web-bluetooth/src/session.test.ts +++ b/packages/web-bluetooth/src/session.test.ts @@ -307,6 +307,8 @@ describe('MeterSession — error handling', () => { s.connect(); await vi.waitFor(() => expect(s.getSnapshot().state).toBe('idle')); expect(s.getSnapshot().error).toBeNull(); + // Flagged so the UI can offer "meter not listed / won't connect?". + expect(s.getSnapshot().cancelled).toBe(true); }); it('goes to error (not idle) on a non-NotFound connect failure', async () => { diff --git a/packages/web-bluetooth/src/session.ts b/packages/web-bluetooth/src/session.ts index 832a084..2f85677 100644 --- a/packages/web-bluetooth/src/session.ts +++ b/packages/web-bluetooth/src/session.ts @@ -19,7 +19,7 @@ import { type MeterControl, type Reading, } from '@libreble/multimeter-protocol'; -import { Transport } from './transport'; +import { Transport, type GattDescription } from './transport'; import { isDemoMode, demoReadingFor, DEFAULT_DEMO_PROFILE, type DemoProfile } from './demo'; export type MeterState = @@ -38,6 +38,7 @@ export interface MeterSnapshot { error: string | null; controls: MeterControl[]; // front-panel controls the active driver exposes (empty when idle) driverId: string | null; // id of the matched/sniffed driver (null until one is committed) + cancelled: boolean; // the last connect ended with the chooser dismissed (no device picked) } const errMsg = (e: unknown) => (e instanceof Error ? `${e.name}: ${e.message}` : String(e)); @@ -96,6 +97,7 @@ export class MeterSession { error: null, controls: [], driverId: null, + cancelled: false, }; } @@ -190,7 +192,7 @@ export class MeterSession { this.set({ state: 'unsupported' }); return; } - this.set({ error: null, state: 'connecting' }); + this.set({ error: null, cancelled: false, state: 'connecting' }); const t = new Transport(); t.onChunk = this.handleChunk; t.onDisconnect = this.handleDisconnect; @@ -222,13 +224,18 @@ export class MeterSession { } catch (e) { // User dismissing the chooser throws NotFoundError — a cancel, not a failure. if (e instanceof DOMException && e.name === 'NotFoundError') { - this.set({ state: 'idle' }); + this.set({ state: 'idle', cancelled: !t.chosen }); return; } this.set({ error: errMsg(e), state: 'error' }); } } + /** Identifiers + GATT layout of the connected meter for a device report (null in demo). */ + async describe(): Promise { + return this.transport ? this.transport.describe() : null; + } + private async realReconnect(): Promise { const t = this.transport; if (!t) { diff --git a/packages/web-bluetooth/src/transport.test.ts b/packages/web-bluetooth/src/transport.test.ts index d11653c..d573f31 100644 --- a/packages/web-bluetooth/src/transport.test.ts +++ b/packages/web-bluetooth/src/transport.test.ts @@ -170,3 +170,27 @@ describe('disconnect / reconnect / events', () => { expect(device.gatt.connect).toHaveBeenCalledTimes(2); }); }); + +describe('describe', () => { + it('reports the chosen device name, matched service and characteristics', async () => { + const notify = new FakeChar(ISSC_NOTIFY, { notify: true }); + const write = new FakeChar(ISSC_WRITE, { write: true, writeWithoutResponse: true }); + const { device } = makeDevice([notify, write], { name: 'UT60BT_AB' }); + installBluetooth(device); + + const t = new Transport(); + expect(t.chosen).toBe(false); + await t.requestAndConnect(); + expect(t.chosen).toBe(true); + + const d = await t.describe(); + expect(d.name).toBe('UT60BT_AB'); + expect(d.service).toBeTruthy(); + expect(d.characteristics).toEqual([ + { uuid: ISSC_NOTIFY, properties: ['notify'] }, + { uuid: ISSC_WRITE, properties: ['write', 'writeWithoutResponse'] }, + ]); + // The fake has no Device Information strings — best-effort, so just empty. + expect(d.deviceInfo).toEqual({}); + }); +}); diff --git a/packages/web-bluetooth/src/transport.ts b/packages/web-bluetooth/src/transport.ts index 4e58f0d..77925f7 100644 --- a/packages/web-bluetooth/src/transport.ts +++ b/packages/web-bluetooth/src/transport.ts @@ -32,6 +32,30 @@ export interface TransportProfile { gatt: DriverGattProfile; } +/** + * What a connected device exposes — identifiers and GATT layout, no measurement data. Feeds the + * in-app device report, so a user can tell us how an unconfirmed meter behaves. + */ +export interface GattDescription { + name: string | null; + service: string | null; // the matched driver service + characteristics: { uuid: string; properties: string[] }[]; + deviceInfo: Record; // Device Information Service strings, when the meter has one +} + +// Device Information Service (0x180a) strings worth reporting. +const DEVICE_INFO_STRINGS: Record = { + manufacturer: 0x2a29, + model: 0x2a24, + hardware: 0x2a27, + firmware: 0x2a26, + software: 0x2a28, +}; + +const CHAR_FLAGS = ['notify', 'indicate', 'read', 'write', 'writeWithoutResponse'] as const; +const charFlags = (c: BluetoothRemoteGATTCharacteristic): string[] => + CHAR_FLAGS.filter(k => c.properties[k]); + const registryProfiles = (): TransportProfile[] => drivers.map(d => ({ id: d.id, gatt: d.gatt })); export class Transport { @@ -44,6 +68,7 @@ export class Transport { private writeChar: BluetoothRemoteGATTCharacteristic | undefined; private profile?: DriverGattProfile; // the matched driver's GATT profile (for reconnect) private matchedId?: string; + private chars: BluetoothRemoteGATTCharacteristic[] = []; static get supported(): boolean { return typeof navigator !== 'undefined' && !!navigator.bluetooth; @@ -53,6 +78,11 @@ export class Transport { return this.device?.name; } + /** True once the user picked a device in the chooser (false if they dismissed it). */ + get chosen(): boolean { + return !!this.device; + } + get connected(): boolean { return !!this.server?.connected; } @@ -141,14 +171,9 @@ export class Transport { this.matchedId = chosen.id; const chars = await svc.getCharacteristics(); + this.chars = chars; dbg(`matched id=${chosen.id} service=${chosen.gatt.service}; characteristics:`); - for (const c of chars) { - const p = c.properties; - const flags = (['notify', 'indicate', 'read', 'write', 'writeWithoutResponse'] as const) - .filter(k => p[k]) - .join(','); - dbg(` ${c.uuid} [${flags}]`); - } + for (const c of chars) dbg(` ${c.uuid} [${charFlags(c).join(',')}]`); // Prefer the profile's UUIDs; fall back to characteristic properties so a firmware // reshuffle doesn't strand us. this.notifyChar = @@ -166,6 +191,38 @@ export class Transport { this.notifyChar.addEventListener('characteristicvaluechanged', this.handleValue); } + /** + * Identifiers + GATT layout of the chosen device, for the device report. The Device Information + * reads are best-effort and on demand (only when the user opens a report), so they never sit in + * the connect path. + */ + async describe(): Promise { + const deviceInfo: Record = {}; + if (this.server?.connected) { + try { + const dis = await this.server.getPrimaryService(DEVICE_INFO_SERVICE); + // Sequential: Android rejects concurrent GATT operations. + for (const [key, uuid] of Object.entries(DEVICE_INFO_STRINGS)) { + try { + const v = await (await dis.getCharacteristic(uuid)).readValue(); + const text = new TextDecoder().decode(v).replace(/\0+$/, '').trim(); + if (text) deviceInfo[key] = text; + } catch { + /* this string isn't exposed */ + } + } + } catch { + /* no Device Information Service */ + } + } + return { + name: this.device?.name ?? null, + service: this.profile?.service ?? null, + characteristics: this.chars.map(c => ({ uuid: c.uuid, properties: charFlags(c) })), + deviceInfo, + }; + } + private handleValue = (e: Event): void => { const dv = (e.target as BluetoothRemoteGATTCharacteristic).value; if (!dv) return;