Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/ISSUE_TEMPLATE/connection-problem.yml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions .github/ISSUE_TEMPLATE/device-report.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -376,6 +377,8 @@ export default function App() {
{announcement}
</div>

<ReportToast meters={meters} />

<ShortcutsHelp open={helpOpen} onClose={() => setHelpOpen(false)} />
</div>
);
Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/components/ReportToast.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<typeof vi.fn> } = {},
): 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(<ReportToast meters={meters([channel()])} />);
expect(container.firstChild).toBeNull();
rerender(<ReportToast meters={meters([channel({ state: 'live', driverId: 'uni-t' })])} />);
expect(container.firstChild).toBeNull();
});

it('offers a connection-problem report after the chooser is dismissed, until dismissed', () => {
const { container } = render(<ReportToast meters={meters([channel({ cancelled: true })])} />);
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(<ReportToast meters={meters(live, { describe })} />);
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(<ReportToast meters={meters(live, { describe })} />);
expect(again.container.firstChild).toBeNull();
});

it('never asks about demo meters', () => {
const { container } = render(
<ReportToast
meters={meters([channel({ state: 'live', driverId: 'ut181a' })], { isDemo: true })}
/>,
);
expect(container.firstChild).toBeNull();
});
});
120 changes: 120 additions & 0 deletions apps/web/src/components/ReportToast.tsx
Original file line number Diff line number Diff line change
@@ -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<Set<string>>(() => 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 (
<Toast onDismiss={finish}>
<p>
<strong className="font-semibold text-zinc-100">{name(unconfirmed)}</strong> is{' '}
{verification(driverId)?.text}. Does it read right?
</p>
<button type="button" onClick={() => void report()} className={ACTION}>
Report it
</button>
</Toast>
);
}

if (problem && !problemDismissed) {
return (
<Toast onDismiss={() => setProblemDismissed(true)}>
<p>Meter not in the list, or won't connect?</p>
<a
href={connectionProblemUrl(problem.error)}
target="_blank"
rel="noopener noreferrer"
onClick={() => setProblemDismissed(true)}
className={ACTION}
>
Tell us which one
</a>
</Toast>
);
}

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 (
<div
role="status"
className="fixed inset-x-4 bottom-4 z-40 ml-auto max-w-sm rounded-lg border border-zinc-700 bg-zinc-900 p-3 pr-9 text-sm text-zinc-300 shadow-xl"
>
{children}
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
title="Dismiss"
className="absolute right-1.5 top-1.5 rounded-md p-1.5 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300"
>
<span aria-hidden="true">✕</span>
</button>
</div>
);
}
39 changes: 39 additions & 0 deletions apps/web/src/lib/report.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading