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
59 changes: 32 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A modern beekeeping management application designed for both mobile and desktop
- **Multilingual**: Available in multiple languages with community translations
- **Data Portability**: Import and export your data — your records stay yours
- **Open Source & Self-Hostable**: MIT-licensed and free to run on your own hardware
- **HiveScale Integration**: Claim and monitor self-hosted HiveScale devices, including weight, temperature, battery, solar, and cellular telemetry
- **HiveHub Integration**: Claim and monitor self-hosted HiveHub (formerly HiveScale) devices with up to 18 hives each — weight, in-hive climate, traffic, sound, power and connectivity — plus inspection mode, audio recordings, firmware management and alert emails

## Getting Started

Expand Down Expand Up @@ -99,31 +99,36 @@ The application will be available at http://localhost.

#### Optional

| Variable | Description |
| --------------------------- | ------------------------------------------------------------------------------ |
| `PASSKEY_RP_ID` | WebAuthn relying-party ID for passkeys (default: `localhost`) |
| `COOKIE_DOMAIN` | Set in production when frontend and backend share a parent domain |
| `MAIL_PROVIDER` | Force a specific provider: `resend`, `smtp`, or `none` (auto-selects if unset) |
| `RESEND_API_KEY` | API key for Resend email provider |
| `SMTP_HOST` | SMTP server hostname |
| `SMTP_PORT` | SMTP server port |
| `SMTP_USER` | SMTP username |
| `SMTP_PASS` | SMTP password |
| `SMTP_SECURE` | Use TLS (`true` for port 465, `false` for 587) |
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid TLS certificates (default `true`; set `false` for self-signed) |
| `FROM_EMAIL` | Sender email address |
| `STORAGE_TYPE` | `s3` (default) or `local` for filesystem storage |
| `STORAGE_LOCAL_PATH` | Directory for local file storage (default: `/data/uploads`) |
| `S3_ENDPOINT` | S3-compatible endpoint URL |
| `S3_REGION` | S3 region (default: `us-east-1`) |
| `S3_BUCKET` | S3 bucket name |
| `S3_ACCESS_KEY_ID` | S3 access key |
| `S3_SECRET_ACCESS_KEY` | S3 secret key |
| `SENTRY_DSN` | Backend Sentry DSN |
| `VITE_SENTRY_DSN` | Frontend Sentry DSN |
| `VITE_SENTRY_ENVIRONMENT` | Frontend Sentry environment |
| `HIVESCALE_API_BASE_URL` | Base URL of the HiveScale backend for scale integration |
| `HIVESCALE_SERVICE_API_KEY` | Shared service key used by HivePal to call HiveScale |
| Variable | Description |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `PASSKEY_RP_ID` | WebAuthn relying-party ID for passkeys (default: `localhost`) |
| `COOKIE_DOMAIN` | Set in production when frontend and backend share a parent domain |
| `MAIL_PROVIDER` | Force a specific provider: `resend`, `smtp`, or `none` (auto-selects if unset) |
| `RESEND_API_KEY` | API key for Resend email provider |
| `SMTP_HOST` | SMTP server hostname |
| `SMTP_PORT` | SMTP server port |
| `SMTP_USER` | SMTP username |
| `SMTP_PASS` | SMTP password |
| `SMTP_SECURE` | Use TLS (`true` for port 465, `false` for 587) |
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid TLS certificates (default `true`; set `false` for self-signed) |
| `FROM_EMAIL` | Sender email address |
| `STORAGE_TYPE` | `s3` (default) or `local` for filesystem storage |
| `STORAGE_LOCAL_PATH` | Directory for local file storage (default: `/data/uploads`) |
| `S3_ENDPOINT` | S3-compatible endpoint URL |
| `S3_REGION` | S3 region (default: `us-east-1`) |
| `S3_BUCKET` | S3 bucket name |
| `S3_ACCESS_KEY_ID` | S3 access key |
| `S3_SECRET_ACCESS_KEY` | S3 secret key |
| `SENTRY_DSN` | Backend Sentry DSN |
| `VITE_SENTRY_DSN` | Frontend Sentry DSN |
| `VITE_SENTRY_ENVIRONMENT` | Frontend Sentry environment |
| `HIVEHUB_API_BASE_URL` | Base URL of the HiveHub backend (legacy name `HIVESCALE_API_BASE_URL`) |
| `HIVEHUB_SERVICE_API_KEY` | Service key HivePal sends to HiveHub; must equal HiveHub's `HIVEPAL_SERVICE_API_KEY` (legacy name `HIVESCALE_SERVICE_API_KEY`) |
| `JWT_SECRET` | Signs the user token forwarded to HiveHub; must equal HiveHub's `HIVEPAL_JWT_SECRET`. Required for the HiveHub integration |
| `HIVESCALE_SD_IMPORT_MAX_FILE_SIZE` | Max HiveHub SD-import upload in bytes (default 250 MB) |
| `HIVEHUB_FIRMWARE_MAX_FILE_SIZE` | Max HiveHub firmware upload in bytes (default 16 MB) |

HiveHub alert emails use the configured mail provider and link to `FRONTEND_URL`. See [`apps/hivescale/hivescale-integration.md`](apps/hivescale/hivescale-integration.md) for the full HiveHub setup.

## Development

Expand All @@ -150,7 +155,7 @@ pnpm dev
/apps
/frontend - React frontend application
/backend - NestJS API server
/hivescale - HiveScale integration notes and compose overlay
/hivescale - HiveHub integration notes and a compose file for HivePal with the HiveHub variables
/e2e - End-to-end tests with Playwright
/packages
/shared-schemas - Shared Zod validation schemas
Expand Down
20 changes: 10 additions & 10 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@
- [ ] Compact, single-column layouts for mobile


## HiveScale Integration
## HiveHub Integration

- [x] Proxy HiveScale backend routes through the HivePal backend
- [x] Add HiveScale page for claiming devices and viewing latest measurements
- [x] Add scale channel naming for mapping Scale 1 / Scale 2 to hive names
- [x] Display off-grid telemetry fields from HiveScale measurements
- [x] Chart battery, solar, and cellular telemetry when available
- [ ] Verify HivePal calibration-mode controls against the deployed HiveScale backend routes
- [ ] Add user-facing screenshots to the HiveScale documentation
- [ ] Add explicit empty states for partially configured off-grid devices
- [x] Proxy HiveHub backend routes through the HivePal backend
- [x] HiveHub page for claiming devices and viewing latest measurements
- [x] Name all 18 hive slots and link them to HivePal hives (stored in HiveHub)
- [x] Display off-grid telemetry (battery, solar, signal) on the General card and Health tab
- [x] Chart battery and solar telemetry when available
- [x] Calibration-mode controls against the HiveHub calibration routes
- [x] Inspection mode (start/stop, history, chart shading, prompt from HivePal inspections)
- [x] Replace the HivePal swarm alert with HiveHub insight alert emails
- [ ] Add user-facing screenshots to the HiveHub documentation
- [ ] Add alerts for low battery, missing solar monitor, and poor cellular signal
- [ ] swarm alert integration
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "HiveHubAlertNotification" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"alertId" INTEGER NOT NULL,
"severity" TEXT NOT NULL,
"notifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "HiveHubAlertNotification_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "HiveHubAlertNotification_userId_deviceId_idx" ON "HiveHubAlertNotification"("userId", "deviceId");

-- CreateIndex
CREATE UNIQUE INDEX "HiveHubAlertNotification_userId_deviceId_alertId_key" ON "HiveHubAlertNotification"("userId", "deviceId", "alertId");

-- AddForeignKey
ALTER TABLE "HiveHubAlertNotification" ADD CONSTRAINT "HiveHubAlertNotification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
18 changes: 18 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ model User {
createdActions Action[] @relation("ActionCreator")
createdQuickChecks QuickCheck[] @relation("QuickCheckCreator")
createdWorkerTokens WorkerToken[] @relation("WorkerTokenCreator")
hiveHubAlertNotifications HiveHubAlertNotification[]

// Better Auth relations
sessions Session[]
Expand Down Expand Up @@ -1137,3 +1138,20 @@ enum AccountTransferJobStatus {
COMPLETED
FAILED
}

// Which HiveHub insight alerts a user has already been emailed about, so the
// alert job only mails an alert when it is new or has escalated. `alertId` is
// the id of the alert occurrence in HiveHub's insight history (a recurrence of
// the same detector gets a fresh id).
model HiveHubAlertNotification {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deviceId String
alertId Int
severity String
notifiedAt DateTime @default(now())

@@unique([userId, deviceId, alertId])
@@index([userId, deviceId])
}
114 changes: 114 additions & 0 deletions apps/backend/src/hivescale/hivehub-alert.rules.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
resolveAlertPreferences,
selectAlertsToNotify,
severityRank,
} from './hivehub-alert.rules';
import type { HiveHubInsightHistoryAlert } from './hivescale.service';

function alert(
id: number,
severity: string,
overrides: Partial<HiveHubInsightHistoryAlert> = {},
): HiveHubInsightHistoryAlert {
return {
id,
alert_key: `swarm-imminent-ch${id}`,
category: 'swarm',
channel: 1,
severity,
peak_severity: severity,
title: 'Imminent swarm warning',
description: '',
confidence: 0.8,
first_seen_at: '2026-09-01T10:00:00Z',
last_seen_at: '2026-09-01T11:00:00Z',
resolved_at: null,
status: 'active',
...overrides,
};
}

describe('selectAlertsToNotify', () => {
it('mails new alerts at or above the minimum severity', () => {
const out = selectAlertsToNotify(
[alert(1, 'watch'), alert(2, 'warning'), alert(3, 'critical')],
new Map(),
'warning',
);
expect(out.map((o) => o.alert.id)).toEqual([3, 2]);
expect(out.every((o) => o.previousSeverity === null)).toBe(true);
});

it('does not mail an alert again at the same severity', () => {
const out = selectAlertsToNotify(
[alert(1, 'warning')],
new Map([[1, 'warning']]),
'watch',
);
expect(out).toEqual([]);
});

it('mails an alert again when it escalates', () => {
const out = selectAlertsToNotify(
[alert(1, 'critical')],
new Map([[1, 'warning']]),
'watch',
);
expect(out).toHaveLength(1);
expect(out[0].previousSeverity).toBe('warning');
});

it('uses the peak severity and ignores resolved rows', () => {
const out = selectAlertsToNotify(
[
alert(1, 'watch', { peak_severity: 'critical' }),
alert(2, 'critical', { status: 'resolved' }),
],
new Map(),
'critical',
);
expect(out.map((o) => o.alert.id)).toEqual([1]);
});
});

describe('resolveAlertPreferences', () => {
it('is off by default', () => {
expect(resolveAlertPreferences(null)).toEqual({
enabled: false,
minSeverity: 'warning',
});
});

it('keeps users who had the old swarm alert switched on', () => {
expect(
resolveAlertPreferences({ swarmAlert: { enabled: true } }).enabled,
).toBe(true);
});

it('prefers the new settings over the legacy flag', () => {
expect(
resolveAlertPreferences({
swarmAlert: { enabled: true },
hiveHubAlerts: { enabled: false, minSeverity: 'critical' },
}),
).toEqual({ enabled: false, minSeverity: 'critical' });
});

it('falls back to warning for an unknown minimum severity', () => {
expect(
resolveAlertPreferences({
hiveHubAlerts: { enabled: true, minSeverity: 'info' },
}).minSeverity,
).toBe('warning');
});
});

describe('severityRank', () => {
it('orders the HiveHub severities', () => {
expect(severityRank('info')).toBeLessThan(severityRank('watch'));
expect(severityRank('watch')).toBeLessThan(severityRank('warning'));
expect(severityRank('warning')).toBeLessThan(severityRank('critical'));
expect(severityRank('bogus')).toBe(0);
});
});
79 changes: 79 additions & 0 deletions apps/backend/src/hivescale/hivehub-alert.rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type {
HiveHubAlertPreferences,
HiveHubAlertSeverity,
} from 'shared-schemas';
import type { HiveHubInsightHistoryAlert } from './hivescale.service';

const SEVERITY_RANK: Record<string, number> = {
info: 1,
watch: 2,
warning: 3,
critical: 4,
};

export function severityRank(severity: string | null | undefined): number {
return SEVERITY_RANK[(severity ?? '').toLowerCase()] ?? 0;
}

export interface AlertToNotify {
alert: HiveHubInsightHistoryAlert;
/** Severity the user was last mailed about, or null for a new alert. */
previousSeverity: string | null;
}

/**
* Pick the active HiveHub alerts a user should be emailed about.
*
* An alert is mailed when it reaches `minSeverity` and is either new (never
* mailed) or has escalated past the severity last mailed. HiveHub gives a
* recurrence of the same detector a fresh history id, so a swarm warning that
* resolves and fires again next week is mailed again.
*/
export function selectAlertsToNotify(
active: HiveHubInsightHistoryAlert[],
notified: Map<number, string>,
minSeverity: HiveHubAlertPreferences['minSeverity'],
): AlertToNotify[] {
const threshold = severityRank(minSeverity);
const out: AlertToNotify[] = [];
for (const alert of active) {
if (alert.status !== 'active') continue;
const severity = alert.peak_severity || alert.severity;
const rank = severityRank(severity);
if (rank < threshold) continue;
const previous = notified.get(alert.id) ?? null;
if (previous !== null && rank <= severityRank(previous)) continue;
out.push({ alert, previousSeverity: previous });
}
// Most severe first, so the email subject names the worst one.
return out.sort(
(a, b) =>
severityRank(b.alert.peak_severity || b.alert.severity) -
severityRank(a.alert.peak_severity || a.alert.severity),
);
}

/**
* Alert preferences with defaults applied. Users who had the retired HivePal
* swarm check switched on keep getting alerts after the upgrade.
*/
export function resolveAlertPreferences(
preferences: unknown,
): HiveHubAlertPreferences {
const prefs = (preferences ?? {}) as {
hiveHubAlerts?: Partial<HiveHubAlertPreferences>;
swarmAlert?: { enabled?: boolean };
};
const minSeverity = prefs.hiveHubAlerts?.minSeverity;
return {
enabled: prefs.hiveHubAlerts?.enabled ?? prefs.swarmAlert?.enabled ?? false,
minSeverity:
minSeverity === 'watch' ||
minSeverity === 'warning' ||
minSeverity === 'critical'
? minSeverity
: 'warning',
};
}

export type { HiveHubAlertSeverity };
29 changes: 29 additions & 0 deletions apps/backend/src/hivescale/hivehub-alert.scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { HiveHubAlertService } from './hivehub-alert.service';

@Injectable()
export class HiveHubAlertScheduler {
private readonly logger = new Logger(HiveHubAlertScheduler.name);
private running = false;

constructor(private readonly hiveHubAlertService: HiveHubAlertService) {}

/**
* HiveHub's insight reconciler refreshes alerts every 15 minutes by default
* (INSIGHTS_RECONCILE_INTERVAL_SECONDS), so checking more often finds nothing
* new.
*/
@Cron('0 */15 * * * *')
async handleAlertCheck() {
if (this.running) return;
this.running = true;
try {
await this.hiveHubAlertService.checkAllUsers();
} catch (error) {
this.logger.error('HiveHub alert scheduler encountered an error', error);
} finally {
this.running = false;
}
}
}
Loading
Loading