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
183 changes: 183 additions & 0 deletions lib/diagnostics/eventReportSchedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Reports on specific events the owner turned on (Settings → Advanced), such as
* a charger that could not identify the connected car.
*
* Checked every few minutes ({@link EVENT_REPORT_CHECK_INTERVAL_MS}) from a
* while after start ({@link EVENT_REPORT_FIRST_CHECK_DELAY_MS}), over the device events kept in the
* last {@link EVENT_REPORT_LOOKBACK_MS} (`deviceEventStore.ts`). A report waits
* those minutes on purpose: it then carries what followed the event too.
* Which events send a report, what it is about and what it says lives in
* {@link REPORTED_EVENTS}; add an event there.
*
* One report per event per device a day, and at most
* {@link EVENT_REPORTS_MAX_PER_DAY} attempts a day in all: one that could not
* go out for good, such as on an install that cannot send, counts too. What was sent lives in the
* userdata database and is written before sending, so a restart, or a charger
* that keeps failing, never sends the same report twice. One that could not go
* out is tried again on a later check while its event is still in the lookback.
*/
import type {
SupportReportDeviceEvent, SupportReportErrorCode, SupportReportRequest, SupportReportResponse,
} from '../../packages/contracts/src/supportReport';
import { readEventDiagnosticsReportsEnabled } from '../../packages/shared-domain/src/settings/eventDiagnosticsReports';
import { isUnknownRecord } from '../../packages/shared-domain/src/recordGuards';
import type { HomeMembershipPort } from '../home/membership';
import { getLogger } from '../logging/logger';
import type { SettingsPort } from '../ports/homeyRuntime';
import type { UserdataDatabase } from '../store/userdataDatabase';
import { EVENT_DIAGNOSTICS_REPORTS } from '../utils/settingsKeys';
import type { DeviceEventStore } from './deviceEventStore';

const MINUTE_MS = 60 * 1000;
const DAY_MS = 24 * 60 * MINUTE_MS;
export const EVENT_REPORT_CHECK_INTERVAL_MS = 5 * MINUTE_MS;
/**
* Like the daily report, a while after start so a report never captures a
* half-started app, and two minutes off its hourly check, so the two reports
* are not captured together and the second refused for the minute between sends.
*/
export const EVENT_REPORT_FIRST_CHECK_DELAY_MS = 17 * MINUTE_MS;
/** Covers the first check after start and several later ones, so a report Homey refused is tried again. */
export const EVENT_REPORT_LOOKBACK_MS = 30 * MINUTE_MS;
export const EVENT_REPORTS_MAX_PER_DAY = 6;
const logger = getLogger('diagnostics/event-report');

/** One report an event asks for: about which device, when it happened, and what to say. */
type EventReport = {
/** One report per this a day: the event and its device. */
key: string;
event: string;
deviceId: string;
occurredAtMs: number;
problem: string;
};

const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);

/**
* The events that send a report, each turning a kept event into the report
* it asks for; an event without the fields its report needs asks for none.
*/
export const REPORTED_EVENTS: ReadonlyMap<string, (event: SupportReportDeviceEvent) => EventReport[]> = new Map([
// A charger's window closed with no car identified (`notes/ev-car-link/README.md`).
['ev_car_link_unmatched', (event: SupportReportDeviceEvent): EventReport[] => {
const chargerId = text(event.details.chargerId);
if (chargerId === undefined) return [];
const { connection } = event.details;
const pluggedInAtMs = isUnknownRecord(connection) && typeof connection.atMs === 'number'
? connection.atMs : event.atMs;
const reason = text(event.details.reason);
return [{
key: `${event.event}|${chargerId}`,
event: event.event,
deviceId: chargerId,
occurredAtMs: pluggedInAtMs,
problem: `Sent because ${text(event.details.chargerName) ?? chargerId} was plugged in and PELS could not `
+ `identify the connected car${reason === undefined ? '' : ` (${reason})`}. `
+ 'The owner turned on reports on specific events.',
}];
}],
]);

/** A report that did not go out, and may on a later check. */
const TRIED_AGAIN: ReadonlySet<SupportReportErrorCode> = new Set<SupportReportErrorCode>([
'report_busy', 'capture_failed', 'sender_unavailable',
]);

export type EventReportStateStore = {
/** Whether this event's report for this device went out, or may have, in the last day. */
hasSent(key: string): boolean;
countSentSince(fromMs: number): number;
markSent(key: string, atMs: number): void;
unmarkSent(key: string): void;
forgetBefore(beforeMs: number): void;
};

const SCHEMA = `
CREATE TABLE IF NOT EXISTS event_report_sent (
key TEXT PRIMARY KEY,
sent_at_ms INTEGER NOT NULL
);
`;

export const createEventReportStateStore = (db: UserdataDatabase): EventReportStateStore => {
db.exec(SCHEMA);
const read = db.prepare('SELECT 1 FROM event_report_sent WHERE key = ?');
const count = db.prepare('SELECT COUNT(*) AS sent FROM event_report_sent WHERE sent_at_ms >= ?');
const write = db.prepare('INSERT INTO event_report_sent (key, sent_at_ms) VALUES (?, ?) '
+ 'ON CONFLICT (key) DO UPDATE SET sent_at_ms = excluded.sent_at_ms');
const remove = db.prepare('DELETE FROM event_report_sent WHERE key = ?');
const expire = db.prepare('DELETE FROM event_report_sent WHERE sent_at_ms < ?');
return {
hasSent: (key) => read.get(key) !== undefined,
countSentSince: (fromMs) => {
const value = count.get(fromMs)?.sent;
return typeof value === 'number' ? value : 0;
},
markSent: (key, atMs) => { write.run(key, atMs); },
unmarkSent: (key) => { remove.run(key); },
forgetBefore: (beforeMs) => { expire.run(beforeMs); },
};
};

const outcomeOf = (response: SupportReportResponse): { triedAgain: boolean; outcome: string } => {
if (response.ok) {
const { state } = response.report.submission;
return { triedAgain: state === 'failed', outcome: state };
}
return { triedAgain: TRIED_AGAIN.has(response.code), outcome: response.code };
};

export class EventReportSchedule {
private running = false;

constructor(
private readonly settings: SettingsPort,
private readonly state: EventReportStateStore,
private readonly events: Pick<DeviceEventStore, 'read'>,
private readonly membership: Pick<HomeMembershipPort, 'getHomeIdForDevice'>,
private readonly reports: {
sendOnEvent(request: SupportReportRequest, stillWanted: () => boolean): Promise<SupportReportResponse>;
},
) {}

private isOn(): boolean {
return readEventDiagnosticsReportsEnabled(this.settings.get(EVENT_DIAGNOSTICS_REPORTS));
}

/**
* Send the report for the first kept event in the lookback that asks for one
* not sent today, when the owner turned these reports on. One per check: the
* report service sends one a minute. Never throws.
*/
async check(nowMs: number): Promise<void> {
if (this.running) return;
this.running = true;
try {
if (!this.isOn()) return;
this.state.forgetBefore(nowMs - DAY_MS);
if (this.state.countSentSince(nowMs - DAY_MS) >= EVENT_REPORTS_MAX_PER_DAY) return;
const due = this.events.read('all', nowMs - EVENT_REPORT_LOOKBACK_MS, nowMs)
.flatMap((event) => REPORTED_EVENTS.get(event.event)?.(event) ?? [])
.find((report) => !this.state.hasSent(report.key));
if (!due) return;
const request: SupportReportRequest = {
subject: { kind: 'device', homeId: this.membership.getHomeIdForDevice(due.deviceId), deviceId: due.deviceId },
problem: due.problem,
expected: '',
occurredAtMs: due.occurredAtMs,
historyWindow: 'incident',
};
this.state.markSent(due.key, nowMs);
const { triedAgain, outcome } = outcomeOf(await this.reports.sendOnEvent(request, () => this.isOn()));
if (triedAgain) this.state.unmarkSent(due.key);
logger.info({ event: 'event_report_attempted', reportedEvent: due.event, deviceId: due.deviceId, outcome });
} catch (error) {
logger.warn({
event: 'event_report_check_failed', reason: error instanceof Error ? error.message : String(error),
});
} finally {
this.running = false;
}
}
}
45 changes: 32 additions & 13 deletions lib/diagnostics/supportReportService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ const countToLeaveOut = (items: readonly unknown[], excessBytes: number): number
};

/**
* The document with the oldest of its log lines and, in a daily report, of its
* activity left out, the fewest that make the message fit, each saying how
* many; `null` when it is too large without them. An owner's report keeps all
* its activity: the owner can choose one device or a shorter window instead.
* The document with the oldest of its log lines and, in a report nobody pressed
* Send for, of its activity left out, the fewest that make the message fit,
* each saying how many; `null` when it is too large without them. An owner's
* report keeps all its activity: the owner can choose one device or a shorter
* window instead.
*/
const fitToSize = (document: SupportReportDocument): FittedMessage | null => {
const whole = toMessage(document);
Expand All @@ -61,7 +62,7 @@ const fitToSize = (document: SupportReportDocument): FittedMessage | null => {
const logLeftOut = countToLeaveOut(logLines, whole.bytes - SUPPORT_REPORT_MAX_BYTES);
const stillExcess = whole.bytes - SUPPORT_REPORT_MAX_BYTES
- logLines.slice(0, logLeftOut).reduce((sum, line) => sum + jsonBytes(line), 0);
const activityLeftOut = document.trigger === 'daily' && activity.state === 'available' && stillExcess > 0
const activityLeftOut = document.trigger !== 'owner' && activity.state === 'available' && stillExcess > 0
? countToLeaveOut([...activity.entries].reverse(), stillExcess) : 0;
const trimmed: SupportReportDocument = {
...document,
Expand All @@ -79,8 +80,9 @@ const fitToSize = (document: SupportReportDocument): FittedMessage | null => {

/**
* Owns prepared snapshots and dispatch outcomes. A submission starts only from
* the owner's Send, or from the daily report the owner turned on
* (`dailyReportSchedule.ts`); no incident starts one on its own.
* the owner's Send, or from a report the owner turned on: the daily report
* (`dailyReportSchedule.ts`) or reports on specific events
* (`eventReportSchedule.ts`).
*/
export class SupportReportService {
private readonly reports = new Map<string, PreparedSupportReport>();
Expand All @@ -106,17 +108,34 @@ export class SupportReportService {
* nothing.
*/
async sendDaily(stillWanted: () => boolean): Promise<SupportReportResponse> {
const availability = await this.transport.availability();
if (availability !== 'available') {
return { ok: false, code: availability === 'app_store_required' ? 'app_store_required' : 'sender_unavailable' };
}
const prepared = await this.prepareRequest({
return this.sendUnattended({
subject: { kind: 'app', homeId: MAIN_HOME_ID },
problem: DAILY_REPORT_PROBLEM,
expected: '',
occurredAtMs: Date.now(),
historyWindow: 'day',
}, 'daily');
}, 'daily', stillWanted);
}

/**
* A report on a specific event the owner asked to be told about, prepared and
* sent in one step, like the daily report. Only the owner's switch calls this
* (`eventReportSchedule.ts`), which also says what the report is about.
*/
async sendOnEvent(request: SupportReportRequest, stillWanted: () => boolean): Promise<SupportReportResponse> {
return this.sendUnattended(request, 'event', stillWanted);
}

private async sendUnattended(
request: SupportReportRequest,
trigger: Exclude<SupportReportTrigger, 'owner'>,
stillWanted: () => boolean,
): Promise<SupportReportResponse> {
const availability = await this.transport.availability();
if (availability !== 'available') {
return { ok: false, code: availability === 'app_store_required' ? 'app_store_required' : 'sender_unavailable' };
}
const prepared = await this.prepareRequest(request, trigger);
if (!prepared.ok) return prepared;
if (!stillWanted()) return { ok: false, code: 'consent_required' };
return this.send(prepared.report.document.id, { consent: true });
Expand Down
1 change: 1 addition & 0 deletions lib/utils/settingsKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
export {
CAPACITY_ENABLED,
DAILY_DIAGNOSTICS_REPORT,
EVENT_DIAGNOSTICS_REPORTS,
GRID_IMPORT_ENABLED,
GRID_IMPORT_LIMIT_KW,
CAPACITY_LIMIT_KW,
Expand Down
3 changes: 3 additions & 0 deletions notes/ev-car-link/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,9 @@ written for it:
meanwhile (owner, 2026-10-25), so only the owner's confirmation links it. On 2026-10-24
a Polestar's app reported its plug-in 12 h 35 min after the charger did.

When the owner turned on reports on specific events, `ev_car_link_unmatched` also sends a
report about the charger a few minutes later (`notes/support-reports.md`).

Connection times say how they were seen: `plug_in`, or `already_plugged_in_at_start`
when the charger was plugged in when PELS started, so the time is the start, not the
plug-in, and a lateness measured from it is too short.
2 changes: 1 addition & 1 deletion notes/settings-key-ownership.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ from outside PELS.
Keys with owners so far: `mode_aliases`, `mode_device_targets`, `pv_forecast_source`,
`ev_charge_target_settings`,
`homey_price_formula`, `price_scheme` + `powerhour_device_id`, `web_app_cloud_host`,
`daily_diagnostics_report`.
`daily_diagnostics_report`, `event_diagnostics_reports`.
`price_optimization_settings` has one shared parser for device entries, legacy defaults,
and provenance in `packages/shared-domain/src/settings/priceOptimization.ts`.
The same owner defines the adjustment range and stored sign. The runtime and settings
Expand Down
34 changes: 27 additions & 7 deletions notes/support-reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ when the user selects **Send report**, and the settings UI sends it straight awa
The UI shows the user's own description, not the captured report, which is not
written for people to read.

The only other sender is the daily report, and only when the owner turned it on
(Settings → Advanced → Diagnostics reports, the `daily_diagnostics_report` setting;
off by default, offered on every install). Homey files every report as submitted by
the user, and the switch is what makes that true of a report nobody pressed Send for
(owner, 2026-10-25). No incident sends a report on its own, and there is no analytics
collection. A report says which it is: `trigger` is `owner` or `daily`.
The only other senders are the daily report and reports on specific events, each only
when the owner turned it on (Settings → Advanced → Diagnostics reports: the
`daily_diagnostics_report` and `event_diagnostics_reports` settings; off by default,
offered on every install). Homey files every report as submitted by the user, and the
switch is what makes that true of a report nobody pressed Send for (owner, 2026-10-25).
There is no analytics collection. A report says which it is: `trigger` is `owner`,
`daily` or `event`.

Reports contain the description, incident time, selected home/device, an explicitly
selected slice of persisted device activity, the device events PELS kept, PELS's
Expand Down Expand Up @@ -77,7 +78,8 @@ together about 190 KiB. The kept log comes on top. A report that would
outgrow the limit leaves out the oldest log lines first and says how many. An owner's
report still too large without any log is rejected, and no other evidence is cut: the
owner can choose one device or a shorter window. Nobody can do that for the daily
report, so it then leaves out its oldest activity too, and says how many. A versioned opening marker and a closing
report or a report on an event, so they then leave out their oldest activity too, and
say how many. A versioned opening marker and a closing
marker containing the report identifier let received content be checked for truncation.

At most four reports are retained in memory for 15 minutes. Preparing another
Expand Down Expand Up @@ -106,3 +108,21 @@ day, and an uncertain one is never sent again. One Homey refused or that could n
prepared tries again 3 hours later. An install that cannot send at all (anything but
an App Store install) captures nothing and checks again a day later. The switch is
read again once the report is prepared, so turning it off meanwhile sends nothing.

## Reports on specific events

A report about the device an event concerns, from an hour before the event through
capture, saying what happened (`SupportReportService.sendOnEvent`). The events that send
one, and what each report is about, are listed in `REPORTED_EVENTS` in
`lib/diagnostics/eventReportSchedule.ts`; a new event is one entry there. Today that is
`ev_car_link_unmatched`, a charger whose window closed with no car identified, reported
about the charger as of its plug-in.

The schedule checks every 5 minutes from 17 minutes after start (two minutes off the daily
report's hourly check), over the device events kept in the last 30 minutes, so a report
also carries what followed its event. One report per event and device a day, and at most
6 attempts a day in all. What was sent lives in the
userdata database and is written before sending, so a restart or a charger that keeps
failing never sends one twice; one that could not go out (busy, refused, unprepared) is
tried again on a later check while its event is in the lookback. An uncertain one is
never sent again. The switch is read again once the report is prepared.
7 changes: 5 additions & 2 deletions packages/contracts/src/supportReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,11 @@ export type SupportReportEvidence = {
omissions: readonly string[];
};

/** `owner`: the owner sent it; `daily`: the daily report the owner turned on. */
export type SupportReportTrigger = 'owner' | 'daily';
/**
* `owner`: the owner sent it; `daily`: the daily report the owner turned on;
* `event`: a report on a specific event, which the owner also turned on.
*/
export type SupportReportTrigger = 'owner' | 'daily' | 'event';

/** Immutable snapshot prepared for one submission. */
export type SupportReportDocument = SupportReportEvidence & {
Expand Down
7 changes: 7 additions & 0 deletions packages/settings-ui/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,13 @@ <h3 class="section-title">Diagnostics reports</h3>
<small class="field__hint">Once a day, PELS sends the developer a report like Report a problem does, covering the last day.</small>
</span>
</div>
<div class="md-switch-row">
<md-switch id="advanced-event-reports" disabled aria-labelledby="advanced-event-reports-label"></md-switch>
<span class="md-switch-row__content">
<span class="md-switch-row__label pels-text-settings-label" id="advanced-event-reports-label">Send a report on specific events</span>
<small class="field__hint">When PELS sees something go wrong, such as a charger that could not identify the connected car, it sends the developer a report about it a few minutes later.</small>
</span>
</div>
</div>
</details>
<details class="settings-collapse">
Expand Down
4 changes: 2 additions & 2 deletions packages/settings-ui/src/ui/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
waitForHomey,
} from './homey.ts';
import { showToast, showToastError } from './toast.ts';
import { initDailyReportSwitch } from './dailyReportSwitch.ts';
import { initDiagnosticsReportSwitches } from './diagnosticsReportSwitches.ts';
import { refreshDevices, renderDevices } from './devices.ts';
import { refreshPowerData } from './uiRefreshTasks.ts';
import {
Expand Down Expand Up @@ -534,7 +534,7 @@ const initializeBootHandlers = () => {
}
initDebugLoggingSwitches();
initAdvancedHandlers();
void initDailyReportSwitch();
void initDiagnosticsReportSwitches();
markSettingsUi('boot:handlers-ready');
};

Expand Down
Loading
Loading