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?
+