diff --git a/README.md b/README.md index ead70119f..5c3b0efd3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/TODO.md b/TODO.md index 9f7778308..b4fc80196 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/apps/backend/prisma/migrations/20260929120000_add_hivehub_alert_notifications/migration.sql b/apps/backend/prisma/migrations/20260929120000_add_hivehub_alert_notifications/migration.sql new file mode 100644 index 000000000..0630b6224 --- /dev/null +++ b/apps/backend/prisma/migrations/20260929120000_add_hivehub_alert_notifications/migration.sql @@ -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; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index d3c63e80a..1f6ceec99 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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[] @@ -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]) +} diff --git a/apps/backend/src/hivescale/hivehub-alert.rules.spec.ts b/apps/backend/src/hivescale/hivehub-alert.rules.spec.ts new file mode 100644 index 000000000..4663580a3 --- /dev/null +++ b/apps/backend/src/hivescale/hivehub-alert.rules.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/backend/src/hivescale/hivehub-alert.rules.ts b/apps/backend/src/hivescale/hivehub-alert.rules.ts new file mode 100644 index 000000000..d7b7a7304 --- /dev/null +++ b/apps/backend/src/hivescale/hivehub-alert.rules.ts @@ -0,0 +1,79 @@ +import type { + HiveHubAlertPreferences, + HiveHubAlertSeverity, +} from 'shared-schemas'; +import type { HiveHubInsightHistoryAlert } from './hivescale.service'; + +const SEVERITY_RANK: Record = { + 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, + 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; + 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 }; diff --git a/apps/backend/src/hivescale/hivehub-alert.scheduler.ts b/apps/backend/src/hivescale/hivehub-alert.scheduler.ts new file mode 100644 index 000000000..a32ea7489 --- /dev/null +++ b/apps/backend/src/hivescale/hivehub-alert.scheduler.ts @@ -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; + } + } +} diff --git a/apps/backend/src/hivescale/hivehub-alert.service.ts b/apps/backend/src/hivescale/hivehub-alert.service.ts new file mode 100644 index 000000000..89f0cf0c2 --- /dev/null +++ b/apps/backend/src/hivescale/hivehub-alert.service.ts @@ -0,0 +1,170 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { MailService, HiveHubAlertEmailItem } from '../mail/mail.service'; +import { + HiveHubDevice, + HiveHubInsightHistoryAlert, + HiveScaleService, +} from './hivescale.service'; +import { + resolveAlertPreferences, + selectAlertsToNotify, +} from './hivehub-alert.rules'; + +/** + * Emails HiveHub insight alerts (swarm, robbing, queenless, winter risk, …) to + * the HivePal users who can see the device. + * + * HiveHub computes the alerts and keeps their lifecycle in its insight history; + * this job only decides who still needs to hear about which one. It replaces + * the old HivePal-side weight-drop "swarm alert", which duplicated the swarm + * detector with less information and only covered two scales. + */ +@Injectable() +export class HiveHubAlertService { + private readonly logger = new Logger(HiveHubAlertService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly hiveScaleService: HiveScaleService, + private readonly mailService: MailService, + ) {} + + async checkAllUsers(): Promise { + if (!this.hiveScaleService.isConfigured()) { + return; + } + + const users = await this.prisma.user.findMany({ + select: { + id: true, + email: true, + name: true, + role: true, + preferences: true, + }, + }); + + for (const user of users) { + const prefs = resolveAlertPreferences(user.preferences); + if (!prefs.enabled) continue; + try { + await this.checkUser(user, prefs.minSeverity); + } catch (error) { + this.logger.warn( + `HiveHub alert check failed for user ${user.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } + + private async checkUser( + user: { id: string; email: string; name: string | null; role: string }, + minSeverity: 'watch' | 'warning' | 'critical', + ): Promise { + const token = this.hiveScaleService.tokenFor(user); + const devices = await this.hiveScaleService.listDevices(token); + + const items: HiveHubAlertEmailItem[] = []; + const toRecord: Array<{ + deviceId: string; + alert: HiveHubInsightHistoryAlert; + }> = []; + + for (const device of devices) { + let active: HiveHubInsightHistoryAlert[]; + try { + const history = await this.hiveScaleService.getDeviceInsightsHistory( + token, + device.device_id, + { status: 'active', limit: 200 }, + ); + active = history.alerts ?? []; + } catch (error) { + this.logger.warn( + `Could not read insights for ${device.device_id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + continue; + } + + const previous = await this.prisma.hiveHubAlertNotification.findMany({ + where: { userId: user.id, deviceId: device.device_id }, + }); + const notified = new Map(previous.map((n) => [n.alertId, n.severity])); + + for (const { alert, previousSeverity } of selectAlertsToNotify( + active, + notified, + minSeverity, + )) { + items.push({ + deviceId: device.device_id, + deviceName: device.display_name || device.device_id, + hiveName: hiveName(device, alert.channel), + severity: alert.peak_severity || alert.severity, + previousSeverity, + category: alert.category, + title: alert.title, + description: alert.description, + confidence: alert.confidence, + firstSeenAt: alert.first_seen_at, + }); + toRecord.push({ deviceId: device.device_id, alert }); + } + + // Forget alerts that are no longer active so the table stays small. + const activeIds = active.map((alert) => alert.id); + await this.prisma.hiveHubAlertNotification.deleteMany({ + where: { + userId: user.id, + deviceId: device.device_id, + alertId: { notIn: activeIds }, + }, + }); + } + + if (items.length === 0) return; + + const sent = await this.mailService.sendHiveHubAlertEmail({ + email: user.email, + userName: user.name, + items, + }); + if (!sent) return; + + for (const { deviceId, alert } of toRecord) { + const severity = alert.peak_severity || alert.severity; + await this.prisma.hiveHubAlertNotification.upsert({ + where: { + userId_deviceId_alertId: { + userId: user.id, + deviceId, + alertId: alert.id, + }, + }, + create: { userId: user.id, deviceId, alertId: alert.id, severity }, + update: { severity, notifiedAt: new Date() }, + }); + } + this.logger.log(`Sent ${items.length} HiveHub alert(s) to user ${user.id}`); + } +} + +function hiveName( + device: HiveHubDevice, + channel: number | null, +): string | null { + if (!channel) return null; + const names = device.channels?.names ?? {}; + const legacy = + channel === 1 + ? device.channels?.scale_1 + : channel === 2 + ? device.channels?.scale_2 + : null; + return names[String(channel)] || legacy || `Hive ${channel}`; +} diff --git a/apps/backend/src/hivescale/hivescale.controller.ts b/apps/backend/src/hivescale/hivescale.controller.ts index 238af860e..b7a2ed160 100644 --- a/apps/backend/src/hivescale/hivescale.controller.ts +++ b/apps/backend/src/hivescale/hivescale.controller.ts @@ -5,28 +5,57 @@ import { Delete, Get, Param, + ParseIntPipe, Patch, Post, Query, Req, + Res, + StreamableFile, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { JwtService } from '@nestjs/jwt'; import { ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { + HIVEHUB_MAX_HIVES, + hiveHubCalibrationModeStartSchema, + hiveHubChannelsPatchSchema, + hiveHubClaimDeviceSchema, + hiveHubConfigPatchSchema, + hiveHubInspectionStartSchema, + hiveHubInspectionStopSchema, + hiveHubInspectionUpdateSchema, + hiveHubMeasurementDeleteSchema, + hiveHubMeasurementQuerySchema, + hiveHubRecordingRequestSchema, + hiveHubShareDeviceSchema, + hiveHubTempCompensationFitSchema, + type HiveHubCalibrationModeStart, + type HiveHubChannelsPatch, + type HiveHubClaimDevice, + type HiveHubConfigPatch, + type HiveHubInspectionStart, + type HiveHubInspectionStop, + type HiveHubInspectionUpdate, + type HiveHubMeasurementDelete, + type HiveHubMeasurementQuery, + type HiveHubRecordingRequest, + type HiveHubShareDevice, + type HiveHubTempCompensationFit, +} from 'shared-schemas'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequestWithUser } from '../auth/interface/request-with-user.interface'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; import { - HiveScaleCalibrationModeStartDto, - HiveScaleChannelsPatchDto, - HiveScaleClaimDeviceDto, - HiveScaleConfigPatchDto, - HiveScaleMeasurementQuery, + HIVEHUB_FIRMWARE_BOARDS, + HIVEHUB_FIRMWARE_TARGETS, + HiveHubStream, + HiveScaleFirmwareBoard, + HiveScaleFirmwareTarget, HiveScaleService, - HiveScaleShareDeviceDto, - HiveScaleTempCompensationFitDto, } from './hivescale.service'; // SD backup uploads are fully buffered in memory (file.buffer), so cap the @@ -36,39 +65,75 @@ const SD_IMPORT_MAX_FILE_SIZE = Number( process.env.HIVESCALE_SD_IMPORT_MAX_FILE_SIZE ?? 250 * 1024 * 1024, ); -// Firmware-relay commands target a sub-device paired in slot 1 or 2. Default to -// slot 1 when omitted and reject anything else. -function parseRelaySlot(raw?: string): 1 | 2 { +// Firmware images are buffered in memory too. HiveHub itself refuses anything +// over MAX_FIRMWARE_BYTES (16 MB by default), so reject bigger files here +// before they are read into memory and forwarded. +const FIRMWARE_MAX_FILE_SIZE = Number( + process.env.HIVEHUB_FIRMWARE_MAX_FILE_SIZE ?? 16 * 1024 * 1024, +); + +/** + * Firmware relays target the sub-device paired with one hive, 1..18. Default + * to hive 1 when omitted and reject anything else. + */ +export function parseRelaySlot(raw?: string): number { if (raw === undefined || raw === '') return 1; - if (raw === '1') return 1; - if (raw === '2') return 2; - throw new BadRequestException('slot must be 1 or 2'); + const slot = Number(raw); + if (!Number.isInteger(slot) || slot < 1 || slot > HIVEHUB_MAX_HIVES) { + throw new BadRequestException( + `slot must be a hive index between 1 and ${HIVEHUB_MAX_HIVES}`, + ); + } + return slot; +} + +/** Query/multipart booleans arrive as strings. */ +function parseFlag(raw?: string): boolean { + return raw === 'true' || raw === '1'; +} + +function optionalInt(raw?: string): number | undefined { + if (raw === undefined || raw === '') return undefined; + const value = Number(raw); + if (!Number.isInteger(value)) { + throw new BadRequestException(`Expected an integer, got "${raw}"`); + } + return value; +} + +/** Copy HiveHub's stream headers onto the reply and hand the body to Nest. */ +function toStreamableFile( + res: Response, + upstream: HiveHubStream, + fallbackFilename: string, +): StreamableFile { + res.setHeader('Content-Type', upstream.contentType); + if (upstream.contentLength) { + res.setHeader('Content-Length', upstream.contentLength); + } + res.setHeader( + 'Content-Disposition', + upstream.contentDisposition ?? `attachment; filename="${fallbackFilename}"`, + ); + res.setHeader('Cache-Control', 'no-store'); + return new StreamableFile(upstream.stream); } @ApiTags('hivescale') @Controller('hivescale') @UseGuards(JwtAuthGuard) export class HiveScaleController { - constructor( - private readonly hiveScaleService: HiveScaleService, - private readonly jwtService: JwtService, - ) {} + constructor(private readonly hiveScaleService: HiveScaleService) {} private extractToken(req: RequestWithUser): string { - const user = req.user; - return this.jwtService.sign({ - sub: user.id, - email: user.email, - role: user.role, - name: user.name, - passwordChangeRequired: user.passwordChangeRequired ?? false, - }); + return this.hiveScaleService.tokenFor(req.user); } @Post('devices/claim') claimDevice( @Req() req: RequestWithUser, - @Body() payload: HiveScaleClaimDeviceDto, + @Body(new ZodValidationPipe(hiveHubClaimDeviceSchema)) + payload: HiveHubClaimDevice, ) { return this.hiveScaleService.claimDevice(this.extractToken(req), payload); } @@ -112,7 +177,8 @@ export class HiveScaleController { updateDeviceConfig( @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, - @Body() payload: HiveScaleConfigPatchDto, + @Body(new ZodValidationPipe(hiveHubConfigPatchSchema)) + payload: HiveHubConfigPatch, ) { return this.hiveScaleService.updateDeviceConfig( this.extractToken(req), @@ -125,7 +191,8 @@ export class HiveScaleController { fitTempCompensation( @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, - @Body() payload: HiveScaleTempCompensationFitDto, + @Body(new ZodValidationPipe(hiveHubTempCompensationFitSchema)) + payload: HiveHubTempCompensationFit, ) { return this.hiveScaleService.fitTempCompensation( this.extractToken(req), @@ -134,11 +201,23 @@ export class HiveScaleController { ); } + @Get('devices/:deviceId/channels') + getDeviceChannels( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + ) { + return this.hiveScaleService.getDeviceChannels( + this.extractToken(req), + deviceId, + ); + } + @Patch('devices/:deviceId/channels') updateDeviceChannels( @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, - @Body() payload: HiveScaleChannelsPatchDto, + @Body(new ZodValidationPipe(hiveHubChannelsPatchSchema)) + payload: HiveHubChannelsPatch, ) { return this.hiveScaleService.updateDeviceChannels( this.extractToken(req), @@ -151,7 +230,8 @@ export class HiveScaleController { startCalibrationMode( @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, - @Body() payload: HiveScaleCalibrationModeStartDto, + @Body(new ZodValidationPipe(hiveHubCalibrationModeStartSchema)) + payload: HiveHubCalibrationModeStart, ) { return this.hiveScaleService.startCalibrationMode( this.extractToken(req), @@ -171,24 +251,55 @@ export class HiveScaleController { ); } + @Post('devices/:deviceId/provisioning/start') + startProvisioning( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + ) { + return this.hiveScaleService.startProvisioning( + this.extractToken(req), + deviceId, + ); + } + @Post('devices/:deviceId/firmware') - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors( + FileInterceptor('file', { limits: { fileSize: FIRMWARE_MAX_FILE_SIZE } }), + ) uploadFirmware( @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, @UploadedFile() file: Express.Multer.File, - @Body() body: { version?: string; target?: string; active?: string }, + @Body() + body: { + version?: string; + target?: string; + board?: string; + active?: string; + }, ) { if (!file) { throw new BadRequestException('No firmware file provided'); } - const target = - body.target === 'beecounter' || - body.target === 'hivescale' || - body.target === 'hiveinside' - ? body.target - : undefined; + const target = body.target?.trim().toLowerCase(); + if ( + target && + !HIVEHUB_FIRMWARE_TARGETS.includes(target as HiveScaleFirmwareTarget) + ) { + throw new BadRequestException( + "target must be 'hivehub', 'beecounter' or 'hiveinside'", + ); + } + const board = body.board?.trim().toLowerCase(); + if ( + board && + !HIVEHUB_FIRMWARE_BOARDS.includes(board as HiveScaleFirmwareBoard) + ) { + throw new BadRequestException( + `board must be one of ${HIVEHUB_FIRMWARE_BOARDS.join(', ')}`, + ); + } return this.hiveScaleService.uploadFirmware( this.extractToken(req), @@ -196,7 +307,8 @@ export class HiveScaleController { file, { version: body.version ?? '', - target, + target: (target || undefined) as HiveScaleFirmwareTarget | undefined, + board: (board || undefined) as HiveScaleFirmwareBoard | undefined, // Multipart fields arrive as strings; treat anything but "false" as true, // and default to true when omitted. active: body.active === undefined ? true : body.active !== 'false', @@ -231,11 +343,13 @@ export class HiveScaleController { @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, @Query('slot') slot?: string, + @Query('force') force?: string, ) { return this.hiveScaleService.queueHiveInsideUpdate( this.extractToken(req), deviceId, parseRelaySlot(slot), + parseFlag(force), ); } @@ -244,11 +358,13 @@ export class HiveScaleController { @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, @Query('slot') slot?: string, + @Query('force') force?: string, ) { return this.hiveScaleService.queueBeeCounterUpdate( this.extractToken(req), deviceId, parseRelaySlot(slot), + parseFlag(force), ); } @@ -262,6 +378,7 @@ export class HiveScaleController { @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, @UploadedFile() file: Express.Multer.File, + @Body() body: { force?: string }, ) { if (!file) { throw new BadRequestException('No SD data file provided'); @@ -271,14 +388,68 @@ export class HiveScaleController { this.extractToken(req), deviceId, file, + { force: parseFlag(body?.force) }, + ); + } + + @Post('devices/:deviceId/measurements/delete') + deleteMeasurements( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + @Body(new ZodValidationPipe(hiveHubMeasurementDeleteSchema)) + payload: HiveHubMeasurementDelete, + ) { + return this.hiveScaleService.deleteMeasurements( + this.extractToken(req), + deviceId, + payload, + ); + } + + @Get('devices/:deviceId/export/measurements/summary') + exportSummary( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + @Query('start_at') startAt?: string, + @Query('end_at') endAt?: string, + ) { + return this.hiveScaleService.exportSummary( + this.extractToken(req), + deviceId, + { start_at: startAt || undefined, end_at: endAt || undefined }, + ); + } + + @Get('devices/:deviceId/export/measurements') + async exportMeasurements( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + @Res({ passthrough: true }) res: Response, + @Query('start_at') startAt?: string, + @Query('end_at') endAt?: string, + @Query('hive') hive?: string | string[], + ) { + const hives = (Array.isArray(hive) ? hive : hive ? [hive] : []).map( + (value) => parseRelaySlot(value), ); + const upstream = await this.hiveScaleService.exportMeasurements( + this.extractToken(req), + deviceId, + { + start_at: startAt || undefined, + end_at: endAt || undefined, + hive: hives.length > 0 ? hives : undefined, + }, + ); + return toStreamableFile(res, upstream, `${deviceId}-measurements.ndjson`); } @Get('devices/:deviceId/measurements') listMeasurements( @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, - @Query() query: HiveScaleMeasurementQuery, + @Query(new ZodValidationPipe(hiveHubMeasurementQuerySchema, 'query')) + query: HiveHubMeasurementQuery, ) { return this.hiveScaleService.listMeasurements( this.extractToken(req), @@ -291,12 +462,12 @@ export class HiveScaleController { latestMeasurements( @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, - @Query('limit') limit?: number, + @Query('limit') limit?: string, ) { return this.hiveScaleService.latestMeasurements( this.extractToken(req), deviceId, - limit, + optionalInt(limit), ); } @@ -304,12 +475,12 @@ export class HiveScaleController { getInsights( @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, - @Query('lookback_days') lookbackDays?: number, + @Query('lookback_days') lookbackDays?: string, ) { return this.hiveScaleService.getDeviceInsights( this.extractToken(req), deviceId, - lookbackDays !== undefined ? Number(lookbackDays) : undefined, + optionalInt(lookbackDays), ); } @@ -331,7 +502,7 @@ export class HiveScaleController { @Query('status') status?: 'all' | 'active' | 'resolved', @Query('category') category?: string, @Query('since') since?: string, - @Query('limit') limit?: number, + @Query('limit') limit?: string, ) { return this.hiveScaleService.getDeviceInsightsHistory( this.extractToken(req), @@ -340,11 +511,151 @@ export class HiveScaleController { status, category, since, - limit: limit !== undefined ? Number(limit) : undefined, + limit: optionalInt(limit), }, ); } + @Get('devices/:deviceId/inspections/status') + getInspectionStatus( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + ) { + return this.hiveScaleService.getInspectionStatus( + this.extractToken(req), + deviceId, + ); + } + + @Get('devices/:deviceId/inspections') + listInspections( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + @Query('start_at') startAt?: string, + @Query('end_at') endAt?: string, + @Query('limit') limit?: string, + ) { + return this.hiveScaleService.listInspections( + this.extractToken(req), + deviceId, + { + start_at: startAt || undefined, + end_at: endAt || undefined, + limit: optionalInt(limit), + }, + ); + } + + @Post('devices/:deviceId/inspections/start') + startInspection( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + @Body(new ZodValidationPipe(hiveHubInspectionStartSchema)) + payload: HiveHubInspectionStart, + ) { + return this.hiveScaleService.startInspection( + this.extractToken(req), + deviceId, + payload, + ); + } + + @Post('devices/:deviceId/inspections/stop') + stopInspection( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + @Body(new ZodValidationPipe(hiveHubInspectionStopSchema)) + payload: HiveHubInspectionStop, + ) { + return this.hiveScaleService.stopInspection( + this.extractToken(req), + deviceId, + payload, + ); + } + + @Patch('devices/:deviceId/inspections/:inspectionId') + updateInspection( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + @Param('inspectionId', ParseIntPipe) inspectionId: number, + @Body(new ZodValidationPipe(hiveHubInspectionUpdateSchema)) + payload: HiveHubInspectionUpdate, + ) { + return this.hiveScaleService.updateInspection( + this.extractToken(req), + deviceId, + inspectionId, + payload, + ); + } + + @Get('devices/:deviceId/recordings') + listRecordings( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + @Query('hive') hive?: string, + @Query('limit') limit?: string, + ) { + return this.hiveScaleService.listRecordings( + this.extractToken(req), + deviceId, + { + hive: hive ? parseRelaySlot(hive) : undefined, + limit: optionalInt(limit), + }, + ); + } + + @Post('devices/:deviceId/recordings') + requestRecording( + @Req() req: RequestWithUser, + @Param('deviceId') deviceId: string, + @Query(new ZodValidationPipe(hiveHubRecordingRequestSchema, 'query')) + query: HiveHubRecordingRequest, + ) { + return this.hiveScaleService.requestRecording( + this.extractToken(req), + deviceId, + query, + ); + } + + @Get('recordings/:recordingId') + getRecording( + @Req() req: RequestWithUser, + @Param('recordingId', ParseIntPipe) recordingId: number, + ) { + return this.hiveScaleService.getRecording( + this.extractToken(req), + recordingId, + ); + } + + @Get('recordings/:recordingId/audio.wav') + async recordingWav( + @Req() req: RequestWithUser, + @Param('recordingId', ParseIntPipe) recordingId: number, + @Res({ passthrough: true }) res: Response, + ) { + const upstream = await this.hiveScaleService.recordingWav( + this.extractToken(req), + recordingId, + ); + return toStreamableFile(res, upstream, `recording-${recordingId}.wav`); + } + + @Delete('recordings/:recordingId') + deleteRecording( + @Req() req: RequestWithUser, + @Param('recordingId', ParseIntPipe) recordingId: number, + ) { + return this.hiveScaleService.deleteRecording( + this.extractToken(req), + recordingId, + ); + } + @Get('devices/:deviceId/members') listMembers( @Req() req: RequestWithUser, @@ -357,7 +668,8 @@ export class HiveScaleController { shareDevice( @Req() req: RequestWithUser, @Param('deviceId') deviceId: string, - @Body() payload: HiveScaleShareDeviceDto, + @Body(new ZodValidationPipe(hiveHubShareDeviceSchema)) + payload: HiveHubShareDevice, ) { return this.hiveScaleService.shareDevice( this.extractToken(req), diff --git a/apps/backend/src/hivescale/hivescale.helpers.spec.ts b/apps/backend/src/hivescale/hivescale.helpers.spec.ts new file mode 100644 index 000000000..a9e3c3993 --- /dev/null +++ b/apps/backend/src/hivescale/hivescale.helpers.spec.ts @@ -0,0 +1,108 @@ +import { BadRequestException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { + hiveHubChannelsPatchSchema, + hiveHubConfigPatchSchema, +} from 'shared-schemas'; +import { parseRelaySlot } from './hivescale.controller'; +import { hiveInsideSlots } from './hivescale.service'; +import { deviceIdsInRecords, parseSdMeasurements } from './sd-import.parser'; + +describe('parseRelaySlot', () => { + it('defaults to hive 1', () => { + expect(parseRelaySlot(undefined)).toBe(1); + expect(parseRelaySlot('')).toBe(1); + }); + + it('accepts every hive a device can report', () => { + expect(parseRelaySlot('3')).toBe(3); + expect(parseRelaySlot('18')).toBe(18); + }); + + it('rejects out-of-range and non-integer slots', () => { + for (const raw of ['0', '19', '1.5', 'abc']) { + expect(() => parseRelaySlot(raw)).toThrow(BadRequestException); + } + }); +}); + +describe('hiveInsideSlots', () => { + it('finds the hives that report a HiveInside node', () => { + expect( + hiveInsideSlots({ + hives: [ + { index: 4, ble: { present: true, sensor_type: 'HiveInside' } }, + { index: 1, ble: { present: true, sensor_type: 'RuuviTag' } }, + { index: 7, ble: { present: true, board: 'nrf54lm20a' } }, + { index: 9, ble: { present: false, sensor_type: 'HiveInside' } }, + { index: 2 }, + ], + }), + ).toEqual([4, 7]); + }); + + it('falls back to the legacy slots without per-hive data', () => { + expect(hiveInsideSlots(undefined)).toEqual([1, 2]); + expect(hiveInsideSlots({})).toEqual([1, 2]); + }); +}); + +describe('SD import device detection', () => { + it('lists the devices stamped into the backup', () => { + const file = Buffer.from( + [ + JSON.stringify({ device_id: 'hub-a', measured_at: 1 }), + JSON.stringify({ device_id: 'hub-b', measured_at: 2 }), + JSON.stringify({ device_id: 'hub-a', measured_at: 3 }), + JSON.stringify({ measured_at: 4 }), + ].join('\n'), + ); + const { records } = parseSdMeasurements(file, 'measurements.ndjson'); + expect(deviceIdsInRecords(records)).toEqual(['hub-a', 'hub-b']); + }); +}); + +describe('shared HiveHub schemas', () => { + it('accepts per-hive names and links for hives 1..18', () => { + const parsed = hiveHubChannelsPatchSchema.parse({ + names: { '1': 'North', '18': '' }, + hive_ids: { '3': 'a-uuid', '4': null }, + }); + expect(parsed.names).toEqual({ '1': 'North', '18': '' }); + }); + + it('rejects hive indexes outside 1..18', () => { + expect( + hiveHubChannelsPatchSchema.safeParse({ names: { '19': 'x' } }).success, + ).toBe(false); + expect( + hiveHubChannelsPatchSchema.safeParse({ hive_ids: { '0': 'x' } }).success, + ).toBe(false); + }); + + it('refuses to switch off every HiveTraffic emitter bank', () => { + expect( + hiveHubConfigPatchSchema.safeParse({ + beecounter_bank1_enabled: false, + beecounter_bank2_enabled: false, + beecounter_bank3_enabled: false, + }).success, + ).toBe(false); + expect( + hiveHubConfigPatchSchema.safeParse({ beecounter_bank1_enabled: false }) + .success, + ).toBe(true); + }); + + it('bounds the night window and inspection timeout', () => { + expect( + hiveHubConfigPatchSchema.safeParse({ + beecounter_night_start_minute: 1440, + }).success, + ).toBe(false); + expect( + hiveHubConfigPatchSchema.safeParse({ inspection_timeout_minutes: 0 }) + .success, + ).toBe(false); + }); +}); diff --git a/apps/backend/src/hivescale/hivescale.module.ts b/apps/backend/src/hivescale/hivescale.module.ts index 999707107..4c17a9aec 100644 --- a/apps/backend/src/hivescale/hivescale.module.ts +++ b/apps/backend/src/hivescale/hivescale.module.ts @@ -3,22 +3,30 @@ import { ConfigModule } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { UsersModule } from '../users/users.module'; import { MailModule } from '../mail/mail.module'; +import { PrismaService } from '../prisma/prisma.service'; import { HiveScaleController } from './hivescale.controller'; import { HiveScaleService } from './hivescale.service'; -import { SwarmAlertService } from './swarm-alert.service'; -import { SwarmAlertScheduler } from './swarm-alert.scheduler'; +import { HiveHubAlertService } from './hivehub-alert.service'; +import { HiveHubAlertScheduler } from './hivehub-alert.scheduler'; @Module({ imports: [ ConfigModule, UsersModule, MailModule, + // HiveHub verifies the forwarded token with the same secret + // (HIVEPAL_JWT_SECRET on the HiveHub side). JwtModule.register({ secret: process.env.JWT_SECRET, signOptions: { expiresIn: '7d' }, }), ], controllers: [HiveScaleController], - providers: [HiveScaleService, SwarmAlertService, SwarmAlertScheduler], + providers: [ + HiveScaleService, + HiveHubAlertService, + HiveHubAlertScheduler, + PrismaService, + ], }) export class HiveScaleModule {} diff --git a/apps/backend/src/hivescale/hivescale.service.ts b/apps/backend/src/hivescale/hivescale.service.ts index d9aef6cd0..a950330b0 100644 --- a/apps/backend/src/hivescale/hivescale.service.ts +++ b/apps/backend/src/hivescale/hivescale.service.ts @@ -1,18 +1,35 @@ import { BadGatewayException, BadRequestException, + ConflictException, Injectable, InternalServerErrorException, HttpException, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; import axios, { AxiosError, Method } from 'axios'; import FormData from 'form-data'; +import { Readable } from 'stream'; +import type { + HiveHubCalibrationModeStart, + HiveHubChannelsPatch, + HiveHubClaimDevice, + HiveHubConfigPatch, + HiveHubInspectionStart, + HiveHubInspectionStop, + HiveHubInspectionUpdate, + HiveHubMeasurementDelete, + HiveHubMeasurementQuery, + HiveHubRecordingRequest, + HiveHubShareDevice, + HiveHubTempCompensationFit, +} from 'shared-schemas'; import { UsersService } from '../users/users.service'; -import { parseSdMeasurements } from './sd-import.parser'; +import { deviceIdsInRecords, parseSdMeasurements } from './sd-import.parser'; -// The HiveScale backend accepts up to 20k measurements per import request; we +// The HiveHub backend accepts up to 20k measurements per import request; we // forward the parsed SD file in chunks no larger than this so a multi-month // download (tens of thousands of rows) is split into several requests. const SD_IMPORT_CHUNK_SIZE = 5000; @@ -24,7 +41,7 @@ export interface HiveScaleSdImportResult { parsed: number; /** Non-empty lines that could not be parsed as JSON. */ skipped: number; - /** Records forwarded to the HiveScale backend. */ + /** Records forwarded to the HiveHub backend. */ received: number; /** New measurement rows actually stored. */ inserted: number; @@ -44,15 +61,27 @@ interface HiveScaleImportResponse { // 'hivescale'). The HiveHub backend accepts the 'hivehub' alias and normalizes // it back to 'hivescale' internally, so we forward whichever value the client // sends and keep accepting the legacy 'hivescale' value too. -export type HiveScaleFirmwareTarget = - | 'hivehub' - | 'hivescale' - | 'beecounter' - | 'hiveinside'; +export const HIVEHUB_FIRMWARE_TARGETS = [ + 'hivehub', + 'hivescale', + 'beecounter', + 'hiveinside', +] as const; +export type HiveScaleFirmwareTarget = (typeof HIVEHUB_FIRMWARE_TARGETS)[number]; + +// Boards the HiveHub backend knows (server/firmware.py). Empty lets HiveHub +// derive the board from the filename or fall back to its default. +export const HIVEHUB_FIRMWARE_BOARDS = [ + 'esp32', + 'esp32-c6', + 'nrf54lm20a', +] as const; +export type HiveScaleFirmwareBoard = (typeof HIVEHUB_FIRMWARE_BOARDS)[number]; export interface HiveScaleFirmwareUploadDto { version: string; target?: HiveScaleFirmwareTarget; + board?: HiveScaleFirmwareBoard; active?: boolean; } @@ -61,10 +90,12 @@ export interface HiveScaleRelayUpdateResult { id: number; command_type: string; payload: { slot: number }; + version?: string | null; + current_version?: string | null; } export interface HiveScaleAutoQueuedUpdate { - slot: 1 | 2; + slot: number; status: 'queued' | 'failed'; command_id?: number; error?: string; @@ -80,38 +111,19 @@ export interface HiveScaleFirmwareUploadResult { crc32: number; /** * When a HiveInside image is uploaded as the active release, HivePal also - * auto-queues the OTA relay to the in-hive sensors (slots 1 & 2) so that + * queues the OTA relay to every hive that reports a HiveInside node, so that * uploading and updating are a single action. One entry per slot; omitted for * non-HiveInside uploads and inactive releases. */ auto_queued_updates?: HiveScaleAutoQueuedUpdate[]; } -export interface HiveScaleFirmwareStatus { - device_id: string; - target: string; - current_version: string | null; - latest_version: string | null; - /** True when the latest available release is a global/official build (no owner). */ - latest_is_official: boolean; - approved_version: string | null; - update_available: boolean; - /** Update available but not yet approved by the owner — the device won't auto-flash. */ - pending_approval: boolean; -} - -export interface HiveScaleFirmwareApproveResult { +export interface HiveScaleRelayStatus { status: string; - device_id: string; - version: string; - command_id: number; - /** - * When a HiveInside image is uploaded as the active release, HivePal also - * auto-queues the OTA relay to the in-hive sensors (slots 1 & 2) so that - * uploading and updating are a single action. One entry per slot; omitted for - * non-HiveInside uploads and inactive releases. - */ - auto_queued_updates?: HiveScaleAutoQueuedUpdate[]; + message: string | null; + version: string | null; + created_at: string | null; + completed_at: string | null; } export interface HiveScaleFirmwareStatus { @@ -125,6 +137,12 @@ export interface HiveScaleFirmwareStatus { update_available: boolean; /** Update available but not yet approved by the owner — the device won't auto-flash. */ pending_approval: boolean; + device_board?: string | null; + other_board_releases?: unknown[]; + hiveinside_latest_version?: string | null; + hiveinside_relays?: Record; + beecounter_latest_version?: string | null; + beecounter_relays?: Record; } export interface HiveScaleFirmwareApproveResult { @@ -134,63 +152,59 @@ export interface HiveScaleFirmwareApproveResult { command_id: number; } -export interface HiveScaleClaimDeviceDto { - claim_code: string; - display_name?: string; - scale_1_display_name?: string; - scale_2_display_name?: string; +export interface HiveHubDevice { + device_id: string; + display_name: string | null; + claimed_at: string | null; + last_seen_at: string | null; + last_firmware_version: string | null; + role: 'owner' | 'admin' | 'viewer'; + channels?: { + scale_1?: string | null; + scale_2?: string | null; + names?: Record; + hive_ids?: Record; + }; } -export type HiveScaleTempcoSource = 'ambient' | 'hive_1' | 'hive_2'; - -export interface HiveScaleConfigPatchDto { - send_interval_seconds?: number; - scale1_offset?: number; - scale1_factor?: number; - scale2_offset?: number; - scale2_factor?: number; - // Load-cell temperature compensation (applied in the HiveScale backend on - // read; the device keeps sending raw weights). See HiveScale - // docs/temperature-compensation.md. - tempco_enabled?: boolean; - tempco_source?: HiveScaleTempcoSource; - tempco_ref_temp_c?: number; - scale1_tempco_kg_per_c?: number; - scale2_tempco_kg_per_c?: number; +export interface HiveHubInsightHistoryAlert { + id: number; + alert_key: string; + category: string; + channel: number | null; + severity: string; + peak_severity: string; + title: string; + description: string; + confidence: number; + first_seen_at: string | null; + last_seen_at: string | null; + resolved_at: string | null; + status: 'active' | 'resolved'; } -export interface HiveScaleTempCompensationFitDto { - scale: 1 | 2; - lookback_days?: number; - temp_source?: HiveScaleTempcoSource; - calibration_mode_only?: boolean; - apply?: boolean; +export interface HiveHubInsightHistory { + device_id?: string; + count?: number; + active_count?: number; + alerts: HiveHubInsightHistoryAlert[]; } -export interface HiveScaleChannelsPatchDto { - scale_1_display_name?: string; - scale_2_display_name?: string; - hives?: { - index: number; - display_name?: string | null; - hive_id?: string | null; - }[]; +/** A streamed response from HiveHub (audio, NDJSON export). */ +export interface HiveHubStream { + stream: Readable; + contentType: string; + contentLength?: string; + contentDisposition?: string; } -export interface HiveScaleShareDeviceDto { +/** The claims HiveHub reads from the forwarded token (it only needs `sub`). */ +export interface HiveHubTokenUser { + id: string; email: string; - role: 'admin' | 'viewer'; -} - -export interface HiveScaleMeasurementQuery { - limit?: number; - start_at?: string; - end_at?: string; -} - -export interface HiveScaleCalibrationModeStartDto { - interval_seconds?: number; - timeout_seconds?: number; + name?: string | null; + role?: string | null; + passwordChangeRequired?: boolean; } interface HiveScaleMember { @@ -200,6 +214,37 @@ interface HiveScaleMember { created_at: string | null; } +interface HiveHubLatestMeasurement { + hives?: Array<{ + index?: number; + ble?: { present?: boolean; sensor_type?: string; board?: string }; + }>; +} + +/** + * Hive slots that report a HiveInside node in the latest measurement. Falls back + * to the two legacy slots when the device sends no per-hive data (pre-0.20 + * firmware), which is what HivePal always queued before. + */ +export function hiveInsideSlots( + latest: HiveHubLatestMeasurement | undefined, +): number[] { + const hives = latest?.hives; + if (!hives || hives.length === 0) return [1, 2]; + return hives + .filter((hive) => { + const ble = hive.ble; + if (!ble || ble.present === false) return false; + return ( + /hiveinside/i.test(ble.sensor_type ?? '') || + /nrf54/i.test(ble.board ?? '') + ); + }) + .map((hive) => Number(hive.index)) + .filter((index) => Number.isInteger(index) && index >= 1) + .sort((a, b) => a - b); +} + @Injectable() export class HiveScaleService { private readonly baseUrl: string; @@ -208,6 +253,7 @@ export class HiveScaleService { constructor( private readonly configService: ConfigService, private readonly usersService: UsersService, + private readonly jwtService: JwtService, ) { // HiveScale was renamed to HiveHub. Prefer the new HIVEHUB_* variables but // keep accepting the legacy HIVESCALE_* names so existing deployments keep @@ -224,6 +270,26 @@ export class HiveScaleService { ); } + /** True when both the HiveHub URL and service key are configured. */ + isConfigured(): boolean { + return Boolean(this.baseUrl && this.serviceApiKey); + } + + /** + * Sign the token HiveHub expects for a HivePal user. HiveHub verifies it with + * the shared JWT secret and takes the user from `sub`; the session cookie the + * browser sent is never forwarded. + */ + tokenFor(user: HiveHubTokenUser): string { + return this.jwtService.sign({ + sub: user.id, + email: user.email, + role: user.role, + name: user.name ?? null, + passwordChangeRequired: user.passwordChangeRequired ?? false, + }); + } + /** Return the first env var that is set to a non-empty (trimmed) value. */ private firstConfigured(...keys: string[]): string { for (const key of keys) { @@ -253,6 +319,13 @@ export class HiveScaleService { return this.serviceApiKey; } + private authHeaders(accessToken: string) { + return { + Authorization: `Bearer ${accessToken}`, + 'X-HivePal-Service-Key': this.requireServiceApiKey(), + }; + } + private async request( accessToken: string, method: Method, @@ -266,11 +339,12 @@ export class HiveScaleService { method, data: options.data, params: options.params, + // Repeated query keys (`hive=1&hive=2`) rather than `hive[]=1`. + paramsSerializer: { indexes: null }, timeout: 10000, headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - 'X-HivePal-Service-Key': this.requireServiceApiKey(), + ...this.authHeaders(accessToken), }, }); @@ -279,45 +353,110 @@ export class HiveScaleService { if (axios.isAxiosError(error)) { this.handleAxiosError(error); } - throw new BadGatewayException('HiveScale backend request failed'); + throw new BadGatewayException('HiveHub backend request failed'); + } + } + + /** GET a binary/streamed HiveHub response and hand the stream back. */ + private async requestStream( + accessToken: string, + path: string, + params?: Record, + ): Promise { + try { + const response = await axios.request({ + baseURL: this.requireBaseUrl(), + url: path, + method: 'GET', + params, + paramsSerializer: { indexes: null }, + responseType: 'stream', + // A long export streams for a while; this bounds the wait for headers. + timeout: 120000, + headers: this.authHeaders(accessToken), + }); + const header = (name: string) => { + const value: unknown = response.headers[name]; + return typeof value === 'string' ? value : undefined; + }; + return { + stream: response.data, + contentType: header('content-type') ?? 'application/octet-stream', + contentLength: header('content-length'), + contentDisposition: header('content-disposition'), + }; + } catch (error) { + if (axios.isAxiosError(error)) { + // The body of an errored stream request is itself a stream; read it so + // the upstream `detail` still reaches the user. + const data: unknown = error.response?.data; + if (error.response && data instanceof Readable) { + const chunks: Buffer[] = []; + for await (const chunk of data) { + chunks.push(Buffer.from(chunk as Buffer)); + } + const text = Buffer.concat(chunks).toString('utf8'); + try { + error.response.data = JSON.parse(text) as unknown; + } catch { + error.response.data = text; + } + } + this.handleAxiosError(error); + } + throw new BadGatewayException('HiveHub backend request failed'); } } private handleAxiosError(error: AxiosError): never { if (error.response) { const responseData = error.response.data as - | { detail?: string; message?: string } + | { detail?: unknown; message?: string } | string | undefined; - const message = + const detail = typeof responseData === 'string' ? responseData - : responseData?.detail || - responseData?.message || - 'HiveScale backend error'; + : (responseData?.detail ?? responseData?.message); + // FastAPI validation errors carry `detail` as a list of issues. + const message = + typeof detail === 'string' + ? detail + : Array.isArray(detail) + ? detail + .map((issue: { msg?: string }) => issue?.msg) + .filter(Boolean) + .join('; ') || 'HiveHub backend error' + : 'HiveHub backend error'; throw new HttpException(message, error.response.status); } - throw new BadGatewayException('HiveScale backend is unavailable'); + throw new BadGatewayException('HiveHub backend is unavailable'); } - claimDevice(accessToken: string, payload: HiveScaleClaimDeviceDto) { + private devicePath(deviceId: string, suffix = ''): string { + return `/api/v1/app/devices/${encodeURIComponent(deviceId)}${suffix}`; + } + + // ── Devices ────────────────────────────────────────────────────────────── + + claimDevice(accessToken: string, payload: HiveHubClaimDevice) { return this.request(accessToken, 'POST', '/api/v1/app/devices/claim', { data: payload, }); } listDevices(accessToken: string) { - return this.request(accessToken, 'GET', '/api/v1/app/devices'); + return this.request( + accessToken, + 'GET', + '/api/v1/app/devices', + ); } removeDevice(accessToken: string, deviceId: string) { - return this.request( - accessToken, - 'DELETE', - `/api/v1/app/devices/${deviceId}`, - ); + return this.request(accessToken, 'DELETE', this.devicePath(deviceId)); } /** @@ -330,7 +469,7 @@ export class HiveScaleService { return this.request( accessToken, 'DELETE', - `/api/v1/app/devices/${deviceId}/claim`, + this.devicePath(deviceId, '/claim'), ); } @@ -338,62 +477,66 @@ export class HiveScaleService { return this.request( accessToken, 'GET', - `/api/v1/app/devices/${deviceId}/config`, + this.devicePath(deviceId, '/config'), ); } updateDeviceConfig( accessToken: string, deviceId: string, - payload: HiveScaleConfigPatchDto, + payload: HiveHubConfigPatch, ) { return this.request( accessToken, 'PATCH', - `/api/v1/app/devices/${deviceId}/config`, - { - data: payload, - }, + this.devicePath(deviceId, '/config'), + { data: payload }, ); } fitTempCompensation( accessToken: string, deviceId: string, - payload: HiveScaleTempCompensationFitDto, + payload: HiveHubTempCompensationFit, ) { return this.request( accessToken, 'POST', - `/api/v1/app/devices/${deviceId}/temp-compensation/fit`, + this.devicePath(deviceId, '/temp-compensation/fit'), { data: payload }, ); } + getDeviceChannels(accessToken: string, deviceId: string) { + return this.request( + accessToken, + 'GET', + this.devicePath(deviceId, '/channels'), + ); + } + updateDeviceChannels( accessToken: string, deviceId: string, - payload: HiveScaleChannelsPatchDto, + payload: HiveHubChannelsPatch, ) { return this.request( accessToken, 'PATCH', - `/api/v1/app/devices/${deviceId}/channels`, - { - data: payload, - }, + this.devicePath(deviceId, '/channels'), + { data: payload }, ); } startCalibrationMode( accessToken: string, deviceId: string, - payload: HiveScaleCalibrationModeStartDto, + payload: HiveHubCalibrationModeStart, ) { return this.request( accessToken, 'POST', - `/api/v1/app/devices/${deviceId}/calibration/start`, + this.devicePath(deviceId, '/calibration/start'), { data: payload }, ); } @@ -402,31 +545,72 @@ export class HiveScaleService { return this.request( accessToken, 'POST', - `/api/v1/app/devices/${deviceId}/calibration/stop`, + this.devicePath(deviceId, '/calibration/stop'), + ); + } + + /** Open the hub's setup access point on its next check-in. */ + startProvisioning(accessToken: string, deviceId: string) { + return this.request( + accessToken, + 'POST', + this.devicePath(deviceId, '/provisioning/start'), ); } - queueHiveInsideUpdate(accessToken: string, deviceId: string, slot: 1 | 2) { + // ── Firmware ───────────────────────────────────────────────────────────── + + queueHiveInsideUpdate( + accessToken: string, + deviceId: string, + slot: number, + force = false, + ) { return this.request( accessToken, 'POST', - `/api/v1/app/devices/${deviceId}/commands/update-hiveinside`, - { params: { slot } }, + this.devicePath(deviceId, '/commands/update-hiveinside'), + { params: { slot, force } }, + ); + } + + queueBeeCounterUpdate( + accessToken: string, + deviceId: string, + slot: number, + force = false, + ) { + return this.request( + accessToken, + 'POST', + this.devicePath(deviceId, '/commands/update-beecounter'), + { params: { slot, force } }, ); } /** - * Queue the HiveInside OTA relay for both sensor slots, best-effort. Used after - * a HiveInside firmware upload so a published release is pushed to the in-hive - * sensors without a separate manual step. A slot with no paired sensor simply - * yields a no-op command on the HiveScale, so we always queue both and report - * the per-slot outcome instead of failing the upload. + * Queue the HiveInside OTA relay for every hive that reports a HiveInside + * node, best-effort. Used after a HiveInside firmware upload so a published + * release is pushed to the in-hive sensors without a separate manual step. + * HiveHub refuses a relay that is not newer than what the node runs (409); + * that is reported per slot rather than failing the upload. */ private async queueHiveInsideUpdateAllSlots( accessToken: string, deviceId: string, ): Promise { - const slots: Array<1 | 2> = [1, 2]; + let slots: number[]; + try { + const latest = await this.latestMeasurements( + accessToken, + deviceId, + 1, + ); + slots = hiveInsideSlots(latest[0]); + } catch { + slots = [1, 2]; + } + const results: HiveScaleAutoQueuedUpdate[] = []; for (const slot of slots) { try { @@ -450,15 +634,6 @@ export class HiveScaleService { return results; } - queueBeeCounterUpdate(accessToken: string, deviceId: string, slot: 1 | 2) { - return this.request( - accessToken, - 'POST', - `/api/v1/app/devices/${deviceId}/commands/update-beecounter`, - { params: { slot } }, - ); - } - async uploadFirmware( accessToken: string, deviceId: string, @@ -471,12 +646,7 @@ export class HiveScaleService { } const target = dto.target ?? 'hivehub'; - if ( - target !== 'hivehub' && - target !== 'hivescale' && - target !== 'beecounter' && - target !== 'hiveinside' - ) { + if (!HIVEHUB_FIRMWARE_TARGETS.includes(target)) { throw new BadRequestException( "target must be 'hivehub', 'beecounter' or 'hiveinside'", ); @@ -490,36 +660,36 @@ export class HiveScaleService { }); form.append('version', version); form.append('target', target); + // Empty lets HiveHub take the board from the filename (hivehub_esp32-c6_…) + // or fall back to its default. + form.append('board', dto.board ?? ''); // FastAPI Form(bool) accepts the string "true"/"false". form.append('active', String(dto.active ?? true)); try { const response = await axios.request({ baseURL: this.requireBaseUrl(), - url: `/api/v1/app/devices/${deviceId}/firmware`, + url: this.devicePath(deviceId, '/firmware'), method: 'POST', data: form, - // Firmware images can be a few MB; allow a generous timeout and no - // body-size cap on the proxy hop. + // Firmware images can be a few MB; the size is capped by the upload + // interceptor, so the proxy hop itself needs no body-size limit. timeout: 120000, maxBodyLength: Infinity, maxContentLength: Infinity, headers: { ...form.getHeaders(), - Authorization: `Bearer ${accessToken}`, - 'X-HivePal-Service-Key': this.requireServiceApiKey(), + ...this.authHeaders(accessToken), }, }); const result = response.data; - // Uploading a HiveInside image used to only *register* the release; the OTA - // relay then had to be triggered separately. We now auto-queue the relay to - // the in-hive sensors (both slots) right after a successful upload so that - // publishing a HiveInside build also pushes it. Only do this for an *active* - // release: queuing relays the latest active release, so an inactive upload - // would otherwise relay an older build. Best-effort — the upload has already - // succeeded, so queue failures are reported per slot rather than thrown. + // Uploading a HiveInside image only *registers* the release on HiveHub; + // the OTA relay has to be queued separately. Do that right away for every + // node on this hub so that publishing a build also pushes it. Only for an + // *active* release: a relay sends the latest active release, so an + // inactive upload would otherwise relay an older build. if (result.target === 'hiveinside' && result.active) { result.auto_queued_updates = await this.queueHiveInsideUpdateAllSlots( accessToken, @@ -532,7 +702,7 @@ export class HiveScaleService { if (axios.isAxiosError(error)) { this.handleAxiosError(error); } - throw new BadGatewayException('HiveScale firmware upload failed'); + throw new BadGatewayException('HiveHub firmware upload failed'); } } @@ -540,7 +710,7 @@ export class HiveScaleService { return this.request( accessToken, 'GET', - `/api/v1/app/devices/${deviceId}/firmware/status`, + this.devicePath(deviceId, '/firmware/status'), ); } @@ -548,14 +718,17 @@ export class HiveScaleService { return this.request( accessToken, 'POST', - `/api/v1/app/devices/${deviceId}/firmware/approve`, + this.devicePath(deviceId, '/firmware/approve'), ); } + // ── Measurements ───────────────────────────────────────────────────────── + async importSdMeasurements( accessToken: string, deviceId: string, file: Express.Multer.File, + options: { force?: boolean } = {}, ): Promise { const { records, skipped } = parseSdMeasurements( file.buffer, @@ -564,10 +737,27 @@ export class HiveScaleService { if (records.length === 0) { throw new BadRequestException( - 'No measurements found in the uploaded file. Expected a HiveScale .ndjson backup or the .tar SD download.', + 'No measurements found in the uploaded file. Expected a HiveHub .ndjson backup or the .tar SD download.', ); } + // Every record is pinned to the selected device below, so a card pulled + // from a different hub would silently attach its readings to this one. + // The firmware stamps each line with the device that recorded it; refuse a + // mismatch unless the user confirmed it. + const otherDevices = deviceIdsInRecords(records).filter( + (id) => id !== deviceId, + ); + if (otherDevices.length > 0 && !options.force) { + throw new ConflictException({ + statusCode: 409, + code: 'device_mismatch', + message: `This file was recorded by ${otherDevices.join(', ')}, not ${deviceId}.`, + target_device_id: deviceId, + file_device_ids: otherDevices, + }); + } + // Pin every record to the selected device so the upload cannot smuggle in // readings for a device the user does not own (the backend re-checks too). const measurements = records.map((record) => ({ @@ -584,7 +774,7 @@ export class HiveScaleService { const result = await this.request( accessToken, 'POST', - `/api/v1/app/devices/${deviceId}/measurements/import`, + this.devicePath(deviceId, '/measurements/import'), { data: { measurements: chunk } }, ); received += result.received; @@ -606,25 +796,69 @@ export class HiveScaleService { listMeasurements( accessToken: string, deviceId: string, - query: HiveScaleMeasurementQuery, + query: HiveHubMeasurementQuery, ) { return this.request( accessToken, 'GET', - `/api/v1/app/devices/${deviceId}/measurements`, + this.devicePath(deviceId, '/measurements'), { params: query as Record }, ); } - latestMeasurements(accessToken: string, deviceId: string, limit?: number) { - return this.request( + latestMeasurements( + accessToken: string, + deviceId: string, + limit?: number, + ) { + return this.request( accessToken, 'GET', - `/api/v1/app/devices/${deviceId}/measurements/latest`, + this.devicePath(deviceId, '/measurements/latest'), { params: { limit } }, ); } + deleteMeasurements( + accessToken: string, + deviceId: string, + payload: HiveHubMeasurementDelete, + ) { + return this.request( + accessToken, + 'POST', + this.devicePath(deviceId, '/measurements/delete'), + { data: payload }, + ); + } + + exportSummary( + accessToken: string, + deviceId: string, + query: { start_at?: string; end_at?: string }, + ) { + return this.request( + accessToken, + 'GET', + this.devicePath(deviceId, '/export/measurements/summary'), + { params: query }, + ); + } + + exportMeasurements( + accessToken: string, + deviceId: string, + query: { start_at?: string; end_at?: string; hive?: number[] }, + ) { + return this.requestStream( + accessToken, + this.devicePath(deviceId, '/export/measurements'), + query, + ); + } + + // ── Insights ───────────────────────────────────────────────────────────── + getDeviceInsights( accessToken: string, deviceId: string, @@ -633,7 +867,7 @@ export class HiveScaleService { return this.request( accessToken, 'GET', - `/api/v1/app/devices/${deviceId}/insights`, + this.devicePath(deviceId, '/insights'), { params: lookbackDays !== undefined @@ -647,7 +881,7 @@ export class HiveScaleService { return this.request( accessToken, 'GET', - `/api/v1/app/devices/${deviceId}/insights/summary`, + this.devicePath(deviceId, '/insights/summary'), ); } @@ -666,19 +900,135 @@ export class HiveScaleService { if (query.category !== undefined) params.category = query.category; if (query.since !== undefined) params.since = query.since; if (query.limit !== undefined) params.limit = query.limit; - return this.request( + return this.request( accessToken, 'GET', - `/api/v1/app/devices/${deviceId}/insights/history`, + this.devicePath(deviceId, '/insights/history'), { params: Object.keys(params).length > 0 ? params : undefined }, ); } + // ── Inspection mode ────────────────────────────────────────────────────── + + getInspectionStatus(accessToken: string, deviceId: string) { + return this.request( + accessToken, + 'GET', + this.devicePath(deviceId, '/inspections/status'), + ); + } + + listInspections( + accessToken: string, + deviceId: string, + query: { start_at?: string; end_at?: string; limit?: number }, + ) { + return this.request( + accessToken, + 'GET', + this.devicePath(deviceId, '/inspections'), + { params: query }, + ); + } + + startInspection( + accessToken: string, + deviceId: string, + payload: HiveHubInspectionStart, + ) { + return this.request( + accessToken, + 'POST', + this.devicePath(deviceId, '/inspections/start'), + { data: payload }, + ); + } + + stopInspection( + accessToken: string, + deviceId: string, + payload: HiveHubInspectionStop, + ) { + return this.request( + accessToken, + 'POST', + this.devicePath(deviceId, '/inspections/stop'), + { data: payload }, + ); + } + + updateInspection( + accessToken: string, + deviceId: string, + inspectionId: number, + payload: HiveHubInspectionUpdate, + ) { + return this.request( + accessToken, + 'PATCH', + this.devicePath(deviceId, `/inspections/${inspectionId}`), + { data: payload }, + ); + } + + // ── Hive audio recordings ──────────────────────────────────────────────── + + listRecordings( + accessToken: string, + deviceId: string, + query: { hive?: number; limit?: number }, + ) { + return this.request( + accessToken, + 'GET', + this.devicePath(deviceId, '/recordings'), + { params: query }, + ); + } + + requestRecording( + accessToken: string, + deviceId: string, + query: HiveHubRecordingRequest, + ) { + return this.request( + accessToken, + 'POST', + this.devicePath(deviceId, '/recordings'), + { params: query }, + ); + } + + getRecording(accessToken: string, recordingId: number) { + return this.request( + accessToken, + 'GET', + `/api/v1/app/recordings/${recordingId}`, + ); + } + + recordingWav(accessToken: string, recordingId: number) { + return this.requestStream( + accessToken, + `/api/v1/app/recordings/${recordingId}/audio.wav`, + ); + } + + deleteRecording(accessToken: string, recordingId: number) { + return this.request( + accessToken, + 'DELETE', + `/api/v1/app/recordings/${recordingId}`, + ); + } + + // ── Members ────────────────────────────────────────────────────────────── + async listMembers(accessToken: string, deviceId: string) { const members = await this.request( accessToken, 'GET', - `/api/v1/app/devices/${deviceId}/members`, + this.devicePath(deviceId, '/members'), ); return Promise.all( @@ -696,7 +1046,7 @@ export class HiveScaleService { async shareDevice( accessToken: string, deviceId: string, - payload: HiveScaleShareDeviceDto, + payload: HiveHubShareDevice, ) { const email = payload.email.trim().toLowerCase(); const user = await this.usersService.findByEmail(email); @@ -708,7 +1058,7 @@ export class HiveScaleService { return this.request( accessToken, 'POST', - `/api/v1/app/devices/${deviceId}/members`, + this.devicePath(deviceId, '/members'), { data: { user_id: user.id, @@ -722,7 +1072,7 @@ export class HiveScaleService { return this.request( accessToken, 'DELETE', - `/api/v1/app/devices/${deviceId}/members/${encodeURIComponent(memberUserId)}`, + this.devicePath(deviceId, `/members/${encodeURIComponent(memberUserId)}`), ); } } diff --git a/apps/backend/src/hivescale/sd-import.parser.ts b/apps/backend/src/hivescale/sd-import.parser.ts index 6cc741db6..1709f48dd 100644 --- a/apps/backend/src/hivescale/sd-import.parser.ts +++ b/apps/backend/src/hivescale/sd-import.parser.ts @@ -1,5 +1,5 @@ /** - * Parses measurement records out of a HiveScale SD-card download. + * Parses measurement records out of a HiveHub SD-card download. * * The firmware keeps an append-only `/measurements.ndjson` backup (one JSON * object per line) and, in AP mode, lets the beekeeper download the whole card @@ -111,3 +111,21 @@ export function parseSdMeasurements( return parseNdjson(text); } + +/** + * Distinct `device_id` values stamped into the records, in first-seen order. + * The firmware writes the recording device into every backup line, which is + * what lets an import notice a card pulled from a different hub. + */ +export function deviceIdsInRecords( + records: Record[], +): string[] { + const seen = new Set(); + for (const record of records) { + const id = record.device_id; + if (typeof id === 'string' && id.trim()) { + seen.add(id.trim()); + } + } + return [...seen]; +} diff --git a/apps/backend/src/hivescale/swarm-alert.scheduler.ts b/apps/backend/src/hivescale/swarm-alert.scheduler.ts deleted file mode 100644 index d47c5242b..000000000 --- a/apps/backend/src/hivescale/swarm-alert.scheduler.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { SwarmAlertService } from './swarm-alert.service'; - -@Injectable() -export class SwarmAlertScheduler { - private readonly logger = new Logger(SwarmAlertScheduler.name); - - constructor(private readonly swarmAlertService: SwarmAlertService) {} - - /** - * Check for swarm alerts every 15 minutes. - * The interval should be less than or equal to the HiveScale send interval - * so no measurement is missed between checks. - */ - @Cron('0 */15 * * * *') - async handleSwarmAlertCheck() { - this.logger.log('Running scheduled swarm alert check'); - - try { - await this.swarmAlertService.checkAllUsers(); - } catch (error) { - this.logger.error('Swarm alert scheduler encountered an error', error); - } - } -} diff --git a/apps/backend/src/hivescale/swarm-alert.service.ts b/apps/backend/src/hivescale/swarm-alert.service.ts deleted file mode 100644 index efa6fc23f..000000000 --- a/apps/backend/src/hivescale/swarm-alert.service.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { UsersService } from '../users/users.service'; -import { HiveScaleService } from './hivescale.service'; -import { MailService } from '../mail/mail.service'; -import { UserPreferences } from 'shared-schemas'; - -// Cooldown between repeated alerts for the same device (6 hours in ms) -const ALERT_COOLDOWN_MS = 6 * 60 * 60 * 1000; - -interface HiveScaleMeasurement { - recorded_at: string; - scale_1_weight_kg: number | null; - scale_2_weight_kg: number | null; - [key: string]: unknown; -} - -interface HiveScaleDevice { - id: string; - display_name: string | null; - scale_1_display_name: string | null; - scale_2_display_name: string | null; - [key: string]: unknown; -} - -@Injectable() -export class SwarmAlertService { - private readonly logger = new Logger(SwarmAlertService.name); - - constructor( - private readonly usersService: UsersService, - private readonly hiveScaleService: HiveScaleService, - private readonly mailService: MailService, - ) {} - - /** - * Main entry point called by the scheduler. - * Iterates over all users that have swarm alerts enabled and checks their devices. - */ - async checkAllUsers(): Promise { - this.logger.log('Starting swarm alert check for all users'); - - // Fetch every user that has preferences stored - // We use the raw prisma call through usersService to avoid a large abstraction. - // getUsersWithSwarmAlertsEnabled is a convenience method we delegate to usersService. - let users: Array<{ id: string; email: string; name: string | null }>; - try { - users = await this.usersService.getAllUsers(); - } catch (error) { - this.logger.error('Failed to fetch users for swarm alert check', error); - return; - } - - for (const user of users) { - try { - await this.checkUserDevices(user); - } catch (error) { - this.logger.error( - `Swarm alert check failed for user ${user.id}`, - error, - ); - } - } - - this.logger.log('Swarm alert check completed'); - } - - private async checkUserDevices(user: { - id: string; - email: string; - name: string | null; - }): Promise { - const preferences = await this.usersService.getUserPreferences(user.id); - - if (!preferences?.swarmAlert?.enabled) { - return; // Swarm alerts disabled for this user - } - - const { - weightDropKg = 2, - measurementWindow = 3, - lastAlertedAt = {}, - } = preferences.swarmAlert; - - // Fetch devices for this user - let devices: HiveScaleDevice[]; - try { - devices = (await this.hiveScaleService.listDevices( - user.id, - )) as HiveScaleDevice[]; - } catch { - // HiveScale may not be configured or the user has no devices - return; - } - - if (!Array.isArray(devices) || devices.length === 0) { - return; - } - - const updatedLastAlertedAt = { ...lastAlertedAt }; - let preferencesChanged = false; - - for (const device of devices) { - const deviceChanged = await this.checkDevice( - user, - device, - { weightDropKg, measurementWindow }, - updatedLastAlertedAt, - ); - if (deviceChanged) { - preferencesChanged = true; - } - } - - if (preferencesChanged) { - const updatedPreferences: UserPreferences = { - ...preferences, - swarmAlert: { - ...preferences.swarmAlert, - lastAlertedAt: updatedLastAlertedAt, - }, - }; - await this.usersService.updateUserPreferences( - user.id, - updatedPreferences, - ); - } - } - - /** - * Checks a single device's scale channels for a swarm-indicating weight drop. - * Returns true if an alert was sent (and cooldown state was updated). - */ - private async checkDevice( - user: { id: string; email: string; name: string | null }, - device: HiveScaleDevice, - config: { weightDropKg: number; measurementWindow: number }, - updatedLastAlertedAt: Record, - ): Promise { - const deviceId = device.id; - - if (this.isWithinCooldown(updatedLastAlertedAt[deviceId])) { - this.logger.debug( - `Skipping device ${deviceId} for user ${user.id} – within cooldown window`, - ); - return false; - } - - const sorted = await this.fetchSortedMeasurements( - user.id, - deviceId, - config.measurementWindow, - ); - if (!sorted) { - return false; - } - - let preferencesChanged = false; - - for (const channel of ['scale_1', 'scale_2'] as const) { - const sent = await this.evaluateChannel( - user, - device, - channel, - sorted, - config, - ); - if (sent) { - // Record cooldown – use composite key so both channels can be tracked independently - const cooldownKey = `${deviceId}__${channel}`; - updatedLastAlertedAt[cooldownKey] = new Date().toISOString(); - preferencesChanged = true; - // Only alert once per device+channel per check run - break; - } - } - - return preferencesChanged; - } - - /** - * Returns true if the device is still within the alert cooldown window. - */ - private isWithinCooldown(lastAlerted: string | undefined): boolean { - if (!lastAlerted) { - return false; - } - const elapsed = Date.now() - new Date(lastAlerted).getTime(); - return elapsed < ALERT_COOLDOWN_MS; - } - - /** - * Fetches the latest N+1 measurements and returns them sorted oldest-first. - * Returns null when measurements cannot be fetched or are insufficient. - */ - private async fetchSortedMeasurements( - userId: string, - deviceId: string, - measurementWindow: number, - ): Promise { - let measurements: HiveScaleMeasurement[]; - try { - measurements = (await this.hiveScaleService.latestMeasurements( - userId, - deviceId, - measurementWindow + 1, - )) as HiveScaleMeasurement[]; - } catch { - return null; - } - - if (!Array.isArray(measurements) || measurements.length < 2) { - return null; - } - - // Measurements are returned newest-first; sort so index 0 = oldest - return [...measurements].sort( - (a, b) => - new Date(a.recorded_at).getTime() - new Date(b.recorded_at).getTime(), - ); - } - - /** - * Evaluates a single scale channel for a weight drop and sends an alert email - * when the configured threshold is exceeded. Returns true if an email was sent. - */ - private async evaluateChannel( - user: { id: string; email: string; name: string | null }, - device: HiveScaleDevice, - channel: 'scale_1' | 'scale_2', - sorted: HiveScaleMeasurement[], - config: { weightDropKg: number; measurementWindow: number }, - ): Promise { - const deviceId = device.id; - const { weightDropKg, measurementWindow } = config; - - const weightKey = - channel === 'scale_1' ? 'scale_1_weight_kg' : 'scale_2_weight_kg'; - const displayName = - channel === 'scale_1' - ? (device.scale_1_display_name ?? 'Scale 1') - : (device.scale_2_display_name ?? 'Scale 2'); - - // Extract the most recent `measurementWindow` values - const window = sorted.slice(-measurementWindow); - const validReadings = window - .map((m) => m[weightKey]) - .filter((w): w is number => w !== null && !Number.isNaN(w)); - - if (validReadings.length < 2) { - return false; - } - - const oldest = validReadings[0]; - const newest = validReadings[validReadings.length - 1]; - const drop = oldest - newest; - - this.logger.debug( - `Device ${deviceId} ${channel}: oldest=${oldest} newest=${newest} drop=${drop} threshold=${weightDropKg}`, - ); - - if (drop < weightDropKg) { - return false; - } - - this.logger.warn( - `Swarm alert triggered for user ${user.id}, device ${deviceId}, ${channel}: drop=${drop.toFixed(2)} kg`, - ); - - return this.mailService.sendSwarmAlertEmail({ - email: user.email, - userName: user.name, - deviceId, - deviceName: device.display_name ?? deviceId, - scaleChannel: channel, - scaleDisplayName: displayName, - previousWeightKg: oldest, - latestWeightKg: newest, - dropKg: drop, - measurementWindow, - detectedAt: new Date().toISOString(), - }); - } -} diff --git a/apps/backend/src/mail/mail.service.ts b/apps/backend/src/mail/mail.service.ts index f76e29382..0c8f90baa 100644 --- a/apps/backend/src/mail/mail.service.ts +++ b/apps/backend/src/mail/mail.service.ts @@ -1,21 +1,20 @@ import { Injectable, Logger } from '@nestjs/common'; import { passwordResetTemplate } from './templates/password-reset.template'; -import { swarmAlertTemplate } from './templates/swarm-alert.template'; +import { + hiveHubAlertTemplate, + HiveHubAlertTemplateItem, +} from './templates/hivehub-alert.template'; import { magicLinkTemplate } from './templates/magic-link.template'; import { MailConfigService } from './mail-config.service'; -export interface SwarmAlertEmailOptions { +export interface HiveHubAlertEmailItem extends HiveHubAlertTemplateItem { + deviceId: string; +} + +export interface HiveHubAlertEmailOptions { email: string; userName: string | null; - deviceId: string; - deviceName: string; - scaleChannel: 'scale_1' | 'scale_2'; - scaleDisplayName: string; - previousWeightKg: number; - latestWeightKg: number; - dropKg: number; - measurementWindow: number; - detectedAt: string; + items: HiveHubAlertEmailItem[]; } @Injectable() @@ -69,22 +68,25 @@ export class MailService { } } - async sendSwarmAlertEmail(options: SwarmAlertEmailOptions): Promise { + async sendHiveHubAlertEmail( + options: HiveHubAlertEmailOptions, + ): Promise { const provider = this.mailConfig.getProvider(); if (!provider) { this.logger.log( - `Email sending disabled - would have sent swarm alert email to ${options.email} for device ${options.deviceId}`, + `Email sending disabled - would have sent ${options.items.length} HiveHub alert(s) to ${options.email}`, ); - // Return true so the cooldown timestamp is still written even in dev + // Return true so the alerts are still marked as notified in dev return true; } try { const appUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; - const { subject, html } = swarmAlertTemplate({ - ...options, + const { subject, html } = hiveHubAlertTemplate({ + userName: options.userName, + items: options.items, appName: 'Hive Pal', appUrl, }); @@ -98,18 +100,18 @@ export class MailService { if (!result.success) { this.logger.error( - `Failed to send swarm alert email via ${provider.getName()}:`, + `Failed to send HiveHub alert email via ${provider.getName()}:`, result.error, ); return false; } this.logger.log( - `Swarm alert email sent to ${options.email} for device ${options.deviceId} (${options.scaleChannel}) via ${provider.getName()} with ID: ${result.id}`, + `HiveHub alert email (${options.items.length} alert(s)) sent to ${options.email} via ${provider.getName()} with ID: ${result.id}`, ); return true; } catch (error) { - this.logger.error('Error sending swarm alert email:', error); + this.logger.error('Error sending HiveHub alert email:', error); return false; } } diff --git a/apps/backend/src/mail/templates/hivehub-alert.template.ts b/apps/backend/src/mail/templates/hivehub-alert.template.ts new file mode 100644 index 000000000..6e6c809d2 --- /dev/null +++ b/apps/backend/src/mail/templates/hivehub-alert.template.ts @@ -0,0 +1,124 @@ +export interface HiveHubAlertTemplateItem { + deviceName: string; + hiveName: string | null; + severity: string; + previousSeverity: string | null; + category: string; + title: string; + description: string; + confidence: number; + firstSeenAt: string | null; +} + +interface HiveHubAlertTemplateData { + userName: string | null; + items: HiveHubAlertTemplateItem[]; + appName: string; + appUrl: string; +} + +const SEVERITY_COLORS: Record< + string, + { bg: string; border: string; text: string } +> = { + critical: { bg: '#fef2f2', border: '#dc2626', text: '#991b1b' }, + warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' }, + watch: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' }, + info: { bg: '#f9fafb', border: '#9ca3af', text: '#374151' }, +}; + +/** Alert text comes from HiveHub, so never trust it inside HTML. */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function formatTime(iso: string | null): string { + if (!iso) return ''; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleString('en-GB', { + dateStyle: 'long', + timeStyle: 'short', + }); +} + +function renderItem(item: HiveHubAlertTemplateItem): string { + const colors = SEVERITY_COLORS[item.severity] ?? SEVERITY_COLORS.info; + const where = [item.hiveName, item.deviceName].filter(Boolean).join(' · '); + const escalated = item.previousSeverity + ? ` (was ${escapeHtml(item.previousSeverity)})` + : ''; + const seen = formatTime(item.firstSeenAt); + return ` +
+
+ ${escapeHtml(item.severity)}${escalated} · ${escapeHtml(item.category)} +
+
${escapeHtml(item.title)}
+
${escapeHtml(where)}
+

${escapeHtml(item.description)}

+
+ Confidence ${Math.round(item.confidence * 100)}%${seen ? ` · since ${escapeHtml(seen)}` : ''} +
+
`; +} + +export function hiveHubAlertTemplate(data: HiveHubAlertTemplateData): { + subject: string; + html: string; +} { + const { userName, items, appName, appUrl } = data; + const greeting = userName ? `Hi ${escapeHtml(userName)},` : 'Hi,'; + const first = items[0]; + const firstWhere = first.hiveName ?? first.deviceName; + const subject = + items.length === 1 + ? `🐝 ${first.title} – ${firstWhere}` + : `🐝 ${items.length} HiveHub alerts – ${first.title} (${firstWhere}) and more`; + + const html = ` + + + + + + HiveHub alert + + +
+
+

🐝 ${escapeHtml(appName)}

+

HiveHub ${items.length === 1 ? 'alert' : 'alerts'}

+
+ +

${greeting}

+

Your HiveHub sensors raised ${items.length === 1 ? 'an alert' : `${items.length} alerts`} that ${items.length === 1 ? 'needs' : 'need'} a look:

+ + ${items.map(renderItem).join('\n')} + + + +

+ These alerts are computed from sensor data and can be wrong — weather, + harvesting or an inspection can look like hive activity. Please check the hive before acting. +

+ +
+

You receive this because HiveHub alert emails are enabled in your ${escapeHtml(appName)} settings. + An alert is mailed once, and again only if it gets more severe.

+

This is an automated message. Please do not reply to this email.

+
+
+ + + `; + + return { subject, html }; +} diff --git a/apps/backend/src/mail/templates/swarm-alert.template.ts b/apps/backend/src/mail/templates/swarm-alert.template.ts deleted file mode 100644 index 7a6f3b0c2..000000000 --- a/apps/backend/src/mail/templates/swarm-alert.template.ts +++ /dev/null @@ -1,205 +0,0 @@ -interface SwarmAlertTemplateData { - userName: string | null; - email: string; - deviceId: string; - deviceName: string; - scaleChannel: 'scale_1' | 'scale_2'; - scaleDisplayName: string; - previousWeightKg: number; - latestWeightKg: number; - dropKg: number; - measurementWindow: number; - detectedAt: string; // ISO string - appName: string; - appUrl: string; -} - -export function swarmAlertTemplate(data: SwarmAlertTemplateData): { - subject: string; - html: string; -} { - const { - userName, - deviceName, - scaleDisplayName, - previousWeightKg, - latestWeightKg, - dropKg, - measurementWindow, - detectedAt, - appName, - appUrl, - } = data; - - const greeting = userName ? `Hi ${userName},` : 'Hi,'; - const formattedTime = new Date(detectedAt).toLocaleString('en-GB', { - dateStyle: 'long', - timeStyle: 'short', - }); - - const subject = `🐝 Swarm Alert – ${scaleDisplayName} on ${deviceName} lost ${dropKg.toFixed(1)} kg`; - - const html = ` - - - - - - Swarm Alert - - - -
-
-

🐝 ${appName}

-

Possible Swarm Detected

-
- -

${greeting}

-

- A sudden weight drop was detected on ${scaleDisplayName} - (device: ${deviceName}). This may indicate a swarm has left the hive. -

- -
-
−${dropKg.toFixed(1)} kg
-
weight lost over ${measurementWindow} measurements
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Device${deviceName}
Scale channel${scaleDisplayName}
Weight before${previousWeightKg.toFixed(2)} kg
Weight after${latestWeightKg.toFixed(2)} kg
Drop${dropKg.toFixed(2)} kg
Detected at${formattedTime}
- - - -
- Note: This alert fires when the weight drops by more than your configured - threshold within a short measurement window. Rain run-off, wind, or harvesting can also - cause sudden drops — please verify in person. -
- - -
- - - `; - - return { subject, html }; -} diff --git a/apps/frontend/public/locales/da/common.json b/apps/frontend/public/locales/da/common.json index 0f52a844e..bc82f9511 100644 --- a/apps/frontend/public/locales/da/common.json +++ b/apps/frontend/public/locales/da/common.json @@ -79,7 +79,8 @@ "tools": "Værktøjer", "syrupCalculator": "Sirups beregner", "broodTimeline": "Yngel tidslinie", - "swarmManagement": "Sværm styring" + "swarmManagement": "Sværm styring", + "hivehub": "HiveHub" }, "messages": { "confirmDelete": "Er du sikker på at du vil slette denne?", diff --git a/apps/frontend/public/locales/da/hivescale.json b/apps/frontend/public/locales/da/hivescale.json index 37ea42a58..99c974159 100644 --- a/apps/frontend/public/locales/da/hivescale.json +++ b/apps/frontend/public/locales/da/hivescale.json @@ -35,7 +35,7 @@ }, "page": { "title": "HiveHub", - "subtitle": "Live weight and temperature visualisation from your separate HiveHub backend.", + "subtitle": "Vægt, klima, lyd, flyvetrafik og advarsler fra dine HiveHub-enheder.", "refresh": "Refresh", "refreshing": "Refreshing…", "refreshError": "Could not refresh HiveHub data.", @@ -43,16 +43,26 @@ "removeSuccess": "HiveHub device removed from your account.", "removeSuccessShared": "Du har ikke længere adgang til denne HiveHub-enhed. Andre brugere har den stadig, så dens claim-kode forbliver i brug.", "releaseConfirm": "Frigiv {{name}} helt? Alle brugere mister adgang, og enheden kan gøres krav på igen med sin claim-kode. Målinger bevares.", - "releaseSuccess": "HiveHub-enhed frigivet. Der kan gøres krav på den igen med dens claim-kode." + "releaseSuccess": "HiveHub-enhed frigivet. Der kan gøres krav på den igen med dens claim-kode.", + "unavailableTitle": "HiveHub kan ikke nås", + "inspectionActiveTitle": "Inspektionstilstand er slået til", + "inspectionActiveBody": "Målinger skjules, mens staderne er åbne, så det ikke udløser falske advarsler, når du løfter tavler op.", + "openInspections": "Åbn inspektioner", + "tabs": { + "overview": "Oversigt", + "inspections": "Inspektioner", + "audio": "Lyd", + "health": "Tilstand", + "setup": "Opsætning" + } }, "setup": { "title": "Devices", - "description": "Select or remove a claimed HiveHub device. Expand the setup to claim devices, check status, map scales, and calibrate the raw readings.", "removeScale": "Remove scale", "releaseScale": "Frigiv enhed", "noDevices": "No devices claimed yet — open the setup to add one.", - "hide": "Hide setup", - "show": "Show setup" + "selectDevice": "HiveHub-enhed", + "noDevicesYet": "Ingen HiveHub-enhed er gjort krav på endnu. Gør krav på en med koden, som hubben viser i sin opsætningsportal." }, "claim": { "title": "Claim HiveHub device", @@ -65,16 +75,30 @@ "success": "HiveHub device claimed.", "errors": { "missingCode": "Enter the claim code printed/configured for the device." - } + }, + "hive1Name": "Navn på stade 1", + "hive2Name": "Navn på stade 2", + "optional": "Valgfrit" }, "mapping": { - "title": "Scale mapping", - "description": "These labels stay in HiveHub. Later, this is the right place to map Scale 1/2 to HivePal hive IDs for inspection markers.", - "scale1Label": "Display name for Scale 1", - "scale2Label": "Display name for Scale 2", + "title": "Stader", "hiveNamePlaceholder": "Select or type a hive name", - "save": "Save mapping", - "success": "Scale mapping updated." + "save": "Gem stader", + "success": "Stadepladser gemt i HiveHub.", + "slotsDescription": "Giv hver stadeplads et navn, og knyt den til et HivePal-stade. Tilknytningen gør, at inspektioner, stadesiden og advarselsmails ved, hvilken bifamilie en plads er. En grøn prik markerer pladser, som hubben rapporterer lige nu.", + "mapSlot": "Tilknyt plads {{slot}}", + "add": "Tilføj", + "reporting": "Rapporteret af hubben", + "unknownHive": "Stade ikke fundet", + "mappedCount": "{{count}}/{{total}} pladser tilknyttet", + "dialogTitle": "Stadeplads {{slot}}", + "dialogDescription": "Knyt pladsen til et af dine HivePal-stader, og vælg det navn, HiveHub viser for den.", + "linkedHive": "HivePal-stade", + "notLinked": "Ikke tilknyttet", + "linkHint": "Tilknytningen bevares, hvis du omdøber stadet. Vælger du et stade, udfyldes navnet, hvis navnefeltet er tomt.", + "displayName": "Visningsnavn", + "clearSlot": "Ryd plads", + "applySlot": "Anvend" }, "status": { "title": "Device status", @@ -94,6 +118,13 @@ "revokeAccess": "Revoke access", "errors": { "missingEmail": "Enter the email address of an existing HivePal user." + }, + "lastSeen": "Seneste check-in", + "claimedAt": "Gjort krav på", + "roles": { + "owner": "Ejer", + "admin": "Administrator", + "viewer": "Læser" } }, "calibration": { @@ -203,11 +234,9 @@ }, "sourceHint": "Omgivelsestemperaturen følger cellelegemet tæt i stabil tilstand og er den anbefalede standard.", "refLabel": "Referencetemperatur / °C", - "refHint": "Temperatur hvor korrektionen er nul.", "coeffLabel": "{{scaleName}}-koefficient / kg pr. °C", "autoFit": "Auto-tilpas fra historik", "fitting": "Tilpasser…", - "autoFitHint": "Auto-tilpasning udfører en regression af de seneste 3 dages rå vægt mod den EMA-udjævnede valgte temperatur og anvender og aktiverer derefter resultatet. For en ren tilpasning skal du fange et vindue, hvor den fysiske belastning er konstant, og temperaturen svinger.", "save": "Gem temperaturkompensation", "errors": { "refTemp": "Referencetemperaturen skal være et tal (°C).", @@ -219,11 +248,33 @@ "noSignal": "ikke nok temperatur-/vægtsignal.", "fitApplied": "{{scaleName}}: {{coeff}} kg/°C ved {{refTemp}}°C (R²={{r2}}, n={{n}}). Anvendt og aktiveret." } + }, + "perHive": { + "hive": "Stade {{index}}", + "hiveNamed": "Stade {{index}} · {{name}}", + "hiveLabel": "Stade", + "knownWeightPlaceholder": "f.eks. 10", + "manualSaveHint": "Skift stade for at redigere en anden vægt; alle ændringer sendes samlet, når du gemmer. Stader fra nr. 3 og op skrives kun, hvis de er ændret.", + "tempco": { + "refHint": "Den temperatur, hvor vægtene viser korrekt, dvs. den, de blev tareret og kalibreret ved. Der er korrektionen nul; tilpasningen lader den være, medmindre du beder om det.", + "lookbackLabel": "Tilpasningsperiode / dage", + "setRefTemp": "Indstil også referencetemperaturen", + "setRefTempHint": "Erstatter referencen med middeltemperaturen i det tilpassede vindue. Lad være slået fra, hvis vægten blev kalibreret ved en kendt temperatur.", + "fitOnly": "Kun tilpas (forhåndsvisning)", + "fitAndApply": "Tilpas & anvend", + "fitSummary": "{{scaleName}}: {{coeff}} kg/°C, R² {{r2}} over {{n}} målinger fra {{min}}–{{max}} °C (vinduesgennemsnit {{mean}} °C).", + "lowRSquared": "R² under 0,5: temperaturen forklarer kun lidt af denne drift. Tilpas over en periode med konstant belastning og stor forskel mellem dag og nat.", + "autoFitHint": "Auto-tilpasning laver en regression af det valgte stades gemte vægt over tilbageblikvinduet mod den EMA-udjævnede valgte temperatur. \"Kun tilpas\" udfylder koefficienten, så du kan gennemse den, før du gemmer; \"Tilpas & anvend\" gemmer den og slår kompensation til. For en ren tilpasning skal du vælge et vindue, hvor den fysiske belastning er konstant, og temperaturen svinger.", + "errors": { + "lookback": "Tilbageblikket skal være et helt antal dage mellem 1 og 90." + }, + "toasts": { + "fitFilled": "{{scaleName}}: {{coeff}} kg/°C (R²={{r2}}, n={{n}}). Udfyldt — gennemse og gem." + } + } } }, "firmware": { - "title": "Firmware upload", - "description": "Upload a firmware binary and register it as a release. New devices of the matching type pick it up on their next update check.", "type": "Firmware type", "selectType": "Select firmware type", "version": "Version", @@ -239,16 +290,101 @@ "missingFile": "Choose a firmware binary (.bin) to upload.", "missingVersion": "Enter a version, e.g. 0.6.3." }, - "hiveinsideOta": { - "title": "Udløs HiveInside-OTA", - "description": "Upload af en HiveInside-firmware sætter nu automatisk den trådløse opdatering i kø for begge sensor-slots via BLE. Brug denne til at sætte den aktive version i kø igen til et bestemt slot. Hold sensoren tændt og vågen under opdateringen.", - "slot": "Sensor-slot", - "slotOption": "Slot {{slot}}", - "queue": "Sæt HiveInside-OTA i kø", + "update": { + "title": "Firmwareopdatering", + "description": "Vægte opdaterer aldrig af sig selv — du godkender hver ny version her, før denne vægt installerer den.", + "unavailable": "Firmwarestatus er ikke tilgængelig lige nu.", + "availableTitle": "Firmware {{version}} er tilgængelig", + "availableBody": "Denne vægt kører {{current}}. Anvend for at opdatere den til {{version}}.", + "officialSuffix": "Dette er en officiel version.", + "apply": "Anvend opdatering", + "applying": "Anvender…", + "applied": "Firmware {{version}} godkendt — vægten opdaterer ved sit næste check-in.", + "queued": "Opdatering {{version}} godkendt. Vægten installerer den ved sit næste check-in.", + "upToDate": "Firmwaren er opdateret ({{version}}).", + "viewerNotice": "Kun enhedsejere og administratorer kan anvende firmwareopdateringer." + }, + "board": { + "cardTitle": "Firmware", + "cardDescription": "Firmware på denne HiveHub og på de HiveInside-noder og HiveTraffic-tællere, den har tilknyttet. Upload registrerer en version; godkendelse (hub) eller videresendelse (noder) installerer den.", + "current": "Nuværende", + "latest": "Nyeste", + "approved": "Godkendt", + "board": "Board", + "badgePending": "Opdatering afventer godkendelse", + "badgeApproved": "Opdatering godkendt", + "badgeUpToDate": "Opdateret", + "approveFlash": "Godkend & flash {{version}}", + "approveConfirm": "Godkend firmware {{version}} og flash den trådløst?\n\nHiveHub installerer den ved sit næste check-in og genstarter. En fjernplaceret enhed, hvor opdateringen fejler undervejs, kan kræve fysisk adgang for at blive gendannet.", + "otherBoards": "Også uploadet til et andet board (ikke anvendt på denne {{board}}-enhed): {{releases}}. En version når kun en enhed med matchende board — tjek boardet ved upload.", + "otherBoardsUnknown": "Også uploadet til et andet board (ikke anvendt på denne enhed): {{releases}}. En version når kun en enhed med matchende board — tjek boardet ved upload." + }, + "relays": { + "hiveFallback": "Stade {{slot}}", + "latest": "nyeste {{version}}", + "updateAvailable": "opdatering tilgængelig: {{version}}", + "status": { + "pending": "videresendelse i kø", + "running": "videresender…", + "completed": "videresendelse fuldført", + "failed": "videresendelse mislykkedes", + "expired": "videresendelse fik timeout" + }, + "statusTitle": "Videresendelse af {{version}}: {{status}} ({{date}})", + "failedDetail": "Seneste videresendelse mislykkedes — {{message}} ({{date}})", + "noReason": "ingen årsag rapporteret", + "alreadyQueued": "Der er allerede en videresendelse i kø for denne node", "queueing": "Sætter i kø…", - "success": "HiveInside-OTA sat i kø for slot {{slot}}.", - "autoQueued": "HiveInside-OTA automatisk sat i kø for slots {{slots}}.", - "autoQueueFailed": "HiveInside-OTA kunne ikke sættes automatisk i kø for slots {{slots}}." + "relayAnyway": "Videresend alligevel", + "confirm": "Videresend {{name}} {{version}} til {{hive}}?\n\nHiveHub tager det op ved sin næste uploadcyklus — normalt inden for ca. 10 minutter, eller hvilket sendeinterval denne enhed nu bruger. Derefter streamer den billedet over BLE i nogle minutter, og noden genstarter med det. Noden skal være vågen og inden for rækkevidde, ellers mislykkes videresendelsen og skal sættes i kø igen.", + "queued": "Videresendelse af {{version}} sat i kø til {{hive}}.", + "queuedFrom": "Videresendelse sat i kø til {{hive}} ({{current}} → {{version}}).", + "hiveinside": { + "name": "HiveInside", + "title": "HiveInside-noder", + "description": "Firmware, som hver node i stadet annoncerer. Upload af et HiveInside-billede registrerer kun versionen — intet når en node, før du trykker på “Videresend til node”. Versionen, der vises her, er det, der bekræfter, at opdateringen lykkedes.", + "relay": "Videresend til node", + "unnamed": "Annoncerer intet eget navn (HiveInside før 0.5.0)", + "noRelease": "Ingen HiveInside-version uploadet endnu — upload en nedenfor for at videresende den." + }, + "beecounter": { + "name": "HiveTraffic", + "title": "HiveTraffic-tællere", + "description": "Firmware, som hver HiveTraffic-tæller rapporterer. Upload af et HiveTraffic-billede registrerer kun versionen — intet når en tæller, før du trykker på “Videresend til tæller”. Tællere ældre end ca. v0.1.0 rapporterer ingen version, men kan stadig opdateres.", + "relay": "Videresend til tæller", + "unnamed": "Rapporterer intet navn eller adresse", + "noRelease": "Ingen HiveTraffic-version uploadet endnu — upload en nedenfor for at videresende den.", + "countingWarning": "En tæller holder op med at tælle bier under hele overførslen, så videresend, når flyvetrafikken er lav.", + "confirmExtra": "Tælleren holder op med at tælle bier under hele overførslen — al trafik i de få minutter går tabt." + } + }, + "uploadForm": { + "title": "Upload firmware", + "description": "Upload registrerer kun en version — den installerer aldrig noget af sig selv. Godkend hub-opdateringen ovenfor, eller videresend en node- eller tællerversion fra dens række.", + "board": "Board", + "targets": { + "hivehub": "HiveHub-hub", + "hiveinside": "HiveInside", + "beecounter": "HiveTraffic-tæller" + }, + "boards": { + "auto": "Automatisk (fra filnavn)", + "esp32": "ESP32 (klassisk 30-pin)", + "esp32-c6": "ESP32-C6", + "nrf54lm20a": "nRF54LM20A" + }, + "boardHint": { + "hivehub": "Hub-firmware skal angive sit board: vælg et, eller behold automatisk, når filen hedder noget i stil med hivehub_esp32-c6_0.30.5.bin.", + "hiveinside": "HiveInside findes kun til nRF54LM20A. Upload build-artefaktet som det er (f.eks. hiveinside_nrf54lm20a_1.4.0.bin), så udfyldes mål og version ud fra navnet.", + "beecounter": "HiveTraffic-tælleren er en ESP32-C6. Upload applikationsbilledet (f.eks. beecounter_esp32-c6_0.7.1.bin), ikke et sammenflettet factory-billede." + }, + "detected": "{{name}} — udfyldte {{fields}}.", + "nothingDetected": "{{name}} — intet mål, board eller version genkendt i navnet; angiv dem nedenfor.", + "detectedTarget": "mål “{{target}}”", + "detectedBoard": "board {{board}}", + "detectedVersion": "version {{version}}", + "autoQueued": "Videresendelse automatisk sat i kø til {{hives}}.", + "autoQueueFailed": "Kunne ikke automatisk sætte videresendelsen i kø til {{hives}}." } }, "sdData": { @@ -273,12 +409,18 @@ "parsed_other": "Parsed {{count}} records", "skipped_one": " · {{count}} unreadable line skipped.", "skipped_other": " · {{count}} unreadable lines skipped." + }, + "mismatch": { + "title": "Fil fra en anden hub", + "description": "Denne fil er optaget af en anden hub, ikke af {{device}}.", + "fileDevices_one": "Enheds-id i filen:", + "fileDevices_other": "Enheds-id'er i filen:", + "hint": "Importér den kun alligevel, hvis SD-kortet virkelig hører til denne enhed (f.eks. efter udskiftning af hubbens printkort).", + "cancel": "Annullér", + "force": "Importér til denne enhed alligevel" } }, "panel": { - "weight": "Weight", - "hiveTemp": "Hive temp", - "rmsSound": "RMS sound", "ambientTemperature": "Ambient temperature", "ambientHumidity": "Ambient humidity", "batteryCharge": "Battery charge", @@ -288,7 +430,6 @@ "title": "General", "description": "Ambient & power" }, - "humidity": "Hive humidity", "wirelessSensorsBattery": "Wireless sensors battery", "sensorTypes": { "hiveHeart": "HiveHeart", @@ -297,174 +438,36 @@ } }, "diagram": { - "title": "Measurements", - "subtitle": "Weight, temperature, and entrance activity over time", - "noMeasurements": "No measurements for the selected range.", - "noDataForSeries": "No data in the selected range.", - "meta": { - "device": "Device:", - "lastSeen": "Last seen:", - "lastData": "Last data:", - "firmware": "Firmware:" - }, "range": { "all": "All", "from": "From", "to": "To" }, - "series": { - "weight": "{{name}} weight", - "temp": "{{name}} temp", - "micRms": "{{name}} mic RMS", - "beesIn": "{{name}} bees in", - "beesOut": "{{name}} bees out", - "netFlow": "{{name}} net flow", - "ambientTemp": "Ambient temp", - "ambientHumidity": "Ambient humidity", - "batteryVoltage": "Battery voltage", - "batteryCharge": "Battery charge", - "solarVoltage": "Solar voltage", - "solarCurrent": "Solar current", - "solarPower": "Solar power", - "humidity": "{{name}} luftfugtighed", - "pressure": "{{name}} lufttryk", - "fanningBand": "{{name}} fanning band (30–100 Hz)", - "activityBand": "{{name}} activity band (100–200 Hz)" - }, - "group": { - "ambient": "Ambient", - "offGrid": "Off-grid" - }, - "axis": { - "weight": "Weight", - "temperature": "Temperature", - "humidity": "Humidity", - "voltage": "Voltage", - "percent": "Percent", - "current": "Current", - "power": "Power", - "sound": "Sound", - "beecount": "Bee count", - "pressure": "Lufttryk" - }, "marker": { - "inspection": "Inspection", - "boxAdded": "Box added", - "action": { - "maintenance": "Maintenance", - "feeding": "Feeding", - "frames": "Frames", - "treatment": "Treatment" - } - }, - "axisSettings": { - "title": "Axis settings", - "reset": "Reset", - "side": "Side", - "left": "Left", - "right": "Right", - "scale": "Scale", - "auto": "Auto", - "zeroToMax": "0 to max", - "custom": "Custom", - "min": "Min", - "max": "Max", - "autoPlaceholder": "auto" + "inspection": "Inspection" } }, "sound": { - "title": "Hive sound", - "description": "INMP441 FFT band energy (dBFS) — {{left}} (left) · {{right}} (right)", - "hide": "Hide sound", - "show": "Show sound", - "micLeftFallback": "Mic left", - "micRightFallback": "Mic right", - "noDataForChannel": "No data for {{name}} in this range.", - "bandReference": "Band reference", - "thresholds": "Thresholds used by the Insights engine: piping > −45 dBFS, stress > −38 dBFS, queenless hum > −40 dBFS with quiet piping. Calibrate against your own baseline.", - "status": { - "noData": "No data", - "micError": "Mic error", - "bothActive": "{{left}} + {{right}} active", - "oneActive": "{{name}} active" - }, - "table": { - "band": "Band", - "range": "Range", - "significance": "Significance" - }, "bands": { "subBass": { - "label": "Sub-bass", - "description": "Structural vibration / low rumble" + "label": "Sub-bass" }, "hum": { - "label": "Hum", - "description": "Normal colony hum (~200 Hz fundamental)" + "label": "Hum" }, "piping": { - "label": "Piping", - "description": "Queen piping / tooting (pre-swarm signal)" + "label": "Piping" }, "stress": { - "label": "Stress", - "description": "Agitated colony / robbing" + "label": "Stress" }, "high": { - "label": "High", - "description": "Harmonic overtones" + "label": "High" } - }, - "rmsToggleShow": "Show RMS level", - "rmsToggleHide": "Hide RMS level", - "rmsTitle": "RMS sound level", - "rmsSeries": "{{name}} RMS" - }, - "beecounter": { - "title": "Entrance Counter", - "subtitle": "Bees entering and exiting per measurement interval", - "noData": "No BeeCounter data found for this device.", - "offline": "Offline", - "gatesOk": "{{count}} gates OK", - "glitches_one": "{{count}} glitch", - "glitches_other": "{{count}} glitches", - "gatesRatio": "{{healthy}}/{{total}} gates", - "inPeriod": "In (period)", - "outPeriod": "Out (period)", - "netFlow": "Net flow", - "netInbound": "net inbound", - "netOutbound": "net outbound", - "balanced": "balanced", - "view": { - "inOut": "In / Out", - "netFlow": "Net flow", - "cumulative": "Cumulative" - }, - "series": { - "in": "{{name}} in", - "out": "{{name}} out", - "net": "{{name}} net", - "totalIn": "{{name}} total in", - "totalOut": "{{name}} total out" - }, - "diagnostics": { - "title": "Hardware diagnostics", - "gatesHealthy": "Gates healthy", - "glitchCount": "Glitch count", - "totalInLifetime": "Total in (lifetime)", - "totalOutLifetime": "Total out (lifetime)" } }, "insights": { - "title": "Insights", - "description": "Sensor-based alerts derived from weight and temperature trends.", - "updated": "Updated {{ago}}.", - "refresh": "Refresh insights", - "loadError": "Failed to load insights: {{message}}", "allClear": "All clear", - "noActiveAlerts": "No active alerts in the last {{count}} days.", - "activeAlerts_one": "{{count}} active alert:", - "activeAlerts_other": "{{count}} active alerts:", "confidence": "Confidence {{value}}%", "source": "Source: {{source}}", "hiveChannel": "Hive {{channel}}", @@ -549,5 +552,720 @@ "description": "No alerts have been recorded for this device.", "descriptionFiltered": "No alerts have been recorded for this device matching this filter." } + }, + "inspectionMode": { + "title": "Inspektionstilstand", + "description": "Mens inspektionstilstand er slået til, bliver HiveHub ved med at måle, men holder målingerne ude af grafer, indsigter og advarsler, så det ikke forveksles med en sværm, når du løfter tavler op.", + "status": { + "active": "Aktiv siden {{time}}", + "pending": "Afventer, at hubben henter den", + "off": "Fra" + }, + "pendingHint": "Anmodet — hubben henter det ved sit næste check-in.", + "timeoutNote_one": "Slutter automatisk efter {{count}} minut, hvis ingen stopper den.", + "timeoutNote_other": "Slutter automatisk efter {{count}} minutter, hvis ingen stopper den.", + "scope": { + "wholeHub": "Hele hubben", + "label": "Omfatter: {{hives}}" + }, + "start": "Start inspektionstilstand", + "stop": "Stop inspektionstilstand", + "starting": "Starter…", + "stopping": "Stopper…", + "cancel": "Annullér", + "noteLabel": "Note (valgfri)", + "notePlaceholder": "f.eks. Tilføjet et honningmagasin", + "viewerHint": "Kun ejere og administratorer kan starte eller stoppe inspektionstilstand.", + "loadFailed": "Kunne ikke indlæse inspektionsstatus.", + "startDialog": { + "title": "Start inspektionstilstand", + "description": "Målinger fra de valgte stader skjules, indtil du stopper inspektionstilstand, eller den får timeout.", + "hivesLabel": "Stader", + "wholeHub": "Hele hubben (alle stader)", + "selectHive": "Vælg mindst ét stade eller hele hubben.", + "confirm": "Start" + }, + "stopDialog": { + "title": "Stop inspektionstilstand", + "description": "HiveHub vender tilbage til normale målinger og advarsler.", + "confirm": "Stop" + }, + "toast": { + "started": "Inspektionstilstand startet", + "stopped": "Inspektionstilstand stoppet", + "noteSaved": "Note gemt" + }, + "history": { + "title": "Seneste inspektioner", + "empty": "Ingen inspektioner registreret endnu.", + "ongoing": "i gang", + "startedVia": "Startet via {{source}}", + "ended": "Afsluttet: {{reason}}", + "editNote": "Redigér note", + "addNote": "Tilføj note", + "saveNote": "Gem" + }, + "duration": { + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "hoursMinutes": "{{hours}} t {{minutes}} min" + }, + "source": { + "device": "hub-knap", + "api": "app", + "dashboard": "lokalt dashboard" + }, + "endReason": { + "timeout": "timeout", + "device": "hub-knap", + "api": "stoppet i app", + "dashboard": "stoppet på lokalt dashboard" + } + }, + "hiveLink": { + "prompt": { + "message": "Dette stade overvåges af HiveHub {{device}} ({{hive}}). Start inspektionstilstand, så det ikke udløser falske advarsler, når du åbner stadet.", + "active": "Inspektionstilstand er slået til for {{hive}} på HiveHub {{device}}.", + "pending": "Inspektionstilstand anmodet for {{hive}} på HiveHub {{device}} — hubben henter den ved sit næste check-in.", + "activeOther": "Inspektionstilstand er slået til for andre stader på HiveHub {{device}}, ikke for {{hive}}. Stop den først for at starte en for dette stade." + }, + "card": { + "title": "HiveHub", + "description": "Live-målinger fra de tilknyttede HiveHub-sensorer.", + "weight": "Vægt", + "weightChange24h": "Ændring 24 t", + "temperature": "Stadetemperatur", + "humidity": "Luftfugtighed", + "lastReading": "Seneste måling: {{time}}", + "openHiveHub": "Åbn HiveHub" + } + }, + "traffic": { + "title": "HiveTraffic-bitællere", + "description": "Nattilstand og emitterbanker for hver HiveTraffic-tæller, der er parret med denne hub. IR-emitterne er langt den største post i en tællers strømbudget, så disse indstillinger afgør, om den kan klare sig på en off-grid-forsyning.", + "readOnly": "Kun enhedsejere og administratorer kan ændre disse indstillinger. Din rolle er “{{role}}”.", + "hiveFallback": "Stade {{slot}}", + "noChanges": "Ingen ændringer at gemme", + "saved": "Bitællerindstillinger gemt. Tællerne henter dem ved næste check-in.", + "appliesToAll": "Gælder for alle HiveTraffic-tællere, der er parret med denne enhed. Tælleren får besked ved hubbens næste uploadcyklus.", + "status": { + "title": "Tællere lige nu", + "none": "Ingen HiveTraffic-tæller rapporteret i den seneste måling.", + "counting": "Tæller", + "nightIdle": "Nattehvile", + "nightIdleFor_one": "Nattehvile · {{count}} min tilbage", + "nightIdleFor_other": "Nattehvile · {{count}} min tilbage", + "banksRunning": "Aktive emitterbanker: {{banks}}", + "banksUnknown": "Emitterbanker ikke rapporteret (tællerfirmware ældre end 0.3.0)", + "applied": "Banker anvendt", + "pending": "Banker afventer næste check-in" + }, + "night": { + "title": "Nattilstand", + "description": "Honningbier er dagaktive — flyvning kræver lys og stopper alligevel under ca. 10 °C. I dette vindue holder tælleren op med at måle for at spare strøm. Den bliver ved med at annoncere, så målinger og firmwareopdateringer virker stadig.", + "enable": "Slå nattilstand til", + "start": "Natten starter (lokal tid)", + "end": "Natten slutter (lokal tid)", + "windowHelp": "Vinduet må gå hen over midnat (f.eks. 20:00 → 06:00). Hvis start og slut er ens, er vinduet tomt — det gælder aldrig — i stedet for at dække hele døgnet. Lad der være en margin omkring skumring og daggry: aktiviteten topper lige før solnedgang og solopgang.", + "emptyWindow": "Start og slut er ens, så vinduet er tomt, og nattilstand ville aldrig gælde.", + "wrapsMidnight": "Vinduet går hen over midnat: fra {{start}} til {{end}} næste morgen.", + "timezone": "Tidszone", + "tzCustom": "POSIX TZ-streng", + "timezoneHelp": "Hubbens ur kører i UTC. Uden den rigtige tidszone er vinduet forskudt med din UTC-forskel og flytter sig en time ved hvert skift til og fra sommertid.", + "tzPresets": { + "cet": "Centraleuropa (Berlin, Paris, Madrid, Wien)", + "uk": "Storbritannien / Irland", + "eet": "Østeuropa (Helsinki, Athen)", + "usEastern": "USA Eastern", + "usCentral": "USA Central", + "usMountain": "USA Mountain", + "usPacific": "USA Pacific", + "utc": "UTC (ingen lokal tid)", + "custom": "Brugerdefineret…" + }, + "maxTraffic": "Udsæt over (passager pr. cyklus)", + "maxTrafficHelp": "Udsæt nattilstand, så længe der blev talt flere passager end dette (ind + ud) i den seneste uploadcyklus, så en bifamilie, der stadig flyver sent en sommeraften, ikke bliver afskåret. 0 går kun efter uret.", + "save": "Gem nattilstand" + }, + "banks": { + "title": "Emitterbanker", + "description": "Tællerens IR-emittere tændes i tre banker med otte gennemgange hver. Hver bank bruger ca. 80 mA. Sluk en, hvis flyvehullet er smallere end 24 gennemgange, delvist lukket, eller hvis forsyningen ikke kan bære hele printet.", + "bank": "Bank {{bank}} — gennemgange {{gates}}", + "current_one": "{{count}} bank tændt · ca. {{ma}} mA på tællerens 3,3 V-skinne", + "current_other": "{{count}} banker tændt · ca. {{ma}} mA på tællerens 3,3 V-skinne", + "help": "En slukket bank tæller slet ikke sine gennemgange, så totalerne falder nogenlunde forholdsmæssigt — sammenlign ikke en indsnævret tæller med dens egen historik. Mindst én bank skal forblive tændt; fjern parringen af en tæller, der ikke skal tælle noget.", + "firmwareNote": "Kræver tællerfirmware 0.3.0 eller nyere. Indstillingen sendes igen efter hver nulstilling af tælleren.", + "save": "Gem emitterbanker" + }, + "errors": { + "loadFailed": "Kunne ikke indlæse enhedskonfigurationen.", + "saveFailed": "Kunne ikke gemme bitællerindstillingerne", + "invalidTime": "Angiv et gyldigt start- og sluttidspunkt (TT:MM).", + "invalidMaxTraffic": "Udsættelsesgrænsen skal være et helt tal på 0 eller mere.", + "emptyWindow": "Start og slut for nattilstand er ens — vinduet ville aldrig gælde.", + "noBank": "Mindst én emitterbank skal forblive tændt — fjern i stedet parringen af tælleren." + } + }, + "hubAccess": { + "title": "Hub-adgang", + "description": "Få adgang til hubben på stedet uden at åbne kabinettet, og angiv, hvor længe en inspektion må vare.", + "readOnly": "Kun enhedsejere og administratorer kan ændre disse indstillinger. Din rolle er “{{role}}”.", + "noChanges": "Ingen ændringer at gemme", + "cancel": "Annullér", + "ap": { + "title": "Opsætnings-access point", + "description": "Åbner hubbens HiveHub-Setup Wi-Fi-netværk, ligesom et kort tryk på opsætningsknappen: skift Wi-Fi, par sensorer eller download SD-kortets data.", + "lastCheckIn": "Seneste check-in: {{time}}", + "open": "Åbn opsætnings-access point", + "confirmTitle": "Åbn opsætnings-access point?", + "confirmTiming_one": "Anmodningen sættes i kø. Hubben sover mellem cyklusserne, så access pointet åbner efter dens næste check-in — senest om {{count}} minut.", + "confirmTiming_other": "Anmodningen sættes i kø. Hubben sover mellem cyklusserne, så access pointet åbner efter dens næste check-in — senest om {{count}} minutter.", + "confirmTimingUnknown": "Anmodningen sættes i kø. Hubben sover mellem cyklusserne, så access pointet åbner efter dens næste check-in — senest om ét sendeinterval.", + "confirmOffline": "Mens access pointet er åbent, er hubben ikke på Wi-Fi og sender ingen målinger.", + "confirmTimeout": "Det lukker af sig selv efter opsætningsportalens timeout, hvis ingen forbinder sig, så start det kun, når der er nogen ved stadet.", + "confirm": "Sæt access point i kø", + "queueing": "Sætter i kø…", + "queued": "Access point sat i kø. Det åbner efter hubbens næste check-in.", + "failed": "Kunne ikke sætte access pointet i kø" + }, + "inspection": { + "title": "Inspektionstimeout", + "description": "Et sikkerhedsnet: en inspektion slutter af sig selv efter dette antal minutter, så et glemt tryk på knappen ikke skjuler et stades data på ubestemt tid. Standard 60.", + "label": "Timeout (minutter, 1–1440)", + "save": "Gem timeout", + "saved": "Inspektionstimeout gemt", + "saveFailed": "Kunne ikke gemme inspektionstimeouten", + "invalid": "Angiv et helt antal minutter mellem 1 og 1440." + } + }, + "dataTools": { + "title": "Dataeksport & oprydning", + "description": "Tag backup af denne hubs målinger, eller fjern fejlagtige.", + "hiveFallback": "Stade {{slot}}", + "cancel": "Annullér", + "export": { + "title": "Eksportér målinger", + "description": "Download de gemte målinger som en NDJSON-backup.", + "from": "Fra (valgfri)", + "to": "Til (valgfri)", + "rangeHelp": "Lad begge være tomme for at eksportere alt. Datoer dækker hele dage i din lokale tid.", + "hives": "Stader (valgfri)", + "allHives": "Alle stader er inkluderet.", + "someHives_one": "Kun {{count}} valgt stade er inkluderet. Antallet nedenfor dækker alle målinger i perioden.", + "someHives_other": "Kun {{count}} valgte stader er inkluderet. Antallet nedenfor dækker alle målinger i perioden.", + "invalidRange": "Startdatoen ligger efter slutdatoen.", + "summaryFailed": "Kunne ikke tælle målingerne.", + "count_one": "{{count}} måling", + "count_other": "{{count}} målinger", + "span": "Fra {{first}} til {{last}}", + "download": "Download", + "formatHelp": "Én JSON-måling pr. linje. Filen kan importeres igen med SD-kortimporten.", + "noPermission": "Kun enhedsejere og administratorer kan eksportere data. Din rolle er “{{role}}”." + }, + "delete": { + "title": "Slet målinger i en periode", + "description": "Fjerner fejlagtige målinger — for eksempel de vægtudsving, en hub registrerer ved opstart, før dens vægte er kalibreret, og som ellers forvrænger grafer og indsigter.", + "warningTitle": "Dette kan ikke fortrydes", + "warning": "Slettede målinger er væk for altid. Eksportér perioden først, hvis du måske får brug for den.", + "start": "Start", + "end": "Slut", + "claimCode": "Claim-kode", + "claimCodeHelp": "Hubbens claim-kode som en ekstra bekræftelse.", + "review": "Gennemse sletning", + "confirmTitle": "Slet disse målinger?", + "confirmRange": "Fra {{start}} til {{end}}.", + "confirmCount_one": "{{count}} måling slettes permanent.", + "confirmCount_other": "{{count}} målinger slettes permanent.", + "confirmIrreversible": "Dette kan ikke fortrydes.", + "countFailed": "Kunne ikke tælle målingerne i denne periode.", + "confirm_one": "Slet {{count}} måling", + "confirm_other": "Slet {{count}} målinger", + "deleting": "Sletter…", + "success_one": "Slettede {{count}} måling.", + "success_other": "Slettede {{count}} målinger.", + "noPermission": "Kun enhedens ejer kan slette målinger. Din rolle er “{{role}}”.", + "errors": { + "missingRange": "Vælg en start og en slutning.", + "invalidRange": "Slutningen skal være samtidig med eller efter starten.", + "missingClaimCode": "Angiv hubbens claim-kode." + } + } + }, + "alertSettings": { + "title": "HiveHub-mailadvarsler", + "description": "Få en mail, når HiveHub opdager noget ved dine stader — sværmning, røveri, en dronningeløs bifamilie og mere.", + "enable": "Send mig mails om HiveHub-advarsler", + "minSeverity": "Mindste alvorlighed", + "minSeverityHelp": "Advarsler under dette niveau sendes aldrig pr. mail.", + "severity": { + "watch": "Hold øje og højere", + "warning": "Advarsel og højere", + "critical": "Kun kritiske" + }, + "help": { + "schedule": "HivePal tjekker for nye advarsler hvert 15. minut.", + "once": "Hver advarsel mailes én gang, når den opstår, og kun igen, hvis den bliver mere alvorlig.", + "scope": "Omfatter alle HiveHub-enheder, du har adgang til.", + "email": "Kræver, at mail er konfigureret på HivePal-serveren." + }, + "saved": "Advarselsindstillinger gemt", + "saveFailed": "Kunne ikke gemme advarselsindstillingerne", + "loadFailed": "Kunne ikke indlæse dine præferencer." + }, + "health": { + "title": "Sensortilstand", + "description": "Tilstand pr. sensor som rapporteret af {{name}}.", + "descriptionAt": "Sensortilstand fra seneste check-in · {{ago}}.", + "noData": "Ingen sensortilstand rapporteret endnu — den vises, når enheden sender sin første måling.", + "hubHeading": "Hub", + "hiveIndex": "Stade {{n}}", + "noHiveSensors": "Ingen sensorer rapporteret for dette stade.", + "faultNote": "Hubben rapporterer sine egne delsystemer som OK/fejl — den sender ingen fejlkode for dem, så hver fejl ovenfor beskriver i stedet, hvad der svigtede. Flyvehulstælleren rapporterer derimod en statuskode, og den vises sammen med fejlen, hvor en er blevet aflæst.", + "summary": { + "noData": "Ingen sensordata", + "allOk": "Alt OK", + "faults_one": "{{count}} fejl", + "faults_other": "{{count}} fejl" + }, + "state": { + "ok": "OK", + "fault": "Fejl", + "notFitted": "Ikke monteret", + "notHeard": "Ikke modtaget" + }, + "hub": { + "sht_ok": "Omgivelsestemp./fugtighed (SHT40)", + "rtc_ok": "Realtidsur", + "sd_ok": "SD-kort", + "mic_ok": "Mikrofon", + "mic_left_ok": "Mikrofon · venstre", + "mic_right_ok": "Mikrofon · højre", + "battery_monitor_ok": "Batteriovervågning", + "solar_monitor_ok": "Solovervågning" + }, + "hubReason": { + "sht_ok": "Omgivelsessensoren returnerede ikke en gyldig måling i denne cyklus, så dette check-in indeholder ingen omgivelsestemperatur eller -fugtighed. Oftest en fejl på I²C-bussen eller i ledningsføringen; en sensor, der aldrig blev fundet ved opstart, ser ud på samme måde.", + "rtc_ok": "Realtidsuret svarede ikke på I²C-bussen. Tidsstempler falder tilbage til netværkstid, så en måling taget offline kan få forkert dato.", + "sd_ok": "SD-kortet blev ikke monteret i denne cyklus — mangler, er ulæseligt eller har en SPI-/ledningsfejl. Målinger bufres ikke på kortet, så længe dette varer.", + "mic_ok": "Mikrofonoptagelsen gav ingen brugbare samples, så dette check-in indeholder ingen akustiske bånd.", + "mic_left_ok": "Den venstre mikrofon gav ingen brugbare samples.", + "mic_right_ok": "Den højre mikrofon gav ingen brugbare samples.", + "battery_monitor_ok": "Batterimåleren svarede ikke på I²C-bussen — batterispænding og ladetilstand mangler i dette check-in.", + "solar_monitor_ok": "Solovervågningen svarede ikke på I²C-bussen — solspænding, -strøm og -effekt mangler i dette check-in." + }, + "sensor": { + "scale": "Vægt", + "inHive": "Stadesensor", + "counter": "HiveTraffic-tæller", + "hiveheart": "HiveHeart" + }, + "reason": { + "scale": "Der er konfigureret en vægt for dette stade, men den gav ingen brugbar måling i denne cyklus, så dette check-in indeholder ingen vægt for det.", + "inHive": "Ingen annoncering fra den parrede {{kind}} under denne cyklus' BLE-scanning — uden for rækkevidde, afladet batteri eller slukket.", + "inHiveKindFallback": "stadesensor", + "node": "Node {{node}}.", + "lastHeard": "Senest modtaget {{ago}}.", + "notHeardInRange": "Ikke modtaget i den indlæste periode.", + "counter": "Flyvehulstælleren svarede ikke på sin GATT-læsning i denne cyklus — uden for rækkevidde, slukket eller optaget, da HiveHub kaldte op.", + "counterNode": "Tæller {{node}}.", + "lastAnswered": "Senest svaret {{ago}}.", + "notAnsweredInRange": "Den har ikke svaret i den indlæste periode.", + "statusThen": "Status dengang: {{status}}.", + "expandersThen_one": "{{count}} af 3 port-expandere svarede dengang.", + "expandersThen_other": "{{count}} af 3 port-expandere svarede dengang.", + "hiveheart": "HiveHeart blev ikke modtaget i denne cyklus — uden for rækkevidde, afladet batteri eller slukket." + }, + "counter": { + "nightIdle": "nattilstand i hvile", + "nightIdleBadge": "Nattehvile", + "otherBits": "tællerspecifikke bits {{hex}}", + "noFlags": "ingen flag sat", + "statusCode": "status {{hex}}", + "expanders_one": "{{count}}/3 gennemgangs-expandere", + "expanders_other": "{{count}}/3 gennemgangs-expandere", + "glitches_one": "{{count}} forstyrrelse", + "glitches_other": "{{count}} forstyrrelser", + "banks": "Banker {{list}}", + "noBanks": "Ingen emitterbanker" + }, + "meta": { + "firmware": "v{{version}}", + "batteryPercent": "Batteri {{value}} %", + "batteryMv": "Batteri {{value}} mV", + "batteryV": "Batteri {{value}} V", + "rssi": "{{value}} dBm" + } + }, + "hubStatus": { + "title": "Hubstatus", + "description": "Forbindelse, ur og strøm for selve HiveHub.", + "noData": "Ingen måling modtaget fra denne hub endnu.", + "staleWarning": "Ingen måling i {{ago}} — hubben har misset flere check-ins.", + "badge": { + "live": "Live", + "stale": "Forældet", + "inspection": "Inspektion i gang", + "calibration": "Kalibreringstilstand" + }, + "section": { + "connectivity": "Forbindelse", + "battery": "Batteri", + "solar": "Sol" + }, + "row": { + "signal": "Signal", + "transport": "Transport", + "timeSource": "Tidskilde", + "lastSeen": "Senest set", + "lastReading": "Seneste måling", + "firmware": "Firmware", + "bootCount": "Antal opstarter", + "stateOfCharge": "Ladetilstand", + "batteryVoltage": "Spænding", + "batteryAlert": "Batteriadvarsel", + "solarPower": "Soleffekt", + "solarCurrent": "Solstrøm", + "solarBus": "Panelspænding (bus)", + "solarLoad": "Belastningsspænding" + }, + "signalQuality": { + "excellent": "Fremragende", + "good": "God", + "fair": "Middel", + "poor": "Svag" + }, + "transport": { + "wifi": "Wi-Fi", + "cellular": "Mobilnet" + }, + "rtc": { + "ok": "RTC OK", + "fault": "RTC-fejl" + }, + "batteryAlertActive": "Lavt batteri" + }, + "hiveCards": { + "empty": "Denne enhed har ikke rapporteret nogen stader endnu.", + "hiveIndex": "Stade {{n}}", + "weight": "Vægt", + "temperature": "Temperatur", + "humidity": "Luftfugtighed", + "delta24h": "24 t {{value}} kg", + "lastReported": "Senest rapporteret {{ago}}", + "inspection": "Inspektion", + "insightsLoading": "Indlæser indsigter", + "insightsUnavailable": "Indsigter ikke tilgængelige", + "noSensors": "Ingen sensorer rapporteret", + "traffic": "Ind {{in}} · Ud {{out}}", + "trafficTitle": "Bier ind/ud i det seneste interval", + "alerts_one": "{{count}} aktiv advarsel", + "alerts_other": "{{count}} aktive advarsler", + "sensor": { + "scale": "Vægt", + "inHive": "Stadesensor", + "counter": "HiveTraffic-tæller", + "hiveheart": "HiveHeart" + }, + "state": { + "ok": "OK", + "fault": "Ikke modtaget / fejl" + } + }, + "audio": { + "title": "Stadelyd", + "description": "Bed et stades HiveInside-node om at optage, og lyt til optagelsen her. Hubben henter anmodningen, næste gang den vågner, så en optagelse dukker op inden for ét rapporteringsinterval og ikke med det samme.", + "noNodes": "Der er ingen HiveInside-node parret med denne hub. Kun HiveInside har en mikrofon — en HolyIot- eller RuuviTag-beacon kan ikke optage.", + "hive": "Stade", + "hiveFallback": "Stade {{slot}}", + "length": "Længde", + "durationOption_one": "{{count}} sekund", + "durationOption_other": "{{count}} sekunder", + "gain": "Forstærkning", + "gainValue": "{{gain}} dB", + "record": "Optag", + "nextWake": "Når hubben, næste gang den vågner.", + "viewerNote": "En ejer eller administrator kan anmode om en optagelse.", + "lengthHint": "Kortere er som regel bedre: 10–20 sekunder er nok til at høre, hvad en indsigt markerede, og koster mindre batteri og mindre tid, hvor stadet er ude af luften — en forbundet node hverken annoncerer eller måler.", + "requestSuccess": "Optagelse anmodet. Den når hubben, næste gang den vågner.", + "requestError": "Anmodningen kunne ikke sættes i kø: {{message}}", + "listTitle": "Optagelser", + "onlySelectedHive": "Kun {{name}}", + "loadError": "Kunne ikke vise optagelser: {{message}}", + "empty": "Ingen optagelser endnu. Bed et stade om at optage, så dukker den op her. En session er 10–60 sekunders 16 kHz-lyd, der hentes fra noden over BLE.", + "requestedLength_one": "{{count}} s anmodet", + "requestedLength_other": "{{count}} s anmodet", + "requestedBy": "Anmodet af {{name}}", + "status": { + "requested": "Venter på hub", + "streaming": "Optager…", + "ready": "Klar", + "readyIncomplete": "Klar, ikke komplet", + "failed": "Mislykkedes" + }, + "waitingNote": "Venter på, at hubben vågner og henter dette — intet optaget endnu. En anmodning, som hubben ikke har hentet inden for 45 minutter, markeres som mislykket.", + "streamingNote": "Optager — lyden vises her, efterhånden som den kommer ind.", + "streamingPartial": "Optager stadig — dette er det, der er kommet ind indtil videre. Afspil den igen, når sessionen er slut, for at høre det hele.", + "failedFallback": "Denne optagelse mislykkedes.", + "seconds": "{{value}} s", + "bytes": "{{value}} B", + "percent": "{{value}} %", + "download": "Download WAV", + "delete": "Slet", + "deleteConfirm": "Slet denne optagelse? Intet andet vil nogensinde gøre det — der er ingen automatisk oprydning.", + "deleteSuccess": "Optagelse slettet.", + "deleteError": "Kunne ikke slette optagelsen: {{message}}", + "confirmation": { + "verified": "verificeret", + "reported": "ikke verificeret", + "confirmed": "bekræftet af hub", + "unknown": "ikke bekræftet" + }, + "quality": { + "length": "Længde", + "dropped": "Tabt", + "gaps": "Huller", + "hubBuffer": "Hub-buffer", + "clipping": "Klipning", + "checksum": "Kontrolsum", + "cause": { + "dropped": "noden kunne ikke sende lyden hurtigt nok", + "gaps": "pakker gik tabt i luften", + "hubBuffer": "hubbens upload kom bagud i forhold til dens buffer" + }, + "gapNote": "Lyden på begge sider af et hul er ægte, men overgangen er ikke. Årsag: {{causes}}.", + "clippingNote": "Sænk forstærkningen for en renere optagelse.", + "unknownNote": "Hubben rapporterede aldrig om denne session, så intet har tjekket, om lyden er komplet. Det, der er her, kan afspilles." + } + }, + "dashboard": { + "hiveFallback": "Stade {{index}}", + "units": { + "bees": "bier" + }, + "range": { + "24h": "24 t", + "7d": "7 d", + "30d": "30 d", + "365d": "365 d", + "custom": "Brugerdefineret", + "invalid": "Startdatoen må ikke ligge efter slutdatoen." + }, + "header": { + "title": "Mit dashboard", + "description": "Brugerkonfigurerbare widgets. De valgte stader ovenfor styrer de stadebaserede grafer.", + "editDescription": "Redigeringstilstand: træk i håndtaget for at flytte widgets, og træk i hjørnet for at ændre deres størrelse i et gitter med 4 kolonner.", + "edit": "Redigér dashboard", + "done": "Færdig", + "addWidget": "Tilføj widget", + "reset": "Nulstil", + "editBanner": "Dashboardets redigeringstilstand er aktiv. Brug grebet i hver widgets overskrift til at flytte den, og træk i nederste højre hjørne for at ændre bredde eller højde. Layoutændringer gemmes automatisk for denne HiveHub-enhed.", + "dropEnd": "Slip her for at flytte en widget til slutningen af dashboardet.", + "noWidgets": "Ingen widgets endnu. Tilføj en skabelon for at bygge dit dashboard.", + "restoreDefaults": "Gendan standard" + }, + "overview": { + "title": "Stadeoversigt", + "description": "Kompakt status for tilknyttede stader. Vælg felter for at styre dashboard-widgets nedenfor.", + "lastData": "Seneste data", + "hivesOnline": "Stader online", + "hubFirmware": "HiveHub-FW", + "hiveInsideFirmware": "HiveInside-FW", + "selected_one": "{{count}} valgt", + "selected_other": "{{count}} valgt", + "hide": "Skjul oversigt", + "show": "Vis oversigt", + "slot": "Plads {{index}}", + "ok": "OK", + "noData": "Ingen data", + "weight": "Vægt", + "temperature": "Temp.", + "humidity": "RF", + "empty": "Knyt HiveHub-pladser til HivePal-stader i enhedens opsætningspanel for at vise oversigtsfelter her." + }, + "sensors": { + "scale": "vægt", + "inHive": "i stadet", + "accel": "accel.", + "counter": "tæller", + "none": "ingen sensorer" + }, + "widgetShell": { + "move": "Flyt {{title}}", + "moveHint": "Træk for at flytte denne widget", + "remove": "Fjern {{title}}", + "resize": "Ændr størrelse på {{title}}", + "resizeHint": "Træk for at ændre størrelsen på denne widget" + }, + "addWidget": { + "title": "Tilføj widget", + "description": "Start fra en skabelon. Widgeten bruger de valgte stader fra oversigtsgitteret, hvor det er relevant.", + "defaultSize": "Standardstørrelse {{w}} x {{h}}" + }, + "widgets": { + "weightComparison": { + "title": "Vægtsammenligning", + "description": "Sammenlign valgte stader over den aktuelle periode." + }, + "dailyMaxWeight": { + "title": "Daglig maksvægt", + "description": "Højeste kompenserede vægt pr. dag for de valgte stader." + }, + "climate": { + "title": "Stadeklima", + "description": "Temperatur og luftfugtighed for valgte stader." + }, + "power": { + "title": "Strømforsyning", + "description": "Batteriladning, batterispænding og solindgang." + }, + "beeTraffic": { + "title": "Flyvetrafik", + "description": "Samlet ind-/udtrafik og nettoflow." + }, + "soundRms": { + "title": "Lyd / akustiske bånd", + "description": "Akustisk RMS pr. stade og FFT-/HiveHeart-bånd, når de er tilgængelige." + }, + "hiveHeartSpectrum": { + "title": "HiveHeart-spektrum", + "description": "Seneste HiveHeart-FFT-niveauer (0–15) over 16 frekvensområder for de valgte stader." + }, + "vibration": { + "title": "Vibrationsbånd", + "description": "RMS-vibration samt sværm-, vifte- og aktivitetsbånd." + }, + "configurableDiagram": { + "title": "Konfigurerbart diagram", + "description": "Vælg de stade-, lyd-, trafik-, klima- og enhedsmålinger, der skal vises." + }, + "temperatureHeatmap": { + "title": "Temperatur-heatmap", + "description": "Kompakt temperaturoversigt over alle stader." + }, + "insights": { + "title": "Indsigtsfeed", + "description": "Aktive advarsler og uddrag af belæg." + }, + "dataQuality": { + "title": "Datakvalitet", + "description": "Sensortilgængelighed og manglende målinger." + } + }, + "metricGroups": { + "scale": "Vægt", + "climate": "Klima", + "beeTraffic": "Flyvetrafik", + "vibration": "Vibration", + "sound": "Lyd", + "device": "Enhed" + }, + "metrics": { + "weight": "Vægt", + "temperature": "Temperatur", + "humidity": "Luftfugtighed", + "pressure": "Lufttryk", + "beeIn": "Bier ind", + "beeOut": "Bier ud", + "beeNet": "Nettoflow", + "vibration": "Vibration RMS", + "swarmBand": "Sværmbånd", + "fanningBand": "Viftebånd", + "activityBand": "Aktivitetsbånd", + "soundRms": "Lyd RMS", + "soundHum": "Summebånd", + "soundPiping": "Tudebånd", + "soundStress": "Stressbånd", + "hiveHeartFrequency": "HiveHeart-frekvens", + "hiveHeartEnergy": "HiveHeart-energi", + "batterySoc": "Batteriladning", + "batteryVoltage": "Batterispænding", + "solarPower": "Soleffekt" + }, + "axes": { + "weight": "Vægt", + "temperature": "Temperatur", + "humidity": "Luftfugtighed", + "pressure": "Lufttryk", + "beecount": "Antal bier", + "vibration": "Vibration", + "dbfs": "Lydniveau", + "frequency": "Frekvens", + "energy": "Energi", + "percent": "Ladning", + "voltage": "Spænding", + "power": "Effekt" + }, + "soundMetrics": { + "rms": "RMS", + "hiveHeartFrequency": "HiveHeart-frekvens", + "hiveHeartEnergy": "HiveHeart-energi", + "hiveHeartPeak": "HiveHeart-top" + }, + "vibrationMetrics": { + "rms": "RMS", + "swarmBand": "Sværmbånd", + "fanningBand": "Viftebånd", + "activityBand": "Aktivitetsbånd" + }, + "series": { + "temperature": "{{name}} temp.", + "humidity": "{{name}} RF", + "beeIn": "Ind", + "beeOut": "Ud", + "beeNet": "Netto" + }, + "axisScale": { + "auto": "auto", + "setBound": "Angiv {{axis}} {{bound}}", + "clearHint": "Lad være tom og tryk Enter for automatisk", + "tickTitle": "Klik for at angive {{bound}}; ryd for automatisk", + "reset": "nulstil", + "help": "Klik på den laveste eller højeste etiket på y-aksen for at redigere. Ryd værdien for automatisk." + }, + "chart": { + "downloadCsv": "Download CSV" + }, + "inspection": { + "legendHint_one": "{{count}} inspektionsvindue skraveret; målinger kan være forstyrrede", + "legendHint_other": "{{count}} inspektionsvinduer skraveret; målinger kan være forstyrrede" + }, + "empty": { + "noData": "Ingen data for de valgte stader og perioden.", + "climate": "Ingen klimadata for den valgte periode.", + "power": "Ingen strømdata for den valgte periode.", + "beeTraffic": "Ingen bitællerdata for de valgte stader.", + "sound": "Ingen akustiske data pr. stade for de valgte stader.", + "vibration": "Ingen vibrations- eller accelerometerbånddata for de valgte stader.", + "configurable": "Vælg mindst én måling med data for de valgte stader og perioden.", + "dailyMaxWeight": "Ingen vægtdata for de valgte stader og perioden.", + "hiveHeart": "Ingen HiveHeart-FFT-data for de valgte stader og perioden.", + "unknownWidget": "Ukendt widgettype." + }, + "soundFootnote": "Viser akustiske målinger pr. stade først. Ældre venstre/højre mikrofondata knyttes til de konfigurerede stadenavne for plads 1 og 2, når der ikke er nogen akustisk blok pr. stade.", + "heatmap": { + "low": "lav <33 °C", + "optimal": "optimal yngel 33–36 °C", + "high": "høj >36 °C" + }, + "insights": { + "loading": "Indlæser indsigter…", + "unavailable": "Indsigter er ikke tilgængelige.", + "noAlerts": "Ingen aktive advarsler for de valgte stader.", + "showing_one": "Viser {{count}} aktiv advarsel for de valgte stader.", + "showing_other": "Viser {{count}} aktive advarsler for de valgte stader.", + "confidence": "sikkerhed {{percent}}%" + }, + "dataQuality": { + "activeSlots": "Aktive pladser", + "noScale": "Ingen vægt", + "noClimate": "Intet klima", + "noRecentData": "ingen nye data" + }, + "hiveHeart": { + "level": "niveau", + "dominant": "dominerende frekvens {{hz}} Hz", + "noDominant": "ingen dominerende frekvens rapporteret", + "footnote": "Relative niveauer 0–15 (ikke dBFS) for den seneste måling i perioden. Stiplede linjer markerer den dominerende frekvens, som HiveHeart rapporterer; overskrifterne Sub-bas/Summen/Tuden/Stress er omtrentlige og spænder over flere områder." + } } } diff --git a/apps/frontend/public/locales/de/common.json b/apps/frontend/public/locales/de/common.json index c747520ee..4f91f7973 100644 --- a/apps/frontend/public/locales/de/common.json +++ b/apps/frontend/public/locales/de/common.json @@ -36,7 +36,8 @@ "syrupCalculator": "Sirup-Rechner", "broodTimeline": "Brutzeitplan", "swarmManagement": "Schwarmmanagement", - "documentation": "Dokumentation" + "documentation": "Dokumentation", + "hivehub": "HiveHub" }, "liebefelder": { "title": "Liebefelder Schätzmethode", diff --git a/apps/frontend/public/locales/de/hivescale.json b/apps/frontend/public/locales/de/hivescale.json index daca9700b..303ea8dcd 100644 --- a/apps/frontend/public/locales/de/hivescale.json +++ b/apps/frontend/public/locales/de/hivescale.json @@ -35,7 +35,7 @@ }, "page": { "title": "HiveHub", - "subtitle": "Live-Visualisierung von Gewicht und Temperatur aus Ihrem separaten HiveHub-Backend.", + "subtitle": "Gewicht, Klima, Sound, Flugverkehr und Warnungen von Ihren HiveHub-Geräten.", "refresh": "Aktualisieren", "refreshing": "Wird aktualisiert…", "refreshError": "HiveHub-Daten konnten nicht aktualisiert werden.", @@ -43,16 +43,26 @@ "removeSuccess": "HiveHub-Gerät aus Ihrem Konto entfernt.", "removeSuccessShared": "Du hast keinen Zugriff mehr auf dieses HiveHub-Gerät. Andere Nutzer haben es weiterhin, daher bleibt sein Claim-Code in Verwendung.", "releaseConfirm": "{{name}} vollständig freigeben? Alle Nutzer verlieren den Zugriff und das Gerät kann mit seinem Claim-Code erneut beansprucht werden. Messwerte bleiben erhalten.", - "releaseSuccess": "HiveHub-Gerät freigegeben. Es kann mit seinem Claim-Code erneut beansprucht werden." + "releaseSuccess": "HiveHub-Gerät freigegeben. Es kann mit seinem Claim-Code erneut beansprucht werden.", + "unavailableTitle": "HiveHub ist nicht erreichbar", + "inspectionActiveTitle": "Durchsichtsmodus ist aktiv", + "inspectionActiveBody": "Messwerte werden ausgeblendet, solange die Stöcke geöffnet sind, damit das Ziehen von Waben keine Fehlalarme auslöst.", + "openInspections": "Durchsichten öffnen", + "tabs": { + "overview": "Übersicht", + "inspections": "Durchsichten", + "audio": "Audio", + "health": "Zustand", + "setup": "Einrichtung" + } }, "setup": { "title": "Geräte", - "description": "Wählen oder entfernen Sie ein beanspruchtes HiveHub-Gerät. Öffnen Sie die Einrichtung, um Geräte zu beanspruchen, den Status zu prüfen, Waagen zuzuordnen und die Rohwerte zu kalibrieren.", "removeScale": "Waage entfernen", "releaseScale": "Gerät freigeben", "noDevices": "Noch keine Geräte beansprucht – öffnen Sie die Einrichtung, um eines hinzuzufügen.", - "hide": "Einrichtung ausblenden", - "show": "Einrichtung anzeigen" + "selectDevice": "HiveHub-Gerät", + "noDevicesYet": "Noch kein HiveHub-Gerät beansprucht. Beanspruchen Sie eines mit dem Code, den der Hub in seinem Einrichtungsportal anzeigt." }, "claim": { "title": "HiveHub-Gerät beanspruchen", @@ -65,16 +75,30 @@ "success": "HiveHub-Gerät beansprucht.", "errors": { "missingCode": "Geben Sie den auf dem Gerät aufgedruckten/konfigurierten Claim-Code ein." - } + }, + "hive1Name": "Name von Stock 1", + "hive2Name": "Name von Stock 2", + "optional": "Optional" }, "mapping": { - "title": "Waagenzuordnung", - "description": "Diese Bezeichnungen bleiben in HiveHub. Später ist dies der richtige Ort, um Waage 1/2 für Inspektionsmarkierungen den HivePal-Bienenstock-IDs zuzuordnen.", - "scale1Label": "Anzeigename für Waage 1", - "scale2Label": "Anzeigename für Waage 2", + "title": "Stöcke", "hiveNamePlaceholder": "Bienenstocknamen auswählen oder eingeben", - "save": "Zuordnung speichern", - "success": "Waagenzuordnung aktualisiert." + "save": "Stöcke speichern", + "success": "Stockplätze im HiveHub gespeichert.", + "slotsDescription": "Benennen Sie jeden Stockplatz und verknüpfen Sie ihn mit einem HivePal-Stock. Über die Verknüpfung wissen Durchsichten, die Stockseite und Warn-E-Mails, zu welchem Volk ein Platz gehört. Ein grüner Punkt markiert Plätze, die der Hub aktuell meldet.", + "mapSlot": "Platz {{slot}} zuordnen", + "add": "Hinzufügen", + "reporting": "Vom Hub gemeldet", + "unknownHive": "Stock nicht gefunden", + "mappedCount": "{{count}}/{{total}} Plätze zugeordnet", + "dialogTitle": "Stockplatz {{slot}}", + "dialogDescription": "Verknüpfen Sie den Platz mit einem Ihrer HivePal-Stöcke und wählen Sie den Namen, den HiveHub dafür anzeigt.", + "linkedHive": "HivePal-Stock", + "notLinked": "Nicht verknüpft", + "linkHint": "Die Verknüpfung bleibt beim Umbenennen des Stocks erhalten. Bei der Auswahl eines Stocks wird sein Name übernommen, falls das Namensfeld leer ist.", + "displayName": "Anzeigename", + "clearSlot": "Platz leeren", + "applySlot": "Übernehmen" }, "status": { "title": "Gerätestatus", @@ -94,6 +118,13 @@ "revokeAccess": "Zugriff entziehen", "errors": { "missingEmail": "Geben Sie die E-Mail-Adresse eines bestehenden HivePal-Benutzers ein." + }, + "lastSeen": "Letzte Meldung", + "claimedAt": "Beansprucht", + "roles": { + "owner": "Besitzer", + "admin": "Admin", + "viewer": "Betrachter" } }, "calibration": { @@ -203,11 +234,9 @@ }, "sourceHint": "Die Umgebungstemperatur folgt dem Zellkörper im stationären Zustand eng und ist die empfohlene Voreinstellung.", "refLabel": "Referenztemperatur / °C", - "refHint": "Temperatur, bei der die Korrektur null ist.", "coeffLabel": "{{scaleName}} Koeffizient / kg pro °C", "autoFit": "Aus Verlauf automatisch anpassen", "fitting": "Wird angepasst…", - "autoFitHint": "Die automatische Anpassung regressiert das Rohgewicht der letzten 3 Tage gegen die EMA-geglättete gewählte Temperatur und wendet das Ergebnis an und aktiviert es. Für eine saubere Anpassung ein Zeitfenster erfassen, in dem die physische Last konstant ist und die Temperatur schwankt.", "save": "Temperaturkompensation speichern", "errors": { "refTemp": "Die Referenztemperatur muss eine Zahl sein (°C).", @@ -219,11 +248,33 @@ "noSignal": "nicht genug Temperatur-/Gewichtssignal.", "fitApplied": "{{scaleName}}: {{coeff}} kg/°C bei {{refTemp}}°C (R²={{r2}}, n={{n}}). Angewendet und aktiviert." } + }, + "perHive": { + "hive": "Stock {{index}}", + "hiveNamed": "Stock {{index}} · {{name}}", + "hiveLabel": "Stock", + "knownWeightPlaceholder": "z. B. 10", + "manualSaveHint": "Wechseln Sie den Stock, um eine andere Waage zu bearbeiten; alle Änderungen werden beim Speichern gemeinsam gesendet. Stöcke ab Nr. 3 werden nur bei Änderungen geschrieben.", + "tempco": { + "refHint": "Die Temperatur, bei der die Waagen korrekt anzeigen, also die, bei der sie tariert und kalibriert wurden. Dort ist die Korrektur null; die Anpassung lässt sie unverändert, sofern Sie es nicht ausdrücklich wünschen.", + "lookbackLabel": "Anpassungszeitraum / Tage", + "setRefTemp": "Auch die Referenztemperatur setzen", + "setRefTempHint": "Ersetzt die Referenz durch die mittlere Temperatur des angepassten Zeitfensters. Deaktiviert lassen, wenn die Waage bei einer bekannten Temperatur kalibriert wurde.", + "fitOnly": "Nur anpassen (Vorschau)", + "fitAndApply": "Anpassen & übernehmen", + "fitSummary": "{{scaleName}}: {{coeff}} kg/°C, R² {{r2}} über {{n}} Messwerte von {{min}}–{{max}} °C (Fenstermittel {{mean}} °C).", + "lowRSquared": "R² unter 0,5: Die Temperatur erklärt nur wenig von dieser Drift. Passen Sie über einen Zeitraum mit konstanter Last und großer Tag-Nacht-Schwankung an.", + "autoFitHint": "Die automatische Anpassung führt eine Regression des gespeicherten Gewichts des gewählten Stocks über den Rückblickzeitraum gegen die EMA-geglättete gewählte Temperatur durch. „Nur anpassen“ trägt den Koeffizienten zur Prüfung vor dem Speichern ein; „Anpassen & übernehmen“ speichert ihn und aktiviert die Kompensation. Für eine saubere Anpassung wählen Sie ein Zeitfenster, in dem die physische Last konstant ist und die Temperatur schwankt.", + "errors": { + "lookback": "Der Rückblickzeitraum muss eine ganze Zahl von Tagen zwischen 1 und 90 sein." + }, + "toasts": { + "fitFilled": "{{scaleName}}: {{coeff}} kg/°C (R²={{r2}}, n={{n}}). Eingetragen – prüfen und speichern." + } + } } }, "firmware": { - "title": "Firmware-Upload", - "description": "Laden Sie eine Firmware-Binärdatei hoch und registrieren Sie sie als Release. Neue Geräte des passenden Typs übernehmen sie bei ihrer nächsten Update-Prüfung.", "type": "Firmware-Typ", "selectType": "Firmware-Typ auswählen", "version": "Version", @@ -253,16 +304,87 @@ "missingFile": "Wählen Sie eine Firmware-Binärdatei (.bin) zum Hochladen aus.", "missingVersion": "Geben Sie eine Version ein, z. B. 0.6.3." }, - "hiveinsideOta": { - "title": "HiveInside-OTA auslösen", - "description": "Das Hochladen einer HiveInside-Firmware reiht das Over-the-Air-Update jetzt automatisch für beide Sensor-Slots per BLE ein. Nutze dies, um die aktive Version erneut an einen bestimmten Slot zu senden. Halte den Sensor während des Updates eingeschaltet und wach.", - "slot": "Sensor-Slot", - "slotOption": "Slot {{slot}}", - "queue": "HiveInside-OTA einreihen", + "board": { + "cardTitle": "Firmware", + "cardDescription": "Firmware dieses HiveHub sowie der angeschlossenen HiveInside-Knoten und HiveTraffic-Zähler. Das Hochladen registriert eine Version; die Freigabe (Hub) bzw. Weiterleitung (Knoten) installiert sie.", + "current": "Aktuell", + "latest": "Neueste", + "approved": "Freigegeben", + "board": "Board", + "badgePending": "Update wartet auf Freigabe", + "badgeApproved": "Update freigegeben", + "badgeUpToDate": "Aktuell", + "approveFlash": "{{version}} freigeben & flashen", + "approveConfirm": "Firmware {{version}} freigeben und over the air flashen?\n\nDer HiveHub installiert sie bei seiner nächsten Meldung und startet neu. Ein entferntes Gerät, bei dem das Update mittendrin fehlschlägt, kann zur Wiederherstellung physischen Zugriff erfordern.", + "otherBoards": "Auch für ein anderes Board hochgeladen (nicht auf dieses {{board}}-Gerät angewendet): {{releases}}. Eine Version erreicht nur Geräte mit passendem Board – prüfen Sie das Board beim Hochladen.", + "otherBoardsUnknown": "Auch für ein anderes Board hochgeladen (nicht auf dieses Gerät angewendet): {{releases}}. Eine Version erreicht nur Geräte mit passendem Board – prüfen Sie das Board beim Hochladen." + }, + "relays": { + "hiveFallback": "Stock {{slot}}", + "latest": "neueste {{version}}", + "updateAvailable": "Update verfügbar: {{version}}", + "status": { + "pending": "Weiterleitung eingereiht", + "running": "Wird weitergeleitet…", + "completed": "Weiterleitung abgeschlossen", + "failed": "Weiterleitung fehlgeschlagen", + "expired": "Weiterleitung abgelaufen" + }, + "statusTitle": "Weiterleitung von {{version}}: {{status}} ({{date}})", + "failedDetail": "Letzte Weiterleitung fehlgeschlagen – {{message}} ({{date}})", + "noReason": "kein Grund gemeldet", + "alreadyQueued": "Für diesen Knoten ist bereits eine Weiterleitung eingereiht", "queueing": "Wird eingereiht…", - "success": "HiveInside-OTA für Slot {{slot}} eingereiht.", - "autoQueued": "HiveInside-OTA automatisch für Slots {{slots}} eingereiht.", - "autoQueueFailed": "HiveInside-OTA konnte für Slots {{slots}} nicht automatisch eingereiht werden." + "relayAnyway": "Trotzdem weiterleiten", + "confirm": "{{name}} {{version}} an {{hive}} weiterleiten?\n\nDer HiveHub übernimmt dies bei seinem nächsten Upload-Zyklus – normalerweise innerhalb von etwa 10 Minuten bzw. im Sendeintervall dieses Geräts. Anschließend überträgt er das Image einige Minuten lang per BLE, und der Knoten startet damit neu. Der Knoten muss wach und in Reichweite sein, sonst schlägt die Weiterleitung fehl und muss erneut eingereiht werden.", + "queued": "Weiterleitung von {{version}} für {{hive}} eingereiht.", + "queuedFrom": "Weiterleitung für {{hive}} eingereiht ({{current}} → {{version}}).", + "hiveinside": { + "name": "HiveInside", + "title": "HiveInside-Knoten", + "description": "Firmware, die jeder Stockknoten meldet. Das Hochladen eines HiveInside-Images registriert nur die Version – nichts erreicht einen Knoten, bis Sie „An Knoten weiterleiten“ drücken. Die hier angezeigte Version bestätigt, dass das Update geklappt hat.", + "relay": "An Knoten weiterleiten", + "unnamed": "Meldet keinen eigenen Namen (HiveInside vor 0.5.0)", + "noRelease": "Noch keine HiveInside-Version hochgeladen – laden Sie unten eine hoch, um sie weiterzuleiten." + }, + "beecounter": { + "name": "HiveTraffic", + "title": "HiveTraffic-Zähler", + "description": "Firmware, die jeder HiveTraffic-Zähler meldet. Das Hochladen eines HiveTraffic-Images registriert nur die Version – nichts erreicht einen Zähler, bis Sie „An Zähler weiterleiten“ drücken. Zähler älter als etwa v0.1.0 melden keine Version, können aber trotzdem aktualisiert werden.", + "relay": "An Zähler weiterleiten", + "unnamed": "Meldet keinen Namen und keine Adresse", + "noRelease": "Noch keine HiveTraffic-Version hochgeladen – laden Sie unten eine hoch, um sie weiterzuleiten.", + "countingWarning": "Ein Zähler zählt während der gesamten Übertragung keine Bienen – leiten Sie daher bei geringem Flugverkehr weiter.", + "confirmExtra": "Der Zähler zählt während der gesamten Übertragung keine Bienen – jeglicher Flugverkehr in diesen Minuten geht verloren." + } + }, + "uploadForm": { + "title": "Firmware hochladen", + "description": "Das Hochladen registriert nur eine Version – es installiert nie selbstständig etwas. Geben Sie oben das Hub-Update frei oder leiten Sie eine Knoten- oder Zählerversion aus ihrer Zeile weiter.", + "board": "Board", + "targets": { + "hivehub": "HiveHub-Hub", + "hiveinside": "HiveInside", + "beecounter": "HiveTraffic-Zähler" + }, + "boards": { + "auto": "Automatisch (aus Dateiname)", + "esp32": "ESP32 (klassisch, 30-polig)", + "esp32-c6": "ESP32-C6", + "nrf54lm20a": "nRF54LM20A" + }, + "boardHint": { + "hivehub": "Hub-Firmware muss ihr Board angeben: Wählen Sie eines oder belassen Sie „Automatisch“, wenn die Datei z. B. hivehub_esp32-c6_0.30.5.bin heißt.", + "hiveinside": "HiveInside gibt es nur für den nRF54LM20A. Laden Sie das Build-Artefakt unverändert hoch (z. B. hiveinside_nrf54lm20a_1.4.0.bin); Ziel und Version werden aus dem Namen übernommen.", + "beecounter": "Der HiveTraffic-Zähler ist ein ESP32-C6. Laden Sie das Anwendungs-Image hoch (z. B. beecounter_esp32-c6_0.7.1.bin), kein zusammengeführtes Factory-Image." + }, + "detected": "{{name}} – {{fields}} übernommen.", + "nothingDetected": "{{name}} – kein Ziel, Board oder Version im Namen erkannt; bitte unten angeben.", + "detectedTarget": "Ziel „{{target}}“", + "detectedBoard": "Board {{board}}", + "detectedVersion": "Version {{version}}", + "autoQueued": "Weiterleitung automatisch für {{hives}} eingereiht.", + "autoQueueFailed": "Weiterleitung für {{hives}} konnte nicht automatisch eingereiht werden." } }, "sdData": { @@ -287,12 +409,18 @@ "parsed_other": "{{count}} Datensätze verarbeitet", "skipped_one": " · {{count}} unlesbare Zeile übersprungen.", "skipped_other": " · {{count}} unlesbare Zeilen übersprungen." + }, + "mismatch": { + "title": "Datei von einem anderen Hub", + "description": "Diese Datei wurde von einem anderen Hub aufgezeichnet, nicht von {{device}}.", + "fileDevices_one": "Geräte-ID in der Datei:", + "fileDevices_other": "Geräte-IDs in der Datei:", + "hint": "Importieren Sie sie nur dann trotzdem, wenn die SD-Karte wirklich zu diesem Gerät gehört (z. B. nach dem Austausch der Hub-Platine).", + "cancel": "Abbrechen", + "force": "Trotzdem in dieses Gerät importieren" } }, "panel": { - "weight": "Gewicht", - "hiveTemp": "Stocktemperatur", - "rmsSound": "RMS-Schall", "ambientTemperature": "Außentemperatur", "ambientHumidity": "Außenluftfeuchtigkeit", "batteryCharge": "Akkuladung", @@ -302,7 +430,6 @@ "title": "Allgemein", "description": "Umgebung & Stromversorgung" }, - "humidity": "Stockfeuchtigkeit", "wirelessSensorsBattery": "Funksensoren-Akku", "sensorTypes": { "hiveHeart": "HiveHeart", @@ -311,177 +438,36 @@ } }, "diagram": { - "title": "Messungen", - "subtitle": "Gewicht, Temperatur und Flugaktivität im Zeitverlauf", - "noMeasurements": "Keine Messungen für den ausgewählten Zeitraum.", - "noDataForSeries": "Keine Daten im ausgewählten Zeitraum.", - "meta": { - "device": "Gerät:", - "lastSeen": "Zuletzt gesehen:", - "lastData": "Letzte Daten:", - "firmware": "Firmware:" - }, "range": { "all": "Alle", "from": "Von", "to": "Bis" }, - "series": { - "weight": "{{name}} Gewicht", - "temp": "{{name}} Temperatur", - "micRms": "{{name}} Mikro-RMS", - "vibration": "{{name}} Vibration", - "swarmBand": "{{name}} Schwarmband (8–30 Hz)", - "beesIn": "{{name}} Bienen rein", - "beesOut": "{{name}} Bienen raus", - "netFlow": "{{name}} Nettofluss", - "ambientTemp": "Umgebungstemperatur", - "ambientHumidity": "Umgebungsfeuchtigkeit", - "batteryVoltage": "Batteriespannung", - "batteryCharge": "Batterieladung", - "solarVoltage": "Solarspannung", - "solarCurrent": "Solarstrom", - "solarPower": "Solarleistung", - "humidity": "{{name}} Luftfeuchte", - "pressure": "{{name}} Luftdruck", - "fanningBand": "{{name}} Fächelband (30–100 Hz)", - "activityBand": "{{name}} Aktivitätsband (100–200 Hz)" - }, - "group": { - "ambient": "Umgebung", - "offGrid": "Netzunabhängig" - }, - "axis": { - "weight": "Gewicht", - "temperature": "Temperatur", - "humidity": "Luftfeuchtigkeit", - "voltage": "Spannung", - "percent": "Prozent", - "current": "Strom", - "power": "Leistung", - "sound": "Schall", - "beecount": "Bienenanzahl", - "vibration": "Vibration", - "pressure": "Luftdruck" - }, "marker": { - "inspection": "Inspektion", - "boxAdded": "Zarge hinzugefügt", - "action": { - "maintenance": "Wartung", - "feeding": "Fütterung", - "frames": "Rähmchen", - "treatment": "Behandlung" - } - }, - "axisSettings": { - "title": "Achseneinstellungen", - "reset": "Zurücksetzen", - "side": "Seite", - "left": "Links", - "right": "Rechts", - "scale": "Skala", - "auto": "Automatisch", - "zeroToMax": "0 bis Max", - "custom": "Benutzerdefiniert", - "min": "Min", - "max": "Max", - "autoPlaceholder": "auto" + "inspection": "Durchsicht" } }, "sound": { - "title": "Stockgeräusch", - "description": "INMP441 FFT-Bandenergie (dBFS) — {{left}} (links) · {{right}} (rechts)", - "hide": "Geräusch ausblenden", - "show": "Geräusch anzeigen", - "micLeftFallback": "Mikro links", - "micRightFallback": "Mikro rechts", - "noDataForChannel": "Keine Daten für {{name}} in diesem Zeitraum.", - "bandReference": "Band-Referenz", - "thresholds": "Von der Insights-Engine verwendete Schwellenwerte: Tüten > −45 dBFS, Stress > −38 dBFS, weiselloses Summen > −40 dBFS bei leisem Tüten. Kalibrieren Sie anhand Ihres eigenen Ausgangswerts.", - "status": { - "noData": "Keine Daten", - "micError": "Mikrofonfehler", - "bothActive": "{{left}} + {{right}} aktiv", - "oneActive": "{{name}} aktiv" - }, - "table": { - "band": "Band", - "range": "Bereich", - "significance": "Bedeutung" - }, "bands": { "subBass": { - "label": "Sub-Bass", - "description": "Strukturschwingung / tiefes Grollen" + "label": "Sub-Bass" }, "hum": { - "label": "Summen", - "description": "Normales Völkersummen (~200 Hz Grundton)" + "label": "Summen" }, "piping": { - "label": "Tüten", - "description": "Königinnen-Tüten / -Quaken (Vorschwarm-Signal)" + "label": "Tüten" }, "stress": { - "label": "Stress", - "description": "Aufgeregtes Volk / Räuberei" + "label": "Stress" }, "high": { - "label": "Hoch", - "description": "Harmonische Obertöne" + "label": "Hoch" } - }, - "rmsToggleShow": "RMS-Pegel anzeigen", - "rmsToggleHide": "RMS-Pegel ausblenden", - "rmsTitle": "RMS-Schallpegel", - "rmsSeries": "{{name}} RMS" - }, - "beecounter": { - "title": "Fluglochzähler", - "subtitle": "Ein- und ausfliegende Bienen pro Messintervall", - "noData": "Keine BeeCounter-Daten für dieses Gerät gefunden.", - "offline": "Offline", - "gatesOk": "{{count}} Gates OK", - "glitches_one": "{{count}} Störung", - "glitches_other": "{{count}} Störungen", - "gatesRatio": "{{healthy}}/{{total}} Gates", - "inPeriod": "Rein (Zeitraum)", - "outPeriod": "Raus (Zeitraum)", - "netFlow": "Nettofluss", - "netInbound": "netto einfliegend", - "netOutbound": "netto ausfliegend", - "balanced": "ausgeglichen", - "view": { - "inOut": "Rein / Raus", - "netFlow": "Nettofluss", - "cumulative": "Kumuliert" - }, - "series": { - "in": "{{name}} rein", - "out": "{{name}} raus", - "net": "{{name}} netto", - "totalIn": "{{name}} gesamt rein", - "totalOut": "{{name}} gesamt raus" - }, - "diagnostics": { - "title": "Hardware-Diagnose", - "gatesHealthy": "Gates funktionsfähig", - "glitchCount": "Anzahl Störungen", - "totalInLifetime": "Gesamt rein (Lebensdauer)", - "totalOutLifetime": "Gesamt raus (Lebensdauer)" } }, "insights": { - "title": "Erkenntnisse", - "description": "Sensorbasierte Warnungen, abgeleitet aus Gewichts- und Temperaturtrends.", - "updated": "Aktualisiert {{ago}}.", - "refresh": "Erkenntnisse aktualisieren", - "loadError": "Erkenntnisse konnten nicht geladen werden: {{message}}", "allClear": "Alles in Ordnung", - "noActiveAlerts": "Keine aktiven Warnungen in den letzten {{count}} Tagen.", - "activeAlerts_one": "{{count}} aktive Warnung:", - "activeAlerts_other": "{{count}} aktive Warnungen:", "confidence": "Konfidenz {{value}} %", "source": "Quelle: {{source}}", "hiveChannel": "Stock {{channel}}", @@ -566,5 +552,720 @@ "description": "Für dieses Gerät wurden noch keine Warnungen aufgezeichnet.", "descriptionFiltered": "Für dieses Gerät wurden keine Warnungen aufgezeichnet, die diesem Filter entsprechen." } + }, + "inspectionMode": { + "title": "Durchsichtsmodus", + "description": "Während der Durchsichtsmodus aktiv ist, misst HiveHub weiter, hält die Messwerte aber aus Diagrammen, Erkenntnissen und Warnungen heraus, damit das Ziehen von Waben nicht für einen Schwarm gehalten wird.", + "status": { + "active": "Aktiv seit {{time}}", + "pending": "Wartet auf Abholung durch den Hub", + "off": "Aus" + }, + "pendingHint": "Angefordert – der Hub übernimmt es bei seiner nächsten Meldung.", + "timeoutNote_one": "Endet automatisch nach {{count}} Minute, wenn niemand ihn beendet.", + "timeoutNote_other": "Endet automatisch nach {{count}} Minuten, wenn niemand ihn beendet.", + "scope": { + "wholeHub": "Ganzer Hub", + "label": "Umfasst: {{hives}}" + }, + "start": "Durchsichtsmodus starten", + "stop": "Durchsichtsmodus beenden", + "starting": "Wird gestartet…", + "stopping": "Wird beendet…", + "cancel": "Abbrechen", + "noteLabel": "Notiz (optional)", + "notePlaceholder": "z. B. Honigraum aufgesetzt", + "viewerHint": "Nur Besitzer und Admins können den Durchsichtsmodus starten oder beenden.", + "loadFailed": "Der Durchsichtsstatus konnte nicht geladen werden.", + "startDialog": { + "title": "Durchsichtsmodus starten", + "description": "Messwerte der ausgewählten Stöcke werden ausgeblendet, bis Sie den Durchsichtsmodus beenden oder er abläuft.", + "hivesLabel": "Stöcke", + "wholeHub": "Ganzer Hub (alle Stöcke)", + "selectHive": "Wählen Sie mindestens einen Stock oder den ganzen Hub.", + "confirm": "Starten" + }, + "stopDialog": { + "title": "Durchsichtsmodus beenden", + "description": "HiveHub kehrt zu normalen Messwerten und Warnungen zurück.", + "confirm": "Beenden" + }, + "toast": { + "started": "Durchsichtsmodus gestartet", + "stopped": "Durchsichtsmodus beendet", + "noteSaved": "Notiz gespeichert" + }, + "history": { + "title": "Letzte Durchsichten", + "empty": "Noch keine Durchsichten aufgezeichnet.", + "ongoing": "läuft", + "startedVia": "Gestartet über {{source}}", + "ended": "Beendet: {{reason}}", + "editNote": "Notiz bearbeiten", + "addNote": "Notiz hinzufügen", + "saveNote": "Speichern" + }, + "duration": { + "minutes_one": "{{count}} Min.", + "minutes_other": "{{count}} Min.", + "hoursMinutes": "{{hours}} Std. {{minutes}} Min." + }, + "source": { + "device": "Hub-Taste", + "api": "App", + "dashboard": "lokales Dashboard" + }, + "endReason": { + "timeout": "Zeit abgelaufen", + "device": "Hub-Taste", + "api": "in der App beendet", + "dashboard": "im lokalen Dashboard beendet" + } + }, + "hiveLink": { + "prompt": { + "message": "Dieser Stock wird von HiveHub {{device}} ({{hive}}) überwacht. Starten Sie den Durchsichtsmodus, damit das Öffnen des Stocks keine Fehlalarme auslöst.", + "active": "Durchsichtsmodus ist für {{hive}} auf HiveHub {{device}} aktiv.", + "pending": "Durchsichtsmodus für {{hive}} auf HiveHub {{device}} angefordert – der Hub übernimmt ihn bei seiner nächsten Meldung.", + "activeOther": "Durchsichtsmodus ist für andere Stöcke auf HiveHub {{device}} aktiv, nicht für {{hive}}. Beenden Sie ihn zuerst, um einen für diesen Stock zu starten." + }, + "card": { + "title": "HiveHub", + "description": "Live-Messwerte der verknüpften HiveHub-Sensoren.", + "weight": "Gewicht", + "weightChange24h": "Änderung 24 h", + "temperature": "Stocktemperatur", + "humidity": "Luftfeuchtigkeit", + "lastReading": "Letzter Messwert: {{time}}", + "openHiveHub": "HiveHub öffnen" + } + }, + "traffic": { + "title": "HiveTraffic-Bienenzähler", + "description": "Nachtmodus und Emitterbänke für jeden mit diesem Hub gekoppelten HiveTraffic-Zähler. Die IR-Emitter sind mit Abstand der größte Posten im Strombudget eines Zählers – diese Einstellungen entscheiden also, ob er mit einer netzunabhängigen Versorgung auskommt.", + "readOnly": "Nur Gerätebesitzer und Admins können diese Einstellungen ändern. Ihre Rolle ist „{{role}}“.", + "hiveFallback": "Stock {{slot}}", + "noChanges": "Keine Änderungen zum Speichern", + "saved": "Bienenzähler-Einstellungen gespeichert. Die Zähler übernehmen sie bei der nächsten Meldung.", + "appliesToAll": "Gilt für jeden mit diesem Gerät gekoppelten HiveTraffic-Zähler. Der Zähler erhält die Einstellung beim nächsten Upload-Zyklus des Hubs.", + "status": { + "title": "Zähler aktuell", + "none": "Im letzten Messwert wurde kein HiveTraffic-Zähler gemeldet.", + "counting": "Zählt", + "nightIdle": "Nachtruhe", + "nightIdleFor_one": "Nachtruhe · noch {{count}} Min.", + "nightIdleFor_other": "Nachtruhe · noch {{count}} Min.", + "banksRunning": "Aktive Emitterbänke: {{banks}}", + "banksUnknown": "Emitterbänke nicht gemeldet (Zähler-Firmware älter als 0.3.0)", + "applied": "Bänke übernommen", + "pending": "Bänke warten auf nächste Meldung" + }, + "night": { + "title": "Nachtmodus", + "description": "Honigbienen sind tagaktiv – der Flug braucht Licht und endet unter etwa 10 °C ohnehin. In diesem Zeitfenster stellt der Zähler die Erfassung ein, um Strom zu sparen. Er sendet weiterhin Advertisements, sodass Messwerte und Firmware-Updates weiter funktionieren.", + "enable": "Nachtmodus aktivieren", + "start": "Nacht beginnt (Ortszeit)", + "end": "Nacht endet (Ortszeit)", + "windowHelp": "Das Zeitfenster darf über Mitternacht gehen (z. B. 20:00 → 06:00). Sind Beginn und Ende gleich, ist das Fenster leer – es greift nie – statt den ganzen Tag abzudecken. Lassen Sie einen Puffer um Abend- und Morgendämmerung: Die Aktivität erreicht kurz vor Sonnenuntergang und Sonnenaufgang ihren Höhepunkt.", + "emptyWindow": "Beginn und Ende sind gleich, das Fenster ist also leer und der Nachtmodus würde nie greifen.", + "wrapsMidnight": "Das Fenster geht über Mitternacht: von {{start}} bis {{end}} am nächsten Morgen.", + "timezone": "Zeitzone", + "tzCustom": "POSIX-TZ-String", + "timezoneHelp": "Die Uhr des Hubs läuft in UTC. Ohne die richtige Zeitzone ist das Fenster um Ihren UTC-Versatz verschoben und verschiebt sich bei jeder Sommer-/Winterzeitumstellung um eine Stunde.", + "tzPresets": { + "cet": "Mitteleuropa (Berlin, Paris, Madrid, Wien)", + "uk": "Vereinigtes Königreich / Irland", + "eet": "Osteuropa (Helsinki, Athen)", + "usEastern": "USA Ostküste (Eastern)", + "usCentral": "USA Central", + "usMountain": "USA Mountain", + "usPacific": "USA Westküste (Pacific)", + "utc": "UTC (keine Ortszeit)", + "custom": "Benutzerdefiniert…" + }, + "maxTraffic": "Aufschieben ab (Durchgänge pro Zyklus)", + "maxTrafficHelp": "Schiebt den Nachtmodus auf, solange im letzten Upload-Zyklus mehr als so viele Durchgänge (rein + raus) gezählt wurden, damit ein Volk, das an einem Sommerabend noch spät fliegt, nicht abgeschnitten wird. 0 richtet sich nur nach der Uhr.", + "save": "Nachtmodus speichern" + }, + "banks": { + "title": "Emitterbänke", + "description": "Die IR-Emitter des Zählers werden in drei Bänken zu je acht Durchgängen geschaltet. Jede Bank zieht etwa 80 mA. Schalten Sie eine ab, wenn das Flugloch schmaler als 24 Durchgänge ist, teilweise verschlossen ist oder die Versorgung nicht die ganze Platine tragen kann.", + "bank": "Bank {{bank}} – Durchgänge {{gates}}", + "current_one": "{{count}} Bank an · etwa {{ma}} mA auf der 3,3-V-Schiene des Zählers", + "current_other": "{{count}} Bänke an · etwa {{ma}} mA auf der 3,3-V-Schiene des Zählers", + "help": "Eine abgeschaltete Bank zählt ihre Durchgänge gar nicht, die Summen sinken also ungefähr proportional – vergleichen Sie einen verengten Zähler nicht mit seinem eigenen Verlauf. Mindestens eine Bank muss eingeschaltet bleiben; entkoppeln Sie einen Zähler, der nichts zählen soll.", + "firmwareNote": "Erfordert Zähler-Firmware 0.3.0 oder neuer. Die Einstellung wird nach jedem Zähler-Reset erneut gesendet.", + "save": "Emitterbänke speichern" + }, + "errors": { + "loadFailed": "Die Gerätekonfiguration konnte nicht geladen werden.", + "saveFailed": "Die Bienenzähler-Einstellungen konnten nicht gespeichert werden", + "invalidTime": "Geben Sie eine gültige Beginn- und Endzeit ein (HH:MM).", + "invalidMaxTraffic": "Der Aufschub-Schwellenwert muss eine ganze Zahl von 0 oder größer sein.", + "emptyWindow": "Beginn und Ende des Nachtmodus sind gleich – das Fenster würde nie greifen.", + "noBank": "Mindestens eine Emitterbank muss eingeschaltet bleiben – entkoppeln Sie stattdessen den Zähler." + } + }, + "hubAccess": { + "title": "Hub-Zugang", + "description": "Erreichen Sie den Hub vor Ort, ohne das Gehäuse zu öffnen, und legen Sie fest, wie lange eine Durchsicht dauern darf.", + "readOnly": "Nur Gerätebesitzer und Admins können diese Einstellungen ändern. Ihre Rolle ist „{{role}}“.", + "noChanges": "Keine Änderungen zum Speichern", + "cancel": "Abbrechen", + "ap": { + "title": "Einrichtungs-Access-Point", + "description": "Öffnet das WLAN „HiveHub-Setup“ des Hubs, genau wie ein kurzer Druck auf die Setup-Taste: WLAN ändern, Sensoren koppeln oder die SD-Karten-Daten herunterladen.", + "lastCheckIn": "Letzte Meldung: {{time}}", + "open": "Einrichtungs-Access-Point öffnen", + "confirmTitle": "Einrichtungs-Access-Point öffnen?", + "confirmTiming_one": "Die Anfrage wird eingereiht. Der Hub schläft zwischen den Zyklen, daher öffnet sich der Access Point nach seiner nächsten Meldung – spätestens in {{count}} Minute.", + "confirmTiming_other": "Die Anfrage wird eingereiht. Der Hub schläft zwischen den Zyklen, daher öffnet sich der Access Point nach seiner nächsten Meldung – spätestens in {{count}} Minuten.", + "confirmTimingUnknown": "Die Anfrage wird eingereiht. Der Hub schläft zwischen den Zyklen, daher öffnet sich der Access Point nach seiner nächsten Meldung – spätestens nach einem Sendeintervall.", + "confirmOffline": "Solange der Access Point offen ist, ist der Hub nicht im WLAN und sendet keine Messwerte.", + "confirmTimeout": "Er schließt sich nach Ablauf des Einrichtungsportal-Timeouts von selbst, wenn sich niemand verbindet – starten Sie ihn also nur, wenn jemand am Stock ist.", + "confirm": "Access Point einreihen", + "queueing": "Wird eingereiht…", + "queued": "Access Point eingereiht. Er öffnet sich nach der nächsten Meldung des Hubs.", + "failed": "Der Access Point konnte nicht eingereiht werden" + }, + "inspection": { + "title": "Durchsichts-Timeout", + "description": "Ein Sicherheitsnetz: Eine Durchsicht endet nach so vielen Minuten von selbst, damit ein vergessener Tastendruck die Daten eines Stocks nicht unbegrenzt ausblendet. Standard 60.", + "label": "Timeout (Minuten, 1–1440)", + "save": "Timeout speichern", + "saved": "Durchsichts-Timeout gespeichert", + "saveFailed": "Das Durchsichts-Timeout konnte nicht gespeichert werden", + "invalid": "Geben Sie eine ganze Zahl von Minuten zwischen 1 und 1440 ein." + } + }, + "dataTools": { + "title": "Datenexport & Bereinigung", + "description": "Sichern Sie die Messwerte dieses Hubs oder entfernen Sie fehlerhafte.", + "hiveFallback": "Stock {{slot}}", + "cancel": "Abbrechen", + "export": { + "title": "Messwerte exportieren", + "description": "Laden Sie die gespeicherten Messwerte als NDJSON-Sicherung herunter.", + "from": "Von (optional)", + "to": "Bis (optional)", + "rangeHelp": "Lassen Sie beide Felder leer, um alles zu exportieren. Die Daten umfassen ganze Tage in Ihrer Ortszeit.", + "hives": "Stöcke (optional)", + "allHives": "Alle Stöcke sind enthalten.", + "someHives_one": "Nur {{count}} ausgewählter Stock ist enthalten. Die Anzahl unten umfasst alle Messwerte im Zeitraum.", + "someHives_other": "Nur {{count}} ausgewählte Stöcke sind enthalten. Die Anzahl unten umfasst alle Messwerte im Zeitraum.", + "invalidRange": "Das Startdatum liegt nach dem Enddatum.", + "summaryFailed": "Die Messwerte konnten nicht gezählt werden.", + "count_one": "{{count}} Messwert", + "count_other": "{{count}} Messwerte", + "span": "Von {{first}} bis {{last}}", + "download": "Herunterladen", + "formatHelp": "Ein JSON-Messwert pro Zeile. Die Datei kann über den SD-Karten-Import wieder importiert werden.", + "noPermission": "Nur Gerätebesitzer und Admins können Daten exportieren. Ihre Rolle ist „{{role}}“." + }, + "delete": { + "title": "Messwerte in einem Zeitraum löschen", + "description": "Entfernt fehlerhafte Messwerte – zum Beispiel die Gewichtsspitzen, die ein Hub beim Start vor der Kalibrierung seiner Waagen aufzeichnet und die sonst Diagramme und Erkenntnisse verfälschen.", + "warningTitle": "Dies kann nicht rückgängig gemacht werden", + "warning": "Gelöschte Messwerte sind endgültig weg. Exportieren Sie den Zeitraum vorher, falls Sie ihn noch brauchen könnten.", + "start": "Beginn", + "end": "Ende", + "claimCode": "Claim-Code", + "claimCodeHelp": "Der Claim-Code des Hubs als zweite Bestätigung.", + "review": "Löschung prüfen", + "confirmTitle": "Diese Messwerte löschen?", + "confirmRange": "Von {{start}} bis {{end}}.", + "confirmCount_one": "{{count}} Messwert wird dauerhaft gelöscht.", + "confirmCount_other": "{{count}} Messwerte werden dauerhaft gelöscht.", + "confirmIrreversible": "Dies kann nicht rückgängig gemacht werden.", + "countFailed": "Die Messwerte in diesem Zeitraum konnten nicht gezählt werden.", + "confirm_one": "{{count}} Messwert löschen", + "confirm_other": "{{count}} Messwerte löschen", + "deleting": "Wird gelöscht…", + "success_one": "{{count}} Messwert gelöscht.", + "success_other": "{{count}} Messwerte gelöscht.", + "noPermission": "Nur der Gerätebesitzer kann Messwerte löschen. Ihre Rolle ist „{{role}}“.", + "errors": { + "missingRange": "Wählen Sie einen Beginn und ein Ende.", + "invalidRange": "Das Ende muss gleich oder nach dem Beginn liegen.", + "missingClaimCode": "Geben Sie den Claim-Code des Hubs ein." + } + } + }, + "alertSettings": { + "title": "HiveHub-E-Mail-Warnungen", + "description": "Erhalten Sie eine E-Mail, wenn HiveHub an Ihren Stöcken etwas bemerkt – Schwärmen, Räuberei, ein weiselloses Volk und mehr.", + "enable": "Mich per E-Mail über HiveHub-Warnungen informieren", + "minSeverity": "Mindestschweregrad", + "minSeverityHelp": "Warnungen unterhalb dieser Stufe werden nie per E-Mail versendet.", + "severity": { + "watch": "Beobachten und höher", + "warning": "Warnung und höher", + "critical": "Nur kritisch" + }, + "help": { + "schedule": "HivePal prüft alle 15 Minuten auf neue Warnungen.", + "once": "Jede Warnung wird einmal beim Auftreten per E-Mail versendet und erneut nur, wenn sie sich verschärft.", + "scope": "Umfasst alle HiveHub-Geräte, auf die Sie Zugriff haben.", + "email": "Erfordert, dass E-Mail auf dem HivePal-Server konfiguriert ist." + }, + "saved": "Warnungseinstellungen gespeichert", + "saveFailed": "Die Warnungseinstellungen konnten nicht gespeichert werden", + "loadFailed": "Ihre Einstellungen konnten nicht geladen werden." + }, + "health": { + "title": "Sensorzustand", + "description": "Zustand je Sensor, wie von {{name}} gemeldet.", + "descriptionAt": "Sensorzustand der letzten Meldung · {{ago}}.", + "noData": "Noch kein Sensorzustand gemeldet – er erscheint, sobald das Gerät seinen ersten Messwert sendet.", + "hubHeading": "Hub", + "hiveIndex": "Stock {{n}}", + "noHiveSensors": "Für diesen Stock wurden keine Sensoren gemeldet.", + "faultNote": "Der Hub meldet seine eigenen Subsysteme nur als OK/Fehler – er sendet dafür keinen Fehlercode, deshalb beschreibt jeder Fehler oben, was ausgefallen ist. Der Fluglochzähler meldet dagegen einen Statuscode, der beim Fehler angezeigt wird, wo einer gelesen wurde.", + "summary": { + "noData": "Keine Sensordaten", + "allOk": "Alles OK", + "faults_one": "{{count}} Fehler", + "faults_other": "{{count}} Fehler" + }, + "state": { + "ok": "OK", + "fault": "Fehler", + "notFitted": "Nicht verbaut", + "notHeard": "Nicht empfangen" + }, + "hub": { + "sht_ok": "Umgebungstemperatur/-feuchte (SHT40)", + "rtc_ok": "Echtzeituhr", + "sd_ok": "SD-Karte", + "mic_ok": "Mikrofon", + "mic_left_ok": "Mikrofon · links", + "mic_right_ok": "Mikrofon · rechts", + "battery_monitor_ok": "Akkuüberwachung", + "solar_monitor_ok": "Solarüberwachung" + }, + "hubReason": { + "sht_ok": "Der Umgebungssensor hat in diesem Zyklus keinen gültigen Messwert geliefert, daher enthält diese Meldung keine Umgebungstemperatur oder -feuchte. Meist ein Fehler am I²C-Bus oder in der Verkabelung; ein Sensor, der beim Start nie erkannt wurde, erscheint genauso.", + "rtc_ok": "Die Echtzeituhr hat auf dem I²C-Bus nicht geantwortet. Zeitstempel fallen auf die Netzwerkzeit zurück, sodass ein offline erfasster Messwert falsch datiert sein kann.", + "sd_ok": "Die SD-Karte wurde in diesem Zyklus nicht eingebunden – fehlend, unlesbar oder ein SPI-/Verkabelungsfehler. Solange dies anhält, werden Messwerte nicht auf der Karte zwischengespeichert.", + "mic_ok": "Die Mikrofonaufnahme lieferte keine verwertbaren Samples, daher enthält diese Meldung keine akustischen Bänder.", + "mic_left_ok": "Das linke Mikrofon lieferte keine verwertbaren Samples.", + "mic_right_ok": "Das rechte Mikrofon lieferte keine verwertbaren Samples.", + "battery_monitor_ok": "Der Akku-Ladezustandsmesser hat auf dem I²C-Bus nicht geantwortet – Akkuspannung und Ladezustand fehlen in dieser Meldung.", + "solar_monitor_ok": "Die Solarüberwachung hat auf dem I²C-Bus nicht geantwortet – Solarspannung, -strom und -leistung fehlen in dieser Meldung." + }, + "sensor": { + "scale": "Waage", + "inHive": "Stocksensor", + "counter": "HiveTraffic-Zähler", + "hiveheart": "HiveHeart" + }, + "reason": { + "scale": "Für diesen Stock ist eine Waage konfiguriert, sie hat in diesem Zyklus aber keinen verwertbaren Messwert geliefert, daher enthält diese Meldung kein Gewicht für ihn.", + "inHive": "Kein Advertisement vom gekoppelten {{kind}} während des BLE-Scans dieses Zyklus – außer Reichweite, Batterie leer oder ausgeschaltet.", + "inHiveKindFallback": "Stocksensor", + "node": "Knoten {{node}}.", + "lastHeard": "Zuletzt empfangen {{ago}}.", + "notHeardInRange": "Im geladenen Zeitraum nicht empfangen.", + "counter": "Der Fluglochzähler hat in diesem Zyklus nicht auf seine GATT-Abfrage geantwortet – außer Reichweite, ausgeschaltet oder beschäftigt, als HiveHub ihn angefragt hat.", + "counterNode": "Zähler {{node}}.", + "lastAnswered": "Zuletzt geantwortet {{ago}}.", + "notAnsweredInRange": "Er hat im geladenen Zeitraum nicht geantwortet.", + "statusThen": "Status damals: {{status}}.", + "expandersThen_one": "{{count}} von 3 Port-Expandern antwortete damals.", + "expandersThen_other": "{{count}} von 3 Port-Expandern antworteten damals.", + "hiveheart": "Das HiveHeart wurde in diesem Zyklus nicht empfangen – außer Reichweite, Batterie leer oder ausgeschaltet." + }, + "counter": { + "nightIdle": "Nachtmodus-Ruhe", + "nightIdleBadge": "Nachtruhe", + "otherBits": "zählerspezifische Bits {{hex}}", + "noFlags": "keine Flags gesetzt", + "statusCode": "Status {{hex}}", + "expanders_one": "{{count}}/3 Durchgangs-Expander", + "expanders_other": "{{count}}/3 Durchgangs-Expander", + "glitches_one": "{{count}} Störung", + "glitches_other": "{{count}} Störungen", + "banks": "Bänke {{list}}", + "noBanks": "Keine Emitterbänke" + }, + "meta": { + "firmware": "v{{version}}", + "batteryPercent": "Akku {{value}} %", + "batteryMv": "Akku {{value}} mV", + "batteryV": "Akku {{value}} V", + "rssi": "{{value}} dBm" + } + }, + "hubStatus": { + "title": "Hub-Status", + "description": "Verbindung, Uhr und Stromversorgung des HiveHub selbst.", + "noData": "Von diesem Hub wurde noch kein Messwert empfangen.", + "staleWarning": "Seit {{ago}} kein Messwert – der Hub hat mehrere Meldungen verpasst.", + "badge": { + "live": "Live", + "stale": "Veraltet", + "inspection": "Durchsicht läuft", + "calibration": "Kalibrierungsmodus" + }, + "section": { + "connectivity": "Verbindung", + "battery": "Akku", + "solar": "Solar" + }, + "row": { + "signal": "Signal", + "transport": "Übertragung", + "timeSource": "Zeitquelle", + "lastSeen": "Zuletzt gesehen", + "lastReading": "Letzter Messwert", + "firmware": "Firmware", + "bootCount": "Anzahl Starts", + "stateOfCharge": "Ladezustand", + "batteryVoltage": "Spannung", + "batteryAlert": "Akkuwarnung", + "solarPower": "Solarleistung", + "solarCurrent": "Solarstrom", + "solarBus": "Panelspannung (Bus)", + "solarLoad": "Lastspannung" + }, + "signalQuality": { + "excellent": "Ausgezeichnet", + "good": "Gut", + "fair": "Mittel", + "poor": "Schwach" + }, + "transport": { + "wifi": "WLAN", + "cellular": "Mobilfunk" + }, + "rtc": { + "ok": "RTC OK", + "fault": "RTC-Fehler" + }, + "batteryAlertActive": "Akku schwach" + }, + "hiveCards": { + "empty": "Dieses Gerät hat noch keine Stöcke gemeldet.", + "hiveIndex": "Stock {{n}}", + "weight": "Gewicht", + "temperature": "Temperatur", + "humidity": "Luftfeuchtigkeit", + "delta24h": "24 h {{value}} kg", + "lastReported": "Zuletzt gemeldet {{ago}}", + "inspection": "Durchsicht", + "insightsLoading": "Erkenntnisse werden geladen", + "insightsUnavailable": "Erkenntnisse nicht verfügbar", + "noSensors": "Keine Sensoren gemeldet", + "traffic": "Rein {{in}} · Raus {{out}}", + "trafficTitle": "Bienen rein/raus im letzten Intervall", + "alerts_one": "{{count}} aktive Warnung", + "alerts_other": "{{count}} aktive Warnungen", + "sensor": { + "scale": "Waage", + "inHive": "Stocksensor", + "counter": "HiveTraffic-Zähler", + "hiveheart": "HiveHeart" + }, + "state": { + "ok": "OK", + "fault": "Nicht empfangen / Fehler" + } + }, + "audio": { + "title": "Stock-Audio", + "description": "Lassen Sie den HiveInside-Knoten eines Stocks eine Aufnahme machen und hören Sie sie hier an. Der Hub holt die Anfrage beim nächsten Aufwachen ab, daher erscheint eine Aufnahme innerhalb eines Meldeintervalls und nicht sofort.", + "noNodes": "Mit diesem Hub ist kein HiveInside-Knoten gekoppelt. Nur HiveInside hat ein Mikrofon – ein HolyIot- oder RuuviTag-Beacon kann nicht aufnehmen.", + "hive": "Stock", + "hiveFallback": "Stock {{slot}}", + "length": "Länge", + "durationOption_one": "{{count}} Sekunde", + "durationOption_other": "{{count}} Sekunden", + "gain": "Verstärkung", + "gainValue": "{{gain}} dB", + "record": "Aufnehmen", + "nextWake": "Erreicht den Hub bei seinem nächsten Aufwachen.", + "viewerNote": "Ein Besitzer oder Admin kann eine Aufnahme anfordern.", + "lengthHint": "Kürzer ist meist besser: 10–20 Sekunden reichen, um zu hören, was eine Erkenntnis gemeldet hat, und kosten weniger Akku und weniger Zeit, in der der Stock nicht funkt – ein verbundener Knoten sendet weder Advertisements noch erfasst er Messwerte.", + "requestSuccess": "Aufnahme angefordert. Sie erreicht den Hub bei seinem nächsten Aufwachen.", + "requestError": "Die Anfrage konnte nicht eingereiht werden: {{message}}", + "listTitle": "Aufnahmen", + "onlySelectedHive": "Nur {{name}}", + "loadError": "Aufnahmen konnten nicht aufgelistet werden: {{message}}", + "empty": "Noch keine Aufnahmen. Lassen Sie einen Stock aufnehmen, dann erscheint sie hier. Eine Sitzung umfasst 10–60 Sekunden 16-kHz-Audio, das per BLE vom Knoten abgerufen wird.", + "requestedLength_one": "{{count}} s angefordert", + "requestedLength_other": "{{count}} s angefordert", + "requestedBy": "Angefordert von {{name}}", + "status": { + "requested": "Wartet auf Hub", + "streaming": "Nimmt auf…", + "ready": "Bereit", + "readyIncomplete": "Bereit, unvollständig", + "failed": "Fehlgeschlagen" + }, + "waitingNote": "Wartet darauf, dass der Hub aufwacht und die Anfrage abholt – noch nichts aufgenommen. Eine Anfrage, die der Hub nicht innerhalb von 45 Minuten abgeholt hat, wird als fehlgeschlagen markiert.", + "streamingNote": "Aufnahme läuft – das Audio erscheint hier, sobald es eintrifft.", + "streamingPartial": "Aufnahme läuft noch – dies ist, was bisher angekommen ist. Spielen Sie es nach Ende der Sitzung erneut ab, um alles zu hören.", + "failedFallback": "Diese Aufnahme ist fehlgeschlagen.", + "seconds": "{{value}} s", + "bytes": "{{value}} B", + "percent": "{{value}} %", + "download": "WAV herunterladen", + "delete": "Löschen", + "deleteConfirm": "Diese Aufnahme löschen? Sonst wird sie nie gelöscht – es gibt keine automatische Bereinigung.", + "deleteSuccess": "Aufnahme gelöscht.", + "deleteError": "Die Aufnahme konnte nicht gelöscht werden: {{message}}", + "confirmation": { + "verified": "verifiziert", + "reported": "nicht verifiziert", + "confirmed": "vom Hub bestätigt", + "unknown": "nicht bestätigt" + }, + "quality": { + "length": "Länge", + "dropped": "Verworfen", + "gaps": "Lücken", + "hubBuffer": "Hub-Puffer", + "clipping": "Übersteuerung", + "checksum": "Prüfsumme", + "cause": { + "dropped": "der Knoten konnte das Audio nicht schnell genug senden", + "gaps": "Pakete gingen bei der Funkübertragung verloren", + "hubBuffer": "der Upload des Hubs kam mit seinem Puffer nicht hinterher" + }, + "gapNote": "Das Audio beiderseits einer Lücke ist echt, der Übergang jedoch nicht. Ursache: {{causes}}.", + "clippingNote": "Verringern Sie die Verstärkung für eine sauberere Aufnahme.", + "unknownNote": "Der Hub hat zu dieser Sitzung nie etwas gemeldet, daher wurde nicht geprüft, ob das Audio vollständig ist. Was vorhanden ist, lässt sich abspielen." + } + }, + "dashboard": { + "hiveFallback": "Stock {{index}}", + "units": { + "bees": "Bienen" + }, + "range": { + "24h": "24 h", + "7d": "7 T", + "30d": "30 T", + "365d": "365 T", + "custom": "Benutzerdefiniert", + "invalid": "Das Startdatum darf nicht nach dem Enddatum liegen." + }, + "header": { + "title": "Mein Dashboard", + "description": "Frei konfigurierbare Widgets. Die oben ausgewählten Stöcke steuern die stockbezogenen Diagramme.", + "editDescription": "Bearbeitungsmodus: Ziehen Sie am Griff, um Widgets zu verschieben, und an der Ecke, um ihre Größe in einem 4-spaltigen Raster zu ändern.", + "edit": "Dashboard bearbeiten", + "done": "Fertig", + "addWidget": "Widget hinzufügen", + "reset": "Zurücksetzen", + "editBanner": "Der Bearbeitungsmodus des Dashboards ist aktiv. Verschieben Sie ein Widget über den Griff in seiner Kopfzeile und ziehen Sie die untere rechte Ecke, um Breite oder Höhe zu ändern. Layoutänderungen werden für dieses HiveHub-Gerät automatisch gespeichert.", + "dropEnd": "Hier ablegen, um ein Widget ans Ende des Dashboards zu verschieben.", + "noWidgets": "Noch keine Widgets. Fügen Sie eine Vorlage hinzu, um Ihr Dashboard aufzubauen.", + "restoreDefaults": "Standard wiederherstellen" + }, + "overview": { + "title": "Stockübersicht", + "description": "Kompakter Status der zugeordneten Stöcke. Wählen Sie Kacheln aus, um die Dashboard-Widgets darunter zu steuern.", + "lastData": "Letzte Daten", + "hivesOnline": "Stöcke online", + "hubFirmware": "HiveHub-FW", + "hiveInsideFirmware": "HiveInside-FW", + "selected_one": "{{count}} ausgewählt", + "selected_other": "{{count}} ausgewählt", + "hide": "Übersicht ausblenden", + "show": "Übersicht einblenden", + "slot": "Platz {{index}}", + "ok": "OK", + "noData": "Keine Daten", + "weight": "Gewicht", + "temperature": "Temp.", + "humidity": "rF", + "empty": "Ordnen Sie HiveHub-Plätze in der Geräteeinrichtung HivePal-Stöcken zu, um hier Übersichtskacheln anzuzeigen." + }, + "sensors": { + "scale": "Waage", + "inHive": "im Stock", + "accel": "Beschl.", + "counter": "Zähler", + "none": "keine Sensoren" + }, + "widgetShell": { + "move": "{{title}} verschieben", + "moveHint": "Ziehen, um dieses Widget zu verschieben", + "remove": "{{title}} entfernen", + "resize": "Größe von {{title}} ändern", + "resizeHint": "Ziehen, um die Größe dieses Widgets zu ändern" + }, + "addWidget": { + "title": "Widget hinzufügen", + "description": "Beginnen Sie mit einer Vorlage. Das Widget verwendet, wo sinnvoll, die im Übersichtsraster ausgewählten Stöcke.", + "defaultSize": "Standardgröße {{w}} x {{h}}" + }, + "widgets": { + "weightComparison": { + "title": "Gewichtsvergleich", + "description": "Ausgewählte Stöcke über den aktuellen Zeitraum vergleichen." + }, + "dailyMaxWeight": { + "title": "Tageshöchstgewicht", + "description": "Höchstes kompensiertes Gewicht pro Tag für die ausgewählten Stöcke." + }, + "climate": { + "title": "Stockklima", + "description": "Temperatur und Luftfeuchtigkeit der ausgewählten Stöcke." + }, + "power": { + "title": "Stromversorgung", + "description": "Akkuladung, Akkuspannung und Solareingang." + }, + "beeTraffic": { + "title": "Flugverkehr", + "description": "Gesamter Ein-/Ausflug und Nettofluss." + }, + "soundRms": { + "title": "Sound / akustische Bänder", + "description": "Akustischer RMS je Stock und FFT-/HiveHeart-Bänder, sofern verfügbar." + }, + "hiveHeartSpectrum": { + "title": "HiveHeart-Spektrum", + "description": "Neueste HiveHeart-FFT-Pegel (0–15) über 16 Frequenzbereiche für die ausgewählten Stöcke." + }, + "vibration": { + "title": "Vibrationsbänder", + "description": "RMS-Vibration sowie Schwarm-, Fächel- und Aktivitätsbänder." + }, + "configurableDiagram": { + "title": "Konfigurierbares Diagramm", + "description": "Wählen Sie die Stock-, Sound-, Flugverkehrs-, Klima- und Gerätekennzahlen, die dargestellt werden sollen." + }, + "temperatureHeatmap": { + "title": "Temperatur-Heatmap", + "description": "Kompakte Temperaturübersicht aller Stöcke." + }, + "insights": { + "title": "Erkenntnis-Feed", + "description": "Aktive Warnungen und Belege." + }, + "dataQuality": { + "title": "Datenqualität", + "description": "Sensorverfügbarkeit und fehlende Messwerte." + } + }, + "metricGroups": { + "scale": "Waage", + "climate": "Klima", + "beeTraffic": "Flugverkehr", + "vibration": "Vibration", + "sound": "Sound", + "device": "Gerät" + }, + "metrics": { + "weight": "Gewicht", + "temperature": "Temperatur", + "humidity": "Luftfeuchtigkeit", + "pressure": "Luftdruck", + "beeIn": "Bienen rein", + "beeOut": "Bienen raus", + "beeNet": "Nettofluss", + "vibration": "Vibration RMS", + "swarmBand": "Schwarmband", + "fanningBand": "Fächelband", + "activityBand": "Aktivitätsband", + "soundRms": "Sound RMS", + "soundHum": "Summband", + "soundPiping": "Tütband", + "soundStress": "Stressband", + "hiveHeartFrequency": "HiveHeart-Frequenz", + "hiveHeartEnergy": "HiveHeart-Energie", + "batterySoc": "Akkuladung", + "batteryVoltage": "Akkuspannung", + "solarPower": "Solarleistung" + }, + "axes": { + "weight": "Gewicht", + "temperature": "Temperatur", + "humidity": "Luftfeuchtigkeit", + "pressure": "Luftdruck", + "beecount": "Bienenanzahl", + "vibration": "Vibration", + "dbfs": "Schallpegel", + "frequency": "Frequenz", + "energy": "Energie", + "percent": "Ladung", + "voltage": "Spannung", + "power": "Leistung" + }, + "soundMetrics": { + "rms": "RMS", + "hiveHeartFrequency": "HiveHeart-Frequenz", + "hiveHeartEnergy": "HiveHeart-Energie", + "hiveHeartPeak": "HiveHeart-Spitze" + }, + "vibrationMetrics": { + "rms": "RMS", + "swarmBand": "Schwarmband", + "fanningBand": "Fächelband", + "activityBand": "Aktivitätsband" + }, + "series": { + "temperature": "{{name}} Temp.", + "humidity": "{{name}} rF", + "beeIn": "Rein", + "beeOut": "Raus", + "beeNet": "Netto" + }, + "axisScale": { + "auto": "auto", + "setBound": "{{axis}} {{bound}} festlegen", + "clearHint": "Leer lassen und Enter drücken für automatisch", + "tickTitle": "Klicken, um {{bound}} festzulegen; leeren für automatisch", + "reset": "zurücksetzen", + "help": "Klicken Sie auf die niedrigste oder höchste Beschriftung der y-Achse, um sie zu bearbeiten. Leeren Sie den Wert für automatisch." + }, + "chart": { + "downloadCsv": "CSV herunterladen" + }, + "inspection": { + "legendHint_one": "{{count}} Durchsichtszeitraum schattiert; Messwerte können gestört sein", + "legendHint_other": "{{count}} Durchsichtszeiträume schattiert; Messwerte können gestört sein" + }, + "empty": { + "noData": "Keine Daten für die ausgewählten Stöcke und den Zeitraum.", + "climate": "Keine Klimadaten für den ausgewählten Zeitraum.", + "power": "Keine Stromdaten für den ausgewählten Zeitraum.", + "beeTraffic": "Keine Bienenzählerdaten für die ausgewählten Stöcke.", + "sound": "Keine akustischen Daten je Stock für die ausgewählten Stöcke.", + "vibration": "Keine Vibrations- oder Beschleunigungsbanddaten für die ausgewählten Stöcke.", + "configurable": "Wählen Sie mindestens eine Kennzahl mit Daten für die ausgewählten Stöcke und den Zeitraum.", + "dailyMaxWeight": "Keine Gewichtsdaten für die ausgewählten Stöcke und den Zeitraum.", + "hiveHeart": "Keine HiveHeart-FFT-Daten für die ausgewählten Stöcke und den Zeitraum.", + "unknownWidget": "Unbekannter Widget-Typ." + }, + "soundFootnote": "Zeigt zuerst akustische Messwerte je Stock. Ältere linke/rechte Mikrofondaten werden den konfigurierten Stocknamen für die Plätze 1 und 2 zugeordnet, wenn kein akustischer Block je Stock vorhanden ist.", + "heatmap": { + "low": "niedrig <33 °C", + "optimal": "optimale Brut 33–36 °C", + "high": "hoch >36 °C" + }, + "insights": { + "loading": "Erkenntnisse werden geladen…", + "unavailable": "Erkenntnisse sind nicht verfügbar.", + "noAlerts": "Keine aktiven Warnungen für die ausgewählten Stöcke.", + "showing_one": "{{count}} aktive Warnung für die ausgewählten Stöcke.", + "showing_other": "{{count}} aktive Warnungen für die ausgewählten Stöcke.", + "confidence": "Konfidenz {{percent}}%" + }, + "dataQuality": { + "activeSlots": "Aktive Plätze", + "noScale": "Keine Waage", + "noClimate": "Kein Klima", + "noRecentData": "keine aktuellen Daten" + }, + "hiveHeart": { + "level": "Pegel", + "dominant": "dominante Frequenz {{hz}} Hz", + "noDominant": "keine dominante Frequenz gemeldet", + "footnote": "Relative Pegel 0–15 (nicht dBFS) des neuesten Messwerts im Zeitraum. Gestrichelte Linien markieren die von HiveHeart gemeldete dominante Frequenz; die Überschriften Sub-Bass/Summen/Tüten/Stress sind ungefähr und umfassen mehrere Bereiche." + } } } diff --git a/apps/frontend/public/locales/en/common.json b/apps/frontend/public/locales/en/common.json index 9cad7cbe9..6583c37bd 100644 --- a/apps/frontend/public/locales/en/common.json +++ b/apps/frontend/public/locales/en/common.json @@ -36,7 +36,8 @@ "swarmManagement": "Swarm Management", "liebefelder": "Liebefelder Method", "varroaManagement": "Varroa Management", - "documentation": "Documentation" + "documentation": "Documentation", + "hivehub": "HiveHub" }, "actions": { "save": "Save", diff --git a/apps/frontend/public/locales/en/hivescale.json b/apps/frontend/public/locales/en/hivescale.json index 880cacad0..da4772265 100644 --- a/apps/frontend/public/locales/en/hivescale.json +++ b/apps/frontend/public/locales/en/hivescale.json @@ -35,7 +35,7 @@ }, "page": { "title": "HiveHub", - "subtitle": "Live weight and temperature visualisation from your separate HiveHub backend.", + "subtitle": "Weight, climate, sound, traffic and alerts from your HiveHub devices.", "refresh": "Refresh", "refreshing": "Refreshing…", "refreshError": "Could not refresh HiveHub data.", @@ -43,16 +43,26 @@ "removeSuccess": "HiveHub device removed from your account.", "removeSuccessShared": "You no longer have access to this HiveHub device. Other users still have it, so its claim code stays in use.", "releaseConfirm": "Release {{name}} completely? Every user loses access and the device becomes claimable again with its claim code. Readings are kept.", - "releaseSuccess": "HiveHub device released. It can be claimed again with its claim code." + "releaseSuccess": "HiveHub device released. It can be claimed again with its claim code.", + "unavailableTitle": "HiveHub is not reachable", + "inspectionActiveTitle": "Inspection mode is on", + "inspectionActiveBody": "Readings are masked while the hives are open, so lifting frames does not raise false alerts.", + "openInspections": "Open inspections", + "tabs": { + "overview": "Overview", + "inspections": "Inspections", + "audio": "Audio", + "health": "Health", + "setup": "Setup" + } }, "setup": { "title": "Devices", - "description": "Select or remove a claimed HiveHub device. Expand the setup to claim devices, check status, map scales, and calibrate the raw readings.", "removeScale": "Remove scale", "releaseScale": "Release device", "noDevices": "No devices claimed yet — open the setup to add one.", - "hide": "Hide setup", - "show": "Show setup" + "selectDevice": "HiveHub device", + "noDevicesYet": "No HiveHub device claimed yet. Claim one with the code the hub shows in its setup portal." }, "claim": { "title": "Claim HiveHub device", @@ -65,16 +75,30 @@ "success": "HiveHub device claimed.", "errors": { "missingCode": "Enter the claim code printed/configured for the device." - } + }, + "hive1Name": "Name of hive 1", + "hive2Name": "Name of hive 2", + "optional": "Optional" }, "mapping": { - "title": "Scale mapping", - "description": "These labels stay in HiveHub. Later, this is the right place to map Scale 1/2 to HivePal hive IDs for inspection markers.", - "scale1Label": "Display name for Scale 1", - "scale2Label": "Display name for Scale 2", + "title": "Hives", "hiveNamePlaceholder": "Select or type a hive name", - "save": "Save mapping", - "success": "Scale mapping updated." + "save": "Save hives", + "success": "Hive slots saved to HiveHub.", + "slotsDescription": "Name each hive slot and link it to a HivePal hive. The link lets inspections, the hive page and alert emails know which colony a slot is. A green dot marks slots the hub currently reports.", + "mapSlot": "Map slot {{slot}}", + "add": "Add", + "reporting": "Reported by the hub", + "unknownHive": "Hive not found", + "mappedCount": "{{count}}/{{total}} slots mapped", + "dialogTitle": "Hive slot {{slot}}", + "dialogDescription": "Link the slot to one of your HivePal hives and choose the name HiveHub shows for it.", + "linkedHive": "HivePal hive", + "notLinked": "Not linked", + "linkHint": "Linking survives renaming the hive. Picking a hive fills in its name if the name is empty.", + "displayName": "Display name", + "clearSlot": "Clear slot", + "applySlot": "Apply" }, "status": { "title": "Device status", @@ -94,6 +118,13 @@ "revokeAccess": "Revoke access", "errors": { "missingEmail": "Enter the email address of an existing HivePal user." + }, + "lastSeen": "Last check-in", + "claimedAt": "Claimed", + "roles": { + "owner": "Owner", + "admin": "Admin", + "viewer": "Viewer" } }, "calibration": { @@ -203,11 +234,9 @@ }, "sourceHint": "Ambient tracks the cell body closely in steady state and is the recommended default.", "refLabel": "Reference temperature / °C", - "refHint": "Temperature at which the correction is zero.", "coeffLabel": "{{scaleName}} coefficient / kg per °C", "autoFit": "Auto-fit from history", "fitting": "Fitting…", - "autoFitHint": "Auto-fit regresses the last 3 days of raw weight against the EMA-smoothed selected temperature, then applies and enables the result. For a clean fit, capture a window where the physical load is constant and the temperature swings.", "save": "Save temperature compensation", "errors": { "refTemp": "Reference temperature must be a number (°C).", @@ -219,11 +248,33 @@ "noSignal": "not enough temperature/weight signal.", "fitApplied": "{{scaleName}}: {{coeff}} kg/°C @ {{refTemp}}°C (R²={{r2}}, n={{n}}). Applied and enabled." } + }, + "perHive": { + "hive": "Hive {{index}}", + "hiveNamed": "Hive {{index}} · {{name}}", + "hiveLabel": "Hive", + "knownWeightPlaceholder": "e.g. 10", + "manualSaveHint": "Switch hives to edit another scale; all edits are sent together when you save. Hives 3 and up are only written when changed.", + "tempco": { + "refHint": "The temperature at which the scales read true, i.e. the one they were tared and spanned at. The correction is zero there; fitting leaves it alone unless you ask it to.", + "lookbackLabel": "Fit lookback / days", + "setRefTemp": "Also set the reference temperature", + "setRefTempHint": "Replaces the reference with the mean temperature of the fitted window. Leave off if the scale was calibrated at a known temperature.", + "fitOnly": "Fit only (preview)", + "fitAndApply": "Fit & apply", + "fitSummary": "{{scaleName}}: {{coeff}} kg/°C, R² {{r2}} over {{n}} readings from {{min}}–{{max}} °C (window mean {{mean}} °C).", + "lowRSquared": "R² below 0.5: temperature explains little of this drift. Fit over a stretch with a constant load and a wide day/night swing.", + "autoFitHint": "Auto-fit regresses the selected hive's stored weight over the lookback window against the EMA-smoothed selected temperature. \"Fit only\" fills the coefficient for review before you save; \"Fit & apply\" stores it and enables compensation. For a clean fit, pick a window where the physical load is constant and the temperature swings.", + "errors": { + "lookback": "Lookback must be a whole number of days between 1 and 90." + }, + "toasts": { + "fitFilled": "{{scaleName}}: {{coeff}} kg/°C (R²={{r2}}, n={{n}}). Filled in — review and save." + } + } } }, "firmware": { - "title": "Firmware upload", - "description": "Upload a firmware binary and register it as a release. New devices of the matching type pick it up on their next update check.", "type": "Firmware type", "selectType": "Select firmware type", "version": "Version", @@ -253,16 +304,87 @@ "missingFile": "Choose a firmware binary (.bin) to upload.", "missingVersion": "Enter a version, e.g. 0.6.3." }, - "hiveinsideOta": { - "title": "Trigger HiveInside OTA", - "description": "Uploading a HiveInside firmware now automatically queues the OTA relay to both sensor slots over BLE. Use this to re-queue the active release to a specific slot. Keep the sensor powered and awake during the update.", - "slot": "Sensor slot", - "slotOption": "Slot {{slot}}", - "queue": "Queue HiveInside OTA", + "board": { + "cardTitle": "Firmware", + "cardDescription": "Firmware of this HiveHub and of the HiveInside nodes and HiveTraffic counters it carries. Uploading registers a release; approving (hub) or relaying (nodes) installs it.", + "current": "Current", + "latest": "Latest", + "approved": "Approved", + "board": "Board", + "badgePending": "Update pending approval", + "badgeApproved": "Update approved", + "badgeUpToDate": "Up to date", + "approveFlash": "Approve & flash {{version}}", + "approveConfirm": "Approve firmware {{version}} and flash it over the air?\n\nThe HiveHub installs it on its next check-in and reboots. A remote device that fails mid-update may need physical access to recover.", + "otherBoards": "Also uploaded for another board (not applied to this {{board}} device): {{releases}}. A build only reaches a device whose board matches — check the board when uploading.", + "otherBoardsUnknown": "Also uploaded for another board (not applied to this device): {{releases}}. A build only reaches a device whose board matches — check the board when uploading." + }, + "relays": { + "hiveFallback": "Hive {{slot}}", + "latest": "latest {{version}}", + "updateAvailable": "update available: {{version}}", + "status": { + "pending": "relay queued", + "running": "relaying…", + "completed": "relay completed", + "failed": "relay failed", + "expired": "relay timed out" + }, + "statusTitle": "Relay of {{version}}: {{status}} ({{date}})", + "failedDetail": "Last relay failed — {{message}} ({{date}})", + "noReason": "no reason reported", + "alreadyQueued": "A relay is already queued for this node", "queueing": "Queuing…", - "success": "HiveInside OTA queued for slot {{slot}}.", - "autoQueued": "HiveInside OTA auto-queued for slots {{slots}}.", - "autoQueueFailed": "Could not auto-queue the HiveInside OTA for slots {{slots}}." + "relayAnyway": "Relay anyway", + "confirm": "Relay {{name}} {{version}} to {{hive}}?\n\nThe HiveHub picks this up on its next upload cycle — normally within about 10 minutes, or whatever send interval this device uses. It then streams the image over BLE for a few minutes and the node reboots into it. The node must be awake and in range, or the relay fails and has to be queued again.", + "queued": "Relay of {{version}} queued for {{hive}}.", + "queuedFrom": "Relay queued for {{hive}} ({{current}} → {{version}}).", + "hiveinside": { + "name": "HiveInside", + "title": "HiveInside nodes", + "description": "Firmware each in-hive node advertises. Uploading a HiveInside image only registers the release — nothing reaches a node until you press “Relay to node”. The version shown here is what confirms the update took.", + "relay": "Relay to node", + "unnamed": "Advertises no name of its own (HiveInside before 0.5.0)", + "noRelease": "No HiveInside release uploaded yet — upload one below to relay it." + }, + "beecounter": { + "name": "HiveTraffic", + "title": "HiveTraffic counters", + "description": "Firmware each HiveTraffic counter reports. Uploading a HiveTraffic image only registers the release — nothing reaches a counter until you press “Relay to counter”. Counters older than about v0.1.0 report no version but can still be updated.", + "relay": "Relay to counter", + "unnamed": "Reports no name or address", + "noRelease": "No HiveTraffic release uploaded yet — upload one below to relay it.", + "countingWarning": "A counter stops counting bees for the whole transfer, so relay when traffic is low.", + "confirmExtra": "The counter stops counting bees for the whole transfer — any traffic during those few minutes is lost." + } + }, + "uploadForm": { + "title": "Upload firmware", + "description": "Uploading only registers a release — it never installs anything on its own. Approve the hub update above, or relay a node or counter release from its row.", + "board": "Board", + "targets": { + "hivehub": "HiveHub hub", + "hiveinside": "HiveInside", + "beecounter": "HiveTraffic counter" + }, + "boards": { + "auto": "Auto (from filename)", + "esp32": "ESP32 (classic 30-pin)", + "esp32-c6": "ESP32-C6", + "nrf54lm20a": "nRF54LM20A" + }, + "boardHint": { + "hivehub": "Hub firmware must state its board: pick one, or keep auto when the file is named like hivehub_esp32-c6_0.30.5.bin.", + "hiveinside": "HiveInside ships only for the nRF54LM20A. Upload the build artifact as-is (e.g. hiveinside_nrf54lm20a_1.4.0.bin) and target and version are filled in from its name.", + "beecounter": "The HiveTraffic counter is an ESP32-C6. Upload the application image (e.g. beecounter_esp32-c6_0.7.1.bin), not a merged factory image." + }, + "detected": "{{name}} — filled in {{fields}}.", + "nothingDetected": "{{name}} — no target, board or version recognized in the name; set them below.", + "detectedTarget": "target “{{target}}”", + "detectedBoard": "board {{board}}", + "detectedVersion": "version {{version}}", + "autoQueued": "Relay queued automatically for {{hives}}.", + "autoQueueFailed": "Could not queue the relay automatically for {{hives}}." } }, "sdData": { @@ -287,12 +409,18 @@ "parsed_other": "Parsed {{count}} records", "skipped_one": " · {{count}} unreadable line skipped.", "skipped_other": " · {{count}} unreadable lines skipped." + }, + "mismatch": { + "title": "File from a different hub", + "description": "This file was recorded by another hub, not by {{device}}.", + "fileDevices_one": "Device ID in the file:", + "fileDevices_other": "Device IDs in the file:", + "hint": "Import it anyway only if the SD card really belongs to this device (for example after replacing the hub's board).", + "cancel": "Cancel", + "force": "Import into this device anyway" } }, "panel": { - "weight": "Weight", - "hiveTemp": "Hive temp", - "rmsSound": "RMS sound", "ambientTemperature": "Ambient temperature", "ambientHumidity": "Ambient humidity", "batteryCharge": "Battery charge", @@ -302,7 +430,6 @@ "title": "General", "description": "Ambient & power" }, - "humidity": "Hive humidity", "wirelessSensorsBattery": "Wireless sensors battery", "sensorTypes": { "hiveHeart": "HiveHeart", @@ -311,178 +438,36 @@ } }, "diagram": { - "title": "Measurements", - "subtitle": "Weight, temperature, and entrance activity over time", - "noMeasurements": "No measurements for the selected range.", - "noDataForSeries": "No data in the selected range.", - "meta": { - "device": "Device:", - "lastSeen": "Last seen:", - "lastData": "Last data:", - "firmware": "Firmware:", - "hiveInsideFirmware": "HiveInside:" - }, "range": { "all": "All", "from": "From", "to": "To" }, - "series": { - "weight": "{{name}} weight", - "temp": "{{name}} temp", - "micRms": "{{name}} mic RMS", - "vibration": "{{name}} vibration", - "swarmBand": "{{name}} swarm band (8–30 Hz)", - "beesIn": "{{name}} bees in", - "beesOut": "{{name}} bees out", - "netFlow": "{{name}} net flow", - "ambientTemp": "Ambient temp", - "ambientHumidity": "Ambient humidity", - "batteryVoltage": "Battery voltage", - "batteryCharge": "Battery charge", - "solarVoltage": "Solar voltage", - "solarCurrent": "Solar current", - "solarPower": "Solar power", - "humidity": "{{name}} humidity", - "pressure": "{{name}} pressure", - "fanningBand": "{{name}} fanning band (30–100 Hz)", - "activityBand": "{{name}} activity band (100–200 Hz)" - }, - "group": { - "ambient": "Ambient", - "offGrid": "Off-grid" - }, - "axis": { - "weight": "Weight", - "temperature": "Temperature", - "humidity": "Humidity", - "voltage": "Voltage", - "percent": "Percent", - "current": "Current", - "power": "Power", - "sound": "Sound", - "beecount": "Bee count", - "vibration": "Vibration", - "pressure": "Pressure" - }, "marker": { - "inspection": "Inspection", - "boxAdded": "Box added", - "action": { - "maintenance": "Maintenance", - "feeding": "Feeding", - "frames": "Frames", - "treatment": "Treatment" - } - }, - "axisSettings": { - "title": "Axis settings", - "reset": "Reset", - "side": "Side", - "left": "Left", - "right": "Right", - "scale": "Scale", - "auto": "Auto", - "zeroToMax": "0 to max", - "custom": "Custom", - "min": "Min", - "max": "Max", - "autoPlaceholder": "auto" + "inspection": "Inspection" } }, "sound": { - "title": "Hive sound", - "description": "INMP441 FFT band energy (dBFS) — {{left}} (left) · {{right}} (right)", - "hide": "Hide sound", - "show": "Show sound", - "micLeftFallback": "Mic left", - "micRightFallback": "Mic right", - "noDataForChannel": "No data for {{name}} in this range.", - "bandReference": "Band reference", - "thresholds": "Thresholds used by the Insights engine: piping > −45 dBFS, stress > −38 dBFS, queenless hum > −40 dBFS with quiet piping. Calibrate against your own baseline.", - "status": { - "noData": "No data", - "micError": "Mic error", - "bothActive": "{{left}} + {{right}} active", - "oneActive": "{{name}} active" - }, - "table": { - "band": "Band", - "range": "Range", - "significance": "Significance" - }, "bands": { "subBass": { - "label": "Sub-bass", - "description": "Structural vibration / low rumble" + "label": "Sub-bass" }, "hum": { - "label": "Hum", - "description": "Normal colony hum (~200 Hz fundamental)" + "label": "Hum" }, "piping": { - "label": "Piping", - "description": "Queen piping / tooting (pre-swarm signal)" + "label": "Piping" }, "stress": { - "label": "Stress", - "description": "Agitated colony / robbing" + "label": "Stress" }, "high": { - "label": "High", - "description": "Harmonic overtones" + "label": "High" } - }, - "rmsToggleShow": "Show RMS level", - "rmsToggleHide": "Hide RMS level", - "rmsTitle": "RMS sound level", - "rmsSeries": "{{name}} RMS" - }, - "beecounter": { - "title": "Entrance Counter", - "subtitle": "Bees entering and exiting per measurement interval", - "noData": "No BeeCounter data found for this device.", - "offline": "Offline", - "gatesOk": "{{count}} gates OK", - "glitches_one": "{{count}} glitch", - "glitches_other": "{{count}} glitches", - "gatesRatio": "{{healthy}}/{{total}} gates", - "inPeriod": "In (period)", - "outPeriod": "Out (period)", - "netFlow": "Net flow", - "netInbound": "net inbound", - "netOutbound": "net outbound", - "balanced": "balanced", - "view": { - "inOut": "In / Out", - "netFlow": "Net flow", - "cumulative": "Cumulative" - }, - "series": { - "in": "{{name}} in", - "out": "{{name}} out", - "net": "{{name}} net", - "totalIn": "{{name}} total in", - "totalOut": "{{name}} total out" - }, - "diagnostics": { - "title": "Hardware diagnostics", - "gatesHealthy": "Gates healthy", - "glitchCount": "Glitch count", - "totalInLifetime": "Total in (lifetime)", - "totalOutLifetime": "Total out (lifetime)" } }, "insights": { - "title": "Insights", - "description": "Sensor-based alerts derived from weight and temperature trends.", - "updated": "Updated {{ago}}.", - "refresh": "Refresh insights", - "loadError": "Failed to load insights: {{message}}", "allClear": "All clear", - "noActiveAlerts": "No active alerts in the last {{count}} days.", - "activeAlerts_one": "{{count}} active alert:", - "activeAlerts_other": "{{count}} active alerts:", "confidence": "Confidence {{value}}%", "source": "Source: {{source}}", "hiveChannel": "Hive {{channel}}", @@ -567,5 +552,720 @@ "description": "No alerts have been recorded for this device.", "descriptionFiltered": "No alerts have been recorded for this device matching this filter." } + }, + "inspectionMode": { + "title": "Inspection mode", + "description": "While inspection mode is on, HiveHub keeps measuring but keeps the readings out of charts, insights and alerts, so lifting frames isn't mistaken for a swarm.", + "status": { + "active": "Active since {{time}}", + "pending": "Pending hub pickup", + "off": "Off" + }, + "pendingHint": "Requested — the hub picks it up on its next check-in.", + "timeoutNote_one": "Ends automatically after {{count}} minute if nobody stops it.", + "timeoutNote_other": "Ends automatically after {{count}} minutes if nobody stops it.", + "scope": { + "wholeHub": "Whole hub", + "label": "Covers: {{hives}}" + }, + "start": "Start inspection mode", + "stop": "Stop inspection mode", + "starting": "Starting…", + "stopping": "Stopping…", + "cancel": "Cancel", + "noteLabel": "Note (optional)", + "notePlaceholder": "e.g. Added a honey super", + "viewerHint": "Only owners and admins can start or stop inspection mode.", + "loadFailed": "Could not load the inspection status.", + "startDialog": { + "title": "Start inspection mode", + "description": "Readings from the selected hives are masked until you stop inspection mode or it times out.", + "hivesLabel": "Hives", + "wholeHub": "Whole hub (all hives)", + "selectHive": "Pick at least one hive, or the whole hub.", + "confirm": "Start" + }, + "stopDialog": { + "title": "Stop inspection mode", + "description": "HiveHub goes back to normal readings and alerts.", + "confirm": "Stop" + }, + "toast": { + "started": "Inspection mode started", + "stopped": "Inspection mode stopped", + "noteSaved": "Note saved" + }, + "history": { + "title": "Recent inspections", + "empty": "No inspections recorded yet.", + "ongoing": "ongoing", + "startedVia": "Started via {{source}}", + "ended": "Ended: {{reason}}", + "editNote": "Edit note", + "addNote": "Add note", + "saveNote": "Save" + }, + "duration": { + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "hoursMinutes": "{{hours}} h {{minutes}} min" + }, + "source": { + "device": "hub button", + "api": "app", + "dashboard": "local dashboard" + }, + "endReason": { + "timeout": "timed out", + "device": "hub button", + "api": "stopped in app", + "dashboard": "stopped on local dashboard" + } + }, + "hiveLink": { + "prompt": { + "message": "This hive is monitored by HiveHub {{device}} ({{hive}}). Start inspection mode so opening the hive doesn't trigger false alerts.", + "active": "Inspection mode is on for {{hive}} on HiveHub {{device}}.", + "pending": "Inspection mode requested for {{hive}} on HiveHub {{device}} — the hub picks it up on its next check-in.", + "activeOther": "Inspection mode is on for other hives on HiveHub {{device}}, not for {{hive}}. Stop it first to start one for this hive." + }, + "card": { + "title": "HiveHub", + "description": "Live readings from the linked HiveHub sensors.", + "weight": "Weight", + "weightChange24h": "24 h change", + "temperature": "Hive temperature", + "humidity": "Humidity", + "lastReading": "Last reading: {{time}}", + "openHiveHub": "Open HiveHub" + } + }, + "traffic": { + "title": "HiveTraffic bee counters", + "description": "Night mode and emitter banks for every HiveTraffic counter paired to this hub. The IR emitters are by far the largest item in a counter's power budget, so these settings decide whether it fits an off-grid supply.", + "readOnly": "Only device owners and admins can change these settings. Your role is “{{role}}”.", + "hiveFallback": "Hive {{slot}}", + "noChanges": "No changes to save", + "saved": "Bee counter settings saved. Counters pick them up on the next check-in.", + "appliesToAll": "Applies to every HiveTraffic counter paired to this device. The counter is told on the hub's next upload cycle.", + "status": { + "title": "Counters right now", + "none": "No HiveTraffic counter reported in the latest reading.", + "counting": "Counting", + "nightIdle": "Night idle", + "nightIdleFor_one": "Night idle · {{count}} min left", + "nightIdleFor_other": "Night idle · {{count}} min left", + "banksRunning": "Running emitter banks: {{banks}}", + "banksUnknown": "Emitter banks not reported (counter firmware older than 0.3.0)", + "applied": "Banks applied", + "pending": "Banks pending next check-in" + }, + "night": { + "title": "Night mode", + "description": "Honey bees are diurnal — flight needs light and stops below about 10 °C anyway. During this window the counter stops sensing to save power. It keeps advertising, so readings and firmware updates still work.", + "enable": "Enable night mode", + "start": "Night starts (local time)", + "end": "Night ends (local time)", + "windowHelp": "The window may cross midnight (e.g. 20:00 → 06:00). Setting start and end to the same time makes the window empty — it never applies — rather than covering the whole day. Leave a margin around dusk and dawn: activity peaks just before sunset and sunrise.", + "emptyWindow": "Start and end are the same, so the window is empty and night mode would never apply.", + "wrapsMidnight": "The window crosses midnight: from {{start}} until {{end}} the next morning.", + "timezone": "Timezone", + "tzCustom": "POSIX TZ string", + "timezoneHelp": "The hub's clock runs in UTC. Without the right timezone the window is off by your UTC offset and drifts by an hour at each daylight-saving change.", + "tzPresets": { + "cet": "Central Europe (Berlin, Paris, Madrid, Vienna)", + "uk": "United Kingdom / Ireland", + "eet": "Eastern Europe (Helsinki, Athens)", + "usEastern": "US Eastern", + "usCentral": "US Central", + "usMountain": "US Mountain", + "usPacific": "US Pacific", + "utc": "UTC (no local time)", + "custom": "Custom…" + }, + "maxTraffic": "Postpone above (crossings per cycle)", + "maxTrafficHelp": "Postpone night mode while more than this many crossings (in + out) were counted in the last upload cycle, so a colony still flying late on a summer evening is not cut off. 0 goes by the clock only.", + "save": "Save night mode" + }, + "banks": { + "title": "Emitter banks", + "description": "The counter's IR emitters are switched in three banks of eight gates. Each bank draws about 80 mA. Switch one off if the entrance is narrower than 24 gates, part of it is closed, or the supply cannot carry the whole board.", + "bank": "Bank {{bank}} — gates {{gates}}", + "current_one": "{{count}} bank on · about {{ma}} mA on the counter's 3.3 V rail", + "current_other": "{{count}} banks on · about {{ma}} mA on the counter's 3.3 V rail", + "help": "A switched-off bank does not count its gates at all, so totals drop roughly in proportion — do not compare a narrowed counter with its own history. At least one bank must stay on; unpair a counter that should count nothing.", + "firmwareNote": "Needs counter firmware 0.3.0 or newer. The setting is re-sent after every counter reset.", + "save": "Save emitter banks" + }, + "errors": { + "loadFailed": "Could not load the device configuration.", + "saveFailed": "Could not save the bee counter settings", + "invalidTime": "Enter a valid start and end time (HH:MM).", + "invalidMaxTraffic": "The postpone threshold must be a whole number of 0 or more.", + "emptyWindow": "Night mode start and end are the same — the window would never apply.", + "noBank": "At least one emitter bank must stay on — unpair the counter instead." + } + }, + "hubAccess": { + "title": "Hub access", + "description": "Reach the hub on site without opening the enclosure, and set how long an inspection may run.", + "readOnly": "Only device owners and admins can change these settings. Your role is “{{role}}”.", + "noChanges": "No changes to save", + "cancel": "Cancel", + "ap": { + "title": "Setup access point", + "description": "Opens the hub's HiveHub-Setup Wi-Fi network, the same as a short press on the setup button: change Wi-Fi, pair sensors or download the SD card data.", + "lastCheckIn": "Last check-in: {{time}}", + "open": "Open setup access point", + "confirmTitle": "Open the setup access point?", + "confirmTiming_one": "The request is queued. The hub is asleep between cycles, so the access point opens after its next check-in — up to {{count}} minute from now.", + "confirmTiming_other": "The request is queued. The hub is asleep between cycles, so the access point opens after its next check-in — up to {{count}} minutes from now.", + "confirmTimingUnknown": "The request is queued. The hub is asleep between cycles, so the access point opens after its next check-in — up to one send interval from now.", + "confirmOffline": "While the access point is open the hub is off Wi-Fi and sends no readings.", + "confirmTimeout": "It closes by itself after the setup portal timeout if nobody connects, so only start it when someone is at the hive.", + "confirm": "Queue access point", + "queueing": "Queueing…", + "queued": "Access point queued. It opens after the hub's next check-in.", + "failed": "Could not queue the access point" + }, + "inspection": { + "title": "Inspection timeout", + "description": "A safety net: an inspection ends by itself after this many minutes, so a forgotten button press does not blank a hive's data indefinitely. Default 60.", + "label": "Timeout (minutes, 1–1440)", + "save": "Save timeout", + "saved": "Inspection timeout saved", + "saveFailed": "Could not save the inspection timeout", + "invalid": "Enter a whole number of minutes between 1 and 1440." + } + }, + "dataTools": { + "title": "Data export & cleanup", + "description": "Back up this hub's readings or remove bad ones.", + "hiveFallback": "Hive {{slot}}", + "cancel": "Cancel", + "export": { + "title": "Export readings", + "description": "Download the stored readings as an NDJSON backup.", + "from": "From (optional)", + "to": "To (optional)", + "rangeHelp": "Leave both empty to export everything. Dates cover whole days in your local time.", + "hives": "Hives (optional)", + "allHives": "All hives are included.", + "someHives_one": "Only {{count}} selected hive is included. The count below covers all readings in the range.", + "someHives_other": "Only {{count}} selected hives are included. The count below covers all readings in the range.", + "invalidRange": "The start date is after the end date.", + "summaryFailed": "Could not count the readings.", + "count_one": "{{count}} reading", + "count_other": "{{count}} readings", + "span": "From {{first}} to {{last}}", + "download": "Download", + "formatHelp": "One JSON reading per line. The file can be imported again with the SD card import.", + "noPermission": "Only device owners and admins can export data. Your role is “{{role}}”." + }, + "delete": { + "title": "Delete readings in a range", + "description": "Removes spurious readings — for example the weight spikes a hub records at boot before its scales are calibrated, which otherwise distort charts and insights.", + "warningTitle": "This cannot be undone", + "warning": "Deleted readings are gone for good. Export the range first if you might need it.", + "start": "Start", + "end": "End", + "claimCode": "Claim code", + "claimCodeHelp": "The hub's claim code, as a second confirmation.", + "review": "Review deletion", + "confirmTitle": "Delete these readings?", + "confirmRange": "From {{start}} to {{end}}.", + "confirmCount_one": "{{count}} reading will be permanently deleted.", + "confirmCount_other": "{{count}} readings will be permanently deleted.", + "confirmIrreversible": "This cannot be undone.", + "countFailed": "Could not count the readings in this range.", + "confirm_one": "Delete {{count}} reading", + "confirm_other": "Delete {{count}} readings", + "deleting": "Deleting…", + "success_one": "Deleted {{count}} reading.", + "success_other": "Deleted {{count}} readings.", + "noPermission": "Only the device owner can delete readings. Your role is “{{role}}”.", + "errors": { + "missingRange": "Choose a start and an end.", + "invalidRange": "The end must be at or after the start.", + "missingClaimCode": "Enter the hub's claim code." + } + } + }, + "alertSettings": { + "title": "HiveHub email alerts", + "description": "Get an email when HiveHub spots something at your hives — swarming, robbing, a queenless colony and more.", + "enable": "Email me about HiveHub alerts", + "minSeverity": "Minimum severity", + "minSeverityHelp": "Alerts below this level are never emailed.", + "severity": { + "watch": "Watch and above", + "warning": "Warning and above", + "critical": "Critical only" + }, + "help": { + "schedule": "HivePal checks for new alerts every 15 minutes.", + "once": "Each alert is emailed once when it appears, and again only if it escalates.", + "scope": "Covers all HiveHub devices you have access to.", + "email": "Requires email to be configured on the HivePal server." + }, + "saved": "Alert settings saved", + "saveFailed": "Could not save the alert settings", + "loadFailed": "Could not load your preferences." + }, + "health": { + "title": "Sensor health", + "description": "Per-sensor health as reported by {{name}}.", + "descriptionAt": "Sensor health from the last check-in · {{ago}}.", + "noData": "No sensor health reported yet — it appears once the device sends its first reading.", + "hubHeading": "Hub", + "hiveIndex": "Hive {{n}}", + "noHiveSensors": "No sensors reported for this hive.", + "faultNote": "The hub reports its own subsystems as pass/fail — it sends no error code for them, so each fault above says what failed instead. The entrance counter does report a status code, and it is shown with the fault wherever one was read.", + "summary": { + "noData": "No sensor data", + "allOk": "All OK", + "faults_one": "{{count}} fault", + "faults_other": "{{count}} faults" + }, + "state": { + "ok": "OK", + "fault": "Fault", + "notFitted": "Not fitted", + "notHeard": "Not heard" + }, + "hub": { + "sht_ok": "Ambient temp/humidity (SHT40)", + "rtc_ok": "Real-time clock", + "sd_ok": "SD card", + "mic_ok": "Microphone", + "mic_left_ok": "Microphone · left", + "mic_right_ok": "Microphone · right", + "battery_monitor_ok": "Battery monitor", + "solar_monitor_ok": "Solar monitor" + }, + "hubReason": { + "sht_ok": "The ambient sensor did not return a valid reading this cycle, so this check-in carries no ambient temperature or humidity. Usually an I²C bus or wiring fault; a sensor that was never detected at boot reads the same way.", + "rtc_ok": "The real-time clock did not answer on the I²C bus. Timestamps fall back to network time, so a reading taken while offline can be misdated.", + "sd_ok": "The SD card was not mounted this cycle — missing, unreadable, or an SPI/wiring fault. Readings are not buffered to the card while this persists.", + "mic_ok": "The microphone capture returned no usable samples, so this check-in carries no acoustic bands.", + "mic_left_ok": "The left microphone returned no usable samples.", + "mic_right_ok": "The right microphone returned no usable samples.", + "battery_monitor_ok": "The battery fuel gauge did not answer on the I²C bus — battery voltage and state of charge are missing from this check-in.", + "solar_monitor_ok": "The solar monitor did not answer on the I²C bus — solar voltage, current and power are missing from this check-in." + }, + "sensor": { + "scale": "Scale", + "inHive": "In-hive sensor", + "counter": "HiveTraffic counter", + "hiveheart": "HiveHeart" + }, + "reason": { + "scale": "A scale is configured for this hive but produced no usable reading this cycle, so this check-in carries no weight for it.", + "inHive": "No advertisement from the paired {{kind}} during this cycle's BLE scan — out of range, flat battery, or powered off.", + "inHiveKindFallback": "in-hive sensor", + "node": "Node {{node}}.", + "lastHeard": "Last heard {{ago}}.", + "notHeardInRange": "Not heard in the loaded range.", + "counter": "The entrance counter did not answer its GATT read this cycle — out of range, powered off, or busy when HiveHub dialled.", + "counterNode": "Counter {{node}}.", + "lastAnswered": "Last answered {{ago}}.", + "notAnsweredInRange": "It has not answered in the loaded range.", + "statusThen": "Status then: {{status}}.", + "expandersThen_one": "{{count}} of 3 port expanders answering then.", + "expandersThen_other": "{{count}} of 3 port expanders answering then.", + "hiveheart": "The HiveHeart was not heard this cycle — out of range, flat battery, or powered off." + }, + "counter": { + "nightIdle": "night-mode idle", + "nightIdleBadge": "Night idle", + "otherBits": "counter-specific bits {{hex}}", + "noFlags": "no flags set", + "statusCode": "status {{hex}}", + "expanders_one": "{{count}}/3 gate expanders", + "expanders_other": "{{count}}/3 gate expanders", + "glitches_one": "{{count}} glitch", + "glitches_other": "{{count}} glitches", + "banks": "Banks {{list}}", + "noBanks": "No emitter banks" + }, + "meta": { + "firmware": "v{{version}}", + "batteryPercent": "Battery {{value}} %", + "batteryMv": "Battery {{value}} mV", + "batteryV": "Battery {{value}} V", + "rssi": "{{value}} dBm" + } + }, + "hubStatus": { + "title": "Hub status", + "description": "Uplink, clock and power of the HiveHub itself.", + "noData": "No reading received from this hub yet.", + "staleWarning": "No reading for {{ago}} — the hub has missed several check-ins.", + "badge": { + "live": "Live", + "stale": "Stale", + "inspection": "Inspection in progress", + "calibration": "Calibration mode" + }, + "section": { + "connectivity": "Connectivity", + "battery": "Battery", + "solar": "Solar" + }, + "row": { + "signal": "Signal", + "transport": "Transport", + "timeSource": "Time source", + "lastSeen": "Last seen", + "lastReading": "Last reading", + "firmware": "Firmware", + "bootCount": "Boot count", + "stateOfCharge": "State of charge", + "batteryVoltage": "Voltage", + "batteryAlert": "Battery alert", + "solarPower": "Solar power", + "solarCurrent": "Solar current", + "solarBus": "Panel (bus) voltage", + "solarLoad": "Load voltage" + }, + "signalQuality": { + "excellent": "Excellent", + "good": "Good", + "fair": "Fair", + "poor": "Poor" + }, + "transport": { + "wifi": "Wi-Fi", + "cellular": "Cellular" + }, + "rtc": { + "ok": "RTC OK", + "fault": "RTC fault" + }, + "batteryAlertActive": "Low battery" + }, + "hiveCards": { + "empty": "No hives reported by this device yet.", + "hiveIndex": "Hive {{n}}", + "weight": "Weight", + "temperature": "Temperature", + "humidity": "Humidity", + "delta24h": "24 h {{value}} kg", + "lastReported": "Last reported {{ago}}", + "inspection": "Inspection", + "insightsLoading": "Loading insights", + "insightsUnavailable": "Insights unavailable", + "noSensors": "No sensors reported", + "traffic": "In {{in}} · Out {{out}}", + "trafficTitle": "Bees in/out during the last interval", + "alerts_one": "{{count}} active alert", + "alerts_other": "{{count}} active alerts", + "sensor": { + "scale": "Scale", + "inHive": "In-hive sensor", + "counter": "HiveTraffic counter", + "hiveheart": "HiveHeart" + }, + "state": { + "ok": "OK", + "fault": "Not heard / fault" + } + }, + "audio": { + "title": "Hive audio", + "description": "Ask a hive's HiveInside node to record, then listen to it here. The hub collects the request on its next wake, so a recording appears within one reporting interval rather than immediately.", + "noNodes": "No HiveInside node is paired on this hub. Only HiveInside has a microphone — a HolyIot or RuuviTag beacon cannot record.", + "hive": "Hive", + "hiveFallback": "Hive {{slot}}", + "length": "Length", + "durationOption_one": "{{count}} second", + "durationOption_other": "{{count}} seconds", + "gain": "Gain", + "gainValue": "{{gain}} dB", + "record": "Record", + "nextWake": "Reaches the hub on its next wake.", + "viewerNote": "An owner or admin can request a recording.", + "lengthHint": "Shorter is usually better: 10–20 seconds is enough to hear what an insight flagged, and costs less battery and less time with the hive off the air — a connected node neither advertises nor samples.", + "requestSuccess": "Recording requested. It reaches the hub on its next wake.", + "requestError": "The request could not be queued: {{message}}", + "listTitle": "Recordings", + "onlySelectedHive": "Only {{name}}", + "loadError": "Could not list recordings: {{message}}", + "empty": "No recordings yet. Ask a hive to record and it will appear here. A session is 10–60 seconds of 16 kHz audio pulled off the node over BLE.", + "requestedLength_one": "{{count}} s requested", + "requestedLength_other": "{{count}} s requested", + "requestedBy": "Requested by {{name}}", + "status": { + "requested": "Waiting for hub", + "streaming": "Recording…", + "ready": "Ready", + "readyIncomplete": "Ready, not whole", + "failed": "Failed" + }, + "waitingNote": "Waiting for the hub to wake and pick this up — nothing recorded yet. A request the hub has not collected within 45 minutes is marked failed.", + "streamingNote": "Recording — the audio appears here as it arrives.", + "streamingPartial": "Still recording — this is what has arrived so far. Play it again when the session ends to hear the whole thing.", + "failedFallback": "This recording failed.", + "seconds": "{{value}} s", + "bytes": "{{value}} B", + "percent": "{{value}} %", + "download": "Download WAV", + "delete": "Delete", + "deleteConfirm": "Delete this recording? Nothing else ever will — there is no automatic clean-up.", + "deleteSuccess": "Recording deleted.", + "deleteError": "Could not delete the recording: {{message}}", + "confirmation": { + "verified": "verified", + "reported": "unverified", + "confirmed": "hub confirmed", + "unknown": "not confirmed" + }, + "quality": { + "length": "Length", + "dropped": "Dropped", + "gaps": "Gaps", + "hubBuffer": "Hub buffer", + "clipping": "Clipping", + "checksum": "Checksum", + "cause": { + "dropped": "the node could not push audio out fast enough", + "gaps": "packets were lost on the air", + "hubBuffer": "the hub's upload fell behind its buffer" + }, + "gapNote": "Audio either side of a gap is real, but the join is not. Cause: {{causes}}.", + "clippingNote": "Lower the gain for a cleaner recording.", + "unknownNote": "The hub never reported on this session, so nothing checked whether the audio is whole. What is here plays." + } + }, + "dashboard": { + "hiveFallback": "Hive {{index}}", + "units": { + "bees": "bees" + }, + "range": { + "24h": "24h", + "7d": "7d", + "30d": "30d", + "365d": "365d", + "custom": "Custom", + "invalid": "The start date must not be after the end date." + }, + "header": { + "title": "My dashboard", + "description": "User-configurable widgets. The selected hives above control hive-based charts.", + "editDescription": "Edit mode: drag the handle to move widgets and drag the corner to resize them on a 4-column grid.", + "edit": "Edit dashboard", + "done": "Done", + "addWidget": "Add widget", + "reset": "Reset", + "editBanner": "Dashboard edit mode is active. Use the grip handle in each widget header to move it, and drag the lower-right corner to resize width or height. Layout changes are saved automatically for this HiveHub device.", + "dropEnd": "Drop here to move a widget to the end of the dashboard.", + "noWidgets": "No widgets yet. Add a template to build your dashboard.", + "restoreDefaults": "Restore defaults" + }, + "overview": { + "title": "Hive overview", + "description": "Compact status for mapped hives. Select tiles to drive the dashboard widgets below.", + "lastData": "Last data", + "hivesOnline": "Hives online", + "hubFirmware": "HiveHub FW", + "hiveInsideFirmware": "HiveInside FW", + "selected_one": "{{count}} selected", + "selected_other": "{{count}} selected", + "hide": "Hide overview", + "show": "Show overview", + "slot": "Slot {{index}}", + "ok": "OK", + "noData": "No data", + "weight": "Weight", + "temperature": "Temp", + "humidity": "RH", + "empty": "Map HiveHub slots to HivePal hives in the device setup panel to show overview tiles here." + }, + "sensors": { + "scale": "scale", + "inHive": "in-hive", + "accel": "accel", + "counter": "counter", + "none": "no sensors" + }, + "widgetShell": { + "move": "Move {{title}}", + "moveHint": "Drag to move this widget", + "remove": "Remove {{title}}", + "resize": "Resize {{title}}", + "resizeHint": "Drag to resize this widget" + }, + "addWidget": { + "title": "Add widget", + "description": "Start from a template. The widget will use the selected hives from the overview grid where applicable.", + "defaultSize": "Default size {{w}} x {{h}}" + }, + "widgets": { + "weightComparison": { + "title": "Weight comparison", + "description": "Compare selected hives over the current date range." + }, + "dailyMaxWeight": { + "title": "Daily max weight", + "description": "Highest compensated weight per day for the selected hives." + }, + "climate": { + "title": "Hive climate", + "description": "Temperature and humidity for selected hives." + }, + "power": { + "title": "Power health", + "description": "Battery charge, battery voltage, and solar input." + }, + "beeTraffic": { + "title": "Bee traffic", + "description": "Aggregate in/out traffic and net flow." + }, + "soundRms": { + "title": "Sound / acoustic bands", + "description": "Per-hive acoustic RMS and FFT/HiveHeart bands when available." + }, + "hiveHeartSpectrum": { + "title": "HiveHeart spectrum", + "description": "Latest HiveHeart FFT levels (0–15) across 16 frequency ranges for the selected hives." + }, + "vibration": { + "title": "Vibration bands", + "description": "RMS vibration, swarm, fanning, and activity bands." + }, + "configurableDiagram": { + "title": "Configurable diagram", + "description": "Choose the hive, sound, traffic, climate, and device metrics to plot." + }, + "temperatureHeatmap": { + "title": "Temperature heatmap", + "description": "Compact all-hive temperature overview." + }, + "insights": { + "title": "Insights feed", + "description": "Active alerts and evidence snippets." + }, + "dataQuality": { + "title": "Data quality", + "description": "Sensor availability and missing readings." + } + }, + "metricGroups": { + "scale": "Scale", + "climate": "Climate", + "beeTraffic": "Bee traffic", + "vibration": "Vibration", + "sound": "Sound", + "device": "Device" + }, + "metrics": { + "weight": "Weight", + "temperature": "Temperature", + "humidity": "Humidity", + "pressure": "Pressure", + "beeIn": "Bees in", + "beeOut": "Bees out", + "beeNet": "Net flow", + "vibration": "Vibration RMS", + "swarmBand": "Swarm band", + "fanningBand": "Fanning band", + "activityBand": "Activity band", + "soundRms": "Sound RMS", + "soundHum": "Hum band", + "soundPiping": "Piping band", + "soundStress": "Stress band", + "hiveHeartFrequency": "HiveHeart frequency", + "hiveHeartEnergy": "HiveHeart energy", + "batterySoc": "Battery charge", + "batteryVoltage": "Battery voltage", + "solarPower": "Solar power" + }, + "axes": { + "weight": "Weight", + "temperature": "Temperature", + "humidity": "Humidity", + "pressure": "Pressure", + "beecount": "Bee count", + "vibration": "Vibration", + "dbfs": "Sound level", + "frequency": "Frequency", + "energy": "Energy", + "percent": "Charge", + "voltage": "Voltage", + "power": "Power" + }, + "soundMetrics": { + "rms": "RMS", + "hiveHeartFrequency": "HiveHeart frequency", + "hiveHeartEnergy": "HiveHeart energy", + "hiveHeartPeak": "HiveHeart peak" + }, + "vibrationMetrics": { + "rms": "RMS", + "swarmBand": "Swarm band", + "fanningBand": "Fanning band", + "activityBand": "Activity band" + }, + "series": { + "temperature": "{{name}} temp", + "humidity": "{{name}} RH", + "beeIn": "In", + "beeOut": "Out", + "beeNet": "Net" + }, + "axisScale": { + "auto": "auto", + "setBound": "Set {{axis}} {{bound}}", + "clearHint": "Leave empty and press Enter for auto", + "tickTitle": "Click to set {{bound}}; clear for auto", + "reset": "reset", + "help": "Click the lowest or highest y-axis label to edit. Clear the value for auto." + }, + "chart": { + "downloadCsv": "Download CSV" + }, + "inspection": { + "legendHint_one": "{{count}} inspection window shaded; readings may be disturbed", + "legendHint_other": "{{count}} inspection windows shaded; readings may be disturbed" + }, + "empty": { + "noData": "No data for the selected hives and range.", + "climate": "No climate data for the selected range.", + "power": "No power data for the selected range.", + "beeTraffic": "No bee counter data for the selected hives.", + "sound": "No per-hive acoustic data for the selected hives.", + "vibration": "No vibration or accelerometer band data for the selected hives.", + "configurable": "Select at least one metric with data for the selected hives and range.", + "dailyMaxWeight": "No weight data for the selected hives and range.", + "hiveHeart": "No HiveHeart FFT data for the selected hives and range.", + "unknownWidget": "Unknown widget type." + }, + "soundFootnote": "Shows per-hive acoustic readings first. Legacy left/right microphone data is mapped to the configured hive names for slots 1 and 2 when no per-hive acoustic block is present.", + "heatmap": { + "low": "low <33 °C", + "optimal": "optimal brood 33–36 °C", + "high": "high >36 °C" + }, + "insights": { + "loading": "Loading insights…", + "unavailable": "Insights are unavailable.", + "noAlerts": "No active alerts for the selected hives.", + "showing_one": "Showing {{count}} active alert for the selected hives.", + "showing_other": "Showing {{count}} active alerts for the selected hives.", + "confidence": "confidence {{percent}}%" + }, + "dataQuality": { + "activeSlots": "Active slots", + "noScale": "No scale", + "noClimate": "No climate", + "noRecentData": "no recent data" + }, + "hiveHeart": { + "level": "level", + "dominant": "dominant frequency {{hz}} Hz", + "noDominant": "no dominant frequency reported", + "footnote": "Relative levels 0–15 (not dBFS) of the latest reading in the range. Dashed lines mark the dominant frequency HiveHeart reports; the Sub-bass/Hum/Piping/Stress headings are approximate and span several ranges." + } } } diff --git a/apps/frontend/public/locales/es/hivescale.json b/apps/frontend/public/locales/es/hivescale.json index 3b8c3e393..0e82286df 100644 --- a/apps/frontend/public/locales/es/hivescale.json +++ b/apps/frontend/public/locales/es/hivescale.json @@ -35,7 +35,7 @@ }, "page": { "title": "HiveHub", - "subtitle": "Live weight and temperature visualisation from your separate HiveHub backend.", + "subtitle": "Peso, clima, sonido, tráfico y alertas de tus dispositivos HiveHub.", "refresh": "Refresh", "refreshing": "Refreshing…", "refreshError": "Could not refresh HiveHub data.", @@ -43,16 +43,26 @@ "removeSuccess": "HiveHub device removed from your account.", "removeSuccessShared": "Ya no tienes acceso a este dispositivo HiveHub. Otros usuarios lo conservan, por lo que su código de reclamación sigue en uso.", "releaseConfirm": "¿Liberar {{name}} por completo? Todos los usuarios pierden el acceso y el dispositivo vuelve a poder reclamarse con su código. Se conservan las lecturas.", - "releaseSuccess": "Dispositivo HiveHub liberado. Puede reclamarse de nuevo con su código de reclamación." + "releaseSuccess": "Dispositivo HiveHub liberado. Puede reclamarse de nuevo con su código de reclamación.", + "unavailableTitle": "HiveHub no está accesible", + "inspectionActiveTitle": "El modo inspección está activado", + "inspectionActiveBody": "Las lecturas se ocultan mientras las colmenas están abiertas, para que sacar cuadros no genere falsas alertas.", + "openInspections": "Inspecciones en curso", + "tabs": { + "overview": "Resumen", + "inspections": "Inspecciones", + "audio": "Audio", + "health": "Estado", + "setup": "Configuración" + } }, "setup": { "title": "Devices", - "description": "Select or remove a claimed HiveHub device. Expand the setup to claim devices, check status, map scales, and calibrate the raw readings.", "removeScale": "Remove scale", "releaseScale": "Liberar dispositivo", "noDevices": "No devices claimed yet — open the setup to add one.", - "hide": "Hide setup", - "show": "Show setup" + "selectDevice": "Dispositivo HiveHub", + "noDevicesYet": "Aún no has reclamado ningún dispositivo HiveHub. Reclama uno con el código que muestra el hub en su portal de configuración." }, "claim": { "title": "Claim HiveHub device", @@ -65,16 +75,30 @@ "success": "HiveHub device claimed.", "errors": { "missingCode": "Enter the claim code printed/configured for the device." - } + }, + "hive1Name": "Nombre de la colmena 1", + "hive2Name": "Nombre de la colmena 2", + "optional": "Opcional" }, "mapping": { - "title": "Scale mapping", - "description": "These labels stay in HiveHub. Later, this is the right place to map Scale 1/2 to HivePal hive IDs for inspection markers.", - "scale1Label": "Display name for Scale 1", - "scale2Label": "Display name for Scale 2", + "title": "Colmenas", "hiveNamePlaceholder": "Select or type a hive name", - "save": "Save mapping", - "success": "Scale mapping updated." + "save": "Guardar colmenas", + "success": "Posiciones de colmena guardadas en HiveHub.", + "slotsDescription": "Pon nombre a cada posición de colmena y vincúlala a una colmena de HivePal. El vínculo permite que las inspecciones, la página de la colmena y los correos de alerta sepan a qué colonia corresponde una posición. Un punto verde marca las posiciones que el hub notifica actualmente.", + "mapSlot": "Asignar posición {{slot}}", + "add": "Añadir", + "reporting": "Notificada por el hub", + "unknownHive": "Colmena no encontrada", + "mappedCount": "{{count}}/{{total}} posiciones asignadas", + "dialogTitle": "Posición de colmena {{slot}}", + "dialogDescription": "Vincula la posición a una de tus colmenas de HivePal y elige el nombre que HiveHub muestra para ella.", + "linkedHive": "Colmena de HivePal", + "notLinked": "Sin vincular", + "linkHint": "El vínculo se mantiene aunque cambies el nombre de la colmena. Al elegir una colmena se rellena su nombre si el campo está vacío.", + "displayName": "Nombre visible", + "clearSlot": "Vaciar posición", + "applySlot": "Aplicar" }, "status": { "title": "Device status", @@ -94,6 +118,13 @@ "revokeAccess": "Revoke access", "errors": { "missingEmail": "Enter the email address of an existing HivePal user." + }, + "lastSeen": "Última conexión", + "claimedAt": "Reclamado", + "roles": { + "owner": "Propietario", + "admin": "Admin", + "viewer": "Lector" } }, "calibration": { @@ -203,11 +234,9 @@ }, "sourceHint": "La température ambiante suit de près le corps du capteur en régime stable et constitue la valeur par défaut recommandée.", "refLabel": "Température de référence / °C", - "refHint": "Température à laquelle la correction est nulle.", "coeffLabel": "Coefficient {{scaleName}} / kg par °C", "autoFit": "Ajustement automatique depuis l’historique", "fitting": "Ajustement…", - "autoFitHint": "L’ajustement automatique effectue une régression du poids brut des 3 derniers jours par rapport à la température sélectionnée lissée par EMA, puis applique et active le résultat. Pour un ajustement propre, capturez une fenêtre où la charge physique est constante et la température varie.", "save": "Enregistrer la compensation de température", "errors": { "refTemp": "La température de référence doit être un nombre (°C).", @@ -219,11 +248,33 @@ "noSignal": "signal de température/poids insuffisant.", "fitApplied": "{{scaleName}} : {{coeff}} kg/°C à {{refTemp}}°C (R²={{r2}}, n={{n}}). Appliqué et activé." } + }, + "perHive": { + "hive": "Colmena {{index}}", + "hiveNamed": "Colmena {{index}} · {{name}}", + "hiveLabel": "Colmena", + "knownWeightPlaceholder": "p. ej. 10", + "manualSaveHint": "Cambia de colmena para editar otra báscula; todos los cambios se envían juntos al guardar. Las colmenas 3 y siguientes solo se escriben si han cambiado.", + "tempco": { + "refHint": "La temperatura a la que las básculas marcan el peso real, es decir, aquella a la que se taró y calibró su rango. Ahí la corrección es cero; el ajuste no la modifica salvo que lo pidas.", + "lookbackLabel": "Periodo de ajuste / días", + "setRefTemp": "Establecer también la temperatura de referencia", + "setRefTempHint": "Sustituye la referencia por la temperatura media de la ventana ajustada. Déjalo desactivado si la báscula se calibró a una temperatura conocida.", + "fitOnly": "Solo ajustar (vista previa)", + "fitAndApply": "Ajustar y aplicar", + "fitSummary": "{{scaleName}}: {{coeff}} kg/°C, R² {{r2}} en {{n}} lecturas de {{min}}–{{max}} °C (media de la ventana {{mean}} °C).", + "lowRSquared": "R² inferior a 0,5: la temperatura explica poco de esta deriva. Ajusta sobre un periodo con carga constante y una amplia oscilación día/noche.", + "autoFitHint": "El ajuste automático hace una regresión del peso almacenado de la colmena seleccionada durante el periodo elegido frente a la temperatura seleccionada suavizada con EMA. «Solo ajustar» rellena el coeficiente para que lo revises antes de guardar; «Ajustar y aplicar» lo guarda y activa la compensación. Para un buen ajuste, elige un periodo en el que la carga física sea constante y la temperatura varíe.", + "errors": { + "lookback": "El periodo debe ser un número entero de días entre 1 y 90." + }, + "toasts": { + "fitFilled": "{{scaleName}}: {{coeff}} kg/°C (R²={{r2}}, n={{n}}). Valor rellenado: revísalo y guarda." + } + } } }, "firmware": { - "title": "Firmware upload", - "description": "Upload a firmware binary and register it as a release. New devices of the matching type pick it up on their next update check.", "type": "Firmware type", "selectType": "Select firmware type", "version": "Version", @@ -238,6 +289,102 @@ "errors": { "missingFile": "Choose a firmware binary (.bin) to upload.", "missingVersion": "Enter a version, e.g. 0.6.3." + }, + "update": { + "title": "Actualización de firmware", + "description": "Las básculas nunca se actualizan solas: apruebas aquí cada nueva versión antes de que esta báscula la instale.", + "unavailable": "El estado del firmware no está disponible en este momento.", + "availableTitle": "Firmware {{version}} disponible", + "availableBody": "Esta báscula ejecuta la versión {{current}}. Aplica la actualización para pasar a {{version}}.", + "officialSuffix": "Es una versión oficial.", + "apply": "Aplicar actualización", + "applying": "Aplicando…", + "applied": "Firmware {{version}} aprobado: la báscula se actualizará en su próxima conexión.", + "queued": "Actualización {{version}} aprobada. La báscula la instalará en su próxima conexión.", + "upToDate": "El firmware está actualizado ({{version}}).", + "viewerNotice": "Solo los propietarios y administradores del dispositivo pueden aplicar actualizaciones de firmware." + }, + "board": { + "cardTitle": "Firmware", + "cardDescription": "Firmware de este HiveHub y de los nodos HiveInside y contadores HiveTraffic que gestiona. Subir un archivo registra una versión; aprobarla (hub) o retransmitirla (nodos) la instala.", + "current": "Actual", + "latest": "Última", + "approved": "Aprobada", + "board": "Placa", + "badgePending": "Actualización pendiente de aprobación", + "badgeApproved": "Actualización aprobada", + "badgeUpToDate": "Actualizado", + "approveFlash": "Aprobar y flashear {{version}}", + "approveConfirm": "¿Aprobar el firmware {{version}} y flashearlo de forma inalámbrica (OTA)?\n\nEl HiveHub lo instala en su próxima conexión y se reinicia. Un dispositivo remoto que falle a mitad de la actualización puede requerir acceso físico para recuperarlo.", + "otherBoards": "También subido para otra placa (no aplicado a este dispositivo {{board}}): {{releases}}. Una versión solo llega a un dispositivo cuya placa coincide; comprueba la placa al subirla.", + "otherBoardsUnknown": "También subido para otra placa (no aplicado a este dispositivo): {{releases}}. Una versión solo llega a un dispositivo cuya placa coincide; comprueba la placa al subirla." + }, + "relays": { + "hiveFallback": "Colmena {{slot}}", + "latest": "última {{version}}", + "updateAvailable": "actualización disponible: {{version}}", + "status": { + "pending": "retransmisión en cola", + "running": "retransmitiendo…", + "completed": "retransmisión completada", + "failed": "retransmisión fallida", + "expired": "retransmisión caducada" + }, + "statusTitle": "Retransmisión de {{version}}: {{status}} ({{date}})", + "failedDetail": "La última retransmisión falló: {{message}} ({{date}})", + "noReason": "no se indicó ningún motivo", + "alreadyQueued": "Ya hay una retransmisión en cola para este nodo", + "queueing": "Poniendo en cola…", + "relayAnyway": "Retransmitir de todos modos", + "confirm": "¿Retransmitir {{name}} {{version}} a {{hive}}?\n\nEl HiveHub lo recoge en su próximo ciclo de envío, normalmente en unos 10 minutos o según el intervalo de envío de este dispositivo. Después transmite la imagen por BLE durante unos minutos y el nodo se reinicia con ella. El nodo debe estar activo y dentro del alcance; si no, la retransmisión falla y hay que volver a ponerla en cola.", + "queued": "Retransmisión de {{version}} en cola para {{hive}}.", + "queuedFrom": "Retransmisión en cola para {{hive}} ({{current}} → {{version}}).", + "hiveinside": { + "name": "HiveInside", + "title": "Nodos HiveInside", + "description": "Firmware que anuncia cada nodo dentro de la colmena. Subir una imagen de HiveInside solo registra la versión: nada llega a un nodo hasta que pulsas «Retransmitir al nodo». La versión mostrada aquí es lo que confirma que la actualización se ha completado.", + "relay": "Retransmitir al nodo", + "unnamed": "No anuncia un nombre propio (HiveInside anterior a 0.5.0)", + "noRelease": "Aún no se ha subido ninguna versión de HiveInside: sube una abajo para retransmitirla." + }, + "beecounter": { + "name": "HiveTraffic", + "title": "Contadores HiveTraffic", + "description": "Firmware que notifica cada contador HiveTraffic. Subir una imagen de HiveTraffic solo registra la versión: nada llega a un contador hasta que pulsas «Retransmitir al contador». Los contadores anteriores a la v0.1.0 aproximadamente no notifican versión, pero se pueden actualizar igualmente.", + "relay": "Retransmitir al contador", + "unnamed": "No notifica nombre ni dirección", + "noRelease": "Aún no se ha subido ninguna versión de HiveTraffic: sube una abajo para retransmitirla.", + "countingWarning": "Un contador deja de contar abejas durante toda la transferencia, así que retransmite cuando haya poco tráfico.", + "confirmExtra": "El contador deja de contar abejas durante toda la transferencia: se pierde el tráfico de esos pocos minutos." + } + }, + "uploadForm": { + "title": "Subir firmware", + "description": "Subir un archivo solo registra una versión; nunca instala nada por sí solo. Aprueba arriba la actualización del hub o retransmite una versión de nodo o contador desde su fila.", + "board": "Placa", + "targets": { + "hivehub": "Hub HiveHub", + "hiveinside": "HiveInside", + "beecounter": "Contador HiveTraffic" + }, + "boards": { + "auto": "Automático (según el nombre del archivo)", + "esp32": "ESP32 (clásico de 30 pines)", + "esp32-c6": "ESP32-C6", + "nrf54lm20a": "nRF54LM20A" + }, + "boardHint": { + "hivehub": "El firmware del hub debe indicar su placa: elige una, o deja automático si el archivo se llama como hivehub_esp32-c6_0.30.5.bin.", + "hiveinside": "HiveInside solo existe para el nRF54LM20A. Sube el archivo de compilación tal cual (p. ej. hiveinside_nrf54lm20a_1.4.0.bin) y el destino y la versión se rellenan a partir de su nombre.", + "beecounter": "El contador HiveTraffic es un ESP32-C6. Sube la imagen de la aplicación (p. ej. beecounter_esp32-c6_0.7.1.bin), no una imagen de fábrica combinada." + }, + "detected": "{{name}}: se ha rellenado {{fields}}.", + "nothingDetected": "{{name}}: no se ha reconocido destino, placa ni versión en el nombre; indícalos abajo.", + "detectedTarget": "destino «{{target}}»", + "detectedBoard": "placa {{board}}", + "detectedVersion": "versión {{version}}", + "autoQueued": "Retransmisión puesta en cola automáticamente para {{hives}}.", + "autoQueueFailed": "No se pudo poner en cola automáticamente la retransmisión para {{hives}}." } }, "sdData": { @@ -262,12 +409,19 @@ "parsed_other": "Parsed {{count}} records", "skipped_one": " · {{count}} unreadable line skipped.", "skipped_other": " · {{count}} unreadable lines skipped." + }, + "mismatch": { + "title": "Archivo de otro hub", + "description": "Este archivo lo grabó otro hub, no {{device}}.", + "fileDevices_one": "ID de dispositivo en el archivo:", + "fileDevices_other": "ID de dispositivos en el archivo:", + "fileDevices_many": "ID de dispositivos en el archivo:", + "hint": "Impórtalo de todos modos solo si la tarjeta SD pertenece realmente a este dispositivo (por ejemplo, tras sustituir la placa del hub).", + "cancel": "Cancelar", + "force": "Importar en este dispositivo de todos modos" } }, "panel": { - "weight": "Weight", - "hiveTemp": "Hive temp", - "rmsSound": "RMS sound", "ambientTemperature": "Ambient temperature", "ambientHumidity": "Ambient humidity", "batteryCharge": "Battery charge", @@ -277,7 +431,6 @@ "title": "General", "description": "Ambient & power" }, - "humidity": "Hive humidity", "wirelessSensorsBattery": "Wireless sensors battery", "sensorTypes": { "hiveHeart": "HiveHeart", @@ -286,174 +439,36 @@ } }, "diagram": { - "title": "Measurements", - "subtitle": "Weight, temperature, and entrance activity over time", - "noMeasurements": "No measurements for the selected range.", - "noDataForSeries": "No data in the selected range.", - "meta": { - "device": "Device:", - "lastSeen": "Last seen:", - "lastData": "Last data:", - "firmware": "Firmware:" - }, "range": { "all": "All", "from": "From", "to": "To" }, - "series": { - "weight": "{{name}} weight", - "temp": "{{name}} temp", - "micRms": "{{name}} mic RMS", - "beesIn": "{{name}} bees in", - "beesOut": "{{name}} bees out", - "netFlow": "{{name}} net flow", - "ambientTemp": "Ambient temp", - "ambientHumidity": "Ambient humidity", - "batteryVoltage": "Battery voltage", - "batteryCharge": "Battery charge", - "solarVoltage": "Solar voltage", - "solarCurrent": "Solar current", - "solarPower": "Solar power", - "humidity": "{{name}} humedad", - "pressure": "{{name}} presión", - "fanningBand": "{{name}} fanning band (30–100 Hz)", - "activityBand": "{{name}} activity band (100–200 Hz)" - }, - "group": { - "ambient": "Ambient", - "offGrid": "Off-grid" - }, - "axis": { - "weight": "Weight", - "temperature": "Temperature", - "humidity": "Humidity", - "voltage": "Voltage", - "percent": "Percent", - "current": "Current", - "power": "Power", - "sound": "Sound", - "beecount": "Bee count", - "pressure": "Presión" - }, "marker": { - "inspection": "Inspection", - "boxAdded": "Box added", - "action": { - "maintenance": "Maintenance", - "feeding": "Feeding", - "frames": "Frames", - "treatment": "Treatment" - } - }, - "axisSettings": { - "title": "Axis settings", - "reset": "Reset", - "side": "Side", - "left": "Left", - "right": "Right", - "scale": "Scale", - "auto": "Auto", - "zeroToMax": "0 to max", - "custom": "Custom", - "min": "Min", - "max": "Max", - "autoPlaceholder": "auto" + "inspection": "Inspection" } }, "sound": { - "title": "Hive sound", - "description": "INMP441 FFT band energy (dBFS) — {{left}} (left) · {{right}} (right)", - "hide": "Hide sound", - "show": "Show sound", - "micLeftFallback": "Mic left", - "micRightFallback": "Mic right", - "noDataForChannel": "No data for {{name}} in this range.", - "bandReference": "Band reference", - "thresholds": "Thresholds used by the Insights engine: piping > −45 dBFS, stress > −38 dBFS, queenless hum > −40 dBFS with quiet piping. Calibrate against your own baseline.", - "status": { - "noData": "No data", - "micError": "Mic error", - "bothActive": "{{left}} + {{right}} active", - "oneActive": "{{name}} active" - }, - "table": { - "band": "Band", - "range": "Range", - "significance": "Significance" - }, "bands": { "subBass": { - "label": "Sub-bass", - "description": "Structural vibration / low rumble" + "label": "Sub-bass" }, "hum": { - "label": "Hum", - "description": "Normal colony hum (~200 Hz fundamental)" + "label": "Hum" }, "piping": { - "label": "Piping", - "description": "Queen piping / tooting (pre-swarm signal)" + "label": "Piping" }, "stress": { - "label": "Stress", - "description": "Agitated colony / robbing" + "label": "Stress" }, "high": { - "label": "High", - "description": "Harmonic overtones" + "label": "High" } - }, - "rmsToggleShow": "Show RMS level", - "rmsToggleHide": "Hide RMS level", - "rmsTitle": "RMS sound level", - "rmsSeries": "{{name}} RMS" - }, - "beecounter": { - "title": "Entrance Counter", - "subtitle": "Bees entering and exiting per measurement interval", - "noData": "No BeeCounter data found for this device.", - "offline": "Offline", - "gatesOk": "{{count}} gates OK", - "glitches_one": "{{count}} glitch", - "glitches_other": "{{count}} glitches", - "gatesRatio": "{{healthy}}/{{total}} gates", - "inPeriod": "In (period)", - "outPeriod": "Out (period)", - "netFlow": "Net flow", - "netInbound": "net inbound", - "netOutbound": "net outbound", - "balanced": "balanced", - "view": { - "inOut": "In / Out", - "netFlow": "Net flow", - "cumulative": "Cumulative" - }, - "series": { - "in": "{{name}} in", - "out": "{{name}} out", - "net": "{{name}} net", - "totalIn": "{{name}} total in", - "totalOut": "{{name}} total out" - }, - "diagnostics": { - "title": "Hardware diagnostics", - "gatesHealthy": "Gates healthy", - "glitchCount": "Glitch count", - "totalInLifetime": "Total in (lifetime)", - "totalOutLifetime": "Total out (lifetime)" } }, "insights": { - "title": "Insights", - "description": "Sensor-based alerts derived from weight and temperature trends.", - "updated": "Updated {{ago}}.", - "refresh": "Refresh insights", - "loadError": "Failed to load insights: {{message}}", "allClear": "All clear", - "noActiveAlerts": "No active alerts in the last {{count}} days.", - "activeAlerts_one": "{{count}} active alert:", - "activeAlerts_other": "{{count}} active alerts:", "confidence": "Confidence {{value}}%", "source": "Source: {{source}}", "hiveChannel": "Hive {{channel}}", @@ -538,5 +553,740 @@ "description": "No alerts have been recorded for this device.", "descriptionFiltered": "No alerts have been recorded for this device matching this filter." } + }, + "inspectionMode": { + "title": "Modo inspección", + "description": "Mientras el modo inspección está activado, HiveHub sigue midiendo pero excluye las lecturas de los gráficos, análisis y alertas, para que sacar cuadros no se confunda con un enjambre.", + "status": { + "active": "Activo desde {{time}}", + "pending": "Pendiente de que lo recoja el hub", + "off": "Desactivado" + }, + "pendingHint": "Solicitado: el hub lo recoge en su próxima conexión.", + "timeoutNote_one": "Termina automáticamente tras {{count}} minuto si nadie lo detiene.", + "timeoutNote_other": "Termina automáticamente tras {{count}} minutos si nadie lo detiene.", + "timeoutNote_many": "Termina automáticamente tras {{count}} minutos si nadie lo detiene.", + "scope": { + "wholeHub": "Todo el hub", + "label": "Abarca: {{hives}}" + }, + "start": "Iniciar modo inspección", + "stop": "Detener modo inspección", + "starting": "Iniciando…", + "stopping": "Deteniendo…", + "cancel": "Cancelar", + "noteLabel": "Nota (opcional)", + "notePlaceholder": "p. ej. Añadido un alza melaria", + "viewerHint": "Solo los propietarios y administradores pueden iniciar o detener el modo inspección.", + "loadFailed": "No se pudo cargar el estado de la inspección.", + "startDialog": { + "title": "Iniciar modo inspección", + "description": "Las lecturas de las colmenas seleccionadas se ocultan hasta que detengas el modo inspección o se agote el tiempo.", + "hivesLabel": "Colmenas", + "wholeHub": "Todo el hub (todas las colmenas)", + "selectHive": "Elige al menos una colmena o todo el hub.", + "confirm": "Iniciar" + }, + "stopDialog": { + "title": "Detener modo inspección", + "description": "HiveHub vuelve a las lecturas y alertas normales.", + "confirm": "Detener" + }, + "toast": { + "started": "Modo inspección iniciado", + "stopped": "Modo inspección detenido", + "noteSaved": "Nota guardada" + }, + "history": { + "title": "Inspecciones recientes", + "empty": "Aún no hay inspecciones registradas.", + "ongoing": "en curso", + "startedVia": "Iniciada mediante {{source}}", + "ended": "Finalizada: {{reason}}", + "editNote": "Editar nota", + "addNote": "Añadir nota", + "saveNote": "Guardar" + }, + "duration": { + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "minutes_many": "{{count}} min", + "hoursMinutes": "{{hours}} h {{minutes}} min" + }, + "source": { + "device": "botón del hub", + "api": "app", + "dashboard": "panel local" + }, + "endReason": { + "timeout": "tiempo agotado", + "device": "botón del hub", + "api": "detenida en la app", + "dashboard": "detenida en el panel local" + } + }, + "hiveLink": { + "prompt": { + "message": "Esta colmena está supervisada por el HiveHub {{device}} ({{hive}}). Inicia el modo inspección para que abrir la colmena no genere falsas alertas.", + "active": "El modo inspección está activado para {{hive}} en el HiveHub {{device}}.", + "pending": "Modo inspección solicitado para {{hive}} en el HiveHub {{device}}: el hub lo recoge en su próxima conexión.", + "activeOther": "El modo inspección está activado para otras colmenas en el HiveHub {{device}}, pero no para {{hive}}. Detenlo primero para iniciar uno para esta colmena." + }, + "card": { + "title": "HiveHub", + "description": "Lecturas en directo de los sensores HiveHub vinculados.", + "weight": "Peso", + "weightChange24h": "Variación en 24 h", + "temperature": "Temperatura de la colmena", + "humidity": "Humedad", + "lastReading": "Última lectura: {{time}}", + "openHiveHub": "Abrir HiveHub" + } + }, + "traffic": { + "title": "Contadores de abejas HiveTraffic", + "description": "Modo nocturno y bancos de emisores de cada contador HiveTraffic emparejado con este hub. Los emisores IR son, con diferencia, la mayor partida del consumo de un contador, así que estos ajustes deciden si puede funcionar con una alimentación autónoma.", + "readOnly": "Solo los propietarios y administradores del dispositivo pueden cambiar estos ajustes. Tu rol es «{{role}}».", + "hiveFallback": "Colmena {{slot}}", + "noChanges": "No hay cambios que guardar", + "saved": "Ajustes de los contadores de abejas guardados. Los contadores los recogen en la próxima conexión.", + "appliesToAll": "Se aplica a todos los contadores HiveTraffic emparejados con este dispositivo. El contador recibe el cambio en el próximo ciclo de envío del hub.", + "status": { + "title": "Contadores ahora", + "none": "Ningún contador HiveTraffic aparece en la última lectura.", + "counting": "Contando", + "nightIdle": "Reposo nocturno", + "nightIdleFor_one": "Reposo nocturno · quedan {{count}} min", + "nightIdleFor_other": "Reposo nocturno · quedan {{count}} min", + "nightIdleFor_many": "Reposo nocturno · quedan {{count}} min", + "banksRunning": "Bancos de emisores activos: {{banks}}", + "banksUnknown": "Bancos de emisores no notificados (firmware del contador anterior a 0.3.0)", + "applied": "Bancos aplicados", + "pending": "Bancos pendientes de la próxima conexión" + }, + "night": { + "title": "Modo nocturno", + "description": "Las abejas melíferas son diurnas: el vuelo necesita luz y, de todos modos, se detiene por debajo de unos 10 °C. Durante esta franja el contador deja de detectar para ahorrar energía. Sigue emitiendo anuncios, así que las lecturas y las actualizaciones de firmware siguen funcionando.", + "enable": "Activar modo nocturno", + "start": "Inicio de la noche (hora local)", + "end": "Fin de la noche (hora local)", + "windowHelp": "La franja puede pasar la medianoche (p. ej. 20:00 → 06:00). Si pones la misma hora de inicio y fin, la franja queda vacía —nunca se aplica— en lugar de cubrir todo el día. Deja margen alrededor del anochecer y el amanecer: la actividad alcanza su máximo justo antes de la puesta y la salida del sol.", + "emptyWindow": "El inicio y el fin son iguales, así que la franja está vacía y el modo nocturno nunca se aplicaría.", + "wrapsMidnight": "La franja pasa la medianoche: desde las {{start}} hasta las {{end}} de la mañana siguiente.", + "timezone": "Zona horaria", + "tzCustom": "Cadena TZ POSIX", + "timezoneHelp": "El reloj del hub funciona en UTC. Sin la zona horaria correcta, la franja se desplaza según tu diferencia con UTC y se desvía una hora con cada cambio de horario de verano.", + "tzPresets": { + "cet": "Europa central (Berlín, París, Madrid, Viena)", + "uk": "Reino Unido / Irlanda", + "eet": "Europa del Este (Helsinki, Atenas)", + "usEastern": "EE. UU. Este", + "usCentral": "EE. UU. Centro", + "usMountain": "EE. UU. Montaña", + "usPacific": "EE. UU. Pacífico", + "utc": "UTC (sin hora local)", + "custom": "Personalizada…" + }, + "maxTraffic": "Aplazar por encima de (pasos por ciclo)", + "maxTrafficHelp": "Aplaza el modo nocturno mientras se hayan contado más pasos que este número (entradas + salidas) en el último ciclo de envío, para no cortar a una colonia que sigue volando tarde en una noche de verano. 0 = solo según el reloj.", + "save": "Guardar modo nocturno" + }, + "banks": { + "title": "Bancos de emisores", + "description": "Los emisores IR del contador se conmutan en tres bancos de ocho canales. Cada banco consume unos 80 mA. Apaga uno si la piquera tiene menos de 24 canales, si una parte está cerrada o si la alimentación no puede con toda la placa.", + "bank": "Banco {{bank}}: canales {{gates}}", + "current_one": "{{count}} banco encendido · unos {{ma}} mA en la línea de 3,3 V del contador", + "current_other": "{{count}} bancos encendidos · unos {{ma}} mA en la línea de 3,3 V del contador", + "current_many": "{{count}} bancos encendidos · unos {{ma}} mA en la línea de 3,3 V del contador", + "help": "Un banco apagado no cuenta en absoluto sus canales, así que los totales bajan aproximadamente en proporción; no compares un contador reducido con su propio historial. Al menos un banco debe permanecer encendido; desempareja un contador que no deba contar nada.", + "firmwareNote": "Requiere firmware del contador 0.3.0 o posterior. El ajuste se vuelve a enviar tras cada reinicio del contador.", + "save": "Guardar bancos de emisores" + }, + "errors": { + "loadFailed": "No se pudo cargar la configuración del dispositivo.", + "saveFailed": "No se pudieron guardar los ajustes de los contadores de abejas", + "invalidTime": "Introduce una hora de inicio y de fin válidas (HH:MM).", + "invalidMaxTraffic": "El umbral de aplazamiento debe ser un número entero igual o mayor que 0.", + "emptyWindow": "El inicio y el fin del modo nocturno son iguales: la franja nunca se aplicaría.", + "noBank": "Al menos un banco de emisores debe permanecer encendido; desempareja el contador en su lugar." + } + }, + "hubAccess": { + "title": "Acceso al hub", + "description": "Accede al hub in situ sin abrir la caja y define cuánto puede durar una inspección.", + "readOnly": "Solo los propietarios y administradores del dispositivo pueden cambiar estos ajustes. Tu rol es «{{role}}».", + "noChanges": "No hay cambios que guardar", + "cancel": "Cancelar", + "ap": { + "title": "Punto de acceso de configuración", + "description": "Abre la red Wi-Fi HiveHub-Setup del hub, igual que una pulsación corta del botón de configuración: cambiar la Wi-Fi, emparejar sensores o descargar los datos de la tarjeta SD.", + "lastCheckIn": "Última conexión: {{time}}", + "open": "Abrir punto de acceso de configuración", + "confirmTitle": "¿Abrir el punto de acceso de configuración?", + "confirmTiming_one": "La solicitud queda en cola. El hub duerme entre ciclos, así que el punto de acceso se abre tras su próxima conexión, como máximo dentro de {{count}} minuto.", + "confirmTiming_other": "La solicitud queda en cola. El hub duerme entre ciclos, así que el punto de acceso se abre tras su próxima conexión, como máximo dentro de {{count}} minutos.", + "confirmTiming_many": "La solicitud queda en cola. El hub duerme entre ciclos, así que el punto de acceso se abre tras su próxima conexión, como máximo dentro de {{count}} minutos.", + "confirmTimingUnknown": "La solicitud queda en cola. El hub duerme entre ciclos, así que el punto de acceso se abre tras su próxima conexión, como máximo dentro de un intervalo de envío.", + "confirmOffline": "Mientras el punto de acceso está abierto, el hub está desconectado de la Wi-Fi y no envía lecturas.", + "confirmTimeout": "Se cierra solo al agotarse el tiempo del portal de configuración si nadie se conecta, así que actívalo solo cuando haya alguien junto a la colmena.", + "confirm": "Poner punto de acceso en cola", + "queueing": "Poniendo en cola…", + "queued": "Punto de acceso en cola. Se abre tras la próxima conexión del hub.", + "failed": "No se pudo poner en cola el punto de acceso" + }, + "inspection": { + "title": "Tiempo máximo de inspección", + "description": "Una red de seguridad: una inspección termina sola tras estos minutos, para que una pulsación olvidada del botón no oculte indefinidamente los datos de una colmena. Por defecto 60.", + "label": "Tiempo máximo (minutos, 1–1440)", + "save": "Guardar tiempo máximo", + "saved": "Tiempo máximo de inspección guardado", + "saveFailed": "No se pudo guardar el tiempo máximo de inspección", + "invalid": "Introduce un número entero de minutos entre 1 y 1440." + } + }, + "dataTools": { + "title": "Exportación y limpieza de datos", + "description": "Haz una copia de seguridad de las lecturas de este hub o elimina las erróneas.", + "hiveFallback": "Colmena {{slot}}", + "cancel": "Cancelar", + "export": { + "title": "Exportar lecturas", + "description": "Descarga las lecturas almacenadas como copia de seguridad NDJSON.", + "from": "Desde (opcional)", + "to": "Hasta (opcional)", + "rangeHelp": "Deja ambos vacíos para exportarlo todo. Las fechas abarcan días completos en tu hora local.", + "hives": "Colmenas (opcional)", + "allHives": "Se incluyen todas las colmenas.", + "someHives_one": "Solo se incluye {{count}} colmena seleccionada. El recuento de abajo abarca todas las lecturas del periodo.", + "someHives_other": "Solo se incluyen {{count}} colmenas seleccionadas. El recuento de abajo abarca todas las lecturas del periodo.", + "someHives_many": "Solo se incluyen {{count}} colmenas seleccionadas. El recuento de abajo abarca todas las lecturas del periodo.", + "invalidRange": "La fecha de inicio es posterior a la fecha de fin.", + "summaryFailed": "No se pudieron contar las lecturas.", + "count_one": "{{count}} lectura", + "count_other": "{{count}} lecturas", + "count_many": "{{count}} lecturas", + "span": "Del {{first}} al {{last}}", + "download": "Descargar", + "formatHelp": "Una lectura JSON por línea. El archivo se puede volver a importar con la importación de tarjeta SD.", + "noPermission": "Solo los propietarios y administradores del dispositivo pueden exportar datos. Tu rol es «{{role}}»." + }, + "delete": { + "title": "Eliminar lecturas de un periodo", + "description": "Elimina lecturas espurias, por ejemplo los picos de peso que un hub registra al arrancar antes de que sus básculas estén calibradas y que, de lo contrario, distorsionan los gráficos y los análisis.", + "warningTitle": "No se puede deshacer", + "warning": "Las lecturas eliminadas se pierden para siempre. Exporta antes el periodo si puedes necesitarlo.", + "start": "Inicio", + "end": "Fin", + "claimCode": "Código de reclamación", + "claimCodeHelp": "El código de reclamación del hub, como segunda confirmación.", + "review": "Revisar eliminación", + "confirmTitle": "¿Eliminar estas lecturas?", + "confirmRange": "Del {{start}} al {{end}}.", + "confirmCount_one": "Se eliminará permanentemente {{count}} lectura.", + "confirmCount_other": "Se eliminarán permanentemente {{count}} lecturas.", + "confirmCount_many": "Se eliminarán permanentemente {{count}} lecturas.", + "confirmIrreversible": "No se puede deshacer.", + "countFailed": "No se pudieron contar las lecturas de este periodo.", + "confirm_one": "Eliminar {{count}} lectura", + "confirm_other": "Eliminar {{count}} lecturas", + "confirm_many": "Eliminar {{count}} lecturas", + "deleting": "Eliminando…", + "success_one": "{{count}} lectura eliminada.", + "success_other": "{{count}} lecturas eliminadas.", + "success_many": "{{count}} lecturas eliminadas.", + "noPermission": "Solo el propietario del dispositivo puede eliminar lecturas. Tu rol es «{{role}}».", + "errors": { + "missingRange": "Elige un inicio y un fin.", + "invalidRange": "El fin debe ser igual o posterior al inicio.", + "missingClaimCode": "Introduce el código de reclamación del hub." + } + } + }, + "alertSettings": { + "title": "Alertas de HiveHub por correo", + "description": "Recibe un correo cuando HiveHub detecte algo en tus colmenas: enjambrazón, pillaje, una colonia huérfana y más.", + "enable": "Enviarme correos sobre alertas de HiveHub", + "minSeverity": "Gravedad mínima", + "minSeverityHelp": "Las alertas por debajo de este nivel nunca se envían por correo.", + "severity": { + "watch": "Vigilancia y superiores", + "warning": "Advertencia y superiores", + "critical": "Solo críticas" + }, + "help": { + "schedule": "HivePal comprueba si hay alertas nuevas cada 15 minutos.", + "once": "Cada alerta se envía una vez cuando aparece y de nuevo solo si empeora.", + "scope": "Abarca todos los dispositivos HiveHub a los que tienes acceso.", + "email": "Requiere que el correo esté configurado en el servidor de HivePal." + }, + "saved": "Ajustes de alertas guardados", + "saveFailed": "No se pudieron guardar los ajustes de alertas", + "loadFailed": "No se pudieron cargar tus preferencias." + }, + "health": { + "title": "Estado de los sensores", + "description": "Estado de cada sensor según lo notifica {{name}}.", + "descriptionAt": "Estado de los sensores de la última conexión · {{ago}}.", + "noData": "Aún no se ha notificado el estado de los sensores; aparece en cuanto el dispositivo envía su primera lectura.", + "hubHeading": "Hub", + "hiveIndex": "Colmena {{n}}", + "noHiveSensors": "No se han notificado sensores para esta colmena.", + "faultNote": "El hub notifica sus propios subsistemas como correcto/fallo; no envía ningún código de error para ellos, así que cada fallo de arriba indica qué ha fallado. El contador de piquera sí notifica un código de estado, que se muestra con el fallo siempre que se haya leído.", + "summary": { + "noData": "Sin datos de sensores", + "allOk": "Todo OK", + "faults_one": "{{count}} fallo", + "faults_other": "{{count}} fallos", + "faults_many": "{{count}} fallos" + }, + "state": { + "ok": "OK", + "fault": "Fallo", + "notFitted": "No instalado", + "notHeard": "Sin señal" + }, + "hub": { + "sht_ok": "Temp./humedad ambiente (SHT40)", + "rtc_ok": "Reloj de tiempo real", + "sd_ok": "Tarjeta SD", + "mic_ok": "Micrófono", + "mic_left_ok": "Micrófono · izquierdo", + "mic_right_ok": "Micrófono · derecho", + "battery_monitor_ok": "Monitor de batería", + "solar_monitor_ok": "Monitor solar" + }, + "hubReason": { + "sht_ok": "El sensor ambiental no devolvió una lectura válida en este ciclo, así que esta conexión no incluye temperatura ni humedad ambiente. Suele deberse a un fallo del bus I²C o del cableado; un sensor que nunca se detectó al arrancar se muestra igual.", + "rtc_ok": "El reloj de tiempo real no respondió en el bus I²C. Las marcas de tiempo recurren a la hora de red, así que una lectura tomada sin conexión puede tener una fecha incorrecta.", + "sd_ok": "La tarjeta SD no se montó en este ciclo: falta, no se puede leer o hay un fallo de SPI/cableado. Mientras persista, las lecturas no se almacenan en la tarjeta.", + "mic_ok": "La captura del micrófono no devolvió muestras utilizables, así que esta conexión no incluye bandas acústicas.", + "mic_left_ok": "El micrófono izquierdo no devolvió muestras utilizables.", + "mic_right_ok": "El micrófono derecho no devolvió muestras utilizables.", + "battery_monitor_ok": "El medidor de batería no respondió en el bus I²C: faltan la tensión y el estado de carga de la batería en esta conexión.", + "solar_monitor_ok": "El monitor solar no respondió en el bus I²C: faltan la tensión, la corriente y la potencia solar en esta conexión." + }, + "sensor": { + "scale": "Báscula", + "inHive": "Sensor interior", + "counter": "Contador HiveTraffic", + "hiveheart": "HiveHeart" + }, + "reason": { + "scale": "Hay una báscula configurada para esta colmena, pero no produjo ninguna lectura utilizable en este ciclo, así que esta conexión no incluye peso para ella.", + "inHive": "No se recibió ningún anuncio del {{kind}} emparejado durante el escaneo BLE de este ciclo: fuera de alcance, batería agotada o apagado.", + "inHiveKindFallback": "sensor interior", + "node": "Nodo {{node}}.", + "lastHeard": "Última señal {{ago}}.", + "notHeardInRange": "Sin señal en el periodo cargado.", + "counter": "El contador de piquera no respondió a su lectura GATT en este ciclo: fuera de alcance, apagado u ocupado cuando HiveHub lo llamó.", + "counterNode": "Contador {{node}}.", + "lastAnswered": "Última respuesta {{ago}}.", + "notAnsweredInRange": "No ha respondido en el periodo cargado.", + "statusThen": "Estado entonces: {{status}}.", + "expandersThen_one": "{{count}} de 3 expansores de puertos respondía entonces.", + "expandersThen_other": "{{count}} de 3 expansores de puertos respondían entonces.", + "expandersThen_many": "{{count}} de 3 expansores de puertos respondían entonces.", + "hiveheart": "No se recibió señal del HiveHeart en este ciclo: fuera de alcance, batería agotada o apagado." + }, + "counter": { + "nightIdle": "reposo del modo nocturno", + "nightIdleBadge": "Reposo nocturno", + "otherBits": "bits específicos del contador {{hex}}", + "noFlags": "ningún indicador activado", + "statusCode": "estado {{hex}}", + "expanders_one": "{{count}}/3 expansor de canales", + "expanders_other": "{{count}}/3 expansores de canales", + "expanders_many": "{{count}}/3 expansores de canales", + "glitches_one": "{{count}} fallo puntual", + "glitches_other": "{{count}} fallos puntuales", + "glitches_many": "{{count}} fallos puntuales", + "banks": "Bancos {{list}}", + "noBanks": "Sin bancos de emisores" + }, + "meta": { + "firmware": "v{{version}}", + "batteryPercent": "Batería {{value}} %", + "batteryMv": "Batería {{value}} mV", + "batteryV": "Batería {{value}} V", + "rssi": "{{value}} dBm" + } + }, + "hubStatus": { + "title": "Estado del hub", + "description": "Enlace, reloj y alimentación del propio HiveHub.", + "noData": "Aún no se ha recibido ninguna lectura de este hub.", + "staleWarning": "Sin lecturas desde hace {{ago}}: el hub se ha saltado varias conexiones.", + "badge": { + "live": "En directo", + "stale": "Desactualizado", + "inspection": "Inspección en curso", + "calibration": "Modo calibración" + }, + "section": { + "connectivity": "Conectividad", + "battery": "Batería", + "solar": "Solar" + }, + "row": { + "signal": "Señal", + "transport": "Transporte", + "timeSource": "Fuente de hora", + "lastSeen": "Última conexión", + "lastReading": "Última lectura", + "firmware": "Firmware", + "bootCount": "Número de arranques", + "stateOfCharge": "Estado de carga", + "batteryVoltage": "Tensión", + "batteryAlert": "Alerta de batería", + "solarPower": "Potencia solar", + "solarCurrent": "Corriente solar", + "solarBus": "Tensión del panel (bus)", + "solarLoad": "Tensión de carga" + }, + "signalQuality": { + "excellent": "Excelente", + "good": "Buena", + "fair": "Aceptable", + "poor": "Débil" + }, + "transport": { + "wifi": "Wi-Fi", + "cellular": "Móvil" + }, + "rtc": { + "ok": "RTC OK", + "fault": "Fallo del RTC" + }, + "batteryAlertActive": "Batería baja" + }, + "hiveCards": { + "empty": "Este dispositivo aún no ha notificado ninguna colmena.", + "hiveIndex": "Colmena {{n}}", + "weight": "Peso", + "temperature": "Temperatura", + "humidity": "Humedad", + "delta24h": "24 h {{value}} kg", + "lastReported": "Última notificación {{ago}}", + "inspection": "Inspección", + "insightsLoading": "Cargando análisis", + "insightsUnavailable": "Análisis no disponibles", + "noSensors": "No se han notificado sensores", + "traffic": "Entradas {{in}} · Salidas {{out}}", + "trafficTitle": "Abejas que entraron/salieron en el último intervalo", + "alerts_one": "{{count}} alerta activa", + "alerts_other": "{{count}} alertas activas", + "alerts_many": "{{count}} alertas activas", + "sensor": { + "scale": "Báscula", + "inHive": "Sensor interior", + "counter": "Contador HiveTraffic", + "hiveheart": "HiveHeart" + }, + "state": { + "ok": "OK", + "fault": "Sin señal / fallo" + } + }, + "audio": { + "title": "Audio de la colmena", + "description": "Pide al nodo HiveInside de una colmena que grabe y escúchalo aquí. El hub recoge la solicitud en su próximo despertar, así que la grabación aparece dentro de un intervalo de envío y no de inmediato.", + "noNodes": "No hay ningún nodo HiveInside emparejado con este hub. Solo HiveInside tiene micrófono: una baliza HolyIot o RuuviTag no puede grabar.", + "hive": "Colmena", + "hiveFallback": "Colmena {{slot}}", + "length": "Duración", + "durationOption_one": "{{count}} segundo", + "durationOption_other": "{{count}} segundos", + "durationOption_many": "{{count}} segundos", + "gain": "Ganancia", + "gainValue": "{{gain}} dB", + "record": "Grabar", + "nextWake": "Llega al hub en su próximo despertar.", + "viewerNote": "Un propietario o administrador puede solicitar una grabación.", + "lengthHint": "Más corto suele ser mejor: 10–20 segundos bastan para oír lo que señaló un análisis, y cuesta menos batería y menos tiempo con la colmena sin señal; un nodo conectado ni emite anuncios ni toma muestras.", + "requestSuccess": "Grabación solicitada. Llega al hub en su próximo despertar.", + "requestError": "No se pudo poner en cola la solicitud: {{message}}", + "listTitle": "Grabaciones", + "onlySelectedHive": "Solo {{name}}", + "loadError": "No se pudieron listar las grabaciones: {{message}}", + "empty": "Aún no hay grabaciones. Pide a una colmena que grabe y aparecerá aquí. Una sesión son 10–60 segundos de audio a 16 kHz descargados del nodo por BLE.", + "requestedLength_one": "{{count}} s solicitado", + "requestedLength_other": "{{count}} s solicitados", + "requestedLength_many": "{{count}} s solicitados", + "requestedBy": "Solicitado por {{name}}", + "status": { + "requested": "Esperando al hub", + "streaming": "Grabando…", + "ready": "Lista", + "readyIncomplete": "Lista, incompleta", + "failed": "Fallida" + }, + "waitingNote": "Esperando a que el hub despierte y la recoja; aún no se ha grabado nada. Una solicitud que el hub no haya recogido en 45 minutos se marca como fallida.", + "streamingNote": "Grabando: el audio aparece aquí a medida que llega.", + "streamingPartial": "Todavía grabando: esto es lo que ha llegado hasta ahora. Vuelve a reproducirlo cuando termine la sesión para oírlo entero.", + "failedFallback": "Esta grabación ha fallado.", + "seconds": "{{value}} s", + "bytes": "{{value}} B", + "percent": "{{value}} %", + "download": "Descargar WAV", + "delete": "Eliminar", + "deleteConfirm": "¿Eliminar esta grabación? Nada más lo hará: no hay limpieza automática.", + "deleteSuccess": "Grabación eliminada.", + "deleteError": "No se pudo eliminar la grabación: {{message}}", + "confirmation": { + "verified": "verificada", + "reported": "sin verificar", + "confirmed": "confirmada por el hub", + "unknown": "sin confirmar" + }, + "quality": { + "length": "Duración", + "dropped": "Perdidas", + "gaps": "Huecos", + "hubBuffer": "Búfer del hub", + "clipping": "Saturación", + "checksum": "Suma de comprobación", + "cause": { + "dropped": "el nodo no pudo enviar el audio con suficiente rapidez", + "gaps": "se perdieron paquetes en la transmisión inalámbrica", + "hubBuffer": "la subida del hub se quedó atrás respecto a su búfer" + }, + "gapNote": "El audio a ambos lados de un hueco es real, pero la unión no. Causa: {{causes}}.", + "clippingNote": "Baja la ganancia para obtener una grabación más limpia.", + "unknownNote": "El hub nunca informó sobre esta sesión, así que nada comprobó si el audio está completo. Lo que hay se puede reproducir." + } + }, + "dashboard": { + "hiveFallback": "Colmena {{index}}", + "units": { + "bees": "abejas" + }, + "range": { + "24h": "24 h", + "7d": "7 d", + "30d": "30 d", + "365d": "365 d", + "custom": "Personalizado", + "invalid": "La fecha de inicio no debe ser posterior a la fecha de fin." + }, + "header": { + "title": "Mi panel", + "description": "Widgets configurables. Las colmenas seleccionadas arriba controlan los gráficos por colmena.", + "editDescription": "Modo edición: arrastra el asa para mover los widgets y la esquina para cambiar su tamaño en una cuadrícula de 4 columnas.", + "edit": "Editar panel", + "done": "Listo", + "addWidget": "Añadir widget", + "reset": "Restablecer", + "editBanner": "El modo edición del panel está activo. Usa el asa de la cabecera de cada widget para moverlo y arrastra la esquina inferior derecha para cambiar el ancho o el alto. Los cambios de diseño se guardan automáticamente para este dispositivo HiveHub.", + "dropEnd": "Suelta aquí para mover un widget al final del panel.", + "noWidgets": "Aún no hay widgets. Añade una plantilla para crear tu panel.", + "restoreDefaults": "Restaurar valores predeterminados" + }, + "overview": { + "title": "Resumen de colmenas", + "description": "Estado compacto de las colmenas asignadas. Selecciona mosaicos para controlar los widgets del panel de abajo.", + "lastData": "Últimos datos", + "hivesOnline": "Colmenas en línea", + "hubFirmware": "FW HiveHub", + "hiveInsideFirmware": "FW HiveInside", + "selected_one": "{{count}} seleccionada", + "selected_other": "{{count}} seleccionadas", + "selected_many": "{{count}} seleccionadas", + "hide": "Ocultar resumen", + "show": "Mostrar resumen", + "slot": "Posición {{index}}", + "ok": "OK", + "noData": "Sin datos", + "weight": "Peso", + "temperature": "Temp.", + "humidity": "HR", + "empty": "Asigna las posiciones de HiveHub a colmenas de HivePal en el panel de configuración del dispositivo para ver aquí los mosaicos de resumen." + }, + "sensors": { + "scale": "báscula", + "inHive": "interior", + "accel": "acel.", + "counter": "contador", + "none": "sin sensores" + }, + "widgetShell": { + "move": "Mover {{title}}", + "moveHint": "Arrastra para mover este widget", + "remove": "Quitar {{title}}", + "resize": "Cambiar tamaño de {{title}}", + "resizeHint": "Arrastra para cambiar el tamaño de este widget" + }, + "addWidget": { + "title": "Añadir widget", + "description": "Empieza con una plantilla. El widget usará las colmenas seleccionadas en la cuadrícula de resumen cuando corresponda.", + "defaultSize": "Tamaño predeterminado {{w}} x {{h}}" + }, + "widgets": { + "weightComparison": { + "title": "Comparación de peso", + "description": "Compara las colmenas seleccionadas en el periodo actual." + }, + "dailyMaxWeight": { + "title": "Peso máximo diario", + "description": "Peso compensado más alto por día de las colmenas seleccionadas." + }, + "climate": { + "title": "Clima de la colmena", + "description": "Temperatura y humedad de las colmenas seleccionadas." + }, + "power": { + "title": "Estado de la alimentación", + "description": "Carga de la batería, tensión de la batería y aporte solar." + }, + "beeTraffic": { + "title": "Tráfico de abejas", + "description": "Tráfico total de entrada/salida y flujo neto." + }, + "soundRms": { + "title": "Sonido / bandas acústicas", + "description": "RMS acústico por colmena y bandas FFT/HiveHeart cuando están disponibles." + }, + "hiveHeartSpectrum": { + "title": "Espectro HiveHeart", + "description": "Últimos niveles FFT de HiveHeart (0–15) en 16 rangos de frecuencia para las colmenas seleccionadas." + }, + "vibration": { + "title": "Bandas de vibración", + "description": "Vibración RMS y bandas de enjambrazón, ventilación y actividad." + }, + "configurableDiagram": { + "title": "Diagrama configurable", + "description": "Elige las métricas de colmena, sonido, tráfico, clima y dispositivo que quieres representar." + }, + "temperatureHeatmap": { + "title": "Mapa de calor de temperatura", + "description": "Resumen compacto de la temperatura de todas las colmenas." + }, + "insights": { + "title": "Feed de análisis", + "description": "Alertas activas y fragmentos de evidencias." + }, + "dataQuality": { + "title": "Calidad de los datos", + "description": "Disponibilidad de sensores y lecturas que faltan." + } + }, + "metricGroups": { + "scale": "Báscula", + "climate": "Clima", + "beeTraffic": "Tráfico de abejas", + "vibration": "Vibración", + "sound": "Sonido", + "device": "Dispositivo" + }, + "metrics": { + "weight": "Peso", + "temperature": "Temperatura", + "humidity": "Humedad", + "pressure": "Presión", + "beeIn": "Abejas que entran", + "beeOut": "Abejas que salen", + "beeNet": "Flujo neto", + "vibration": "Vibración RMS", + "swarmBand": "Banda de enjambrazón", + "fanningBand": "Banda de ventilación", + "activityBand": "Banda de actividad", + "soundRms": "Sonido RMS", + "soundHum": "Banda de zumbido", + "soundPiping": "Banda de canto de reinas", + "soundStress": "Banda de estrés", + "hiveHeartFrequency": "Frecuencia HiveHeart", + "hiveHeartEnergy": "Energía HiveHeart", + "batterySoc": "Carga de la batería", + "batteryVoltage": "Tensión de la batería", + "solarPower": "Potencia solar" + }, + "axes": { + "weight": "Peso", + "temperature": "Temperatura", + "humidity": "Humedad", + "pressure": "Presión", + "beecount": "Número de abejas", + "vibration": "Vibración", + "dbfs": "Nivel sonoro", + "frequency": "Frecuencia", + "energy": "Energía", + "percent": "Carga", + "voltage": "Tensión", + "power": "Potencia" + }, + "soundMetrics": { + "rms": "RMS", + "hiveHeartFrequency": "Frecuencia HiveHeart", + "hiveHeartEnergy": "Energía HiveHeart", + "hiveHeartPeak": "Pico HiveHeart" + }, + "vibrationMetrics": { + "rms": "RMS", + "swarmBand": "Banda de enjambrazón", + "fanningBand": "Banda de ventilación", + "activityBand": "Banda de actividad" + }, + "series": { + "temperature": "{{name}} temp.", + "humidity": "{{name}} HR", + "beeIn": "Entradas", + "beeOut": "Salidas", + "beeNet": "Neto" + }, + "axisScale": { + "auto": "auto", + "setBound": "Establecer {{bound}} de {{axis}}", + "clearHint": "Déjalo vacío y pulsa Intro para usar auto", + "tickTitle": "Haz clic para establecer {{bound}}; vacíalo para auto", + "reset": "restablecer", + "help": "Haz clic en la etiqueta más baja o más alta del eje Y para editarla. Vacía el valor para usar auto." + }, + "chart": { + "downloadCsv": "Descargar CSV" + }, + "inspection": { + "legendHint_one": "{{count}} periodo de inspección sombreado; las lecturas pueden estar alteradas", + "legendHint_other": "{{count}} periodos de inspección sombreados; las lecturas pueden estar alteradas", + "legendHint_many": "{{count}} periodos de inspección sombreados; las lecturas pueden estar alteradas" + }, + "empty": { + "noData": "No hay datos para las colmenas y el periodo seleccionados.", + "climate": "No hay datos climáticos para el periodo seleccionado.", + "power": "No hay datos de alimentación para el periodo seleccionado.", + "beeTraffic": "No hay datos de contador de abejas para las colmenas seleccionadas.", + "sound": "No hay datos acústicos por colmena para las colmenas seleccionadas.", + "vibration": "No hay datos de vibración ni de bandas del acelerómetro para las colmenas seleccionadas.", + "configurable": "Selecciona al menos una métrica con datos para las colmenas y el periodo seleccionados.", + "dailyMaxWeight": "No hay datos de peso para las colmenas y el periodo seleccionados.", + "hiveHeart": "No hay datos FFT de HiveHeart para las colmenas y el periodo seleccionados.", + "unknownWidget": "Tipo de widget desconocido." + }, + "soundFootnote": "Muestra primero las lecturas acústicas por colmena. Los datos antiguos de los micrófonos izquierdo/derecho se asignan a los nombres de colmena configurados para las posiciones 1 y 2 cuando no hay un bloque acústico por colmena.", + "heatmap": { + "low": "baja <33 °C", + "optimal": "cría óptima 33–36 °C", + "high": "alta >36 °C" + }, + "insights": { + "loading": "Cargando análisis…", + "unavailable": "Los análisis no están disponibles.", + "noAlerts": "No hay alertas activas para las colmenas seleccionadas.", + "showing_one": "Mostrando {{count}} alerta activa para las colmenas seleccionadas.", + "showing_other": "Mostrando {{count}} alertas activas para las colmenas seleccionadas.", + "showing_many": "Mostrando {{count}} alertas activas para las colmenas seleccionadas.", + "confidence": "confianza {{percent}} %" + }, + "dataQuality": { + "activeSlots": "Posiciones activas", + "noScale": "Sin báscula", + "noClimate": "Sin clima", + "noRecentData": "sin datos recientes" + }, + "hiveHeart": { + "level": "nivel", + "dominant": "frecuencia dominante {{hz}} Hz", + "noDominant": "no se ha notificado frecuencia dominante", + "footnote": "Niveles relativos 0–15 (no dBFS) de la última lectura del periodo. Las líneas discontinuas marcan la frecuencia dominante que notifica HiveHeart; los encabezados Subgraves/Zumbido/Canto de reinas/Estrés son aproximados y abarcan varios rangos." + } } } diff --git a/apps/frontend/public/locales/fr/common.json b/apps/frontend/public/locales/fr/common.json index 176cd4d09..1b1b42b01 100644 --- a/apps/frontend/public/locales/fr/common.json +++ b/apps/frontend/public/locales/fr/common.json @@ -30,7 +30,8 @@ "allQueens": "Toutes les reines", "tools": "Outils", "syrupCalculator": "Calculateur de sirop", - "broodTimeline": "Cycle du couvain" + "broodTimeline": "Cycle du couvain", + "hivehub": "HiveHub" }, "actions": { "save": "Enregistrer", diff --git a/apps/frontend/public/locales/fr/hivescale.json b/apps/frontend/public/locales/fr/hivescale.json index 1a5779842..35c8e003c 100644 --- a/apps/frontend/public/locales/fr/hivescale.json +++ b/apps/frontend/public/locales/fr/hivescale.json @@ -35,7 +35,7 @@ }, "page": { "title": "HiveHub", - "subtitle": "Live weight and temperature visualisation from your separate HiveHub backend.", + "subtitle": "Poids, climat, son, trafic et alertes de vos appareils HiveHub.", "refresh": "Refresh", "refreshing": "Refreshing…", "refreshError": "Could not refresh HiveHub data.", @@ -43,16 +43,26 @@ "removeSuccess": "HiveHub device removed from your account.", "removeSuccessShared": "Vous n'avez plus accès à cet appareil HiveHub. D'autres utilisateurs le conservent, son code d'association reste donc utilisé.", "releaseConfirm": "Libérer complètement {{name}} ? Tous les utilisateurs perdent l'accès et l'appareil peut être associé à nouveau avec son code. Les relevés sont conservés.", - "releaseSuccess": "Appareil HiveHub libéré. Il peut être associé à nouveau avec son code d'association." + "releaseSuccess": "Appareil HiveHub libéré. Il peut être associé à nouveau avec son code d'association.", + "unavailableTitle": "HiveHub est injoignable", + "inspectionActiveTitle": "Le mode inspection est activé", + "inspectionActiveBody": "Les relevés sont masqués pendant que les ruches sont ouvertes, pour que le retrait des cadres ne déclenche pas de fausses alertes.", + "openInspections": "Inspections en cours", + "tabs": { + "overview": "Vue d'ensemble", + "inspections": "Inspections", + "audio": "Audio", + "health": "État", + "setup": "Configuration" + } }, "setup": { "title": "Devices", - "description": "Select or remove a claimed HiveHub device. Expand the setup to claim devices, check status, map scales, and calibrate the raw readings.", "removeScale": "Remove scale", "releaseScale": "Libérer l'appareil", "noDevices": "No devices claimed yet — open the setup to add one.", - "hide": "Hide setup", - "show": "Show setup" + "selectDevice": "Appareil HiveHub", + "noDevicesYet": "Aucun appareil HiveHub associé pour le moment. Associez-en un avec le code affiché par le hub dans son portail de configuration." }, "claim": { "title": "Claim HiveHub device", @@ -65,16 +75,30 @@ "success": "HiveHub device claimed.", "errors": { "missingCode": "Enter the claim code printed/configured for the device." - } + }, + "hive1Name": "Nom de la ruche 1", + "hive2Name": "Nom de la ruche 2", + "optional": "Facultatif" }, "mapping": { - "title": "Scale mapping", - "description": "These labels stay in HiveHub. Later, this is the right place to map Scale 1/2 to HivePal hive IDs for inspection markers.", - "scale1Label": "Display name for Scale 1", - "scale2Label": "Display name for Scale 2", + "title": "Ruches", "hiveNamePlaceholder": "Select or type a hive name", - "save": "Save mapping", - "success": "Scale mapping updated." + "save": "Enregistrer les ruches", + "success": "Emplacements de ruche enregistrés dans HiveHub.", + "slotsDescription": "Nommez chaque emplacement de ruche et reliez-le à une ruche HivePal. Ce lien permet aux inspections, à la page de la ruche et aux e-mails d'alerte de savoir à quelle colonie correspond un emplacement. Un point vert signale les emplacements actuellement remontés par le hub.", + "mapSlot": "Attribuer l'emplacement {{slot}}", + "add": "Ajouter", + "reporting": "Remonté par le hub", + "unknownHive": "Ruche introuvable", + "mappedCount": "{{count}}/{{total}} emplacements attribués", + "dialogTitle": "Emplacement de ruche {{slot}}", + "dialogDescription": "Reliez l'emplacement à l'une de vos ruches HivePal et choisissez le nom que HiveHub affiche pour lui.", + "linkedHive": "Ruche HivePal", + "notLinked": "Non reliée", + "linkHint": "Le lien est conservé si la ruche est renommée. Choisir une ruche renseigne son nom si le champ est vide.", + "displayName": "Nom affiché", + "clearSlot": "Vider l'emplacement", + "applySlot": "Appliquer" }, "status": { "title": "Device status", @@ -94,6 +118,13 @@ "revokeAccess": "Revoke access", "errors": { "missingEmail": "Enter the email address of an existing HivePal user." + }, + "lastSeen": "Dernière connexion", + "claimedAt": "Associé le", + "roles": { + "owner": "Propriétaire", + "admin": "Admin", + "viewer": "Lecteur" } }, "calibration": { @@ -203,11 +234,9 @@ }, "sourceHint": "De omgevingstemperatuur volgt het cellichaam in stabiele toestand nauwkeurig en is de aanbevolen standaard.", "refLabel": "Referentietemperatuur / °C", - "refHint": "Temperatuur waarbij de correctie nul is.", "coeffLabel": "{{scaleName}}-coëfficiënt / kg per °C", "autoFit": "Automatisch afstemmen op geschiedenis", "fitting": "Afstemmen…", - "autoFitHint": "Automatisch afstemmen voert een regressie uit van het ruwe gewicht van de afgelopen 3 dagen tegen de met EMA gladgestreken geselecteerde temperatuur en past het resultaat vervolgens toe en schakelt het in. Leg voor een schone afstemming een venster vast waarin de fysieke belasting constant is en de temperatuur varieert.", "save": "Temperatuurcompensatie opslaan", "errors": { "refTemp": "De referentietemperatuur moet een getal zijn (°C).", @@ -219,11 +248,33 @@ "noSignal": "onvoldoende temperatuur-/gewichtssignaal.", "fitApplied": "{{scaleName}}: {{coeff}} kg/°C bij {{refTemp}}°C (R²={{r2}}, n={{n}}). Toegepast en ingeschakeld." } + }, + "perHive": { + "hive": "Ruche {{index}}", + "hiveNamed": "Ruche {{index}} · {{name}}", + "hiveLabel": "Ruche", + "knownWeightPlaceholder": "p. ex. 10", + "manualSaveHint": "Changez de ruche pour modifier une autre balance ; toutes les modifications sont envoyées ensemble à l'enregistrement. Les ruches 3 et suivantes ne sont écrites que si elles ont changé.", + "tempco": { + "refHint": "La température à laquelle les balances indiquent le poids exact, c.-à-d. celle de leur tare et de leur étalonnage. La correction y est nulle ; l'ajustement ne la modifie pas, sauf si vous le demandez.", + "lookbackLabel": "Période d'ajustement / jours", + "setRefTemp": "Définir aussi la température de référence", + "setRefTempHint": "Remplace la référence par la température moyenne de la fenêtre ajustée. Laissez désactivé si la balance a été étalonnée à une température connue.", + "fitOnly": "Ajuster seulement (aperçu)", + "fitAndApply": "Ajuster et appliquer", + "fitSummary": "{{scaleName}} : {{coeff}} kg/°C, R² {{r2}} sur {{n}} relevés entre {{min}} et {{max}} °C (moyenne de la fenêtre {{mean}} °C).", + "lowRSquared": "R² inférieur à 0,5 : la température explique peu cette dérive. Ajustez sur une période à charge constante avec un grand écart jour/nuit.", + "autoFitHint": "L'ajustement automatique calcule une régression du poids enregistré de la ruche sélectionnée sur la période choisie par rapport à la température sélectionnée lissée par EMA. « Ajuster seulement » renseigne le coefficient pour vérification avant l'enregistrement ; « Ajuster et appliquer » l'enregistre et active la compensation. Pour un bon ajustement, choisissez une période où la charge physique est constante et où la température varie.", + "errors": { + "lookback": "La période doit être un nombre entier de jours entre 1 et 90." + }, + "toasts": { + "fitFilled": "{{scaleName}} : {{coeff}} kg/°C (R²={{r2}}, n={{n}}). Valeur renseignée — vérifiez puis enregistrez." + } + } } }, "firmware": { - "title": "Firmware upload", - "description": "Upload a firmware binary and register it as a release. New devices of the matching type pick it up on their next update check.", "type": "Firmware type", "selectType": "Select firmware type", "version": "Version", @@ -238,6 +289,102 @@ "errors": { "missingFile": "Choose a firmware binary (.bin) to upload.", "missingVersion": "Enter a version, e.g. 0.6.3." + }, + "update": { + "title": "Mise à jour du firmware", + "description": "Les balances ne se mettent jamais à jour d'elles-mêmes — vous approuvez ici chaque nouvelle version avant que cette balance l'installe.", + "unavailable": "L'état du firmware est indisponible pour le moment.", + "availableTitle": "Le firmware {{version}} est disponible", + "availableBody": "Cette balance utilise la version {{current}}. Appliquez la mise à jour pour passer à {{version}}.", + "officialSuffix": "Il s'agit d'une version officielle.", + "apply": "Appliquer la mise à jour", + "applying": "Application…", + "applied": "Firmware {{version}} approuvé — la balance se mettra à jour lors de sa prochaine connexion.", + "queued": "Mise à jour {{version}} approuvée. La balance l'installera lors de sa prochaine connexion.", + "upToDate": "Le firmware est à jour ({{version}}).", + "viewerNotice": "Seuls les propriétaires et administrateurs de l'appareil peuvent appliquer des mises à jour du firmware." + }, + "board": { + "cardTitle": "Firmware", + "cardDescription": "Firmware de ce HiveHub ainsi que des nœuds HiveInside et des compteurs HiveTraffic qui lui sont rattachés. Le téléversement enregistre une version ; l'approbation (hub) ou le relais (nœuds) l'installe.", + "current": "Actuelle", + "latest": "Dernière", + "approved": "Approuvée", + "board": "Carte", + "badgePending": "Mise à jour en attente d'approbation", + "badgeApproved": "Mise à jour approuvée", + "badgeUpToDate": "À jour", + "approveFlash": "Approuver et flasher {{version}}", + "approveConfirm": "Approuver le firmware {{version}} et le flasher à distance (OTA) ?\n\nLe HiveHub l'installe lors de sa prochaine connexion puis redémarre. Un appareil distant dont la mise à jour échoue en cours de route peut nécessiter une intervention sur place.", + "otherBoards": "Également téléversé pour une autre carte (non appliqué à cet appareil {{board}}) : {{releases}}. Une version n'atteint qu'un appareil dont la carte correspond — vérifiez la carte lors du téléversement.", + "otherBoardsUnknown": "Également téléversé pour une autre carte (non appliqué à cet appareil) : {{releases}}. Une version n'atteint qu'un appareil dont la carte correspond — vérifiez la carte lors du téléversement." + }, + "relays": { + "hiveFallback": "Ruche {{slot}}", + "latest": "dernière {{version}}", + "updateAvailable": "mise à jour disponible : {{version}}", + "status": { + "pending": "relais en file d'attente", + "running": "relais en cours…", + "completed": "relais terminé", + "failed": "échec du relais", + "expired": "relais expiré" + }, + "statusTitle": "Relais de {{version}} : {{status}} ({{date}})", + "failedDetail": "Échec du dernier relais — {{message}} ({{date}})", + "noReason": "aucune raison indiquée", + "alreadyQueued": "Un relais est déjà en file d'attente pour ce nœud", + "queueing": "Mise en file d'attente…", + "relayAnyway": "Relayer quand même", + "confirm": "Relayer {{name}} {{version}} vers {{hive}} ?\n\nLe HiveHub prend la tâche en charge lors de son prochain cycle d'envoi — normalement dans les 10 minutes environ, ou selon l'intervalle d'envoi de cet appareil. Il transmet ensuite l'image en BLE pendant quelques minutes et le nœud redémarre sur la nouvelle version. Le nœud doit être éveillé et à portée, sinon le relais échoue et doit être remis en file d'attente.", + "queued": "Relais de {{version}} mis en file d'attente pour {{hive}}.", + "queuedFrom": "Relais mis en file d'attente pour {{hive}} ({{current}} → {{version}}).", + "hiveinside": { + "name": "HiveInside", + "title": "Nœuds HiveInside", + "description": "Firmware annoncé par chaque nœud placé dans la ruche. Téléverser une image HiveInside ne fait qu'enregistrer la version — rien n'atteint un nœud tant que vous n'appuyez pas sur « Relayer vers le nœud ». La version affichée ici confirme que la mise à jour a réussi.", + "relay": "Relayer vers le nœud", + "unnamed": "N'annonce pas de nom propre (HiveInside antérieur à 0.5.0)", + "noRelease": "Aucune version HiveInside téléversée pour le moment — téléversez-en une ci-dessous pour la relayer." + }, + "beecounter": { + "name": "HiveTraffic", + "title": "Compteurs HiveTraffic", + "description": "Firmware indiqué par chaque compteur HiveTraffic. Téléverser une image HiveTraffic ne fait qu'enregistrer la version — rien n'atteint un compteur tant que vous n'appuyez pas sur « Relayer vers le compteur ». Les compteurs antérieurs à la v0.1.0 environ n'indiquent pas de version mais peuvent quand même être mis à jour.", + "relay": "Relayer vers le compteur", + "unnamed": "N'indique ni nom ni adresse", + "noRelease": "Aucune version HiveTraffic téléversée pour le moment — téléversez-en une ci-dessous pour la relayer.", + "countingWarning": "Un compteur cesse de compter les abeilles pendant tout le transfert ; relayez donc quand le trafic est faible.", + "confirmExtra": "Le compteur cesse de compter les abeilles pendant tout le transfert — le trafic de ces quelques minutes est perdu." + } + }, + "uploadForm": { + "title": "Téléverser un firmware", + "description": "Le téléversement ne fait qu'enregistrer une version — il n'installe jamais rien de lui-même. Approuvez la mise à jour du hub ci-dessus, ou relayez une version de nœud ou de compteur depuis sa ligne.", + "board": "Carte", + "targets": { + "hivehub": "Hub HiveHub", + "hiveinside": "HiveInside", + "beecounter": "Compteur HiveTraffic" + }, + "boards": { + "auto": "Auto (d'après le nom du fichier)", + "esp32": "ESP32 (classique 30 broches)", + "esp32-c6": "ESP32-C6", + "nrf54lm20a": "nRF54LM20A" + }, + "boardHint": { + "hivehub": "Le firmware du hub doit indiquer sa carte : choisissez-en une, ou laissez sur auto si le fichier est nommé comme hivehub_esp32-c6_0.30.5.bin.", + "hiveinside": "HiveInside n'existe que pour le nRF54LM20A. Téléversez le fichier de build tel quel (p. ex. hiveinside_nrf54lm20a_1.4.0.bin) : la cible et la version sont déduites de son nom.", + "beecounter": "Le compteur HiveTraffic est un ESP32-C6. Téléversez l'image de l'application (p. ex. beecounter_esp32-c6_0.7.1.bin), pas une image d'usine fusionnée." + }, + "detected": "{{name}} — {{fields}} renseigné(s).", + "nothingDetected": "{{name}} — aucune cible, carte ou version reconnue dans le nom ; indiquez-les ci-dessous.", + "detectedTarget": "cible « {{target}} »", + "detectedBoard": "carte {{board}}", + "detectedVersion": "version {{version}}", + "autoQueued": "Relais mis automatiquement en file d'attente pour {{hives}}.", + "autoQueueFailed": "Impossible de mettre automatiquement le relais en file d'attente pour {{hives}}." } }, "sdData": { @@ -262,12 +409,19 @@ "parsed_other": "Parsed {{count}} records", "skipped_one": " · {{count}} unreadable line skipped.", "skipped_other": " · {{count}} unreadable lines skipped." + }, + "mismatch": { + "title": "Fichier provenant d'un autre hub", + "description": "Ce fichier a été enregistré par un autre hub, pas par {{device}}.", + "fileDevices_one": "ID d'appareil dans le fichier :", + "fileDevices_other": "ID d'appareils dans le fichier :", + "fileDevices_many": "ID d'appareils dans le fichier :", + "hint": "Importez-le quand même uniquement si la carte SD appartient vraiment à cet appareil (par exemple après le remplacement de la carte électronique du hub).", + "cancel": "Annuler", + "force": "Importer quand même dans cet appareil" } }, "panel": { - "weight": "Weight", - "hiveTemp": "Hive temp", - "rmsSound": "RMS sound", "ambientTemperature": "Ambient temperature", "ambientHumidity": "Ambient humidity", "batteryCharge": "Battery charge", @@ -277,7 +431,6 @@ "title": "General", "description": "Ambient & power" }, - "humidity": "Hive humidity", "wirelessSensorsBattery": "Wireless sensors battery", "sensorTypes": { "hiveHeart": "HiveHeart", @@ -286,174 +439,36 @@ } }, "diagram": { - "title": "Measurements", - "subtitle": "Weight, temperature, and entrance activity over time", - "noMeasurements": "No measurements for the selected range.", - "noDataForSeries": "No data in the selected range.", - "meta": { - "device": "Device:", - "lastSeen": "Last seen:", - "lastData": "Last data:", - "firmware": "Firmware:" - }, "range": { "all": "All", "from": "From", "to": "To" }, - "series": { - "weight": "{{name}} weight", - "temp": "{{name}} temp", - "micRms": "{{name}} mic RMS", - "beesIn": "{{name}} bees in", - "beesOut": "{{name}} bees out", - "netFlow": "{{name}} net flow", - "ambientTemp": "Ambient temp", - "ambientHumidity": "Ambient humidity", - "batteryVoltage": "Battery voltage", - "batteryCharge": "Battery charge", - "solarVoltage": "Solar voltage", - "solarCurrent": "Solar current", - "solarPower": "Solar power", - "humidity": "{{name}} humidité", - "pressure": "{{name}} pression", - "fanningBand": "{{name}} fanning band (30–100 Hz)", - "activityBand": "{{name}} activity band (100–200 Hz)" - }, - "group": { - "ambient": "Ambient", - "offGrid": "Off-grid" - }, - "axis": { - "weight": "Weight", - "temperature": "Temperature", - "humidity": "Humidity", - "voltage": "Voltage", - "percent": "Percent", - "current": "Current", - "power": "Power", - "sound": "Sound", - "beecount": "Bee count", - "pressure": "Pression" - }, "marker": { - "inspection": "Inspection", - "boxAdded": "Box added", - "action": { - "maintenance": "Maintenance", - "feeding": "Feeding", - "frames": "Frames", - "treatment": "Treatment" - } - }, - "axisSettings": { - "title": "Axis settings", - "reset": "Reset", - "side": "Side", - "left": "Left", - "right": "Right", - "scale": "Scale", - "auto": "Auto", - "zeroToMax": "0 to max", - "custom": "Custom", - "min": "Min", - "max": "Max", - "autoPlaceholder": "auto" + "inspection": "Inspection" } }, "sound": { - "title": "Hive sound", - "description": "INMP441 FFT band energy (dBFS) — {{left}} (left) · {{right}} (right)", - "hide": "Hide sound", - "show": "Show sound", - "micLeftFallback": "Mic left", - "micRightFallback": "Mic right", - "noDataForChannel": "No data for {{name}} in this range.", - "bandReference": "Band reference", - "thresholds": "Thresholds used by the Insights engine: piping > −45 dBFS, stress > −38 dBFS, queenless hum > −40 dBFS with quiet piping. Calibrate against your own baseline.", - "status": { - "noData": "No data", - "micError": "Mic error", - "bothActive": "{{left}} + {{right}} active", - "oneActive": "{{name}} active" - }, - "table": { - "band": "Band", - "range": "Range", - "significance": "Significance" - }, "bands": { "subBass": { - "label": "Sub-bass", - "description": "Structural vibration / low rumble" + "label": "Sub-bass" }, "hum": { - "label": "Hum", - "description": "Normal colony hum (~200 Hz fundamental)" + "label": "Hum" }, "piping": { - "label": "Piping", - "description": "Queen piping / tooting (pre-swarm signal)" + "label": "Piping" }, "stress": { - "label": "Stress", - "description": "Agitated colony / robbing" + "label": "Stress" }, "high": { - "label": "High", - "description": "Harmonic overtones" + "label": "High" } - }, - "rmsToggleShow": "Show RMS level", - "rmsToggleHide": "Hide RMS level", - "rmsTitle": "RMS sound level", - "rmsSeries": "{{name}} RMS" - }, - "beecounter": { - "title": "Entrance Counter", - "subtitle": "Bees entering and exiting per measurement interval", - "noData": "No BeeCounter data found for this device.", - "offline": "Offline", - "gatesOk": "{{count}} gates OK", - "glitches_one": "{{count}} glitch", - "glitches_other": "{{count}} glitches", - "gatesRatio": "{{healthy}}/{{total}} gates", - "inPeriod": "In (period)", - "outPeriod": "Out (period)", - "netFlow": "Net flow", - "netInbound": "net inbound", - "netOutbound": "net outbound", - "balanced": "balanced", - "view": { - "inOut": "In / Out", - "netFlow": "Net flow", - "cumulative": "Cumulative" - }, - "series": { - "in": "{{name}} in", - "out": "{{name}} out", - "net": "{{name}} net", - "totalIn": "{{name}} total in", - "totalOut": "{{name}} total out" - }, - "diagnostics": { - "title": "Hardware diagnostics", - "gatesHealthy": "Gates healthy", - "glitchCount": "Glitch count", - "totalInLifetime": "Total in (lifetime)", - "totalOutLifetime": "Total out (lifetime)" } }, "insights": { - "title": "Insights", - "description": "Sensor-based alerts derived from weight and temperature trends.", - "updated": "Updated {{ago}}.", - "refresh": "Refresh insights", - "loadError": "Failed to load insights: {{message}}", "allClear": "All clear", - "noActiveAlerts": "No active alerts in the last {{count}} days.", - "activeAlerts_one": "{{count}} active alert:", - "activeAlerts_other": "{{count}} active alerts:", "confidence": "Confidence {{value}}%", "source": "Source: {{source}}", "hiveChannel": "Hive {{channel}}", @@ -538,5 +553,740 @@ "description": "No alerts have been recorded for this device.", "descriptionFiltered": "No alerts have been recorded for this device matching this filter." } + }, + "inspectionMode": { + "title": "Mode inspection", + "description": "Tant que le mode inspection est activé, HiveHub continue de mesurer mais exclut les relevés des graphiques, analyses et alertes, pour que le retrait des cadres ne soit pas pris pour un essaimage.", + "status": { + "active": "Actif depuis {{time}}", + "pending": "En attente de prise en charge par le hub", + "off": "Désactivé" + }, + "pendingHint": "Demandé — le hub le prend en charge lors de sa prochaine connexion.", + "timeoutNote_one": "Se termine automatiquement après {{count}} minute si personne ne l'arrête.", + "timeoutNote_other": "Se termine automatiquement après {{count}} minutes si personne ne l'arrête.", + "timeoutNote_many": "Se termine automatiquement après {{count}} minutes si personne ne l'arrête.", + "scope": { + "wholeHub": "Tout le hub", + "label": "Concerne : {{hives}}" + }, + "start": "Démarrer le mode inspection", + "stop": "Arrêter le mode inspection", + "starting": "Démarrage…", + "stopping": "Arrêt…", + "cancel": "Annuler", + "noteLabel": "Note (facultative)", + "notePlaceholder": "p. ex. Hausse ajoutée", + "viewerHint": "Seuls les propriétaires et administrateurs peuvent démarrer ou arrêter le mode inspection.", + "loadFailed": "Impossible de charger l'état de l'inspection.", + "startDialog": { + "title": "Démarrer le mode inspection", + "description": "Les relevés des ruches sélectionnées sont masqués jusqu'à ce que vous arrêtiez le mode inspection ou qu'il expire.", + "hivesLabel": "Ruches", + "wholeHub": "Tout le hub (toutes les ruches)", + "selectHive": "Choisissez au moins une ruche, ou tout le hub.", + "confirm": "Démarrer" + }, + "stopDialog": { + "title": "Arrêter le mode inspection", + "description": "HiveHub revient aux relevés et alertes normaux.", + "confirm": "Arrêter" + }, + "toast": { + "started": "Mode inspection démarré", + "stopped": "Mode inspection arrêté", + "noteSaved": "Note enregistrée" + }, + "history": { + "title": "Inspections récentes", + "empty": "Aucune inspection enregistrée pour le moment.", + "ongoing": "en cours", + "startedVia": "Démarrée via {{source}}", + "ended": "Terminée : {{reason}}", + "editNote": "Modifier la note", + "addNote": "Ajouter une note", + "saveNote": "Enregistrer" + }, + "duration": { + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "minutes_many": "{{count}} min", + "hoursMinutes": "{{hours}} h {{minutes}} min" + }, + "source": { + "device": "bouton du hub", + "api": "application", + "dashboard": "tableau de bord local" + }, + "endReason": { + "timeout": "délai expiré", + "device": "bouton du hub", + "api": "arrêtée dans l'application", + "dashboard": "arrêtée sur le tableau de bord local" + } + }, + "hiveLink": { + "prompt": { + "message": "Cette ruche est surveillée par le HiveHub {{device}} ({{hive}}). Démarrez le mode inspection pour que l'ouverture de la ruche ne déclenche pas de fausses alertes.", + "active": "Le mode inspection est activé pour {{hive}} sur le HiveHub {{device}}.", + "pending": "Mode inspection demandé pour {{hive}} sur le HiveHub {{device}} — le hub le prend en charge lors de sa prochaine connexion.", + "activeOther": "Le mode inspection est activé pour d'autres ruches sur le HiveHub {{device}}, mais pas pour {{hive}}. Arrêtez-le d'abord pour en démarrer un pour cette ruche." + }, + "card": { + "title": "HiveHub", + "description": "Relevés en direct des capteurs HiveHub reliés.", + "weight": "Poids", + "weightChange24h": "Variation sur 24 h", + "temperature": "Température de la ruche", + "humidity": "Humidité", + "lastReading": "Dernier relevé : {{time}}", + "openHiveHub": "Ouvrir HiveHub" + } + }, + "traffic": { + "title": "Compteurs d'abeilles HiveTraffic", + "description": "Mode nuit et banques d'émetteurs de chaque compteur HiveTraffic appairé à ce hub. Les émetteurs IR représentent de loin la plus grande part de la consommation d'un compteur ; ces réglages décident donc s'il peut fonctionner sur une alimentation autonome.", + "readOnly": "Seuls les propriétaires et administrateurs de l'appareil peuvent modifier ces réglages. Votre rôle est « {{role}} ».", + "hiveFallback": "Ruche {{slot}}", + "noChanges": "Aucune modification à enregistrer", + "saved": "Réglages des compteurs d'abeilles enregistrés. Les compteurs les récupèrent lors de la prochaine connexion.", + "appliesToAll": "S'applique à chaque compteur HiveTraffic appairé à cet appareil. Le compteur est informé lors du prochain cycle d'envoi du hub.", + "status": { + "title": "Compteurs en ce moment", + "none": "Aucun compteur HiveTraffic dans le dernier relevé.", + "counting": "Comptage", + "nightIdle": "Veille de nuit", + "nightIdleFor_one": "Veille de nuit · encore {{count}} min", + "nightIdleFor_other": "Veille de nuit · encore {{count}} min", + "nightIdleFor_many": "Veille de nuit · encore {{count}} min", + "banksRunning": "Banques d'émetteurs actives : {{banks}}", + "banksUnknown": "Banques d'émetteurs non indiquées (firmware du compteur antérieur à 0.3.0)", + "applied": "Banques appliquées", + "pending": "Banques en attente de la prochaine connexion" + }, + "night": { + "title": "Mode nuit", + "description": "Les abeilles mellifères sont diurnes — le vol nécessite de la lumière et cesse de toute façon en dessous d'environ 10 °C. Pendant cette plage, le compteur arrête la détection pour économiser l'énergie. Il continue d'émettre ses annonces, donc les relevés et les mises à jour du firmware fonctionnent toujours.", + "enable": "Activer le mode nuit", + "start": "Début de la nuit (heure locale)", + "end": "Fin de la nuit (heure locale)", + "windowHelp": "La plage peut passer minuit (p. ex. 20:00 → 06:00). Définir le début et la fin à la même heure rend la plage vide — elle ne s'applique jamais — au lieu de couvrir toute la journée. Gardez une marge autour du crépuscule et de l'aube : l'activité culmine juste avant le coucher et le lever du soleil.", + "emptyWindow": "Le début et la fin sont identiques ; la plage est donc vide et le mode nuit ne s'appliquerait jamais.", + "wrapsMidnight": "La plage passe minuit : de {{start}} jusqu'à {{end}} le lendemain matin.", + "timezone": "Fuseau horaire", + "tzCustom": "Chaîne TZ POSIX", + "timezoneHelp": "L'horloge du hub fonctionne en UTC. Sans le bon fuseau horaire, la plage est décalée de votre écart avec l'UTC et se décale d'une heure à chaque changement d'heure.", + "tzPresets": { + "cet": "Europe centrale (Berlin, Paris, Madrid, Vienne)", + "uk": "Royaume-Uni / Irlande", + "eet": "Europe de l'Est (Helsinki, Athènes)", + "usEastern": "États-Unis — Est", + "usCentral": "États-Unis — Centre", + "usMountain": "États-Unis — Rocheuses", + "usPacific": "États-Unis — Pacifique", + "utc": "UTC (pas d'heure locale)", + "custom": "Personnalisé…" + }, + "maxTraffic": "Reporter au-delà de (passages par cycle)", + "maxTrafficHelp": "Reporte le mode nuit tant que plus de passages que ce nombre (entrées + sorties) ont été comptés lors du dernier cycle d'envoi, pour ne pas couper une colonie qui vole encore tard un soir d'été. 0 = uniquement selon l'heure.", + "save": "Enregistrer le mode nuit" + }, + "banks": { + "title": "Banques d'émetteurs", + "description": "Les émetteurs IR du compteur sont commutés en trois banques de huit portes. Chaque banque consomme environ 80 mA. Désactivez-en une si l'entrée fait moins de 24 portes, si une partie est fermée ou si l'alimentation ne peut pas supporter toute la carte.", + "bank": "Banque {{bank}} — portes {{gates}}", + "current_one": "{{count}} banque active · environ {{ma}} mA sur le rail 3,3 V du compteur", + "current_other": "{{count}} banques actives · environ {{ma}} mA sur le rail 3,3 V du compteur", + "current_many": "{{count}} banques actives · environ {{ma}} mA sur le rail 3,3 V du compteur", + "help": "Une banque désactivée ne compte plus du tout ses portes, les totaux baissent donc à peu près proportionnellement — ne comparez pas un compteur réduit avec son propre historique. Au moins une banque doit rester active ; désappairez un compteur qui ne doit rien compter.", + "firmwareNote": "Nécessite le firmware de compteur 0.3.0 ou plus récent. Le réglage est renvoyé après chaque réinitialisation du compteur.", + "save": "Enregistrer les banques d'émetteurs" + }, + "errors": { + "loadFailed": "Impossible de charger la configuration de l'appareil.", + "saveFailed": "Impossible d'enregistrer les réglages des compteurs d'abeilles", + "invalidTime": "Saisissez une heure de début et de fin valide (HH:MM).", + "invalidMaxTraffic": "Le seuil de report doit être un nombre entier supérieur ou égal à 0.", + "emptyWindow": "Le début et la fin du mode nuit sont identiques — la plage ne s'appliquerait jamais.", + "noBank": "Au moins une banque d'émetteurs doit rester active — désappairez plutôt le compteur." + } + }, + "hubAccess": { + "title": "Accès au hub", + "description": "Accédez au hub sur place sans ouvrir le boîtier, et définissez la durée maximale d'une inspection.", + "readOnly": "Seuls les propriétaires et administrateurs de l'appareil peuvent modifier ces réglages. Votre rôle est « {{role}} ».", + "noChanges": "Aucune modification à enregistrer", + "cancel": "Annuler", + "ap": { + "title": "Point d'accès de configuration", + "description": "Ouvre le réseau Wi-Fi HiveHub-Setup du hub, comme un appui court sur le bouton de configuration : changer de Wi-Fi, appairer des capteurs ou télécharger les données de la carte SD.", + "lastCheckIn": "Dernière connexion : {{time}}", + "open": "Ouvrir le point d'accès de configuration", + "confirmTitle": "Ouvrir le point d'accès de configuration ?", + "confirmTiming_one": "La demande est mise en file d'attente. Le hub est en veille entre les cycles ; le point d'accès s'ouvre donc après sa prochaine connexion — dans {{count}} minute au plus.", + "confirmTiming_other": "La demande est mise en file d'attente. Le hub est en veille entre les cycles ; le point d'accès s'ouvre donc après sa prochaine connexion — dans {{count}} minutes au plus.", + "confirmTiming_many": "La demande est mise en file d'attente. Le hub est en veille entre les cycles ; le point d'accès s'ouvre donc après sa prochaine connexion — dans {{count}} minutes au plus.", + "confirmTimingUnknown": "La demande est mise en file d'attente. Le hub est en veille entre les cycles ; le point d'accès s'ouvre donc après sa prochaine connexion — au plus tard dans un intervalle d'envoi.", + "confirmOffline": "Tant que le point d'accès est ouvert, le hub est déconnecté du Wi-Fi et n'envoie aucun relevé.", + "confirmTimeout": "Il se ferme de lui-même à l'expiration du délai du portail de configuration si personne ne s'y connecte ; ne le lancez donc que lorsque quelqu'un est au rucher.", + "confirm": "Mettre le point d'accès en file d'attente", + "queueing": "Mise en file d'attente…", + "queued": "Point d'accès mis en file d'attente. Il s'ouvre après la prochaine connexion du hub.", + "failed": "Impossible de mettre le point d'accès en file d'attente" + }, + "inspection": { + "title": "Délai d'inspection", + "description": "Une sécurité : une inspection se termine d'elle-même après ce nombre de minutes, pour qu'un appui oublié sur le bouton ne masque pas indéfiniment les données d'une ruche. Par défaut 60.", + "label": "Délai (minutes, 1–1440)", + "save": "Enregistrer le délai", + "saved": "Délai d'inspection enregistré", + "saveFailed": "Impossible d'enregistrer le délai d'inspection", + "invalid": "Saisissez un nombre entier de minutes entre 1 et 1440." + } + }, + "dataTools": { + "title": "Export et nettoyage des données", + "description": "Sauvegardez les relevés de ce hub ou supprimez les relevés erronés.", + "hiveFallback": "Ruche {{slot}}", + "cancel": "Annuler", + "export": { + "title": "Exporter les relevés", + "description": "Téléchargez les relevés enregistrés sous forme de sauvegarde NDJSON.", + "from": "Du (facultatif)", + "to": "Au (facultatif)", + "rangeHelp": "Laissez les deux vides pour tout exporter. Les dates couvrent des journées entières dans votre heure locale.", + "hives": "Ruches (facultatif)", + "allHives": "Toutes les ruches sont incluses.", + "someHives_one": "Seule {{count}} ruche sélectionnée est incluse. Le nombre ci-dessous porte sur tous les relevés de la période.", + "someHives_other": "Seules {{count}} ruches sélectionnées sont incluses. Le nombre ci-dessous porte sur tous les relevés de la période.", + "someHives_many": "Seules {{count}} ruches sélectionnées sont incluses. Le nombre ci-dessous porte sur tous les relevés de la période.", + "invalidRange": "La date de début est postérieure à la date de fin.", + "summaryFailed": "Impossible de compter les relevés.", + "count_one": "{{count}} relevé", + "count_other": "{{count}} relevés", + "count_many": "{{count}} relevés", + "span": "Du {{first}} au {{last}}", + "download": "Télécharger", + "formatHelp": "Un relevé JSON par ligne. Le fichier peut être réimporté avec l'import de carte SD.", + "noPermission": "Seuls les propriétaires et administrateurs de l'appareil peuvent exporter des données. Votre rôle est « {{role}} »." + }, + "delete": { + "title": "Supprimer les relevés d'une période", + "description": "Supprime les relevés aberrants — par exemple les pics de poids qu'un hub enregistre au démarrage avant l'étalonnage de ses balances, qui faussent sinon les graphiques et les analyses.", + "warningTitle": "Cette action est irréversible", + "warning": "Les relevés supprimés sont définitivement perdus. Exportez d'abord la période si vous pourriez en avoir besoin.", + "start": "Début", + "end": "Fin", + "claimCode": "Code d'association", + "claimCodeHelp": "Le code d'association du hub, comme seconde confirmation.", + "review": "Vérifier la suppression", + "confirmTitle": "Supprimer ces relevés ?", + "confirmRange": "Du {{start}} au {{end}}.", + "confirmCount_one": "{{count}} relevé sera définitivement supprimé.", + "confirmCount_other": "{{count}} relevés seront définitivement supprimés.", + "confirmCount_many": "{{count}} relevés seront définitivement supprimés.", + "confirmIrreversible": "Cette action est irréversible.", + "countFailed": "Impossible de compter les relevés de cette période.", + "confirm_one": "Supprimer {{count}} relevé", + "confirm_other": "Supprimer {{count}} relevés", + "confirm_many": "Supprimer {{count}} relevés", + "deleting": "Suppression…", + "success_one": "{{count}} relevé supprimé.", + "success_other": "{{count}} relevés supprimés.", + "success_many": "{{count}} relevés supprimés.", + "noPermission": "Seul le propriétaire de l'appareil peut supprimer des relevés. Votre rôle est « {{role}} ».", + "errors": { + "missingRange": "Choisissez un début et une fin.", + "invalidRange": "La fin doit être identique ou postérieure au début.", + "missingClaimCode": "Saisissez le code d'association du hub." + } + } + }, + "alertSettings": { + "title": "Alertes HiveHub par e-mail", + "description": "Recevez un e-mail quand HiveHub détecte quelque chose dans vos ruches — essaimage, pillage, colonie orpheline et plus encore.", + "enable": "M'envoyer un e-mail pour les alertes HiveHub", + "minSeverity": "Gravité minimale", + "minSeverityHelp": "Les alertes en dessous de ce niveau ne sont jamais envoyées par e-mail.", + "severity": { + "watch": "Surveillance et plus", + "warning": "Avertissement et plus", + "critical": "Critique uniquement" + }, + "help": { + "schedule": "HivePal vérifie les nouvelles alertes toutes les 15 minutes.", + "once": "Chaque alerte est envoyée une fois lorsqu'elle apparaît, puis de nouveau uniquement si elle s'aggrave.", + "scope": "Couvre tous les appareils HiveHub auxquels vous avez accès.", + "email": "Nécessite que l'e-mail soit configuré sur le serveur HivePal." + }, + "saved": "Réglages des alertes enregistrés", + "saveFailed": "Impossible d'enregistrer les réglages des alertes", + "loadFailed": "Impossible de charger vos préférences." + }, + "health": { + "title": "État des capteurs", + "description": "État de chaque capteur tel que remonté par {{name}}.", + "descriptionAt": "État des capteurs lors de la dernière connexion · {{ago}}.", + "noData": "Aucun état de capteur remonté pour le moment — il apparaît dès que l'appareil envoie son premier relevé.", + "hubHeading": "Hub", + "hiveIndex": "Ruche {{n}}", + "noHiveSensors": "Aucun capteur remonté pour cette ruche.", + "faultNote": "Le hub remonte ses propres sous-systèmes en réussite/échec — il n'envoie aucun code d'erreur pour eux ; chaque défaut ci-dessus indique donc ce qui a échoué. Le compteur d'entrée, lui, remonte un code d'état, affiché avec le défaut chaque fois qu'il a été lu.", + "summary": { + "noData": "Aucune donnée de capteur", + "allOk": "Tout est OK", + "faults_one": "{{count}} défaut", + "faults_other": "{{count}} défauts", + "faults_many": "{{count}} défauts" + }, + "state": { + "ok": "OK", + "fault": "Défaut", + "notFitted": "Non installé", + "notHeard": "Non détecté" + }, + "hub": { + "sht_ok": "Température/humidité ambiantes (SHT40)", + "rtc_ok": "Horloge temps réel", + "sd_ok": "Carte SD", + "mic_ok": "Microphone", + "mic_left_ok": "Microphone · gauche", + "mic_right_ok": "Microphone · droit", + "battery_monitor_ok": "Surveillance de la batterie", + "solar_monitor_ok": "Surveillance solaire" + }, + "hubReason": { + "sht_ok": "Le capteur ambiant n'a pas fourni de relevé valide pendant ce cycle ; cette connexion ne contient donc ni température ni humidité ambiantes. Il s'agit généralement d'un défaut du bus I²C ou du câblage ; un capteur jamais détecté au démarrage donne le même résultat.", + "rtc_ok": "L'horloge temps réel n'a pas répondu sur le bus I²C. Les horodatages se rabattent sur l'heure réseau ; un relevé pris hors ligne peut donc être mal daté.", + "sd_ok": "La carte SD n'a pas été montée pendant ce cycle — absente, illisible ou défaut SPI/câblage. Les relevés ne sont pas mis en mémoire tampon sur la carte tant que cela persiste.", + "mic_ok": "La capture du microphone n'a renvoyé aucun échantillon exploitable ; cette connexion ne contient donc aucune bande acoustique.", + "mic_left_ok": "Le microphone gauche n'a renvoyé aucun échantillon exploitable.", + "mic_right_ok": "Le microphone droit n'a renvoyé aucun échantillon exploitable.", + "battery_monitor_ok": "La jauge de batterie n'a pas répondu sur le bus I²C — la tension et l'état de charge de la batterie manquent dans cette connexion.", + "solar_monitor_ok": "La surveillance solaire n'a pas répondu sur le bus I²C — la tension, le courant et la puissance solaires manquent dans cette connexion." + }, + "sensor": { + "scale": "Balance", + "inHive": "Capteur intérieur", + "counter": "Compteur HiveTraffic", + "hiveheart": "HiveHeart" + }, + "reason": { + "scale": "Une balance est configurée pour cette ruche mais n'a produit aucun relevé exploitable pendant ce cycle ; cette connexion ne contient donc aucun poids pour elle.", + "inHive": "Aucune annonce du {{kind}} appairé lors du scan BLE de ce cycle — hors de portée, batterie vide ou éteint.", + "inHiveKindFallback": "capteur intérieur", + "node": "Nœud {{node}}.", + "lastHeard": "Dernière détection {{ago}}.", + "notHeardInRange": "Non détecté sur la période chargée.", + "counter": "Le compteur d'entrée n'a pas répondu à sa lecture GATT pendant ce cycle — hors de portée, éteint ou occupé lorsque HiveHub l'a appelé.", + "counterNode": "Compteur {{node}}.", + "lastAnswered": "Dernière réponse {{ago}}.", + "notAnsweredInRange": "Il n'a pas répondu sur la période chargée.", + "statusThen": "État à ce moment : {{status}}.", + "expandersThen_one": "{{count}} extenseur de ports sur 3 répondait alors.", + "expandersThen_other": "{{count}} extenseurs de ports sur 3 répondaient alors.", + "expandersThen_many": "{{count}} extenseurs de ports sur 3 répondaient alors.", + "hiveheart": "Le HiveHeart n'a pas été détecté pendant ce cycle — hors de portée, batterie vide ou éteint." + }, + "counter": { + "nightIdle": "veille du mode nuit", + "nightIdleBadge": "Veille de nuit", + "otherBits": "bits propres au compteur {{hex}}", + "noFlags": "aucun indicateur activé", + "statusCode": "état {{hex}}", + "expanders_one": "{{count}}/3 extenseur de portes", + "expanders_other": "{{count}}/3 extenseurs de portes", + "expanders_many": "{{count}}/3 extenseurs de portes", + "glitches_one": "{{count}} anomalie", + "glitches_other": "{{count}} anomalies", + "glitches_many": "{{count}} anomalies", + "banks": "Banques {{list}}", + "noBanks": "Aucune banque d'émetteurs" + }, + "meta": { + "firmware": "v{{version}}", + "batteryPercent": "Batterie {{value}} %", + "batteryMv": "Batterie {{value}} mV", + "batteryV": "Batterie {{value}} V", + "rssi": "{{value}} dBm" + } + }, + "hubStatus": { + "title": "État du hub", + "description": "Liaison, horloge et alimentation du HiveHub lui-même.", + "noData": "Aucun relevé reçu de ce hub pour le moment.", + "staleWarning": "Aucun relevé depuis {{ago}} — le hub a manqué plusieurs connexions.", + "badge": { + "live": "En direct", + "stale": "Obsolète", + "inspection": "Inspection en cours", + "calibration": "Mode étalonnage" + }, + "section": { + "connectivity": "Connectivité", + "battery": "Batterie", + "solar": "Solaire" + }, + "row": { + "signal": "Signal", + "transport": "Transport", + "timeSource": "Source horaire", + "lastSeen": "Dernière connexion", + "lastReading": "Dernier relevé", + "firmware": "Firmware", + "bootCount": "Nombre de démarrages", + "stateOfCharge": "État de charge", + "batteryVoltage": "Tension", + "batteryAlert": "Alerte batterie", + "solarPower": "Puissance solaire", + "solarCurrent": "Courant solaire", + "solarBus": "Tension du panneau (bus)", + "solarLoad": "Tension de charge" + }, + "signalQuality": { + "excellent": "Excellent", + "good": "Bon", + "fair": "Moyen", + "poor": "Faible" + }, + "transport": { + "wifi": "Wi-Fi", + "cellular": "Cellulaire" + }, + "rtc": { + "ok": "RTC OK", + "fault": "Défaut RTC" + }, + "batteryAlertActive": "Batterie faible" + }, + "hiveCards": { + "empty": "Aucune ruche remontée par cet appareil pour le moment.", + "hiveIndex": "Ruche {{n}}", + "weight": "Poids", + "temperature": "Température", + "humidity": "Humidité", + "delta24h": "24 h {{value}} kg", + "lastReported": "Dernière remontée {{ago}}", + "inspection": "Inspection", + "insightsLoading": "Chargement des analyses", + "insightsUnavailable": "Analyses indisponibles", + "noSensors": "Aucun capteur remonté", + "traffic": "Entrées {{in}} · Sorties {{out}}", + "trafficTitle": "Abeilles entrées/sorties pendant le dernier intervalle", + "alerts_one": "{{count}} alerte active", + "alerts_other": "{{count}} alertes actives", + "alerts_many": "{{count}} alertes actives", + "sensor": { + "scale": "Balance", + "inHive": "Capteur intérieur", + "counter": "Compteur HiveTraffic", + "hiveheart": "HiveHeart" + }, + "state": { + "ok": "OK", + "fault": "Non détecté / défaut" + } + }, + "audio": { + "title": "Audio de la ruche", + "description": "Demandez au nœud HiveInside d'une ruche d'enregistrer, puis écoutez l'enregistrement ici. Le hub récupère la demande à son prochain réveil ; un enregistrement apparaît donc dans un intervalle d'envoi plutôt qu'immédiatement.", + "noNodes": "Aucun nœud HiveInside n'est appairé à ce hub. Seul HiveInside dispose d'un microphone — une balise HolyIot ou RuuviTag ne peut pas enregistrer.", + "hive": "Ruche", + "hiveFallback": "Ruche {{slot}}", + "length": "Durée", + "durationOption_one": "{{count}} seconde", + "durationOption_other": "{{count}} secondes", + "durationOption_many": "{{count}} secondes", + "gain": "Gain", + "gainValue": "{{gain}} dB", + "record": "Enregistrer", + "nextWake": "Parvient au hub à son prochain réveil.", + "viewerNote": "Un propriétaire ou un administrateur peut demander un enregistrement.", + "lengthHint": "Plus court est généralement mieux : 10 à 20 secondes suffisent pour entendre ce qu'une analyse a signalé, et cela coûte moins de batterie et moins de temps hors ligne pour la ruche — un nœud connecté n'émet plus d'annonces et ne mesure plus.", + "requestSuccess": "Enregistrement demandé. La demande parvient au hub à son prochain réveil.", + "requestError": "La demande n'a pas pu être mise en file d'attente : {{message}}", + "listTitle": "Enregistrements", + "onlySelectedHive": "Uniquement {{name}}", + "loadError": "Impossible de lister les enregistrements : {{message}}", + "empty": "Aucun enregistrement pour le moment. Demandez à une ruche d'enregistrer et il apparaîtra ici. Une session correspond à 10–60 secondes d'audio 16 kHz récupérées du nœud en BLE.", + "requestedLength_one": "{{count}} s demandée", + "requestedLength_other": "{{count}} s demandées", + "requestedLength_many": "{{count}} s demandées", + "requestedBy": "Demandé par {{name}}", + "status": { + "requested": "En attente du hub", + "streaming": "Enregistrement…", + "ready": "Prêt", + "readyIncomplete": "Prêt, incomplet", + "failed": "Échec" + }, + "waitingNote": "En attente du réveil du hub pour prendre en charge la demande — rien n'est encore enregistré. Une demande que le hub n'a pas récupérée dans les 45 minutes est marquée en échec.", + "streamingNote": "Enregistrement en cours — l'audio apparaît ici au fur et à mesure de son arrivée.", + "streamingPartial": "Enregistrement toujours en cours — voici ce qui est arrivé jusqu'ici. Relancez la lecture à la fin de la session pour tout entendre.", + "failedFallback": "Cet enregistrement a échoué.", + "seconds": "{{value}} s", + "bytes": "{{value}} B", + "percent": "{{value}} %", + "download": "Télécharger le WAV", + "delete": "Supprimer", + "deleteConfirm": "Supprimer cet enregistrement ? Rien d'autre ne le fera — il n'y a pas de nettoyage automatique.", + "deleteSuccess": "Enregistrement supprimé.", + "deleteError": "Impossible de supprimer l'enregistrement : {{message}}", + "confirmation": { + "verified": "vérifié", + "reported": "non vérifié", + "confirmed": "confirmé par le hub", + "unknown": "non confirmé" + }, + "quality": { + "length": "Durée", + "dropped": "Perdus", + "gaps": "Trous", + "hubBuffer": "Tampon du hub", + "clipping": "Saturation", + "checksum": "Somme de contrôle", + "cause": { + "dropped": "le nœud n'a pas pu transmettre l'audio assez vite", + "gaps": "des paquets ont été perdus lors de la transmission radio", + "hubBuffer": "l'envoi du hub a pris du retard sur son tampon" + }, + "gapNote": "L'audio de part et d'autre d'un trou est réel, mais la jonction ne l'est pas. Cause : {{causes}}.", + "clippingNote": "Baissez le gain pour un enregistrement plus propre.", + "unknownNote": "Le hub n'a jamais rendu compte de cette session ; rien n'a donc vérifié si l'audio est complet. Ce qui est présent est lisible." + } + }, + "dashboard": { + "hiveFallback": "Ruche {{index}}", + "units": { + "bees": "abeilles" + }, + "range": { + "24h": "24 h", + "7d": "7 j", + "30d": "30 j", + "365d": "365 j", + "custom": "Personnalisé", + "invalid": "La date de début ne doit pas être postérieure à la date de fin." + }, + "header": { + "title": "Mon tableau de bord", + "description": "Widgets configurables. Les ruches sélectionnées ci-dessus pilotent les graphiques par ruche.", + "editDescription": "Mode édition : faites glisser la poignée pour déplacer les widgets et le coin pour les redimensionner sur une grille de 4 colonnes.", + "edit": "Modifier le tableau de bord", + "done": "Terminé", + "addWidget": "Ajouter un widget", + "reset": "Réinitialiser", + "editBanner": "Le mode édition du tableau de bord est actif. Utilisez la poignée dans l'en-tête de chaque widget pour le déplacer, et faites glisser le coin inférieur droit pour modifier la largeur ou la hauteur. Les changements de disposition sont enregistrés automatiquement pour cet appareil HiveHub.", + "dropEnd": "Déposez ici pour déplacer un widget à la fin du tableau de bord.", + "noWidgets": "Aucun widget pour le moment. Ajoutez un modèle pour construire votre tableau de bord.", + "restoreDefaults": "Rétablir les valeurs par défaut" + }, + "overview": { + "title": "Vue d'ensemble des ruches", + "description": "État résumé des ruches attribuées. Sélectionnez des tuiles pour piloter les widgets du tableau de bord ci-dessous.", + "lastData": "Dernières données", + "hivesOnline": "Ruches en ligne", + "hubFirmware": "FW HiveHub", + "hiveInsideFirmware": "FW HiveInside", + "selected_one": "{{count}} sélectionnée", + "selected_other": "{{count}} sélectionnées", + "selected_many": "{{count}} sélectionnées", + "hide": "Masquer la vue d'ensemble", + "show": "Afficher la vue d'ensemble", + "slot": "Emplacement {{index}}", + "ok": "OK", + "noData": "Aucune donnée", + "weight": "Poids", + "temperature": "Temp.", + "humidity": "HR", + "empty": "Attribuez les emplacements HiveHub à des ruches HivePal dans le panneau de configuration de l'appareil pour afficher ici les tuiles de vue d'ensemble." + }, + "sensors": { + "scale": "balance", + "inHive": "intérieur", + "accel": "accél.", + "counter": "compteur", + "none": "aucun capteur" + }, + "widgetShell": { + "move": "Déplacer {{title}}", + "moveHint": "Faites glisser pour déplacer ce widget", + "remove": "Retirer {{title}}", + "resize": "Redimensionner {{title}}", + "resizeHint": "Faites glisser pour redimensionner ce widget" + }, + "addWidget": { + "title": "Ajouter un widget", + "description": "Partez d'un modèle. Le widget utilisera les ruches sélectionnées dans la grille de vue d'ensemble, le cas échéant.", + "defaultSize": "Taille par défaut {{w}} x {{h}}" + }, + "widgets": { + "weightComparison": { + "title": "Comparaison des poids", + "description": "Comparez les ruches sélectionnées sur la période actuelle." + }, + "dailyMaxWeight": { + "title": "Poids maximal journalier", + "description": "Poids compensé le plus élevé par jour pour les ruches sélectionnées." + }, + "climate": { + "title": "Climat de la ruche", + "description": "Température et humidité des ruches sélectionnées." + }, + "power": { + "title": "État de l'alimentation", + "description": "Charge de la batterie, tension de la batterie et apport solaire." + }, + "beeTraffic": { + "title": "Trafic des abeilles", + "description": "Trafic cumulé entrées/sorties et flux net." + }, + "soundRms": { + "title": "Son / bandes acoustiques", + "description": "RMS acoustique par ruche et bandes FFT/HiveHeart si disponibles." + }, + "hiveHeartSpectrum": { + "title": "Spectre HiveHeart", + "description": "Derniers niveaux FFT HiveHeart (0–15) sur 16 plages de fréquences pour les ruches sélectionnées." + }, + "vibration": { + "title": "Bandes de vibration", + "description": "Vibration RMS et bandes d'essaimage, de ventilation et d'activité." + }, + "configurableDiagram": { + "title": "Diagramme configurable", + "description": "Choisissez les métriques de ruche, de son, de trafic, de climat et d'appareil à tracer." + }, + "temperatureHeatmap": { + "title": "Carte thermique des températures", + "description": "Vue d'ensemble compacte des températures de toutes les ruches." + }, + "insights": { + "title": "Fil des analyses", + "description": "Alertes actives et extraits d'indices." + }, + "dataQuality": { + "title": "Qualité des données", + "description": "Disponibilité des capteurs et relevés manquants." + } + }, + "metricGroups": { + "scale": "Balance", + "climate": "Climat", + "beeTraffic": "Trafic des abeilles", + "vibration": "Vibration", + "sound": "Son", + "device": "Appareil" + }, + "metrics": { + "weight": "Poids", + "temperature": "Température", + "humidity": "Humidité", + "pressure": "Pression", + "beeIn": "Abeilles entrantes", + "beeOut": "Abeilles sortantes", + "beeNet": "Flux net", + "vibration": "Vibration RMS", + "swarmBand": "Bande d'essaimage", + "fanningBand": "Bande de ventilation", + "activityBand": "Bande d'activité", + "soundRms": "Son RMS", + "soundHum": "Bande de bourdonnement", + "soundPiping": "Bande de chant des reines", + "soundStress": "Bande de stress", + "hiveHeartFrequency": "Fréquence HiveHeart", + "hiveHeartEnergy": "Énergie HiveHeart", + "batterySoc": "Charge de la batterie", + "batteryVoltage": "Tension de la batterie", + "solarPower": "Puissance solaire" + }, + "axes": { + "weight": "Poids", + "temperature": "Température", + "humidity": "Humidité", + "pressure": "Pression", + "beecount": "Nombre d'abeilles", + "vibration": "Vibration", + "dbfs": "Niveau sonore", + "frequency": "Fréquence", + "energy": "Énergie", + "percent": "Charge", + "voltage": "Tension", + "power": "Puissance" + }, + "soundMetrics": { + "rms": "RMS", + "hiveHeartFrequency": "Fréquence HiveHeart", + "hiveHeartEnergy": "Énergie HiveHeart", + "hiveHeartPeak": "Pic HiveHeart" + }, + "vibrationMetrics": { + "rms": "RMS", + "swarmBand": "Bande d'essaimage", + "fanningBand": "Bande de ventilation", + "activityBand": "Bande d'activité" + }, + "series": { + "temperature": "{{name}} temp.", + "humidity": "{{name}} HR", + "beeIn": "Entrées", + "beeOut": "Sorties", + "beeNet": "Net" + }, + "axisScale": { + "auto": "auto", + "setBound": "Définir {{bound}} de {{axis}}", + "clearHint": "Laissez vide et appuyez sur Entrée pour auto", + "tickTitle": "Cliquez pour définir {{bound}} ; videz pour auto", + "reset": "réinitialiser", + "help": "Cliquez sur l'étiquette la plus basse ou la plus haute de l'axe Y pour la modifier. Videz la valeur pour auto." + }, + "chart": { + "downloadCsv": "Télécharger en CSV" + }, + "inspection": { + "legendHint_one": "{{count}} période d'inspection grisée ; les relevés peuvent être perturbés", + "legendHint_other": "{{count}} périodes d'inspection grisées ; les relevés peuvent être perturbés", + "legendHint_many": "{{count}} périodes d'inspection grisées ; les relevés peuvent être perturbés" + }, + "empty": { + "noData": "Aucune donnée pour les ruches et la période sélectionnées.", + "climate": "Aucune donnée climatique pour la période sélectionnée.", + "power": "Aucune donnée d'alimentation pour la période sélectionnée.", + "beeTraffic": "Aucune donnée de compteur d'abeilles pour les ruches sélectionnées.", + "sound": "Aucune donnée acoustique par ruche pour les ruches sélectionnées.", + "vibration": "Aucune donnée de vibration ou de bandes d'accéléromètre pour les ruches sélectionnées.", + "configurable": "Sélectionnez au moins une métrique ayant des données pour les ruches et la période sélectionnées.", + "dailyMaxWeight": "Aucune donnée de poids pour les ruches et la période sélectionnées.", + "hiveHeart": "Aucune donnée FFT HiveHeart pour les ruches et la période sélectionnées.", + "unknownWidget": "Type de widget inconnu." + }, + "soundFootnote": "Affiche d'abord les relevés acoustiques par ruche. Les anciennes données des microphones gauche/droit sont associées aux noms de ruche configurés pour les emplacements 1 et 2 lorsqu'aucun bloc acoustique par ruche n'est présent.", + "heatmap": { + "low": "bas <33 °C", + "optimal": "couvain optimal 33–36 °C", + "high": "haut >36 °C" + }, + "insights": { + "loading": "Chargement des analyses…", + "unavailable": "Les analyses sont indisponibles.", + "noAlerts": "Aucune alerte active pour les ruches sélectionnées.", + "showing_one": "{{count}} alerte active affichée pour les ruches sélectionnées.", + "showing_other": "{{count}} alertes actives affichées pour les ruches sélectionnées.", + "showing_many": "{{count}} alertes actives affichées pour les ruches sélectionnées.", + "confidence": "confiance {{percent}} %" + }, + "dataQuality": { + "activeSlots": "Emplacements actifs", + "noScale": "Pas de balance", + "noClimate": "Pas de climat", + "noRecentData": "pas de données récentes" + }, + "hiveHeart": { + "level": "niveau", + "dominant": "fréquence dominante {{hz}} Hz", + "noDominant": "aucune fréquence dominante indiquée", + "footnote": "Niveaux relatifs 0–15 (pas en dBFS) du dernier relevé de la période. Les lignes en pointillés marquent la fréquence dominante indiquée par HiveHeart ; les intitulés Infrabasses/Bourdonnement/Chant des reines/Stress sont approximatifs et couvrent plusieurs plages." + } } } diff --git a/apps/frontend/public/locales/it/common.json b/apps/frontend/public/locales/it/common.json index 64a2a326d..53b3d5ee7 100644 --- a/apps/frontend/public/locales/it/common.json +++ b/apps/frontend/public/locales/it/common.json @@ -22,7 +22,8 @@ "harvests": "Raccolti", "allHarvests": "Tutti i raccolti", "reports": "Report", - "metrics": "Metriche" + "metrics": "Metriche", + "hivehub": "HiveHub" }, "actions": { "save": "Salva", diff --git a/apps/frontend/public/locales/it/hivescale.json b/apps/frontend/public/locales/it/hivescale.json index 7814a0f09..2cd9db818 100644 --- a/apps/frontend/public/locales/it/hivescale.json +++ b/apps/frontend/public/locales/it/hivescale.json @@ -35,7 +35,7 @@ }, "page": { "title": "HiveHub", - "subtitle": "Live weight and temperature visualisation from your separate HiveHub backend.", + "subtitle": "Peso, clima, suono, traffico e avvisi dai tuoi dispositivi HiveHub.", "refresh": "Refresh", "refreshing": "Refreshing…", "refreshError": "Could not refresh HiveHub data.", @@ -43,16 +43,26 @@ "removeSuccess": "HiveHub device removed from your account.", "removeSuccessShared": "Non hai più accesso a questo dispositivo HiveHub. Altri utenti lo mantengono, quindi il suo codice di rivendicazione resta in uso.", "releaseConfirm": "Rilasciare completamente {{name}}? Tutti gli utenti perdono l'accesso e il dispositivo può essere rivendicato di nuovo con il suo codice. Le letture vengono conservate.", - "releaseSuccess": "Dispositivo HiveHub rilasciato. Può essere rivendicato di nuovo con il suo codice." + "releaseSuccess": "Dispositivo HiveHub rilasciato. Può essere rivendicato di nuovo con il suo codice.", + "unavailableTitle": "HiveHub non è raggiungibile", + "inspectionActiveTitle": "La modalità ispezione è attiva", + "inspectionActiveBody": "Le letture vengono mascherate mentre le arnie sono aperte, così sollevare i telaini non genera falsi avvisi.", + "openInspections": "Ispezioni in corso", + "tabs": { + "overview": "Panoramica", + "inspections": "Ispezioni", + "audio": "Audio", + "health": "Stato", + "setup": "Configurazione" + } }, "setup": { "title": "Devices", - "description": "Select or remove a claimed HiveHub device. Expand the setup to claim devices, check status, map scales, and calibrate the raw readings.", "removeScale": "Remove scale", "releaseScale": "Rilascia dispositivo", "noDevices": "No devices claimed yet — open the setup to add one.", - "hide": "Hide setup", - "show": "Show setup" + "selectDevice": "Dispositivo HiveHub", + "noDevicesYet": "Nessun dispositivo HiveHub ancora rivendicato. Rivendicane uno con il codice che l'hub mostra nel suo portale di configurazione." }, "claim": { "title": "Claim HiveHub device", @@ -65,16 +75,30 @@ "success": "HiveHub device claimed.", "errors": { "missingCode": "Enter the claim code printed/configured for the device." - } + }, + "hive1Name": "Nome dell'arnia 1", + "hive2Name": "Nome dell'arnia 2", + "optional": "Facoltativo" }, "mapping": { - "title": "Scale mapping", - "description": "These labels stay in HiveHub. Later, this is the right place to map Scale 1/2 to HivePal hive IDs for inspection markers.", - "scale1Label": "Display name for Scale 1", - "scale2Label": "Display name for Scale 2", + "title": "Arnie", "hiveNamePlaceholder": "Select or type a hive name", - "save": "Save mapping", - "success": "Scale mapping updated." + "save": "Salva arnie", + "success": "Posizioni delle arnie salvate su HiveHub.", + "slotsDescription": "Dai un nome a ogni posizione arnia e collegala a un'arnia di HivePal. Il collegamento permette alle ispezioni, alla pagina dell'arnia e alle email di avviso di sapere a quale colonia corrisponde una posizione. Un punto verde indica le posizioni attualmente segnalate dall'hub.", + "mapSlot": "Assegna posizione {{slot}}", + "add": "Aggiungi", + "reporting": "Segnalata dall'hub", + "unknownHive": "Arnia non trovata", + "mappedCount": "{{count}}/{{total}} posizioni assegnate", + "dialogTitle": "Posizione arnia {{slot}}", + "dialogDescription": "Collega la posizione a una delle tue arnie di HivePal e scegli il nome che HiveHub mostra per essa.", + "linkedHive": "Arnia di HivePal", + "notLinked": "Non collegata", + "linkHint": "Il collegamento resta valido anche se rinomini l'arnia. Scegliendo un'arnia se ne compila il nome, se il campo è vuoto.", + "displayName": "Nome visualizzato", + "clearSlot": "Svuota posizione", + "applySlot": "Applica" }, "status": { "title": "Device status", @@ -94,6 +118,13 @@ "revokeAccess": "Revoke access", "errors": { "missingEmail": "Enter the email address of an existing HivePal user." + }, + "lastSeen": "Ultima connessione", + "claimedAt": "Rivendicato", + "roles": { + "owner": "Proprietario", + "admin": "Admin", + "viewer": "Visualizzatore" } }, "calibration": { @@ -203,11 +234,9 @@ }, "sourceHint": "Okolitá teplota v ustálenom stave úzko sleduje telo snímača a je odporúčaným predvoleným nastavením.", "refLabel": "Referenčná teplota / °C", - "refHint": "Teplota, pri ktorej je korekcia nulová.", "coeffLabel": "Koeficient {{scaleName}} / kg na °C", "autoFit": "Automaticky prispôsobiť z histórie", "fitting": "Prispôsobuje sa…", - "autoFitHint": "Automatické prispôsobenie regresne porovná surovú hmotnosť za posledné 3 dni so zvolenou teplotou vyhladenou pomocou EMA a potom výsledok použije a zapne. Pre čisté prispôsobenie zachyťte okno, v ktorom je fyzické zaťaženie konštantné a teplota kolíše.", "save": "Uložiť teplotnú kompenzáciu", "errors": { "refTemp": "Referenčná teplota musí byť číslo (°C).", @@ -219,11 +248,33 @@ "noSignal": "nedostatočný teplotný/hmotnostný signál.", "fitApplied": "{{scaleName}}: {{coeff}} kg/°C pri {{refTemp}}°C (R²={{r2}}, n={{n}}). Použité a zapnuté." } + }, + "perHive": { + "hive": "Arnia {{index}}", + "hiveNamed": "Arnia {{index}} · {{name}}", + "hiveLabel": "Arnia", + "knownWeightPlaceholder": "es. 10", + "manualSaveHint": "Cambia arnia per modificare un'altra bilancia; tutte le modifiche vengono inviate insieme al salvataggio. Le arnie dalla 3 in poi vengono scritte solo se modificate.", + "tempco": { + "refHint": "La temperatura alla quale le bilance indicano il peso corretto, cioè quella a cui sono state tarate e calibrate. Lì la correzione è zero; l'adattamento non la modifica, a meno che tu non lo chieda.", + "lookbackLabel": "Periodo di adattamento / giorni", + "setRefTemp": "Imposta anche la temperatura di riferimento", + "setRefTempHint": "Sostituisce il riferimento con la temperatura media della finestra adattata. Lascia disattivato se la bilancia è stata calibrata a una temperatura nota.", + "fitOnly": "Solo adatta (anteprima)", + "fitAndApply": "Adatta e applica", + "fitSummary": "{{scaleName}}: {{coeff}} kg/°C, R² {{r2}} su {{n}} letture tra {{min}} e {{max}} °C (media della finestra {{mean}} °C).", + "lowRSquared": "R² inferiore a 0,5: la temperatura spiega poco di questa deriva. Esegui l'adattamento su un periodo con carico costante e un'ampia escursione giorno/notte.", + "autoFitHint": "L'adattamento automatico esegue una regressione del peso memorizzato dell'arnia selezionata nel periodo scelto rispetto alla temperatura selezionata, livellata con EMA. «Solo adatta» compila il coefficiente per la verifica prima del salvataggio; «Adatta e applica» lo salva e attiva la compensazione. Per un buon adattamento, scegli un periodo in cui il carico fisico è costante e la temperatura varia.", + "errors": { + "lookback": "Il periodo deve essere un numero intero di giorni tra 1 e 90." + }, + "toasts": { + "fitFilled": "{{scaleName}}: {{coeff}} kg/°C (R²={{r2}}, n={{n}}). Valore compilato: verificalo e salva." + } + } } }, "firmware": { - "title": "Firmware upload", - "description": "Upload a firmware binary and register it as a release. New devices of the matching type pick it up on their next update check.", "type": "Firmware type", "selectType": "Select firmware type", "version": "Version", @@ -238,6 +289,102 @@ "errors": { "missingFile": "Choose a firmware binary (.bin) to upload.", "missingVersion": "Enter a version, e.g. 0.6.3." + }, + "update": { + "title": "Aggiornamento firmware", + "description": "Le bilance non si aggiornano mai da sole: approvi qui ogni nuova versione prima che questa bilancia la installi.", + "unavailable": "Lo stato del firmware non è disponibile al momento.", + "availableTitle": "È disponibile il firmware {{version}}", + "availableBody": "Questa bilancia esegue la versione {{current}}. Applica l'aggiornamento per passare a {{version}}.", + "officialSuffix": "Questa è una versione ufficiale.", + "apply": "Applica aggiornamento", + "applying": "Applicazione…", + "applied": "Firmware {{version}} approvato: la bilancia si aggiornerà alla prossima connessione.", + "queued": "Aggiornamento {{version}} approvato. La bilancia lo installerà alla prossima connessione.", + "upToDate": "Il firmware è aggiornato ({{version}}).", + "viewerNotice": "Solo i proprietari e gli amministratori del dispositivo possono applicare aggiornamenti firmware." + }, + "board": { + "cardTitle": "Firmware", + "cardDescription": "Firmware di questo HiveHub e dei nodi HiveInside e contatori HiveTraffic a esso collegati. Il caricamento registra una versione; l'approvazione (hub) o l'inoltro (nodi) la installa.", + "current": "Attuale", + "latest": "Più recente", + "approved": "Approvata", + "board": "Scheda", + "badgePending": "Aggiornamento in attesa di approvazione", + "badgeApproved": "Aggiornamento approvato", + "badgeUpToDate": "Aggiornato", + "approveFlash": "Approva e installa {{version}}", + "approveConfirm": "Approvare il firmware {{version}} e installarlo via radio (OTA)?\n\nL'HiveHub lo installa alla prossima connessione e si riavvia. Un dispositivo remoto il cui aggiornamento fallisce a metà potrebbe richiedere un intervento sul posto per essere ripristinato.", + "otherBoards": "Caricato anche per un'altra scheda (non applicato a questo dispositivo {{board}}): {{releases}}. Una versione raggiunge solo un dispositivo con la scheda corrispondente: controlla la scheda durante il caricamento.", + "otherBoardsUnknown": "Caricato anche per un'altra scheda (non applicato a questo dispositivo): {{releases}}. Una versione raggiunge solo un dispositivo con la scheda corrispondente: controlla la scheda durante il caricamento." + }, + "relays": { + "hiveFallback": "Arnia {{slot}}", + "latest": "più recente {{version}}", + "updateAvailable": "aggiornamento disponibile: {{version}}", + "status": { + "pending": "inoltro in coda", + "running": "inoltro in corso…", + "completed": "inoltro completato", + "failed": "inoltro non riuscito", + "expired": "inoltro scaduto" + }, + "statusTitle": "Inoltro di {{version}}: {{status}} ({{date}})", + "failedDetail": "Ultimo inoltro non riuscito: {{message}} ({{date}})", + "noReason": "nessun motivo indicato", + "alreadyQueued": "C'è già un inoltro in coda per questo nodo", + "queueing": "Messa in coda…", + "relayAnyway": "Inoltra comunque", + "confirm": "Inoltrare {{name}} {{version}} a {{hive}}?\n\nL'HiveHub lo prende in carico al prossimo ciclo di invio, di norma entro circa 10 minuti o secondo l'intervallo di invio di questo dispositivo. Poi trasmette l'immagine via BLE per qualche minuto e il nodo si riavvia con la nuova versione. Il nodo deve essere attivo e a portata, altrimenti l'inoltro fallisce e va rimesso in coda.", + "queued": "Inoltro di {{version}} in coda per {{hive}}.", + "queuedFrom": "Inoltro in coda per {{hive}} ({{current}} → {{version}}).", + "hiveinside": { + "name": "HiveInside", + "title": "Nodi HiveInside", + "description": "Firmware annunciato da ogni nodo all'interno dell'arnia. Caricare un'immagine HiveInside registra solo la versione: nulla raggiunge un nodo finché non premi «Inoltra al nodo». La versione mostrata qui conferma che l'aggiornamento è andato a buon fine.", + "relay": "Inoltra al nodo", + "unnamed": "Non annuncia un proprio nome (HiveInside precedente a 0.5.0)", + "noRelease": "Nessuna versione HiveInside ancora caricata: caricane una qui sotto per inoltrarla." + }, + "beecounter": { + "name": "HiveTraffic", + "title": "Contatori HiveTraffic", + "description": "Firmware segnalato da ogni contatore HiveTraffic. Caricare un'immagine HiveTraffic registra solo la versione: nulla raggiunge un contatore finché non premi «Inoltra al contatore». I contatori precedenti a circa la v0.1.0 non segnalano la versione, ma possono comunque essere aggiornati.", + "relay": "Inoltra al contatore", + "unnamed": "Non segnala né nome né indirizzo", + "noRelease": "Nessuna versione HiveTraffic ancora caricata: caricane una qui sotto per inoltrarla.", + "countingWarning": "Un contatore smette di contare le api per tutto il trasferimento, quindi inoltra quando il traffico è basso.", + "confirmExtra": "Il contatore smette di contare le api per tutto il trasferimento: il traffico di quei pochi minuti va perso." + } + }, + "uploadForm": { + "title": "Carica firmware", + "description": "Il caricamento registra solo una versione: non installa mai nulla da solo. Approva l'aggiornamento dell'hub qui sopra oppure inoltra una versione per nodo o contatore dalla sua riga.", + "board": "Scheda", + "targets": { + "hivehub": "Hub HiveHub", + "hiveinside": "HiveInside", + "beecounter": "Contatore HiveTraffic" + }, + "boards": { + "auto": "Automatico (dal nome del file)", + "esp32": "ESP32 (classico a 30 pin)", + "esp32-c6": "ESP32-C6", + "nrf54lm20a": "nRF54LM20A" + }, + "boardHint": { + "hivehub": "Il firmware dell'hub deve indicare la sua scheda: scegline una, oppure lascia automatico se il file ha un nome come hivehub_esp32-c6_0.30.5.bin.", + "hiveinside": "HiveInside esiste solo per nRF54LM20A. Carica il file di build così com'è (es. hiveinside_nrf54lm20a_1.4.0.bin): destinazione e versione vengono ricavate dal nome.", + "beecounter": "Il contatore HiveTraffic è un ESP32-C6. Carica l'immagine dell'applicazione (es. beecounter_esp32-c6_0.7.1.bin), non un'immagine di fabbrica unificata." + }, + "detected": "{{name}}: compilato {{fields}}.", + "nothingDetected": "{{name}}: nessuna destinazione, scheda o versione riconosciuta nel nome; impostale qui sotto.", + "detectedTarget": "destinazione «{{target}}»", + "detectedBoard": "scheda {{board}}", + "detectedVersion": "versione {{version}}", + "autoQueued": "Inoltro messo in coda automaticamente per {{hives}}.", + "autoQueueFailed": "Impossibile mettere in coda automaticamente l'inoltro per {{hives}}." } }, "sdData": { @@ -262,12 +409,19 @@ "parsed_other": "Parsed {{count}} records", "skipped_one": " · {{count}} unreadable line skipped.", "skipped_other": " · {{count}} unreadable lines skipped." + }, + "mismatch": { + "title": "File di un altro hub", + "description": "Questo file è stato registrato da un altro hub, non da {{device}}.", + "fileDevices_one": "ID dispositivo nel file:", + "fileDevices_other": "ID dispositivi nel file:", + "fileDevices_many": "ID dispositivi nel file:", + "hint": "Importalo comunque solo se la scheda SD appartiene davvero a questo dispositivo (ad esempio dopo la sostituzione della scheda elettronica dell'hub).", + "cancel": "Annulla", + "force": "Importa comunque in questo dispositivo" } }, "panel": { - "weight": "Weight", - "hiveTemp": "Hive temp", - "rmsSound": "RMS sound", "ambientTemperature": "Ambient temperature", "ambientHumidity": "Ambient humidity", "batteryCharge": "Battery charge", @@ -277,7 +431,6 @@ "title": "General", "description": "Ambient & power" }, - "humidity": "Hive humidity", "wirelessSensorsBattery": "Wireless sensors battery", "sensorTypes": { "hiveHeart": "HiveHeart", @@ -286,174 +439,36 @@ } }, "diagram": { - "title": "Measurements", - "subtitle": "Weight, temperature, and entrance activity over time", - "noMeasurements": "No measurements for the selected range.", - "noDataForSeries": "No data in the selected range.", - "meta": { - "device": "Device:", - "lastSeen": "Last seen:", - "lastData": "Last data:", - "firmware": "Firmware:" - }, "range": { "all": "All", "from": "From", "to": "To" }, - "series": { - "weight": "{{name}} weight", - "temp": "{{name}} temp", - "micRms": "{{name}} mic RMS", - "beesIn": "{{name}} bees in", - "beesOut": "{{name}} bees out", - "netFlow": "{{name}} net flow", - "ambientTemp": "Ambient temp", - "ambientHumidity": "Ambient humidity", - "batteryVoltage": "Battery voltage", - "batteryCharge": "Battery charge", - "solarVoltage": "Solar voltage", - "solarCurrent": "Solar current", - "solarPower": "Solar power", - "humidity": "{{name}} umidità", - "pressure": "{{name}} pressione", - "fanningBand": "{{name}} fanning band (30–100 Hz)", - "activityBand": "{{name}} activity band (100–200 Hz)" - }, - "group": { - "ambient": "Ambient", - "offGrid": "Off-grid" - }, - "axis": { - "weight": "Weight", - "temperature": "Temperature", - "humidity": "Humidity", - "voltage": "Voltage", - "percent": "Percent", - "current": "Current", - "power": "Power", - "sound": "Sound", - "beecount": "Bee count", - "pressure": "Pressione" - }, "marker": { - "inspection": "Inspection", - "boxAdded": "Box added", - "action": { - "maintenance": "Maintenance", - "feeding": "Feeding", - "frames": "Frames", - "treatment": "Treatment" - } - }, - "axisSettings": { - "title": "Axis settings", - "reset": "Reset", - "side": "Side", - "left": "Left", - "right": "Right", - "scale": "Scale", - "auto": "Auto", - "zeroToMax": "0 to max", - "custom": "Custom", - "min": "Min", - "max": "Max", - "autoPlaceholder": "auto" + "inspection": "Inspection" } }, "sound": { - "title": "Hive sound", - "description": "INMP441 FFT band energy (dBFS) — {{left}} (left) · {{right}} (right)", - "hide": "Hide sound", - "show": "Show sound", - "micLeftFallback": "Mic left", - "micRightFallback": "Mic right", - "noDataForChannel": "No data for {{name}} in this range.", - "bandReference": "Band reference", - "thresholds": "Thresholds used by the Insights engine: piping > −45 dBFS, stress > −38 dBFS, queenless hum > −40 dBFS with quiet piping. Calibrate against your own baseline.", - "status": { - "noData": "No data", - "micError": "Mic error", - "bothActive": "{{left}} + {{right}} active", - "oneActive": "{{name}} active" - }, - "table": { - "band": "Band", - "range": "Range", - "significance": "Significance" - }, "bands": { "subBass": { - "label": "Sub-bass", - "description": "Structural vibration / low rumble" + "label": "Sub-bass" }, "hum": { - "label": "Hum", - "description": "Normal colony hum (~200 Hz fundamental)" + "label": "Hum" }, "piping": { - "label": "Piping", - "description": "Queen piping / tooting (pre-swarm signal)" + "label": "Piping" }, "stress": { - "label": "Stress", - "description": "Agitated colony / robbing" + "label": "Stress" }, "high": { - "label": "High", - "description": "Harmonic overtones" + "label": "High" } - }, - "rmsToggleShow": "Show RMS level", - "rmsToggleHide": "Hide RMS level", - "rmsTitle": "RMS sound level", - "rmsSeries": "{{name}} RMS" - }, - "beecounter": { - "title": "Entrance Counter", - "subtitle": "Bees entering and exiting per measurement interval", - "noData": "No BeeCounter data found for this device.", - "offline": "Offline", - "gatesOk": "{{count}} gates OK", - "glitches_one": "{{count}} glitch", - "glitches_other": "{{count}} glitches", - "gatesRatio": "{{healthy}}/{{total}} gates", - "inPeriod": "In (period)", - "outPeriod": "Out (period)", - "netFlow": "Net flow", - "netInbound": "net inbound", - "netOutbound": "net outbound", - "balanced": "balanced", - "view": { - "inOut": "In / Out", - "netFlow": "Net flow", - "cumulative": "Cumulative" - }, - "series": { - "in": "{{name}} in", - "out": "{{name}} out", - "net": "{{name}} net", - "totalIn": "{{name}} total in", - "totalOut": "{{name}} total out" - }, - "diagnostics": { - "title": "Hardware diagnostics", - "gatesHealthy": "Gates healthy", - "glitchCount": "Glitch count", - "totalInLifetime": "Total in (lifetime)", - "totalOutLifetime": "Total out (lifetime)" } }, "insights": { - "title": "Insights", - "description": "Sensor-based alerts derived from weight and temperature trends.", - "updated": "Updated {{ago}}.", - "refresh": "Refresh insights", - "loadError": "Failed to load insights: {{message}}", "allClear": "All clear", - "noActiveAlerts": "No active alerts in the last {{count}} days.", - "activeAlerts_one": "{{count}} active alert:", - "activeAlerts_other": "{{count}} active alerts:", "confidence": "Confidence {{value}}%", "source": "Source: {{source}}", "hiveChannel": "Hive {{channel}}", @@ -538,5 +553,740 @@ "description": "No alerts have been recorded for this device.", "descriptionFiltered": "No alerts have been recorded for this device matching this filter." } + }, + "inspectionMode": { + "title": "Modalità ispezione", + "description": "Mentre la modalità ispezione è attiva, HiveHub continua a misurare ma esclude le letture da grafici, analisi e avvisi, così sollevare i telaini non viene scambiato per una sciamatura.", + "status": { + "active": "Attiva da {{time}}", + "pending": "In attesa che l'hub la prenda in carico", + "off": "Disattivata" + }, + "pendingHint": "Richiesta: l'hub la prende in carico alla prossima connessione.", + "timeoutNote_one": "Termina automaticamente dopo {{count}} minuto se nessuno la interrompe.", + "timeoutNote_other": "Termina automaticamente dopo {{count}} minuti se nessuno la interrompe.", + "timeoutNote_many": "Termina automaticamente dopo {{count}} minuti se nessuno la interrompe.", + "scope": { + "wholeHub": "Intero hub", + "label": "Riguarda: {{hives}}" + }, + "start": "Avvia modalità ispezione", + "stop": "Interrompi modalità ispezione", + "starting": "Avvio…", + "stopping": "Interruzione…", + "cancel": "Annulla", + "noteLabel": "Nota (facoltativa)", + "notePlaceholder": "es. Aggiunto un melario", + "viewerHint": "Solo i proprietari e gli amministratori possono avviare o interrompere la modalità ispezione.", + "loadFailed": "Impossibile caricare lo stato dell'ispezione.", + "startDialog": { + "title": "Avvia modalità ispezione", + "description": "Le letture delle arnie selezionate vengono mascherate finché non interrompi la modalità ispezione o finché non scade.", + "hivesLabel": "Arnie", + "wholeHub": "Intero hub (tutte le arnie)", + "selectHive": "Scegli almeno un'arnia oppure l'intero hub.", + "confirm": "Avvia" + }, + "stopDialog": { + "title": "Interrompi modalità ispezione", + "description": "HiveHub torna alle letture e agli avvisi normali.", + "confirm": "Interrompi" + }, + "toast": { + "started": "Modalità ispezione avviata", + "stopped": "Modalità ispezione interrotta", + "noteSaved": "Nota salvata" + }, + "history": { + "title": "Ispezioni recenti", + "empty": "Nessuna ispezione ancora registrata.", + "ongoing": "in corso", + "startedVia": "Avviata tramite {{source}}", + "ended": "Terminata: {{reason}}", + "editNote": "Modifica nota", + "addNote": "Aggiungi nota", + "saveNote": "Salva" + }, + "duration": { + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "minutes_many": "{{count}} min", + "hoursMinutes": "{{hours}} h {{minutes}} min" + }, + "source": { + "device": "pulsante dell'hub", + "api": "app", + "dashboard": "dashboard locale" + }, + "endReason": { + "timeout": "tempo scaduto", + "device": "pulsante dell'hub", + "api": "interrotta nell'app", + "dashboard": "interrotta dalla dashboard locale" + } + }, + "hiveLink": { + "prompt": { + "message": "Questa arnia è monitorata dall'HiveHub {{device}} ({{hive}}). Avvia la modalità ispezione così l'apertura dell'arnia non genera falsi avvisi.", + "active": "La modalità ispezione è attiva per {{hive}} sull'HiveHub {{device}}.", + "pending": "Modalità ispezione richiesta per {{hive}} sull'HiveHub {{device}}: l'hub la prende in carico alla prossima connessione.", + "activeOther": "La modalità ispezione è attiva per altre arnie sull'HiveHub {{device}}, non per {{hive}}. Interrompila prima di avviarne una per questa arnia." + }, + "card": { + "title": "HiveHub", + "description": "Letture in tempo reale dai sensori HiveHub collegati.", + "weight": "Peso", + "weightChange24h": "Variazione in 24 h", + "temperature": "Temperatura dell'arnia", + "humidity": "Umidità", + "lastReading": "Ultima lettura: {{time}}", + "openHiveHub": "Apri HiveHub" + } + }, + "traffic": { + "title": "Contatori di api HiveTraffic", + "description": "Modalità notte e banchi di emettitori per ogni contatore HiveTraffic associato a questo hub. Gli emettitori IR sono di gran lunga la voce principale nel consumo di un contatore, quindi queste impostazioni decidono se può funzionare con un'alimentazione autonoma.", + "readOnly": "Solo i proprietari e gli amministratori del dispositivo possono modificare queste impostazioni. Il tuo ruolo è «{{role}}».", + "hiveFallback": "Arnia {{slot}}", + "noChanges": "Nessuna modifica da salvare", + "saved": "Impostazioni dei contatori di api salvate. I contatori le ricevono alla prossima connessione.", + "appliesToAll": "Si applica a ogni contatore HiveTraffic associato a questo dispositivo. Il contatore viene informato al prossimo ciclo di invio dell'hub.", + "status": { + "title": "Contatori al momento", + "none": "Nessun contatore HiveTraffic presente nell'ultima lettura.", + "counting": "Conteggio", + "nightIdle": "Pausa notturna", + "nightIdleFor_one": "Pausa notturna · ancora {{count}} min", + "nightIdleFor_other": "Pausa notturna · ancora {{count}} min", + "nightIdleFor_many": "Pausa notturna · ancora {{count}} min", + "banksRunning": "Banchi di emettitori attivi: {{banks}}", + "banksUnknown": "Banchi di emettitori non segnalati (firmware del contatore precedente a 0.3.0)", + "applied": "Banchi applicati", + "pending": "Banchi in attesa della prossima connessione" + }, + "night": { + "title": "Modalità notte", + "description": "Le api mellifere sono diurne: il volo richiede luce e comunque si interrompe sotto i 10 °C circa. Durante questa fascia il contatore smette di rilevare per risparmiare energia. Continua a trasmettere gli annunci, quindi le letture e gli aggiornamenti firmware funzionano ancora.", + "enable": "Attiva modalità notte", + "start": "Inizio notte (ora locale)", + "end": "Fine notte (ora locale)", + "windowHelp": "La fascia può superare la mezzanotte (es. 20:00 → 06:00). Impostando inizio e fine alla stessa ora la fascia risulta vuota, quindi non si applica mai, invece di coprire l'intera giornata. Lascia un margine attorno a crepuscolo e alba: l'attività raggiunge il picco poco prima del tramonto e dell'alba.", + "emptyWindow": "Inizio e fine coincidono, quindi la fascia è vuota e la modalità notte non si applicherebbe mai.", + "wrapsMidnight": "La fascia supera la mezzanotte: dalle {{start}} fino alle {{end}} del mattino seguente.", + "timezone": "Fuso orario", + "tzCustom": "Stringa TZ POSIX", + "timezoneHelp": "L'orologio dell'hub funziona in UTC. Senza il fuso orario corretto la fascia risulta sfasata della tua differenza da UTC e si sposta di un'ora a ogni cambio dell'ora legale.", + "tzPresets": { + "cet": "Europa centrale (Berlino, Parigi, Madrid, Vienna)", + "uk": "Regno Unito / Irlanda", + "eet": "Europa orientale (Helsinki, Atene)", + "usEastern": "USA Est", + "usCentral": "USA Centro", + "usMountain": "USA Montagne Rocciose", + "usPacific": "USA Pacifico", + "utc": "UTC (nessuna ora locale)", + "custom": "Personalizzato…" + }, + "maxTraffic": "Rinvia sopra (passaggi per ciclo)", + "maxTrafficHelp": "Rinvia la modalità notte finché nell'ultimo ciclo di invio sono stati contati più passaggi (entrate + uscite) di questo valore, così una colonia che vola ancora tardi in una sera d'estate non viene interrotta. 0 = solo in base all'orario.", + "save": "Salva modalità notte" + }, + "banks": { + "title": "Banchi di emettitori", + "description": "Gli emettitori IR del contatore sono commutati in tre banchi da otto varchi. Ogni banco assorbe circa 80 mA. Spegnine uno se l'ingresso è più stretto di 24 varchi, se una parte è chiusa o se l'alimentazione non regge l'intera scheda.", + "bank": "Banco {{bank}} — varchi {{gates}}", + "current_one": "{{count}} banco acceso · circa {{ma}} mA sulla linea a 3,3 V del contatore", + "current_other": "{{count}} banchi accesi · circa {{ma}} mA sulla linea a 3,3 V del contatore", + "current_many": "{{count}} banchi accesi · circa {{ma}} mA sulla linea a 3,3 V del contatore", + "help": "Un banco spento non conta affatto i suoi varchi, quindi i totali calano all'incirca in proporzione: non confrontare un contatore ridotto con il suo storico. Almeno un banco deve restare acceso; dissocia un contatore che non deve contare nulla.", + "firmwareNote": "Richiede il firmware del contatore 0.3.0 o successivo. L'impostazione viene reinviata dopo ogni reset del contatore.", + "save": "Salva banchi di emettitori" + }, + "errors": { + "loadFailed": "Impossibile caricare la configurazione del dispositivo.", + "saveFailed": "Impossibile salvare le impostazioni dei contatori di api", + "invalidTime": "Inserisci un orario di inizio e di fine valido (HH:MM).", + "invalidMaxTraffic": "La soglia di rinvio deve essere un numero intero pari o superiore a 0.", + "emptyWindow": "Inizio e fine della modalità notte coincidono: la fascia non si applicherebbe mai.", + "noBank": "Almeno un banco di emettitori deve restare acceso: dissocia invece il contatore." + } + }, + "hubAccess": { + "title": "Accesso all'hub", + "description": "Raggiungi l'hub sul posto senza aprire il contenitore e imposta quanto può durare un'ispezione.", + "readOnly": "Solo i proprietari e gli amministratori del dispositivo possono modificare queste impostazioni. Il tuo ruolo è «{{role}}».", + "noChanges": "Nessuna modifica da salvare", + "cancel": "Annulla", + "ap": { + "title": "Access point di configurazione", + "description": "Apre la rete Wi-Fi HiveHub-Setup dell'hub, come una breve pressione del pulsante di configurazione: cambiare Wi-Fi, associare sensori o scaricare i dati della scheda SD.", + "lastCheckIn": "Ultima connessione: {{time}}", + "open": "Apri access point di configurazione", + "confirmTitle": "Aprire l'access point di configurazione?", + "confirmTiming_one": "La richiesta è in coda. L'hub dorme tra un ciclo e l'altro, quindi l'access point si apre dopo la sua prossima connessione, entro al massimo {{count}} minuto.", + "confirmTiming_other": "La richiesta è in coda. L'hub dorme tra un ciclo e l'altro, quindi l'access point si apre dopo la sua prossima connessione, entro al massimo {{count}} minuti.", + "confirmTiming_many": "La richiesta è in coda. L'hub dorme tra un ciclo e l'altro, quindi l'access point si apre dopo la sua prossima connessione, entro al massimo {{count}} minuti.", + "confirmTimingUnknown": "La richiesta è in coda. L'hub dorme tra un ciclo e l'altro, quindi l'access point si apre dopo la sua prossima connessione, entro al massimo un intervallo di invio.", + "confirmOffline": "Mentre l'access point è aperto, l'hub è disconnesso dal Wi-Fi e non invia letture.", + "confirmTimeout": "Si chiude da solo allo scadere del timeout del portale di configurazione se nessuno si collega, quindi avvialo solo quando c'è qualcuno presso l'arnia.", + "confirm": "Metti in coda l'access point", + "queueing": "Messa in coda…", + "queued": "Access point in coda. Si apre dopo la prossima connessione dell'hub.", + "failed": "Impossibile mettere in coda l'access point" + }, + "inspection": { + "title": "Timeout dell'ispezione", + "description": "Una rete di sicurezza: un'ispezione termina da sola dopo questo numero di minuti, così una pressione del pulsante dimenticata non oscura a tempo indeterminato i dati di un'arnia. Predefinito 60.", + "label": "Timeout (minuti, 1–1440)", + "save": "Salva timeout", + "saved": "Timeout dell'ispezione salvato", + "saveFailed": "Impossibile salvare il timeout dell'ispezione", + "invalid": "Inserisci un numero intero di minuti tra 1 e 1440." + } + }, + "dataTools": { + "title": "Esportazione e pulizia dei dati", + "description": "Esegui il backup delle letture di questo hub o rimuovi quelle errate.", + "hiveFallback": "Arnia {{slot}}", + "cancel": "Annulla", + "export": { + "title": "Esporta letture", + "description": "Scarica le letture memorizzate come backup NDJSON.", + "from": "Dal (facoltativo)", + "to": "Al (facoltativo)", + "rangeHelp": "Lascia entrambi vuoti per esportare tutto. Le date comprendono giorni interi nella tua ora locale.", + "hives": "Arnie (facoltativo)", + "allHives": "Sono incluse tutte le arnie.", + "someHives_one": "È inclusa solo {{count}} arnia selezionata. Il conteggio qui sotto comprende tutte le letture del periodo.", + "someHives_other": "Sono incluse solo {{count}} arnie selezionate. Il conteggio qui sotto comprende tutte le letture del periodo.", + "someHives_many": "Sono incluse solo {{count}} arnie selezionate. Il conteggio qui sotto comprende tutte le letture del periodo.", + "invalidRange": "La data di inizio è successiva alla data di fine.", + "summaryFailed": "Impossibile contare le letture.", + "count_one": "{{count}} lettura", + "count_other": "{{count}} letture", + "count_many": "{{count}} letture", + "span": "Dal {{first}} al {{last}}", + "download": "Scarica", + "formatHelp": "Una lettura JSON per riga. Il file può essere reimportato con l'importazione da scheda SD.", + "noPermission": "Solo i proprietari e gli amministratori del dispositivo possono esportare i dati. Il tuo ruolo è «{{role}}»." + }, + "delete": { + "title": "Elimina le letture di un periodo", + "description": "Rimuove le letture spurie, ad esempio i picchi di peso che un hub registra all'avvio prima che le bilance siano calibrate e che altrimenti falsano grafici e analisi.", + "warningTitle": "Operazione irreversibile", + "warning": "Le letture eliminate sono perse definitivamente. Esporta prima il periodo se potrebbe servirti.", + "start": "Inizio", + "end": "Fine", + "claimCode": "Codice di rivendicazione", + "claimCodeHelp": "Il codice di rivendicazione dell'hub, come seconda conferma.", + "review": "Verifica eliminazione", + "confirmTitle": "Eliminare queste letture?", + "confirmRange": "Dal {{start}} al {{end}}.", + "confirmCount_one": "{{count}} lettura verrà eliminata definitivamente.", + "confirmCount_other": "{{count}} letture verranno eliminate definitivamente.", + "confirmCount_many": "{{count}} letture verranno eliminate definitivamente.", + "confirmIrreversible": "Operazione irreversibile.", + "countFailed": "Impossibile contare le letture di questo periodo.", + "confirm_one": "Elimina {{count}} lettura", + "confirm_other": "Elimina {{count}} letture", + "confirm_many": "Elimina {{count}} letture", + "deleting": "Eliminazione…", + "success_one": "{{count}} lettura eliminata.", + "success_other": "{{count}} letture eliminate.", + "success_many": "{{count}} letture eliminate.", + "noPermission": "Solo il proprietario del dispositivo può eliminare le letture. Il tuo ruolo è «{{role}}».", + "errors": { + "missingRange": "Scegli un inizio e una fine.", + "invalidRange": "La fine deve essere uguale o successiva all'inizio.", + "missingClaimCode": "Inserisci il codice di rivendicazione dell'hub." + } + } + }, + "alertSettings": { + "title": "Avvisi email di HiveHub", + "description": "Ricevi un'email quando HiveHub rileva qualcosa nelle tue arnie: sciamatura, saccheggio, una colonia orfana e altro ancora.", + "enable": "Inviami email sugli avvisi di HiveHub", + "minSeverity": "Gravità minima", + "minSeverityHelp": "Gli avvisi al di sotto di questo livello non vengono mai inviati via email.", + "severity": { + "watch": "Osservazione e superiori", + "warning": "Attenzione e superiori", + "critical": "Solo critici" + }, + "help": { + "schedule": "HivePal controlla la presenza di nuovi avvisi ogni 15 minuti.", + "once": "Ogni avviso viene inviato una volta quando compare e di nuovo solo se si aggrava.", + "scope": "Riguarda tutti i dispositivi HiveHub a cui hai accesso.", + "email": "Richiede che l'email sia configurata sul server HivePal." + }, + "saved": "Impostazioni degli avvisi salvate", + "saveFailed": "Impossibile salvare le impostazioni degli avvisi", + "loadFailed": "Impossibile caricare le tue preferenze." + }, + "health": { + "title": "Stato dei sensori", + "description": "Stato di ogni sensore come segnalato da {{name}}.", + "descriptionAt": "Stato dei sensori dall'ultima connessione · {{ago}}.", + "noData": "Nessuno stato dei sensori ancora segnalato: compare non appena il dispositivo invia la prima lettura.", + "hubHeading": "Hub", + "hiveIndex": "Arnia {{n}}", + "noHiveSensors": "Nessun sensore segnalato per questa arnia.", + "faultNote": "L'hub segnala i propri sottosistemi come ok/guasto: non invia alcun codice di errore per essi, quindi ogni guasto qui sopra indica cosa non ha funzionato. Il contatore d'ingresso invece segnala un codice di stato, mostrato insieme al guasto ogni volta che è stato letto.", + "summary": { + "noData": "Nessun dato dei sensori", + "allOk": "Tutto OK", + "faults_one": "{{count}} guasto", + "faults_other": "{{count}} guasti", + "faults_many": "{{count}} guasti" + }, + "state": { + "ok": "OK", + "fault": "Guasto", + "notFitted": "Non installato", + "notHeard": "Non rilevato" + }, + "hub": { + "sht_ok": "Temperatura/umidità ambiente (SHT40)", + "rtc_ok": "Orologio in tempo reale", + "sd_ok": "Scheda SD", + "mic_ok": "Microfono", + "mic_left_ok": "Microfono · sinistro", + "mic_right_ok": "Microfono · destro", + "battery_monitor_ok": "Monitor della batteria", + "solar_monitor_ok": "Monitor solare" + }, + "hubReason": { + "sht_ok": "Il sensore ambientale non ha restituito una lettura valida in questo ciclo, quindi questa connessione non contiene temperatura né umidità ambiente. Di solito è un guasto del bus I²C o del cablaggio; un sensore mai rilevato all'avvio appare allo stesso modo.", + "rtc_ok": "L'orologio in tempo reale non ha risposto sul bus I²C. I timestamp ripiegano sull'ora di rete, quindi una lettura effettuata offline può avere una data errata.", + "sd_ok": "La scheda SD non è stata montata in questo ciclo: mancante, illeggibile o guasto SPI/cablaggio. Finché il problema persiste, le letture non vengono salvate sulla scheda.", + "mic_ok": "L'acquisizione del microfono non ha restituito campioni utilizzabili, quindi questa connessione non contiene bande acustiche.", + "mic_left_ok": "Il microfono sinistro non ha restituito campioni utilizzabili.", + "mic_right_ok": "Il microfono destro non ha restituito campioni utilizzabili.", + "battery_monitor_ok": "Il misuratore della batteria non ha risposto sul bus I²C: tensione e stato di carica della batteria mancano in questa connessione.", + "solar_monitor_ok": "Il monitor solare non ha risposto sul bus I²C: tensione, corrente e potenza solari mancano in questa connessione." + }, + "sensor": { + "scale": "Bilancia", + "inHive": "Sensore interno", + "counter": "Contatore HiveTraffic", + "hiveheart": "HiveHeart" + }, + "reason": { + "scale": "Per questa arnia è configurata una bilancia, ma in questo ciclo non ha prodotto alcuna lettura utilizzabile, quindi questa connessione non contiene il suo peso.", + "inHive": "Nessun annuncio dal {{kind}} associato durante la scansione BLE di questo ciclo: fuori portata, batteria scarica o spento.", + "inHiveKindFallback": "sensore interno", + "node": "Nodo {{node}}.", + "lastHeard": "Ultimo rilevamento {{ago}}.", + "notHeardInRange": "Non rilevato nel periodo caricato.", + "counter": "Il contatore d'ingresso non ha risposto alla lettura GATT in questo ciclo: fuori portata, spento o occupato quando HiveHub lo ha chiamato.", + "counterNode": "Contatore {{node}}.", + "lastAnswered": "Ultima risposta {{ago}}.", + "notAnsweredInRange": "Non ha risposto nel periodo caricato.", + "statusThen": "Stato allora: {{status}}.", + "expandersThen_one": "{{count}} espansore di porte su 3 rispondeva allora.", + "expandersThen_other": "{{count}} espansori di porte su 3 rispondevano allora.", + "expandersThen_many": "{{count}} espansori di porte su 3 rispondevano allora.", + "hiveheart": "L'HiveHeart non è stato rilevato in questo ciclo: fuori portata, batteria scarica o spento." + }, + "counter": { + "nightIdle": "pausa della modalità notte", + "nightIdleBadge": "Pausa notturna", + "otherBits": "bit specifici del contatore {{hex}}", + "noFlags": "nessun flag impostato", + "statusCode": "stato {{hex}}", + "expanders_one": "{{count}}/3 espansore dei varchi", + "expanders_other": "{{count}}/3 espansori dei varchi", + "expanders_many": "{{count}}/3 espansori dei varchi", + "glitches_one": "{{count}} anomalia", + "glitches_other": "{{count}} anomalie", + "glitches_many": "{{count}} anomalie", + "banks": "Banchi {{list}}", + "noBanks": "Nessun banco di emettitori" + }, + "meta": { + "firmware": "v{{version}}", + "batteryPercent": "Batteria {{value}} %", + "batteryMv": "Batteria {{value}} mV", + "batteryV": "Batteria {{value}} V", + "rssi": "{{value}} dBm" + } + }, + "hubStatus": { + "title": "Stato dell'hub", + "description": "Collegamento, orologio e alimentazione dell'HiveHub stesso.", + "noData": "Nessuna lettura ancora ricevuta da questo hub.", + "staleWarning": "Nessuna lettura da {{ago}}: l'hub ha saltato diverse connessioni.", + "badge": { + "live": "In tempo reale", + "stale": "Non aggiornato", + "inspection": "Ispezione in corso", + "calibration": "Modalità calibrazione" + }, + "section": { + "connectivity": "Connettività", + "battery": "Batteria", + "solar": "Solare" + }, + "row": { + "signal": "Segnale", + "transport": "Trasporto", + "timeSource": "Fonte dell'ora", + "lastSeen": "Ultima connessione", + "lastReading": "Ultima lettura", + "firmware": "Firmware", + "bootCount": "Numero di avvii", + "stateOfCharge": "Stato di carica", + "batteryVoltage": "Tensione", + "batteryAlert": "Avviso batteria", + "solarPower": "Potenza solare", + "solarCurrent": "Corrente solare", + "solarBus": "Tensione del pannello (bus)", + "solarLoad": "Tensione di carico" + }, + "signalQuality": { + "excellent": "Ottimo", + "good": "Buono", + "fair": "Discreto", + "poor": "Scarso" + }, + "transport": { + "wifi": "Wi-Fi", + "cellular": "Rete mobile" + }, + "rtc": { + "ok": "RTC OK", + "fault": "Guasto RTC" + }, + "batteryAlertActive": "Batteria scarica" + }, + "hiveCards": { + "empty": "Nessuna arnia ancora segnalata da questo dispositivo.", + "hiveIndex": "Arnia {{n}}", + "weight": "Peso", + "temperature": "Temperatura", + "humidity": "Umidità", + "delta24h": "24 h {{value}} kg", + "lastReported": "Ultima segnalazione {{ago}}", + "inspection": "Ispezione", + "insightsLoading": "Caricamento analisi", + "insightsUnavailable": "Analisi non disponibili", + "noSensors": "Nessun sensore segnalato", + "traffic": "Entrate {{in}} · Uscite {{out}}", + "trafficTitle": "Api entrate/uscite nell'ultimo intervallo", + "alerts_one": "{{count}} avviso attivo", + "alerts_other": "{{count}} avvisi attivi", + "alerts_many": "{{count}} avvisi attivi", + "sensor": { + "scale": "Bilancia", + "inHive": "Sensore interno", + "counter": "Contatore HiveTraffic", + "hiveheart": "HiveHeart" + }, + "state": { + "ok": "OK", + "fault": "Non rilevato / guasto" + } + }, + "audio": { + "title": "Audio dell'arnia", + "description": "Chiedi al nodo HiveInside di un'arnia di registrare, poi ascolta qui la registrazione. L'hub raccoglie la richiesta al suo prossimo risveglio, quindi una registrazione compare entro un intervallo di invio e non immediatamente.", + "noNodes": "Nessun nodo HiveInside è associato a questo hub. Solo HiveInside ha un microfono: un beacon HolyIot o RuuviTag non può registrare.", + "hive": "Arnia", + "hiveFallback": "Arnia {{slot}}", + "length": "Durata", + "durationOption_one": "{{count}} secondo", + "durationOption_other": "{{count}} secondi", + "durationOption_many": "{{count}} secondi", + "gain": "Guadagno", + "gainValue": "{{gain}} dB", + "record": "Registra", + "nextWake": "Raggiunge l'hub al suo prossimo risveglio.", + "viewerNote": "Un proprietario o un amministratore può richiedere una registrazione.", + "lengthHint": "Più breve di solito è meglio: 10–20 secondi bastano per sentire ciò che un'analisi ha segnalato, e consumano meno batteria e meno tempo con l'arnia fuori onda: un nodo connesso non trasmette annunci né campiona.", + "requestSuccess": "Registrazione richiesta. Raggiunge l'hub al suo prossimo risveglio.", + "requestError": "Impossibile mettere in coda la richiesta: {{message}}", + "listTitle": "Registrazioni", + "onlySelectedHive": "Solo {{name}}", + "loadError": "Impossibile elencare le registrazioni: {{message}}", + "empty": "Nessuna registrazione ancora. Chiedi a un'arnia di registrare e comparirà qui. Una sessione è di 10–60 secondi di audio a 16 kHz scaricati dal nodo via BLE.", + "requestedLength_one": "{{count}} s richiesto", + "requestedLength_other": "{{count}} s richiesti", + "requestedLength_many": "{{count}} s richiesti", + "requestedBy": "Richiesta da {{name}}", + "status": { + "requested": "In attesa dell'hub", + "streaming": "Registrazione…", + "ready": "Pronta", + "readyIncomplete": "Pronta, incompleta", + "failed": "Non riuscita" + }, + "waitingNote": "In attesa che l'hub si risvegli e prenda in carico la richiesta: ancora nulla registrato. Una richiesta non raccolta dall'hub entro 45 minuti viene contrassegnata come non riuscita.", + "streamingNote": "Registrazione in corso: l'audio compare qui man mano che arriva.", + "streamingPartial": "Registrazione ancora in corso: questo è ciò che è arrivato finora. Riproducila di nuovo al termine della sessione per sentirla per intero.", + "failedFallback": "Questa registrazione non è riuscita.", + "seconds": "{{value}} s", + "bytes": "{{value}} B", + "percent": "{{value}} %", + "download": "Scarica WAV", + "delete": "Elimina", + "deleteConfirm": "Eliminare questa registrazione? Nient'altro lo farà: non c'è una pulizia automatica.", + "deleteSuccess": "Registrazione eliminata.", + "deleteError": "Impossibile eliminare la registrazione: {{message}}", + "confirmation": { + "verified": "verificata", + "reported": "non verificata", + "confirmed": "confermata dall'hub", + "unknown": "non confermata" + }, + "quality": { + "length": "Durata", + "dropped": "Persi", + "gaps": "Interruzioni", + "hubBuffer": "Buffer dell'hub", + "clipping": "Saturazione", + "checksum": "Checksum", + "cause": { + "dropped": "il nodo non è riuscito a inviare l'audio abbastanza velocemente", + "gaps": "alcuni pacchetti sono andati persi durante la trasmissione radio", + "hubBuffer": "il caricamento dell'hub è rimasto indietro rispetto al suo buffer" + }, + "gapNote": "L'audio ai lati di un'interruzione è reale, ma la giunzione no. Causa: {{causes}}.", + "clippingNote": "Abbassa il guadagno per una registrazione più pulita.", + "unknownNote": "L'hub non ha mai riferito su questa sessione, quindi nulla ha verificato se l'audio è completo. Ciò che è presente è riproducibile." + } + }, + "dashboard": { + "hiveFallback": "Arnia {{index}}", + "units": { + "bees": "api" + }, + "range": { + "24h": "24 h", + "7d": "7 g", + "30d": "30 g", + "365d": "365 g", + "custom": "Personalizzato", + "invalid": "La data di inizio non deve essere successiva alla data di fine." + }, + "header": { + "title": "La mia dashboard", + "description": "Widget configurabili. Le arnie selezionate qui sopra controllano i grafici per arnia.", + "editDescription": "Modalità modifica: trascina la maniglia per spostare i widget e trascina l'angolo per ridimensionarli su una griglia a 4 colonne.", + "edit": "Modifica dashboard", + "done": "Fatto", + "addWidget": "Aggiungi widget", + "reset": "Reimposta", + "editBanner": "La modalità modifica della dashboard è attiva. Usa la maniglia nell'intestazione di ogni widget per spostarlo e trascina l'angolo in basso a destra per ridimensionarne larghezza o altezza. Le modifiche al layout vengono salvate automaticamente per questo dispositivo HiveHub.", + "dropEnd": "Rilascia qui per spostare un widget alla fine della dashboard.", + "noWidgets": "Nessun widget ancora. Aggiungi un modello per costruire la tua dashboard.", + "restoreDefaults": "Ripristina predefiniti" + }, + "overview": { + "title": "Panoramica arnie", + "description": "Stato compatto delle arnie assegnate. Seleziona i riquadri per controllare i widget della dashboard qui sotto.", + "lastData": "Ultimi dati", + "hivesOnline": "Arnie online", + "hubFirmware": "FW HiveHub", + "hiveInsideFirmware": "FW HiveInside", + "selected_one": "{{count}} selezionata", + "selected_other": "{{count}} selezionate", + "selected_many": "{{count}} selezionate", + "hide": "Nascondi panoramica", + "show": "Mostra panoramica", + "slot": "Posizione {{index}}", + "ok": "OK", + "noData": "Nessun dato", + "weight": "Peso", + "temperature": "Temp.", + "humidity": "UR", + "empty": "Assegna le posizioni HiveHub alle arnie di HivePal nel pannello di configurazione del dispositivo per mostrare qui i riquadri della panoramica." + }, + "sensors": { + "scale": "bilancia", + "inHive": "interno", + "accel": "accel.", + "counter": "contatore", + "none": "nessun sensore" + }, + "widgetShell": { + "move": "Sposta {{title}}", + "moveHint": "Trascina per spostare questo widget", + "remove": "Rimuovi {{title}}", + "resize": "Ridimensiona {{title}}", + "resizeHint": "Trascina per ridimensionare questo widget" + }, + "addWidget": { + "title": "Aggiungi widget", + "description": "Parti da un modello. Il widget userà le arnie selezionate nella griglia della panoramica, dove applicabile.", + "defaultSize": "Dimensione predefinita {{w}} x {{h}}" + }, + "widgets": { + "weightComparison": { + "title": "Confronto del peso", + "description": "Confronta le arnie selezionate nel periodo corrente." + }, + "dailyMaxWeight": { + "title": "Peso massimo giornaliero", + "description": "Peso compensato più alto al giorno per le arnie selezionate." + }, + "climate": { + "title": "Clima dell'arnia", + "description": "Temperatura e umidità delle arnie selezionate." + }, + "power": { + "title": "Stato dell'alimentazione", + "description": "Carica della batteria, tensione della batteria e apporto solare." + }, + "beeTraffic": { + "title": "Traffico delle api", + "description": "Traffico complessivo di entrata/uscita e flusso netto." + }, + "soundRms": { + "title": "Suono / bande acustiche", + "description": "RMS acustico per arnia e bande FFT/HiveHeart quando disponibili." + }, + "hiveHeartSpectrum": { + "title": "Spettro HiveHeart", + "description": "Ultimi livelli FFT di HiveHeart (0–15) su 16 intervalli di frequenza per le arnie selezionate." + }, + "vibration": { + "title": "Bande di vibrazione", + "description": "Vibrazione RMS e bande di sciamatura, ventilazione e attività." + }, + "configurableDiagram": { + "title": "Diagramma configurabile", + "description": "Scegli le metriche di arnia, suono, traffico, clima e dispositivo da tracciare." + }, + "temperatureHeatmap": { + "title": "Mappa termica della temperatura", + "description": "Panoramica compatta della temperatura di tutte le arnie." + }, + "insights": { + "title": "Feed delle analisi", + "description": "Avvisi attivi ed estratti delle evidenze." + }, + "dataQuality": { + "title": "Qualità dei dati", + "description": "Disponibilità dei sensori e letture mancanti." + } + }, + "metricGroups": { + "scale": "Bilancia", + "climate": "Clima", + "beeTraffic": "Traffico delle api", + "vibration": "Vibrazione", + "sound": "Suono", + "device": "Dispositivo" + }, + "metrics": { + "weight": "Peso", + "temperature": "Temperatura", + "humidity": "Umidità", + "pressure": "Pressione", + "beeIn": "Api in entrata", + "beeOut": "Api in uscita", + "beeNet": "Flusso netto", + "vibration": "Vibrazione RMS", + "swarmBand": "Banda di sciamatura", + "fanningBand": "Banda di ventilazione", + "activityBand": "Banda di attività", + "soundRms": "Suono RMS", + "soundHum": "Banda del ronzio", + "soundPiping": "Banda del canto delle regine", + "soundStress": "Banda di stress", + "hiveHeartFrequency": "Frequenza HiveHeart", + "hiveHeartEnergy": "Energia HiveHeart", + "batterySoc": "Carica della batteria", + "batteryVoltage": "Tensione della batteria", + "solarPower": "Potenza solare" + }, + "axes": { + "weight": "Peso", + "temperature": "Temperatura", + "humidity": "Umidità", + "pressure": "Pressione", + "beecount": "Numero di api", + "vibration": "Vibrazione", + "dbfs": "Livello sonoro", + "frequency": "Frequenza", + "energy": "Energia", + "percent": "Carica", + "voltage": "Tensione", + "power": "Potenza" + }, + "soundMetrics": { + "rms": "RMS", + "hiveHeartFrequency": "Frequenza HiveHeart", + "hiveHeartEnergy": "Energia HiveHeart", + "hiveHeartPeak": "Picco HiveHeart" + }, + "vibrationMetrics": { + "rms": "RMS", + "swarmBand": "Banda di sciamatura", + "fanningBand": "Banda di ventilazione", + "activityBand": "Banda di attività" + }, + "series": { + "temperature": "{{name}} temp.", + "humidity": "{{name}} UR", + "beeIn": "Entrate", + "beeOut": "Uscite", + "beeNet": "Netto" + }, + "axisScale": { + "auto": "auto", + "setBound": "Imposta {{bound}} di {{axis}}", + "clearHint": "Lascia vuoto e premi Invio per auto", + "tickTitle": "Fai clic per impostare {{bound}}; svuota per auto", + "reset": "reimposta", + "help": "Fai clic sull'etichetta più bassa o più alta dell'asse Y per modificarla. Svuota il valore per auto." + }, + "chart": { + "downloadCsv": "Scarica CSV" + }, + "inspection": { + "legendHint_one": "{{count}} periodo di ispezione ombreggiato; le letture potrebbero essere alterate", + "legendHint_other": "{{count}} periodi di ispezione ombreggiati; le letture potrebbero essere alterate", + "legendHint_many": "{{count}} periodi di ispezione ombreggiati; le letture potrebbero essere alterate" + }, + "empty": { + "noData": "Nessun dato per le arnie e il periodo selezionati.", + "climate": "Nessun dato climatico per il periodo selezionato.", + "power": "Nessun dato di alimentazione per il periodo selezionato.", + "beeTraffic": "Nessun dato del contatore di api per le arnie selezionate.", + "sound": "Nessun dato acustico per arnia per le arnie selezionate.", + "vibration": "Nessun dato di vibrazione o delle bande dell'accelerometro per le arnie selezionate.", + "configurable": "Seleziona almeno una metrica con dati per le arnie e il periodo selezionati.", + "dailyMaxWeight": "Nessun dato di peso per le arnie e il periodo selezionati.", + "hiveHeart": "Nessun dato FFT di HiveHeart per le arnie e il periodo selezionati.", + "unknownWidget": "Tipo di widget sconosciuto." + }, + "soundFootnote": "Mostra prima le letture acustiche per arnia. I dati precedenti dei microfoni sinistro/destro vengono associati ai nomi delle arnie configurati per le posizioni 1 e 2 quando non è presente un blocco acustico per arnia.", + "heatmap": { + "low": "bassa <33 °C", + "optimal": "covata ottimale 33–36 °C", + "high": "alta >36 °C" + }, + "insights": { + "loading": "Caricamento analisi…", + "unavailable": "Le analisi non sono disponibili.", + "noAlerts": "Nessun avviso attivo per le arnie selezionate.", + "showing_one": "Visualizzazione di {{count}} avviso attivo per le arnie selezionate.", + "showing_other": "Visualizzazione di {{count}} avvisi attivi per le arnie selezionate.", + "showing_many": "Visualizzazione di {{count}} avvisi attivi per le arnie selezionate.", + "confidence": "affidabilità {{percent}}%" + }, + "dataQuality": { + "activeSlots": "Posizioni attive", + "noScale": "Nessuna bilancia", + "noClimate": "Nessun dato climatico", + "noRecentData": "nessun dato recente" + }, + "hiveHeart": { + "level": "livello", + "dominant": "frequenza dominante {{hz}} Hz", + "noDominant": "nessuna frequenza dominante segnalata", + "footnote": "Livelli relativi 0–15 (non dBFS) dell'ultima lettura del periodo. Le linee tratteggiate indicano la frequenza dominante segnalata da HiveHeart; le intestazioni Sub-bassi/Ronzio/Canto delle regine/Stress sono approssimative e comprendono più intervalli." + } } } diff --git a/apps/frontend/public/locales/nl/common.json b/apps/frontend/public/locales/nl/common.json index 4c3b93d82..55a402aae 100644 --- a/apps/frontend/public/locales/nl/common.json +++ b/apps/frontend/public/locales/nl/common.json @@ -21,7 +21,8 @@ "allInspections": "Alle inspecites", "schedule": "Planning", "recent": "Recent", - "upcoming": "Aankomende" + "upcoming": "Aankomende", + "hivehub": "HiveHub" }, "status": { "completed": "Afgerond" diff --git a/apps/frontend/public/locales/nl/hivescale.json b/apps/frontend/public/locales/nl/hivescale.json index f0f142a0a..2ae9e8a59 100644 --- a/apps/frontend/public/locales/nl/hivescale.json +++ b/apps/frontend/public/locales/nl/hivescale.json @@ -35,7 +35,7 @@ }, "page": { "title": "HiveHub", - "subtitle": "Live weight and temperature visualisation from your separate HiveHub backend.", + "subtitle": "Gewicht, klimaat, geluid, vliegverkeer en meldingen van je HiveHub-apparaten.", "refresh": "Refresh", "refreshing": "Refreshing…", "refreshError": "Could not refresh HiveHub data.", @@ -43,16 +43,26 @@ "removeSuccess": "HiveHub device removed from your account.", "removeSuccessShared": "Je hebt geen toegang meer tot dit HiveHub-apparaat. Andere gebruikers hebben het nog, dus de claimcode blijft in gebruik.", "releaseConfirm": "{{name}} volledig vrijgeven? Alle gebruikers verliezen toegang en het apparaat kan opnieuw worden geclaimd met zijn claimcode. Metingen blijven bewaard.", - "releaseSuccess": "HiveHub-apparaat vrijgegeven. Het kan opnieuw worden geclaimd met zijn claimcode." + "releaseSuccess": "HiveHub-apparaat vrijgegeven. Het kan opnieuw worden geclaimd met zijn claimcode.", + "unavailableTitle": "HiveHub is niet bereikbaar", + "inspectionActiveTitle": "Inspectiemodus staat aan", + "inspectionActiveBody": "Metingen worden gemaskeerd terwijl de kasten open zijn, zodat het uitnemen van ramen geen valse meldingen geeft.", + "openInspections": "Inspecties openen", + "tabs": { + "overview": "Overzicht", + "inspections": "Inspecties", + "audio": "Audio", + "health": "Status", + "setup": "Instellingen" + } }, "setup": { "title": "Devices", - "description": "Select or remove a claimed HiveHub device. Expand the setup to claim devices, check status, map scales, and calibrate the raw readings.", "removeScale": "Remove scale", "releaseScale": "Apparaat vrijgeven", "noDevices": "No devices claimed yet — open the setup to add one.", - "hide": "Hide setup", - "show": "Show setup" + "selectDevice": "HiveHub-apparaat", + "noDevicesYet": "Nog geen HiveHub-apparaat geclaimd. Claim er een met de code die de hub in zijn installatieportaal toont." }, "claim": { "title": "Claim HiveHub device", @@ -65,16 +75,30 @@ "success": "HiveHub device claimed.", "errors": { "missingCode": "Enter the claim code printed/configured for the device." - } + }, + "hive1Name": "Naam van kast 1", + "hive2Name": "Naam van kast 2", + "optional": "Optioneel" }, "mapping": { - "title": "Scale mapping", - "description": "These labels stay in HiveHub. Later, this is the right place to map Scale 1/2 to HivePal hive IDs for inspection markers.", - "scale1Label": "Display name for Scale 1", - "scale2Label": "Display name for Scale 2", + "title": "Kasten", "hiveNamePlaceholder": "Select or type a hive name", - "save": "Save mapping", - "success": "Scale mapping updated." + "save": "Kasten opslaan", + "success": "Kastplaatsen opgeslagen in HiveHub.", + "slotsDescription": "Geef elke kastplaats een naam en koppel hem aan een HivePal-kast. Via de koppeling weten inspecties, de kastpagina en meldingsmails welk volk bij een plaats hoort. Een groene stip markeert plaatsen die de hub op dit moment meldt.", + "mapSlot": "Plaats {{slot}} koppelen", + "add": "Toevoegen", + "reporting": "Gemeld door de hub", + "unknownHive": "Kast niet gevonden", + "mappedCount": "{{count}}/{{total}} plaatsen gekoppeld", + "dialogTitle": "Kastplaats {{slot}}", + "dialogDescription": "Koppel de plaats aan een van je HivePal-kasten en kies de naam die HiveHub ervoor toont.", + "linkedHive": "HivePal-kast", + "notLinked": "Niet gekoppeld", + "linkHint": "De koppeling blijft behouden als je de kast hernoemt. Kies je een kast, dan wordt de naam ingevuld als het naamveld leeg is.", + "displayName": "Weergavenaam", + "clearSlot": "Plaats leegmaken", + "applySlot": "Toepassen" }, "status": { "title": "Device status", @@ -94,6 +118,13 @@ "revokeAccess": "Revoke access", "errors": { "missingEmail": "Enter the email address of an existing HivePal user." + }, + "lastSeen": "Laatste melding", + "claimedAt": "Geclaimd", + "roles": { + "owner": "Eigenaar", + "admin": "Beheerder", + "viewer": "Kijker" } }, "calibration": { @@ -203,11 +234,9 @@ }, "sourceHint": "De omgevingstemperatuur volgt het cellichaam in stabiele toestand nauwkeurig en is de aanbevolen standaard.", "refLabel": "Referentietemperatuur / °C", - "refHint": "Temperatuur waarbij de correctie nul is.", "coeffLabel": "{{scaleName}}-coëfficiënt / kg per °C", "autoFit": "Automatisch afstemmen op geschiedenis", "fitting": "Afstemmen…", - "autoFitHint": "Automatisch afstemmen voert een regressie uit van het ruwe gewicht van de afgelopen 3 dagen tegen de met EMA gladgestreken geselecteerde temperatuur en past het resultaat vervolgens toe en schakelt het in. Leg voor een schone afstemming een venster vast waarin de fysieke belasting constant is en de temperatuur varieert.", "save": "Temperatuurcompensatie opslaan", "errors": { "refTemp": "De referentietemperatuur moet een getal zijn (°C).", @@ -219,11 +248,33 @@ "noSignal": "onvoldoende temperatuur-/gewichtssignaal.", "fitApplied": "{{scaleName}}: {{coeff}} kg/°C bij {{refTemp}}°C (R²={{r2}}, n={{n}}). Toegepast en ingeschakeld." } + }, + "perHive": { + "hive": "Kast {{index}}", + "hiveNamed": "Kast {{index}} · {{name}}", + "hiveLabel": "Kast", + "knownWeightPlaceholder": "bijv. 10", + "manualSaveHint": "Wissel van kast om een andere weegschaal te bewerken; alle wijzigingen worden samen verstuurd wanneer je opslaat. Kasten vanaf 3 worden alleen weggeschreven als ze gewijzigd zijn.", + "tempco": { + "refHint": "De temperatuur waarbij de weegschalen correct wegen, d.w.z. die waarbij ze getarreerd en gekalibreerd zijn. Daar is de correctie nul; het fitten laat deze ongemoeid, tenzij je daarom vraagt.", + "lookbackLabel": "Fitperiode / dagen", + "setRefTemp": "Ook de referentietemperatuur instellen", + "setRefTempHint": "Vervangt de referentie door de gemiddelde temperatuur van het gefitte venster. Laat uit als de weegschaal bij een bekende temperatuur gekalibreerd is.", + "fitOnly": "Alleen fitten (voorbeeld)", + "fitAndApply": "Fitten & toepassen", + "fitSummary": "{{scaleName}}: {{coeff}} kg/°C, R² {{r2}} over {{n}} metingen van {{min}}–{{max}} °C (venstergemiddelde {{mean}} °C).", + "lowRSquared": "R² onder 0,5: de temperatuur verklaart weinig van deze drift. Fit over een periode met een constante belasting en een groot dag-/nachtverschil.", + "autoFitHint": "Automatisch fitten voert een regressie uit van het opgeslagen gewicht van de gekozen kast over de terugkijkperiode tegen de EMA-afgevlakte gekozen temperatuur. \"Alleen fitten\" vult de coëfficiënt in zodat je hem kunt controleren voordat je opslaat; \"Fitten & toepassen\" slaat hem op en schakelt de compensatie in. Kies voor een schone fit een venster waarin de fysieke belasting constant is en de temperatuur schommelt.", + "errors": { + "lookback": "De terugkijkperiode moet een geheel aantal dagen tussen 1 en 90 zijn." + }, + "toasts": { + "fitFilled": "{{scaleName}}: {{coeff}} kg/°C (R²={{r2}}, n={{n}}). Ingevuld — controleer en sla op." + } + } } }, "firmware": { - "title": "Firmware upload", - "description": "Upload a firmware binary and register it as a release. New devices of the matching type pick it up on their next update check.", "type": "Firmware type", "selectType": "Select firmware type", "version": "Version", @@ -238,6 +289,102 @@ "errors": { "missingFile": "Choose a firmware binary (.bin) to upload.", "missingVersion": "Enter a version, e.g. 0.6.3." + }, + "update": { + "title": "Firmware-update", + "description": "Weegschalen updaten nooit vanzelf — je keurt hier elke nieuwe build goed voordat deze weegschaal hem installeert.", + "unavailable": "Firmwarestatus is op dit moment niet beschikbaar.", + "availableTitle": "Firmware {{version}} is beschikbaar", + "availableBody": "Deze weegschaal draait {{current}}. Pas toe om hem bij te werken naar {{version}}.", + "officialSuffix": "Dit is een officiële build.", + "apply": "Update toepassen", + "applying": "Bezig met toepassen…", + "applied": "Firmware {{version}} goedgekeurd — de weegschaal werkt bij bij de volgende melding.", + "queued": "Update {{version}} goedgekeurd. De weegschaal installeert hem bij de volgende melding.", + "upToDate": "Firmware is up-to-date ({{version}}).", + "viewerNotice": "Alleen eigenaars en beheerders van het apparaat kunnen firmware-updates toepassen." + }, + "board": { + "cardTitle": "Firmware", + "cardDescription": "Firmware van deze HiveHub en van de HiveInside-nodes en HiveTraffic-tellers die eraan gekoppeld zijn. Uploaden registreert een release; goedkeuren (hub) of doorsturen (nodes) installeert hem.", + "current": "Huidig", + "latest": "Nieuwste", + "approved": "Goedgekeurd", + "board": "Board", + "badgePending": "Update wacht op goedkeuring", + "badgeApproved": "Update goedgekeurd", + "badgeUpToDate": "Up-to-date", + "approveFlash": "{{version}} goedkeuren & flashen", + "approveConfirm": "Firmware {{version}} goedkeuren en draadloos flashen?\n\nDe HiveHub installeert hem bij de volgende melding en start opnieuw op. Een apparaat op afstand waarbij de update halverwege mislukt, kan fysieke toegang nodig hebben om te herstellen.", + "otherBoards": "Ook geüpload voor een ander board (niet toegepast op dit {{board}}-apparaat): {{releases}}. Een build bereikt alleen een apparaat met een overeenkomend board — controleer het board bij het uploaden.", + "otherBoardsUnknown": "Ook geüpload voor een ander board (niet toegepast op dit apparaat): {{releases}}. Een build bereikt alleen een apparaat met een overeenkomend board — controleer het board bij het uploaden." + }, + "relays": { + "hiveFallback": "Kast {{slot}}", + "latest": "nieuwste {{version}}", + "updateAvailable": "update beschikbaar: {{version}}", + "status": { + "pending": "doorsturen in wachtrij", + "running": "bezig met doorsturen…", + "completed": "doorsturen voltooid", + "failed": "doorsturen mislukt", + "expired": "doorsturen verlopen" + }, + "statusTitle": "Doorsturen van {{version}}: {{status}} ({{date}})", + "failedDetail": "Laatste doorsturen mislukt — {{message}} ({{date}})", + "noReason": "geen reden gemeld", + "alreadyQueued": "Er staat al een doorsturing in de wachtrij voor deze node", + "queueing": "In wachtrij plaatsen…", + "relayAnyway": "Toch doorsturen", + "confirm": "{{name}} {{version}} doorsturen naar {{hive}}?\n\nDe HiveHub pakt dit op bij de volgende uploadcyclus — normaal binnen ongeveer 10 minuten, of het verzendinterval dat dit apparaat gebruikt. Daarna streamt hij het image enkele minuten via BLE en start de node ermee opnieuw op. De node moet wakker en binnen bereik zijn, anders mislukt het doorsturen en moet het opnieuw in de wachtrij worden gezet.", + "queued": "Doorsturen van {{version}} in de wachtrij gezet voor {{hive}}.", + "queuedFrom": "Doorsturen in de wachtrij gezet voor {{hive}} ({{current}} → {{version}}).", + "hiveinside": { + "name": "HiveInside", + "title": "HiveInside-nodes", + "description": "Firmware die elke node in de kast meldt. Het uploaden van een HiveInside-image registreert alleen de release — er bereikt niets een node totdat je op „Doorsturen naar node” drukt. De hier getoonde versie bevestigt dat de update gelukt is.", + "relay": "Doorsturen naar node", + "unnamed": "Meldt geen eigen naam (HiveInside vóór 0.5.0)", + "noRelease": "Nog geen HiveInside-release geüpload — upload er hieronder een om hem door te sturen." + }, + "beecounter": { + "name": "HiveTraffic", + "title": "HiveTraffic-tellers", + "description": "Firmware die elke HiveTraffic-teller meldt. Het uploaden van een HiveTraffic-image registreert alleen de release — er bereikt niets een teller totdat je op „Doorsturen naar teller” drukt. Tellers ouder dan ongeveer v0.1.0 melden geen versie, maar kunnen toch worden bijgewerkt.", + "relay": "Doorsturen naar teller", + "unnamed": "Meldt geen naam of adres", + "noRelease": "Nog geen HiveTraffic-release geüpload — upload er hieronder een om hem door te sturen.", + "countingWarning": "Een teller telt tijdens de hele overdracht geen bijen, dus stuur door wanneer er weinig vliegverkeer is.", + "confirmExtra": "De teller telt tijdens de hele overdracht geen bijen — al het vliegverkeer in die paar minuten gaat verloren." + } + }, + "uploadForm": { + "title": "Firmware uploaden", + "description": "Uploaden registreert alleen een release — het installeert nooit zelf iets. Keur hierboven de hub-update goed, of stuur een node- of tellerrelease door vanuit de bijbehorende rij.", + "board": "Board", + "targets": { + "hivehub": "HiveHub-hub", + "hiveinside": "HiveInside", + "beecounter": "HiveTraffic-teller" + }, + "boards": { + "auto": "Automatisch (uit bestandsnaam)", + "esp32": "ESP32 (klassiek, 30-pins)", + "esp32-c6": "ESP32-C6", + "nrf54lm20a": "nRF54LM20A" + }, + "boardHint": { + "hivehub": "Hub-firmware moet zijn board vermelden: kies er een, of laat op automatisch staan als het bestand heet zoals hivehub_esp32-c6_0.30.5.bin.", + "hiveinside": "HiveInside bestaat alleen voor de nRF54LM20A. Upload het build-artefact ongewijzigd (bijv. hiveinside_nrf54lm20a_1.4.0.bin); doel en versie worden uit de naam ingevuld.", + "beecounter": "De HiveTraffic-teller is een ESP32-C6. Upload het applicatie-image (bijv. beecounter_esp32-c6_0.7.1.bin), geen samengevoegd factory-image." + }, + "detected": "{{name}} — {{fields}} ingevuld.", + "nothingDetected": "{{name}} — geen doel, board of versie herkend in de naam; stel ze hieronder in.", + "detectedTarget": "doel „{{target}}”", + "detectedBoard": "board {{board}}", + "detectedVersion": "versie {{version}}", + "autoQueued": "Doorsturen automatisch in de wachtrij gezet voor {{hives}}.", + "autoQueueFailed": "Kon het doorsturen niet automatisch in de wachtrij zetten voor {{hives}}." } }, "sdData": { @@ -262,12 +409,18 @@ "parsed_other": "Parsed {{count}} records", "skipped_one": " · {{count}} unreadable line skipped.", "skipped_other": " · {{count}} unreadable lines skipped." + }, + "mismatch": { + "title": "Bestand van een andere hub", + "description": "Dit bestand is opgenomen door een andere hub, niet door {{device}}.", + "fileDevices_one": "Apparaat-ID in het bestand:", + "fileDevices_other": "Apparaat-ID's in het bestand:", + "hint": "Importeer het alleen toch als de SD-kaart echt bij dit apparaat hoort (bijvoorbeeld na het vervangen van de printplaat van de hub).", + "cancel": "Annuleren", + "force": "Toch in dit apparaat importeren" } }, "panel": { - "weight": "Weight", - "hiveTemp": "Hive temp", - "rmsSound": "RMS sound", "ambientTemperature": "Ambient temperature", "ambientHumidity": "Ambient humidity", "batteryCharge": "Battery charge", @@ -277,7 +430,6 @@ "title": "General", "description": "Ambient & power" }, - "humidity": "Hive humidity", "wirelessSensorsBattery": "Wireless sensors battery", "sensorTypes": { "hiveHeart": "HiveHeart", @@ -286,174 +438,36 @@ } }, "diagram": { - "title": "Measurements", - "subtitle": "Weight, temperature, and entrance activity over time", - "noMeasurements": "No measurements for the selected range.", - "noDataForSeries": "No data in the selected range.", - "meta": { - "device": "Device:", - "lastSeen": "Last seen:", - "lastData": "Last data:", - "firmware": "Firmware:" - }, "range": { "all": "All", "from": "From", "to": "To" }, - "series": { - "weight": "{{name}} weight", - "temp": "{{name}} temp", - "micRms": "{{name}} mic RMS", - "beesIn": "{{name}} bees in", - "beesOut": "{{name}} bees out", - "netFlow": "{{name}} net flow", - "ambientTemp": "Ambient temp", - "ambientHumidity": "Ambient humidity", - "batteryVoltage": "Battery voltage", - "batteryCharge": "Battery charge", - "solarVoltage": "Solar voltage", - "solarCurrent": "Solar current", - "solarPower": "Solar power", - "humidity": "{{name}} vochtigheid", - "pressure": "{{name}} luchtdruk", - "fanningBand": "{{name}} fanning band (30–100 Hz)", - "activityBand": "{{name}} activity band (100–200 Hz)" - }, - "group": { - "ambient": "Ambient", - "offGrid": "Off-grid" - }, - "axis": { - "weight": "Weight", - "temperature": "Temperature", - "humidity": "Humidity", - "voltage": "Voltage", - "percent": "Percent", - "current": "Current", - "power": "Power", - "sound": "Sound", - "beecount": "Bee count", - "pressure": "Luchtdruk" - }, "marker": { - "inspection": "Inspection", - "boxAdded": "Box added", - "action": { - "maintenance": "Maintenance", - "feeding": "Feeding", - "frames": "Frames", - "treatment": "Treatment" - } - }, - "axisSettings": { - "title": "Axis settings", - "reset": "Reset", - "side": "Side", - "left": "Left", - "right": "Right", - "scale": "Scale", - "auto": "Auto", - "zeroToMax": "0 to max", - "custom": "Custom", - "min": "Min", - "max": "Max", - "autoPlaceholder": "auto" + "inspection": "Inspection" } }, "sound": { - "title": "Hive sound", - "description": "INMP441 FFT band energy (dBFS) — {{left}} (left) · {{right}} (right)", - "hide": "Hide sound", - "show": "Show sound", - "micLeftFallback": "Mic left", - "micRightFallback": "Mic right", - "noDataForChannel": "No data for {{name}} in this range.", - "bandReference": "Band reference", - "thresholds": "Thresholds used by the Insights engine: piping > −45 dBFS, stress > −38 dBFS, queenless hum > −40 dBFS with quiet piping. Calibrate against your own baseline.", - "status": { - "noData": "No data", - "micError": "Mic error", - "bothActive": "{{left}} + {{right}} active", - "oneActive": "{{name}} active" - }, - "table": { - "band": "Band", - "range": "Range", - "significance": "Significance" - }, "bands": { "subBass": { - "label": "Sub-bass", - "description": "Structural vibration / low rumble" + "label": "Sub-bass" }, "hum": { - "label": "Hum", - "description": "Normal colony hum (~200 Hz fundamental)" + "label": "Hum" }, "piping": { - "label": "Piping", - "description": "Queen piping / tooting (pre-swarm signal)" + "label": "Piping" }, "stress": { - "label": "Stress", - "description": "Agitated colony / robbing" + "label": "Stress" }, "high": { - "label": "High", - "description": "Harmonic overtones" + "label": "High" } - }, - "rmsToggleShow": "Show RMS level", - "rmsToggleHide": "Hide RMS level", - "rmsTitle": "RMS sound level", - "rmsSeries": "{{name}} RMS" - }, - "beecounter": { - "title": "Entrance Counter", - "subtitle": "Bees entering and exiting per measurement interval", - "noData": "No BeeCounter data found for this device.", - "offline": "Offline", - "gatesOk": "{{count}} gates OK", - "glitches_one": "{{count}} glitch", - "glitches_other": "{{count}} glitches", - "gatesRatio": "{{healthy}}/{{total}} gates", - "inPeriod": "In (period)", - "outPeriod": "Out (period)", - "netFlow": "Net flow", - "netInbound": "net inbound", - "netOutbound": "net outbound", - "balanced": "balanced", - "view": { - "inOut": "In / Out", - "netFlow": "Net flow", - "cumulative": "Cumulative" - }, - "series": { - "in": "{{name}} in", - "out": "{{name}} out", - "net": "{{name}} net", - "totalIn": "{{name}} total in", - "totalOut": "{{name}} total out" - }, - "diagnostics": { - "title": "Hardware diagnostics", - "gatesHealthy": "Gates healthy", - "glitchCount": "Glitch count", - "totalInLifetime": "Total in (lifetime)", - "totalOutLifetime": "Total out (lifetime)" } }, "insights": { - "title": "Insights", - "description": "Sensor-based alerts derived from weight and temperature trends.", - "updated": "Updated {{ago}}.", - "refresh": "Refresh insights", - "loadError": "Failed to load insights: {{message}}", "allClear": "All clear", - "noActiveAlerts": "No active alerts in the last {{count}} days.", - "activeAlerts_one": "{{count}} active alert:", - "activeAlerts_other": "{{count}} active alerts:", "confidence": "Confidence {{value}}%", "source": "Source: {{source}}", "hiveChannel": "Hive {{channel}}", @@ -538,5 +552,720 @@ "description": "No alerts have been recorded for this device.", "descriptionFiltered": "No alerts have been recorded for this device matching this filter." } + }, + "inspectionMode": { + "title": "Inspectiemodus", + "description": "Zolang de inspectiemodus aan staat, blijft HiveHub meten maar houdt de metingen buiten grafieken, inzichten en meldingen, zodat het uitnemen van ramen niet voor een zwerm wordt aangezien.", + "status": { + "active": "Actief sinds {{time}}", + "pending": "Wacht op ophalen door hub", + "off": "Uit" + }, + "pendingHint": "Aangevraagd — de hub pakt het op bij de volgende melding.", + "timeoutNote_one": "Stopt automatisch na {{count}} minuut als niemand hem stopt.", + "timeoutNote_other": "Stopt automatisch na {{count}} minuten als niemand hem stopt.", + "scope": { + "wholeHub": "Hele hub", + "label": "Omvat: {{hives}}" + }, + "start": "Inspectiemodus starten", + "stop": "Inspectiemodus stoppen", + "starting": "Bezig met starten…", + "stopping": "Bezig met stoppen…", + "cancel": "Annuleren", + "noteLabel": "Notitie (optioneel)", + "notePlaceholder": "bijv. Honingkamer opgezet", + "viewerHint": "Alleen eigenaars en beheerders kunnen de inspectiemodus starten of stoppen.", + "loadFailed": "Kon de inspectiestatus niet laden.", + "startDialog": { + "title": "Inspectiemodus starten", + "description": "Metingen van de geselecteerde kasten worden gemaskeerd totdat je de inspectiemodus stopt of deze verloopt.", + "hivesLabel": "Kasten", + "wholeHub": "Hele hub (alle kasten)", + "selectHive": "Kies minstens één kast, of de hele hub.", + "confirm": "Starten" + }, + "stopDialog": { + "title": "Inspectiemodus stoppen", + "description": "HiveHub keert terug naar normale metingen en meldingen.", + "confirm": "Stoppen" + }, + "toast": { + "started": "Inspectiemodus gestart", + "stopped": "Inspectiemodus gestopt", + "noteSaved": "Notitie opgeslagen" + }, + "history": { + "title": "Recente inspecties", + "empty": "Nog geen inspecties vastgelegd.", + "ongoing": "bezig", + "startedVia": "Gestart via {{source}}", + "ended": "Beëindigd: {{reason}}", + "editNote": "Notitie bewerken", + "addNote": "Notitie toevoegen", + "saveNote": "Opslaan" + }, + "duration": { + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "hoursMinutes": "{{hours}} u {{minutes}} min" + }, + "source": { + "device": "hubknop", + "api": "app", + "dashboard": "lokaal dashboard" + }, + "endReason": { + "timeout": "verlopen", + "device": "hubknop", + "api": "gestopt in app", + "dashboard": "gestopt op lokaal dashboard" + } + }, + "hiveLink": { + "prompt": { + "message": "Deze kast wordt bewaakt door HiveHub {{device}} ({{hive}}). Start de inspectiemodus zodat het openen van de kast geen valse meldingen geeft.", + "active": "Inspectiemodus staat aan voor {{hive}} op HiveHub {{device}}.", + "pending": "Inspectiemodus aangevraagd voor {{hive}} op HiveHub {{device}} — de hub pakt het op bij de volgende melding.", + "activeOther": "Inspectiemodus staat aan voor andere kasten op HiveHub {{device}}, niet voor {{hive}}. Stop hem eerst om er een voor deze kast te starten." + }, + "card": { + "title": "HiveHub", + "description": "Live metingen van de gekoppelde HiveHub-sensoren.", + "weight": "Gewicht", + "weightChange24h": "Verandering 24 u", + "temperature": "Kasttemperatuur", + "humidity": "Luchtvochtigheid", + "lastReading": "Laatste meting: {{time}}", + "openHiveHub": "HiveHub openen" + } + }, + "traffic": { + "title": "HiveTraffic-bijentellers", + "description": "Nachtmodus en emitterbanken voor elke HiveTraffic-teller die aan deze hub gekoppeld is. De IR-emitters zijn verreweg de grootste post in het stroombudget van een teller, dus deze instellingen bepalen of hij op een off-grid voeding past.", + "readOnly": "Alleen eigenaars en beheerders van het apparaat kunnen deze instellingen wijzigen. Je rol is „{{role}}”.", + "hiveFallback": "Kast {{slot}}", + "noChanges": "Geen wijzigingen om op te slaan", + "saved": "Bijentellerinstellingen opgeslagen. Tellers nemen ze over bij de volgende melding.", + "appliesToAll": "Geldt voor elke HiveTraffic-teller die aan dit apparaat gekoppeld is. De teller krijgt het door bij de volgende uploadcyclus van de hub.", + "status": { + "title": "Tellers op dit moment", + "none": "Geen HiveTraffic-teller gemeld in de laatste meting.", + "counting": "Telt", + "nightIdle": "Nachtrust", + "nightIdleFor_one": "Nachtrust · nog {{count}} min", + "nightIdleFor_other": "Nachtrust · nog {{count}} min", + "banksRunning": "Actieve emitterbanken: {{banks}}", + "banksUnknown": "Emitterbanken niet gemeld (tellerfirmware ouder dan 0.3.0)", + "applied": "Banken toegepast", + "pending": "Banken wachten op volgende melding" + }, + "night": { + "title": "Nachtmodus", + "description": "Honingbijen zijn dagactief — vliegen vereist licht en stopt onder ongeveer 10 °C sowieso. Tijdens dit venster stopt de teller met meten om stroom te besparen. Hij blijft adverteren, dus metingen en firmware-updates werken nog steeds.", + "enable": "Nachtmodus inschakelen", + "start": "Nacht begint (lokale tijd)", + "end": "Nacht eindigt (lokale tijd)", + "windowHelp": "Het venster mag over middernacht lopen (bijv. 20:00 → 06:00). Als begin en einde gelijk zijn, is het venster leeg — het geldt nooit — in plaats van de hele dag te beslaan. Houd een marge rond schemering en dageraad aan: de activiteit piekt vlak voor zonsondergang en zonsopgang.", + "emptyWindow": "Begin en einde zijn gelijk, dus het venster is leeg en de nachtmodus zou nooit gelden.", + "wrapsMidnight": "Het venster loopt over middernacht: van {{start}} tot {{end}} de volgende ochtend.", + "timezone": "Tijdzone", + "tzCustom": "POSIX-TZ-string", + "timezoneHelp": "De klok van de hub loopt in UTC. Zonder de juiste tijdzone verschuift het venster met je UTC-afwijking en schuift het bij elke zomer-/wintertijdwissel een uur op.", + "tzPresets": { + "cet": "Midden-Europa (Berlijn, Parijs, Madrid, Wenen)", + "uk": "Verenigd Koninkrijk / Ierland", + "eet": "Oost-Europa (Helsinki, Athene)", + "usEastern": "VS Eastern", + "usCentral": "VS Central", + "usMountain": "VS Mountain", + "usPacific": "VS Pacific", + "utc": "UTC (geen lokale tijd)", + "custom": "Aangepast…" + }, + "maxTraffic": "Uitstellen boven (passages per cyclus)", + "maxTrafficHelp": "Stel de nachtmodus uit zolang er in de laatste uploadcyclus meer dan dit aantal passages (in + uit) is geteld, zodat een volk dat op een zomeravond nog laat vliegt niet wordt afgekapt. 0 gaat alleen op de klok af.", + "save": "Nachtmodus opslaan" + }, + "banks": { + "title": "Emitterbanken", + "description": "De IR-emitters van de teller worden geschakeld in drie banken van acht poortjes. Elke bank verbruikt ongeveer 80 mA. Schakel er een uit als het vlieggat smaller is dan 24 poortjes, deels gesloten is, of de voeding het hele board niet aankan.", + "bank": "Bank {{bank}} — poortjes {{gates}}", + "current_one": "{{count}} bank aan · ongeveer {{ma}} mA op de 3,3 V-rail van de teller", + "current_other": "{{count}} banken aan · ongeveer {{ma}} mA op de 3,3 V-rail van de teller", + "help": "Een uitgeschakelde bank telt zijn poortjes helemaal niet, dus de totalen dalen ongeveer evenredig — vergelijk een versmalde teller niet met zijn eigen geschiedenis. Minstens één bank moet aan blijven; ontkoppel een teller die niets moet tellen.", + "firmwareNote": "Vereist tellerfirmware 0.3.0 of nieuwer. De instelling wordt na elke reset van de teller opnieuw verstuurd.", + "save": "Emitterbanken opslaan" + }, + "errors": { + "loadFailed": "Kon de apparaatconfiguratie niet laden.", + "saveFailed": "Kon de bijentellerinstellingen niet opslaan", + "invalidTime": "Voer een geldige begin- en eindtijd in (UU:MM).", + "invalidMaxTraffic": "De uitsteldrempel moet een geheel getal van 0 of meer zijn.", + "emptyWindow": "Begin en einde van de nachtmodus zijn gelijk — het venster zou nooit gelden.", + "noBank": "Minstens één emitterbank moet aan blijven — ontkoppel in plaats daarvan de teller." + } + }, + "hubAccess": { + "title": "Hubtoegang", + "description": "Bereik de hub ter plaatse zonder de behuizing te openen, en stel in hoe lang een inspectie mag duren.", + "readOnly": "Alleen eigenaars en beheerders van het apparaat kunnen deze instellingen wijzigen. Je rol is „{{role}}”.", + "noChanges": "Geen wijzigingen om op te slaan", + "cancel": "Annuleren", + "ap": { + "title": "Installatie-accesspoint", + "description": "Opent het wifi-netwerk HiveHub-Setup van de hub, net als een korte druk op de setupknop: wifi wijzigen, sensoren koppelen of de SD-kaartgegevens downloaden.", + "lastCheckIn": "Laatste melding: {{time}}", + "open": "Installatie-accesspoint openen", + "confirmTitle": "Het installatie-accesspoint openen?", + "confirmTiming_one": "Het verzoek wordt in de wachtrij gezet. De hub slaapt tussen cycli, dus het accesspoint opent na de volgende melding — uiterlijk over {{count}} minuut.", + "confirmTiming_other": "Het verzoek wordt in de wachtrij gezet. De hub slaapt tussen cycli, dus het accesspoint opent na de volgende melding — uiterlijk over {{count}} minuten.", + "confirmTimingUnknown": "Het verzoek wordt in de wachtrij gezet. De hub slaapt tussen cycli, dus het accesspoint opent na de volgende melding — uiterlijk over één verzendinterval.", + "confirmOffline": "Zolang het accesspoint open is, is de hub niet met wifi verbonden en verstuurt hij geen metingen.", + "confirmTimeout": "Het sluit vanzelf na de time-out van het installatieportaal als niemand verbinding maakt, dus start het alleen als er iemand bij de kast is.", + "confirm": "Accesspoint in wachtrij zetten", + "queueing": "In wachtrij plaatsen…", + "queued": "Accesspoint in de wachtrij gezet. Het opent na de volgende melding van de hub.", + "failed": "Kon het accesspoint niet in de wachtrij zetten" + }, + "inspection": { + "title": "Inspectie-time-out", + "description": "Een vangnet: een inspectie stopt vanzelf na dit aantal minuten, zodat een vergeten druk op de knop de gegevens van een kast niet eindeloos wegmaskeert. Standaard 60.", + "label": "Time-out (minuten, 1–1440)", + "save": "Time-out opslaan", + "saved": "Inspectie-time-out opgeslagen", + "saveFailed": "Kon de inspectie-time-out niet opslaan", + "invalid": "Voer een geheel aantal minuten in tussen 1 en 1440." + } + }, + "dataTools": { + "title": "Gegevensexport & opschonen", + "description": "Maak een back-up van de metingen van deze hub of verwijder foutieve.", + "hiveFallback": "Kast {{slot}}", + "cancel": "Annuleren", + "export": { + "title": "Metingen exporteren", + "description": "Download de opgeslagen metingen als NDJSON-back-up.", + "from": "Van (optioneel)", + "to": "Tot (optioneel)", + "rangeHelp": "Laat beide leeg om alles te exporteren. Datums omvatten hele dagen in je lokale tijd.", + "hives": "Kasten (optioneel)", + "allHives": "Alle kasten zijn inbegrepen.", + "someHives_one": "Alleen {{count}} geselecteerde kast is inbegrepen. Het aantal hieronder omvat alle metingen in de periode.", + "someHives_other": "Alleen {{count}} geselecteerde kasten zijn inbegrepen. Het aantal hieronder omvat alle metingen in de periode.", + "invalidRange": "De begindatum ligt na de einddatum.", + "summaryFailed": "Kon de metingen niet tellen.", + "count_one": "{{count}} meting", + "count_other": "{{count}} metingen", + "span": "Van {{first}} tot {{last}}", + "download": "Downloaden", + "formatHelp": "Eén JSON-meting per regel. Het bestand kan opnieuw worden geïmporteerd met de SD-kaartimport.", + "noPermission": "Alleen eigenaars en beheerders van het apparaat kunnen gegevens exporteren. Je rol is „{{role}}”." + }, + "delete": { + "title": "Metingen in een periode verwijderen", + "description": "Verwijdert foutieve metingen — bijvoorbeeld de gewichtspieken die een hub bij het opstarten vastlegt voordat zijn weegschalen gekalibreerd zijn, en die anders grafieken en inzichten vertekenen.", + "warningTitle": "Dit kan niet ongedaan worden gemaakt", + "warning": "Verwijderde metingen zijn definitief weg. Exporteer de periode eerst als je die misschien nog nodig hebt.", + "start": "Begin", + "end": "Einde", + "claimCode": "Claimcode", + "claimCodeHelp": "De claimcode van de hub, als tweede bevestiging.", + "review": "Verwijdering controleren", + "confirmTitle": "Deze metingen verwijderen?", + "confirmRange": "Van {{start}} tot {{end}}.", + "confirmCount_one": "{{count}} meting wordt definitief verwijderd.", + "confirmCount_other": "{{count}} metingen worden definitief verwijderd.", + "confirmIrreversible": "Dit kan niet ongedaan worden gemaakt.", + "countFailed": "Kon de metingen in deze periode niet tellen.", + "confirm_one": "{{count}} meting verwijderen", + "confirm_other": "{{count}} metingen verwijderen", + "deleting": "Bezig met verwijderen…", + "success_one": "{{count}} meting verwijderd.", + "success_other": "{{count}} metingen verwijderd.", + "noPermission": "Alleen de eigenaar van het apparaat kan metingen verwijderen. Je rol is „{{role}}”.", + "errors": { + "missingRange": "Kies een begin en een einde.", + "invalidRange": "Het einde moet gelijk aan of na het begin liggen.", + "missingClaimCode": "Voer de claimcode van de hub in." + } + } + }, + "alertSettings": { + "title": "HiveHub-e-mailmeldingen", + "description": "Ontvang een e-mail wanneer HiveHub iets opmerkt bij je kasten — zwermen, roverij, een moerloos volk en meer.", + "enable": "Mail me over HiveHub-meldingen", + "minSeverity": "Minimale ernst", + "minSeverityHelp": "Meldingen onder dit niveau worden nooit gemaild.", + "severity": { + "watch": "Observeren en hoger", + "warning": "Waarschuwing en hoger", + "critical": "Alleen kritiek" + }, + "help": { + "schedule": "HivePal controleert elke 15 minuten op nieuwe meldingen.", + "once": "Elke melding wordt één keer gemaild wanneer die verschijnt, en alleen opnieuw als die ernstiger wordt.", + "scope": "Geldt voor alle HiveHub-apparaten waartoe je toegang hebt.", + "email": "Vereist dat e-mail op de HivePal-server is geconfigureerd." + }, + "saved": "Meldingsinstellingen opgeslagen", + "saveFailed": "Kon de meldingsinstellingen niet opslaan", + "loadFailed": "Kon je voorkeuren niet laden." + }, + "health": { + "title": "Sensorstatus", + "description": "Status per sensor zoals gemeld door {{name}}.", + "descriptionAt": "Sensorstatus van de laatste melding · {{ago}}.", + "noData": "Nog geen sensorstatus gemeld — die verschijnt zodra het apparaat zijn eerste meting verstuurt.", + "hubHeading": "Hub", + "hiveIndex": "Kast {{n}}", + "noHiveSensors": "Geen sensoren gemeld voor deze kast.", + "faultNote": "De hub meldt zijn eigen subsystemen alleen als goed/fout — hij stuurt er geen foutcode voor, dus elke storing hierboven vermeldt wat er misging. De vlieggatteller meldt wel een statuscode, en die wordt bij de storing getoond waar er een is uitgelezen.", + "summary": { + "noData": "Geen sensorgegevens", + "allOk": "Alles OK", + "faults_one": "{{count}} storing", + "faults_other": "{{count}} storingen" + }, + "state": { + "ok": "OK", + "fault": "Storing", + "notFitted": "Niet geplaatst", + "notHeard": "Niet ontvangen" + }, + "hub": { + "sht_ok": "Omgevingstemperatuur/-vochtigheid (SHT40)", + "rtc_ok": "Realtimeklok", + "sd_ok": "SD-kaart", + "mic_ok": "Microfoon", + "mic_left_ok": "Microfoon · links", + "mic_right_ok": "Microfoon · rechts", + "battery_monitor_ok": "Batterijmonitor", + "solar_monitor_ok": "Zonnepaneelmonitor" + }, + "hubReason": { + "sht_ok": "De omgevingssensor gaf deze cyclus geen geldige meting, dus deze melding bevat geen omgevingstemperatuur of -vochtigheid. Meestal een fout in de I²C-bus of bedrading; een sensor die bij het opstarten nooit is gedetecteerd, ziet er hetzelfde uit.", + "rtc_ok": "De realtimeklok antwoordde niet op de I²C-bus. Tijdstempels vallen terug op netwerktijd, dus een meting die offline is gedaan kan een verkeerde datum hebben.", + "sd_ok": "De SD-kaart is deze cyclus niet gekoppeld — ontbrekend, onleesbaar, of een SPI-/bedradingsfout. Zolang dit aanhoudt, worden metingen niet op de kaart gebufferd.", + "mic_ok": "De microfoonopname leverde geen bruikbare samples op, dus deze melding bevat geen akoestische banden.", + "mic_left_ok": "De linkermicrofoon leverde geen bruikbare samples op.", + "mic_right_ok": "De rechtermicrofoon leverde geen bruikbare samples op.", + "battery_monitor_ok": "De batterijmeter antwoordde niet op de I²C-bus — batterijspanning en laadtoestand ontbreken in deze melding.", + "solar_monitor_ok": "De zonnepaneelmonitor antwoordde niet op de I²C-bus — zonnespanning, -stroom en -vermogen ontbreken in deze melding." + }, + "sensor": { + "scale": "Weegschaal", + "inHive": "Kastsensor", + "counter": "HiveTraffic-teller", + "hiveheart": "HiveHeart" + }, + "reason": { + "scale": "Voor deze kast is een weegschaal geconfigureerd, maar die gaf deze cyclus geen bruikbare meting, dus deze melding bevat geen gewicht ervoor.", + "inHive": "Geen advertentie van de gekoppelde {{kind}} tijdens de BLE-scan van deze cyclus — buiten bereik, lege batterij of uitgeschakeld.", + "inHiveKindFallback": "kastsensor", + "node": "Node {{node}}.", + "lastHeard": "Laatst ontvangen {{ago}}.", + "notHeardInRange": "Niet ontvangen in de geladen periode.", + "counter": "De vlieggatteller beantwoordde zijn GATT-uitlezing deze cyclus niet — buiten bereik, uitgeschakeld of bezet toen HiveHub verbinding zocht.", + "counterNode": "Teller {{node}}.", + "lastAnswered": "Laatst geantwoord {{ago}}.", + "notAnsweredInRange": "Hij heeft niet geantwoord in de geladen periode.", + "statusThen": "Status toen: {{status}}.", + "expandersThen_one": "{{count}} van 3 port-expanders antwoordde toen.", + "expandersThen_other": "{{count}} van 3 port-expanders antwoordden toen.", + "hiveheart": "De HiveHeart is deze cyclus niet ontvangen — buiten bereik, lege batterij of uitgeschakeld." + }, + "counter": { + "nightIdle": "nachtmodus-rust", + "nightIdleBadge": "Nachtrust", + "otherBits": "tellerspecifieke bits {{hex}}", + "noFlags": "geen vlaggen gezet", + "statusCode": "status {{hex}}", + "expanders_one": "{{count}}/3 poortje-expanders", + "expanders_other": "{{count}}/3 poortje-expanders", + "glitches_one": "{{count}} storing", + "glitches_other": "{{count}} storingen", + "banks": "Banken {{list}}", + "noBanks": "Geen emitterbanken" + }, + "meta": { + "firmware": "v{{version}}", + "batteryPercent": "Batterij {{value}} %", + "batteryMv": "Batterij {{value}} mV", + "batteryV": "Batterij {{value}} V", + "rssi": "{{value}} dBm" + } + }, + "hubStatus": { + "title": "Hubstatus", + "description": "Verbinding, klok en voeding van de HiveHub zelf.", + "noData": "Nog geen meting ontvangen van deze hub.", + "staleWarning": "Al {{ago}} geen meting — de hub heeft meerdere meldingen gemist.", + "badge": { + "live": "Live", + "stale": "Verouderd", + "inspection": "Inspectie bezig", + "calibration": "Kalibratiemodus" + }, + "section": { + "connectivity": "Verbinding", + "battery": "Batterij", + "solar": "Zonne-energie" + }, + "row": { + "signal": "Signaal", + "transport": "Transport", + "timeSource": "Tijdbron", + "lastSeen": "Laatst gezien", + "lastReading": "Laatste meting", + "firmware": "Firmware", + "bootCount": "Aantal opstarts", + "stateOfCharge": "Laadtoestand", + "batteryVoltage": "Spanning", + "batteryAlert": "Batterijwaarschuwing", + "solarPower": "Zonnevermogen", + "solarCurrent": "Zonnestroom", + "solarBus": "Paneelspanning (bus)", + "solarLoad": "Belastingsspanning" + }, + "signalQuality": { + "excellent": "Uitstekend", + "good": "Goed", + "fair": "Redelijk", + "poor": "Zwak" + }, + "transport": { + "wifi": "Wifi", + "cellular": "Mobiel netwerk" + }, + "rtc": { + "ok": "RTC OK", + "fault": "RTC-storing" + }, + "batteryAlertActive": "Batterij bijna leeg" + }, + "hiveCards": { + "empty": "Dit apparaat heeft nog geen kasten gemeld.", + "hiveIndex": "Kast {{n}}", + "weight": "Gewicht", + "temperature": "Temperatuur", + "humidity": "Luchtvochtigheid", + "delta24h": "24 u {{value}} kg", + "lastReported": "Laatst gemeld {{ago}}", + "inspection": "Inspectie", + "insightsLoading": "Inzichten laden", + "insightsUnavailable": "Inzichten niet beschikbaar", + "noSensors": "Geen sensoren gemeld", + "traffic": "In {{in}} · Uit {{out}}", + "trafficTitle": "Bijen in/uit tijdens het laatste interval", + "alerts_one": "{{count}} actieve melding", + "alerts_other": "{{count}} actieve meldingen", + "sensor": { + "scale": "Weegschaal", + "inHive": "Kastsensor", + "counter": "HiveTraffic-teller", + "hiveheart": "HiveHeart" + }, + "state": { + "ok": "OK", + "fault": "Niet ontvangen / storing" + } + }, + "audio": { + "title": "Kastaudio", + "description": "Vraag de HiveInside-node van een kast om op te nemen en luister hier naar de opname. De hub haalt het verzoek op bij het volgende ontwaken, dus een opname verschijnt binnen één meldinterval en niet meteen.", + "noNodes": "Er is geen HiveInside-node gekoppeld aan deze hub. Alleen HiveInside heeft een microfoon — een HolyIot- of RuuviTag-beacon kan niet opnemen.", + "hive": "Kast", + "hiveFallback": "Kast {{slot}}", + "length": "Lengte", + "durationOption_one": "{{count}} seconde", + "durationOption_other": "{{count}} seconden", + "gain": "Versterking", + "gainValue": "{{gain}} dB", + "record": "Opnemen", + "nextWake": "Bereikt de hub bij het volgende ontwaken.", + "viewerNote": "Een eigenaar of beheerder kan een opname aanvragen.", + "lengthHint": "Korter is meestal beter: 10–20 seconden is genoeg om te horen wat een inzicht signaleerde, en kost minder batterij en minder tijd dat de kast uit de lucht is — een verbonden node adverteert niet en meet niet.", + "requestSuccess": "Opname aangevraagd. Die bereikt de hub bij het volgende ontwaken.", + "requestError": "Het verzoek kon niet in de wachtrij worden gezet: {{message}}", + "listTitle": "Opnames", + "onlySelectedHive": "Alleen {{name}}", + "loadError": "Kon opnames niet ophalen: {{message}}", + "empty": "Nog geen opnames. Vraag een kast om op te nemen en de opname verschijnt hier. Een sessie is 10–60 seconden 16 kHz-audio die via BLE van de node wordt gehaald.", + "requestedLength_one": "{{count}} s aangevraagd", + "requestedLength_other": "{{count}} s aangevraagd", + "requestedBy": "Aangevraagd door {{name}}", + "status": { + "requested": "Wacht op hub", + "streaming": "Bezig met opnemen…", + "ready": "Klaar", + "readyIncomplete": "Klaar, niet compleet", + "failed": "Mislukt" + }, + "waitingNote": "Wacht tot de hub ontwaakt en dit ophaalt — nog niets opgenomen. Een verzoek dat de hub niet binnen 45 minuten heeft opgehaald, wordt als mislukt gemarkeerd.", + "streamingNote": "Bezig met opnemen — de audio verschijnt hier zodra die binnenkomt.", + "streamingPartial": "Nog bezig met opnemen — dit is wat er tot nu toe binnen is. Speel het opnieuw af wanneer de sessie klaar is om alles te horen.", + "failedFallback": "Deze opname is mislukt.", + "seconds": "{{value}} s", + "bytes": "{{value}} B", + "percent": "{{value}} %", + "download": "WAV downloaden", + "delete": "Verwijderen", + "deleteConfirm": "Deze opname verwijderen? Niets anders zal dat ooit doen — er is geen automatische opschoning.", + "deleteSuccess": "Opname verwijderd.", + "deleteError": "Kon de opname niet verwijderen: {{message}}", + "confirmation": { + "verified": "geverifieerd", + "reported": "niet geverifieerd", + "confirmed": "bevestigd door hub", + "unknown": "niet bevestigd" + }, + "quality": { + "length": "Lengte", + "dropped": "Weggevallen", + "gaps": "Onderbrekingen", + "hubBuffer": "Hubbuffer", + "clipping": "Clipping", + "checksum": "Controlesom", + "cause": { + "dropped": "de node kon de audio niet snel genoeg versturen", + "gaps": "er gingen pakketten verloren in de lucht", + "hubBuffer": "de upload van de hub liep achter op zijn buffer" + }, + "gapNote": "De audio aan weerszijden van een onderbreking is echt, maar de overgang niet. Oorzaak: {{causes}}.", + "clippingNote": "Verlaag de versterking voor een schonere opname.", + "unknownNote": "De hub heeft nooit over deze sessie gerapporteerd, dus niets heeft gecontroleerd of de audio compleet is. Wat er is, kan worden afgespeeld." + } + }, + "dashboard": { + "hiveFallback": "Kast {{index}}", + "units": { + "bees": "bijen" + }, + "range": { + "24h": "24 u", + "7d": "7 d", + "30d": "30 d", + "365d": "365 d", + "custom": "Aangepast", + "invalid": "De begindatum mag niet na de einddatum liggen." + }, + "header": { + "title": "Mijn dashboard", + "description": "Door de gebruiker instelbare widgets. De hierboven geselecteerde kasten bepalen de kastgebonden grafieken.", + "editDescription": "Bewerkmodus: sleep aan de greep om widgets te verplaatsen en aan de hoek om hun grootte aan te passen op een raster van 4 kolommen.", + "edit": "Dashboard bewerken", + "done": "Klaar", + "addWidget": "Widget toevoegen", + "reset": "Herstellen", + "editBanner": "De bewerkmodus van het dashboard is actief. Gebruik de greep in de kop van elke widget om hem te verplaatsen, en sleep de rechterbenedenhoek om breedte of hoogte aan te passen. Lay-outwijzigingen worden automatisch opgeslagen voor dit HiveHub-apparaat.", + "dropEnd": "Zet hier neer om een widget naar het einde van het dashboard te verplaatsen.", + "noWidgets": "Nog geen widgets. Voeg een sjabloon toe om je dashboard op te bouwen.", + "restoreDefaults": "Standaardinstellingen herstellen" + }, + "overview": { + "title": "Kastoverzicht", + "description": "Compacte status van gekoppelde kasten. Selecteer tegels om de dashboardwidgets hieronder aan te sturen.", + "lastData": "Laatste gegevens", + "hivesOnline": "Kasten online", + "hubFirmware": "HiveHub-FW", + "hiveInsideFirmware": "HiveInside-FW", + "selected_one": "{{count}} geselecteerd", + "selected_other": "{{count}} geselecteerd", + "hide": "Overzicht verbergen", + "show": "Overzicht tonen", + "slot": "Plaats {{index}}", + "ok": "OK", + "noData": "Geen gegevens", + "weight": "Gewicht", + "temperature": "Temp", + "humidity": "RV", + "empty": "Koppel HiveHub-plaatsen aan HivePal-kasten in het instellingenpaneel van het apparaat om hier overzichtstegels te tonen." + }, + "sensors": { + "scale": "weegschaal", + "inHive": "in kast", + "accel": "versnelling", + "counter": "teller", + "none": "geen sensoren" + }, + "widgetShell": { + "move": "{{title}} verplaatsen", + "moveHint": "Sleep om deze widget te verplaatsen", + "remove": "{{title}} verwijderen", + "resize": "Grootte van {{title}} wijzigen", + "resizeHint": "Sleep om de grootte van deze widget te wijzigen" + }, + "addWidget": { + "title": "Widget toevoegen", + "description": "Begin met een sjabloon. De widget gebruikt waar van toepassing de geselecteerde kasten uit het overzichtsraster.", + "defaultSize": "Standaardgrootte {{w}} x {{h}}" + }, + "widgets": { + "weightComparison": { + "title": "Gewichtsvergelijking", + "description": "Vergelijk geselecteerde kasten over de huidige periode." + }, + "dailyMaxWeight": { + "title": "Dagelijks maximumgewicht", + "description": "Hoogste gecompenseerde gewicht per dag voor de geselecteerde kasten." + }, + "climate": { + "title": "Kastklimaat", + "description": "Temperatuur en luchtvochtigheid voor geselecteerde kasten." + }, + "power": { + "title": "Voedingsstatus", + "description": "Batterijlading, batterijspanning en zonne-invoer." + }, + "beeTraffic": { + "title": "Vliegverkeer", + "description": "Totaal in-/uitvliegen en netto stroom." + }, + "soundRms": { + "title": "Geluid / akoestische banden", + "description": "Akoestische RMS per kast en FFT-/HiveHeart-banden indien beschikbaar." + }, + "hiveHeartSpectrum": { + "title": "HiveHeart-spectrum", + "description": "Nieuwste HiveHeart-FFT-niveaus (0–15) over 16 frequentiebereiken voor de geselecteerde kasten." + }, + "vibration": { + "title": "Trillingsbanden", + "description": "RMS-trilling en zwerm-, waaier- en activiteitsbanden." + }, + "configurableDiagram": { + "title": "Instelbaar diagram", + "description": "Kies de kast-, geluids-, vliegverkeers-, klimaat- en apparaatwaarden om weer te geven." + }, + "temperatureHeatmap": { + "title": "Temperatuur-heatmap", + "description": "Compact temperatuuroverzicht van alle kasten." + }, + "insights": { + "title": "Inzichtenfeed", + "description": "Actieve meldingen en onderbouwing." + }, + "dataQuality": { + "title": "Gegevenskwaliteit", + "description": "Beschikbaarheid van sensoren en ontbrekende metingen." + } + }, + "metricGroups": { + "scale": "Weegschaal", + "climate": "Klimaat", + "beeTraffic": "Vliegverkeer", + "vibration": "Trilling", + "sound": "Geluid", + "device": "Apparaat" + }, + "metrics": { + "weight": "Gewicht", + "temperature": "Temperatuur", + "humidity": "Luchtvochtigheid", + "pressure": "Luchtdruk", + "beeIn": "Bijen in", + "beeOut": "Bijen uit", + "beeNet": "Netto stroom", + "vibration": "Trilling RMS", + "swarmBand": "Zwermband", + "fanningBand": "Waaierband", + "activityBand": "Activiteitsband", + "soundRms": "Geluid RMS", + "soundHum": "Zoemband", + "soundPiping": "Tuutband", + "soundStress": "Stressband", + "hiveHeartFrequency": "HiveHeart-frequentie", + "hiveHeartEnergy": "HiveHeart-energie", + "batterySoc": "Batterijlading", + "batteryVoltage": "Batterijspanning", + "solarPower": "Zonnevermogen" + }, + "axes": { + "weight": "Gewicht", + "temperature": "Temperatuur", + "humidity": "Luchtvochtigheid", + "pressure": "Luchtdruk", + "beecount": "Aantal bijen", + "vibration": "Trilling", + "dbfs": "Geluidsniveau", + "frequency": "Frequentie", + "energy": "Energie", + "percent": "Lading", + "voltage": "Spanning", + "power": "Vermogen" + }, + "soundMetrics": { + "rms": "RMS", + "hiveHeartFrequency": "HiveHeart-frequentie", + "hiveHeartEnergy": "HiveHeart-energie", + "hiveHeartPeak": "HiveHeart-piek" + }, + "vibrationMetrics": { + "rms": "RMS", + "swarmBand": "Zwermband", + "fanningBand": "Waaierband", + "activityBand": "Activiteitsband" + }, + "series": { + "temperature": "{{name}} temp", + "humidity": "{{name}} RV", + "beeIn": "In", + "beeOut": "Uit", + "beeNet": "Netto" + }, + "axisScale": { + "auto": "auto", + "setBound": "{{axis}} {{bound}} instellen", + "clearHint": "Laat leeg en druk op Enter voor automatisch", + "tickTitle": "Klik om {{bound}} in te stellen; leegmaken voor automatisch", + "reset": "herstellen", + "help": "Klik op het laagste of hoogste label van de y-as om het te bewerken. Maak de waarde leeg voor automatisch." + }, + "chart": { + "downloadCsv": "CSV downloaden" + }, + "inspection": { + "legendHint_one": "{{count}} inspectievenster gearceerd; metingen kunnen verstoord zijn", + "legendHint_other": "{{count}} inspectievensters gearceerd; metingen kunnen verstoord zijn" + }, + "empty": { + "noData": "Geen gegevens voor de geselecteerde kasten en periode.", + "climate": "Geen klimaatgegevens voor de geselecteerde periode.", + "power": "Geen voedingsgegevens voor de geselecteerde periode.", + "beeTraffic": "Geen bijentellergegevens voor de geselecteerde kasten.", + "sound": "Geen akoestische gegevens per kast voor de geselecteerde kasten.", + "vibration": "Geen trillings- of versnellingsbandgegevens voor de geselecteerde kasten.", + "configurable": "Selecteer minstens één waarde met gegevens voor de geselecteerde kasten en periode.", + "dailyMaxWeight": "Geen gewichtsgegevens voor de geselecteerde kasten en periode.", + "hiveHeart": "Geen HiveHeart-FFT-gegevens voor de geselecteerde kasten en periode.", + "unknownWidget": "Onbekend widgettype." + }, + "soundFootnote": "Toont eerst akoestische metingen per kast. Oudere microfoongegevens links/rechts worden gekoppeld aan de ingestelde kastnamen voor plaatsen 1 en 2 wanneer er geen akoestisch blok per kast aanwezig is.", + "heatmap": { + "low": "laag <33 °C", + "optimal": "optimaal broed 33–36 °C", + "high": "hoog >36 °C" + }, + "insights": { + "loading": "Inzichten laden…", + "unavailable": "Inzichten zijn niet beschikbaar.", + "noAlerts": "Geen actieve meldingen voor de geselecteerde kasten.", + "showing_one": "{{count}} actieve melding voor de geselecteerde kasten.", + "showing_other": "{{count}} actieve meldingen voor de geselecteerde kasten.", + "confidence": "betrouwbaarheid {{percent}}%" + }, + "dataQuality": { + "activeSlots": "Actieve plaatsen", + "noScale": "Geen weegschaal", + "noClimate": "Geen klimaat", + "noRecentData": "geen recente gegevens" + }, + "hiveHeart": { + "level": "niveau", + "dominant": "dominante frequentie {{hz}} Hz", + "noDominant": "geen dominante frequentie gemeld", + "footnote": "Relatieve niveaus 0–15 (geen dBFS) van de nieuwste meting in de periode. Stippellijnen markeren de dominante frequentie die HiveHeart meldt; de kopjes Sub-bas/Zoem/Tuten/Stress zijn bij benadering en beslaan meerdere bereiken." + } } } diff --git a/apps/frontend/public/locales/ru/common.json b/apps/frontend/public/locales/ru/common.json index 10645c53e..c0b9034ab 100644 --- a/apps/frontend/public/locales/ru/common.json +++ b/apps/frontend/public/locales/ru/common.json @@ -36,7 +36,8 @@ "swarmManagement": "Управление роением", "liebefelder": "Либфельдерский метод", "varroaManagement": "Управление варроа", - "documentation": "Документация" + "documentation": "Документация", + "hivehub": "HiveHub" }, "actions": { "save": "Сохранить", diff --git a/apps/frontend/public/locales/ru/hivescale.json b/apps/frontend/public/locales/ru/hivescale.json index 80b1573bf..5672f4303 100644 --- a/apps/frontend/public/locales/ru/hivescale.json +++ b/apps/frontend/public/locales/ru/hivescale.json @@ -35,20 +35,34 @@ }, "page": { "title": "HiveHub", - "subtitle": "Визуализация живого веса и температуры из вашего отдельного бэкенда HiveHub.", + "subtitle": "Вес, микроклимат, звук, лёт и оповещения с ваших устройств HiveHub.", "refresh": "Обновить", "refreshing": "Обновление…", "refreshError": "Не удалось обновить данные HiveHub.", "removeConfirm": "Удалить {{name}} из вашего аккаунта HivePal? Вы сможете снова добавить его с помощью кода привязки, когда ни у кого больше не будет доступа.", - "removeSuccess": "Устройство HiveHub удалено из вашего аккаунта." + "removeSuccess": "Устройство HiveHub удалено из вашего аккаунта.", + "removeSuccessShared": "У вас больше нет доступа к этому устройству HiveHub. У других пользователей доступ остаётся, поэтому его код привязки по-прежнему используется.", + "releaseConfirm": "Полностью освободить {{name}}? Все пользователи потеряют доступ, и устройство снова можно будет привязать с помощью его кода привязки. Показания сохранятся.", + "releaseSuccess": "Устройство HiveHub освобождено. Его снова можно привязать с помощью кода привязки.", + "unavailableTitle": "HiveHub недоступен", + "inspectionActiveTitle": "Режим осмотра включён", + "inspectionActiveBody": "Пока ульи открыты, показания скрываются, чтобы извлечение рамок не вызывало ложных оповещений.", + "openInspections": "Открытые осмотры", + "tabs": { + "overview": "Обзор", + "inspections": "Осмотры", + "audio": "Аудио", + "health": "Состояние", + "setup": "Настройка" + } }, "setup": { "title": "Устройства", - "description": "Выберите или удалите привязанное устройство HiveHub. Разверните настройку, чтобы привязать устройства, проверить статус, сопоставить весы и откалибровать необработанные показания.", "removeScale": "Удалить весы", "noDevices": "Устройства ещё не привязаны — откройте настройку, чтобы добавить.", - "hide": "Скрыть настройку", - "show": "Показать настройку" + "releaseScale": "Освободить устройство", + "selectDevice": "Устройство HiveHub", + "noDevicesYet": "Устройства HiveHub ещё не привязаны. Привяжите устройство с помощью кода, который хаб показывает на своём портале настройки." }, "claim": { "title": "Привязать устройство HiveHub", @@ -61,16 +75,30 @@ "success": "Устройство HiveHub привязано.", "errors": { "missingCode": "Введите код привязки, напечатанный/настроенный для устройства." - } + }, + "hive1Name": "Название улья 1", + "hive2Name": "Название улья 2", + "optional": "Необязательно" }, "mapping": { - "title": "Сопоставление весов", - "description": "Эти метки остаются в HiveHub. В дальнейшем это подходящее место для сопоставления Весов 1/2 с идентификаторами ульев HivePal для маркеров осмотров.", - "scale1Label": "Отображаемое имя для Весов 1", - "scale2Label": "Отображаемое имя для Весов 2", + "title": "Ульи", "hiveNamePlaceholder": "Выберите или введите название улья", - "save": "Сохранить сопоставление", - "success": "Сопоставление весов обновлено." + "save": "Сохранить ульи", + "success": "Слоты ульев сохранены в HiveHub.", + "slotsDescription": "Дайте имя каждому слоту улья и свяжите его с ульем в HivePal. Благодаря связи осмотры, страница улья и письма с оповещениями знают, какой семье соответствует слот. Зелёная точка отмечает слоты, о которых хаб сейчас сообщает.", + "mapSlot": "Сопоставить слот {{slot}}", + "add": "Добавить", + "reporting": "Сообщается хабом", + "unknownHive": "Улей не найден", + "mappedCount": "Сопоставлено слотов: {{count}}/{{total}}", + "dialogTitle": "Слот улья {{slot}}", + "dialogDescription": "Свяжите слот с одним из ваших ульев в HivePal и выберите имя, которое HiveHub будет для него показывать.", + "linkedHive": "Улей в HivePal", + "notLinked": "Не связан", + "linkHint": "Связь сохраняется при переименовании улья. При выборе улья его название подставляется, если имя не заполнено.", + "displayName": "Отображаемое имя", + "clearSlot": "Очистить слот", + "applySlot": "Применить" }, "status": { "title": "Статус устройства", @@ -90,6 +118,13 @@ "revokeAccess": "Отозвать доступ", "errors": { "missingEmail": "Введите адрес электронной почты существующего пользователя HivePal." + }, + "lastSeen": "Последний сеанс связи", + "claimedAt": "Привязано", + "roles": { + "owner": "Владелец", + "admin": "Администратор", + "viewer": "Наблюдатель" } }, "calibration": { @@ -199,11 +234,9 @@ }, "sourceHint": "Окружающая среда в установившемся режиме точно отслеживает корпус ячейки и является рекомендуемым значением по умолчанию.", "refLabel": "Опорная температура / °C", - "refHint": "Температура, при которой поправка равна нулю.", "coeffLabel": "Коэффициент {{scaleName}} / кг на °C", "autoFit": "Автоподбор по истории", "fitting": "Подбор…", - "autoFitHint": "Автоподбор регрессирует последние 3 дня необработанного веса против EMA-сглаженной выбранной температуры, затем применяет и включает результат. Для чистого подбора захватите окно, где физическая нагрузка постоянна, а температура колеблется.", "save": "Сохранить температурную компенсацию", "errors": { "refTemp": "Опорная температура должна быть числом (°C).", @@ -215,11 +248,33 @@ "noSignal": "недостаточно сигнала температуры/веса.", "fitApplied": "{{scaleName}}: {{coeff}} кг/°C при {{refTemp}}°C (R²={{r2}}, n={{n}}). Применено и включено." } + }, + "perHive": { + "hive": "Улей {{index}}", + "hiveNamed": "Улей {{index}} · {{name}}", + "hiveLabel": "Улей", + "knownWeightPlaceholder": "напр. 10", + "manualSaveHint": "Переключите улей, чтобы изменить другие весы; все изменения отправляются вместе при сохранении. Ульи с 3-го и далее записываются, только если они изменены.", + "tempco": { + "refHint": "Температура, при которой весы показывают точно, т. е. при которой их тарировали и калибровали по эталону. При ней поправка равна нулю; подбор не меняет её, если вы об этом не попросите.", + "lookbackLabel": "Период подбора / дни", + "setRefTemp": "Также задать опорную температуру", + "setRefTempHint": "Заменяет опорную температуру средней температурой за период подбора. Не включайте, если весы калибровались при известной температуре.", + "fitOnly": "Только подобрать (предпросмотр)", + "fitAndApply": "Подобрать и применить", + "fitSummary": "{{scaleName}}: {{coeff}} кг/°C, R² {{r2}} по {{n}} показаниям в диапазоне {{min}}–{{max}} °C (среднее за период {{mean}} °C).", + "lowRSquared": "R² ниже 0,5: температура слабо объясняет этот дрейф. Выполните подбор на отрезке с постоянной нагрузкой и большим перепадом температур день/ночь.", + "autoFitHint": "Автоподбор строит регрессию сохранённого веса выбранного улья за период подбора по выбранной температуре, сглаженной EMA. «Только подобрать» заполняет коэффициент для проверки перед сохранением; «Подобрать и применить» сохраняет его и включает компенсацию. Для чистого подбора выберите период, когда физическая нагрузка постоянна, а температура колеблется.", + "errors": { + "lookback": "Период подбора должен быть целым числом дней от 1 до 90." + }, + "toasts": { + "fitFilled": "{{scaleName}}: {{coeff}} кг/°C (R²={{r2}}, n={{n}}). Заполнено — проверьте и сохраните." + } + } } }, "firmware": { - "title": "Загрузка прошивки", - "description": "Загрузите бинарный файл прошивки и зарегистрируйте его как релиз. Новые устройства соответствующего типа подхватят её при следующей проверке обновлений.", "type": "Тип прошивки", "selectType": "Выберите тип прошивки", "version": "Версия", @@ -249,16 +304,87 @@ "missingFile": "Выберите бинарный файл прошивки (.bin) для загрузки.", "missingVersion": "Введите версию, например 0.6.3." }, - "hiveinsideOta": { - "title": "Запустить OTA HiveInside", - "description": "Загрузка прошивки HiveInside теперь автоматически ставит в очередь OTA-ретрансляцию на оба слота датчиков по BLE. Используйте это, чтобы повторно поставить активный релиз в очередь для конкретного слота. Держите датчик включённым и в активном состоянии во время обновления.", - "slot": "Слот датчика", - "slotOption": "Слот {{slot}}", - "queue": "Поставить OTA HiveInside в очередь", + "board": { + "cardTitle": "Прошивка", + "cardDescription": "Прошивка этого HiveHub, а также подключённых к нему узлов HiveInside и счётчиков HiveTraffic. Загрузка регистрирует релиз; установка происходит после одобрения (хаб) или передачи (узлы).", + "current": "Текущая", + "latest": "Последняя", + "approved": "Одобрена", + "board": "Плата", + "badgePending": "Обновление ожидает одобрения", + "badgeApproved": "Обновление одобрено", + "badgeUpToDate": "Актуальна", + "approveFlash": "Одобрить и прошить {{version}}", + "approveConfirm": "Одобрить прошивку {{version}} и установить её по воздуху?\n\nHiveHub установит её при следующем сеансе связи и перезагрузится. Удалённому устройству, у которого обновление прервётся, для восстановления может потребоваться физический доступ.", + "otherBoards": "Также загружено для другой платы (не применяется к этому устройству {{board}}): {{releases}}. Сборка попадает только на устройство с подходящей платой — проверяйте плату при загрузке.", + "otherBoardsUnknown": "Также загружено для другой платы (не применяется к этому устройству): {{releases}}. Сборка попадает только на устройство с подходящей платой — проверяйте плату при загрузке." + }, + "relays": { + "hiveFallback": "Улей {{slot}}", + "latest": "последняя {{version}}", + "updateAvailable": "доступно обновление: {{version}}", + "status": { + "pending": "передача в очереди", + "running": "передача…", + "completed": "передача завершена", + "failed": "передача не удалась", + "expired": "время передачи истекло" + }, + "statusTitle": "Передача {{version}}: {{status}} ({{date}})", + "failedDetail": "Последняя передача не удалась — {{message}} ({{date}})", + "noReason": "причина не сообщена", + "alreadyQueued": "Передача для этого узла уже в очереди", "queueing": "Постановка в очередь…", - "success": "OTA HiveInside поставлена в очередь для слота {{slot}}.", - "autoQueued": "OTA HiveInside автоматически поставлена в очередь для слотов {{slots}}.", - "autoQueueFailed": "Не удалось автоматически поставить OTA HiveInside в очередь для слотов {{slots}}." + "relayAnyway": "Всё равно передать", + "confirm": "Передать {{name}} {{version}} на {{hive}}?\n\nHiveHub заберёт задание в следующем цикле отправки — обычно примерно в течение 10 минут или того интервала отправки, который использует это устройство. Затем он в течение нескольких минут передаёт образ по BLE, и узел перезагружается с новой прошивкой. Узел должен быть активен и в зоне досягаемости, иначе передача не удастся и её придётся поставить в очередь заново.", + "queued": "Передача {{version}} поставлена в очередь для {{hive}}.", + "queuedFrom": "Передача поставлена в очередь для {{hive}} ({{current}} → {{version}}).", + "hiveinside": { + "name": "HiveInside", + "title": "Узлы HiveInside", + "description": "Прошивка, о которой сообщает каждый узел в улье. Загрузка образа HiveInside только регистрирует релиз — на узел ничего не попадёт, пока вы не нажмёте «Передать на узел». Показанная здесь версия подтверждает, что обновление установлено.", + "relay": "Передать на узел", + "unnamed": "Не сообщает собственного имени (HiveInside до 0.5.0)", + "noRelease": "Релиз HiveInside ещё не загружен — загрузите его ниже, чтобы передать." + }, + "beecounter": { + "name": "HiveTraffic", + "title": "Счётчики HiveTraffic", + "description": "Прошивка, о которой сообщает каждый счётчик HiveTraffic. Загрузка образа HiveTraffic только регистрирует релиз — на счётчик ничего не попадёт, пока вы не нажмёте «Передать на счётчик». Счётчики старше примерно v0.1.0 не сообщают версию, но их всё равно можно обновить.", + "relay": "Передать на счётчик", + "unnamed": "Не сообщает имени или адреса", + "noRelease": "Релиз HiveTraffic ещё не загружен — загрузите его ниже, чтобы передать.", + "countingWarning": "Во время всей передачи счётчик не считает пчёл, поэтому передавайте, когда лёт слабый.", + "confirmExtra": "Во время всей передачи счётчик не считает пчёл — весь лёт за эти несколько минут будет потерян." + } + }, + "uploadForm": { + "title": "Загрузить прошивку", + "description": "Загрузка только регистрирует релиз — сама по себе она ничего не устанавливает. Одобрите обновление хаба выше или передайте релиз для узла или счётчика из его строки.", + "board": "Плата", + "targets": { + "hivehub": "Хаб HiveHub", + "hiveinside": "HiveInside", + "beecounter": "Счётчик HiveTraffic" + }, + "boards": { + "auto": "Авто (по имени файла)", + "esp32": "ESP32 (классическая, 30 выводов)", + "esp32-c6": "ESP32-C6", + "nrf54lm20a": "nRF54LM20A" + }, + "boardHint": { + "hivehub": "Для прошивки хаба нужно указать плату: выберите её или оставьте «Авто», если файл назван, например, hivehub_esp32-c6_0.30.5.bin.", + "hiveinside": "HiveInside выпускается только для nRF54LM20A. Загрузите артефакт сборки без изменений (напр. hiveinside_nrf54lm20a_1.4.0.bin) — цель и версия будут заполнены по его имени.", + "beecounter": "Счётчик HiveTraffic построен на ESP32-C6. Загружайте образ приложения (напр. beecounter_esp32-c6_0.7.1.bin), а не объединённый заводской образ." + }, + "detected": "{{name}} — заполнено: {{fields}}.", + "nothingDetected": "{{name}} — в имени не распознаны цель, плата или версия; укажите их ниже.", + "detectedTarget": "цель «{{target}}»", + "detectedBoard": "плата {{board}}", + "detectedVersion": "версия {{version}}", + "autoQueued": "Передача автоматически поставлена в очередь для {{hives}}.", + "autoQueueFailed": "Не удалось автоматически поставить передачу в очередь для {{hives}}." } }, "sdData": { @@ -283,12 +409,20 @@ "parsed_other": "Разобрано {{count}} записей", "skipped_one": " · пропущена {{count}} нечитаемая строка.", "skipped_other": " · пропущено {{count}} нечитаемых строк." + }, + "mismatch": { + "title": "Файл с другого хаба", + "description": "Этот файл записан другим хабом, а не {{device}}.", + "fileDevices_one": "ID устройства в файле:", + "fileDevices_few": "ID устройств в файле:", + "fileDevices_many": "ID устройств в файле:", + "fileDevices_other": "ID устройств в файле:", + "hint": "Импортируйте его всё равно только в том случае, если SD-карта действительно принадлежит этому устройству (например, после замены платы хаба).", + "cancel": "Отмена", + "force": "Всё равно импортировать в это устройство" } }, "panel": { - "weight": "Вес", - "hiveTemp": "Температура улья", - "rmsSound": "RMS-звук", "ambientTemperature": "Температура окружающей среды", "ambientHumidity": "Влажность окружающей среды", "batteryCharge": "Заряд батареи", @@ -298,7 +432,6 @@ "title": "Общее", "description": "Среда и питание" }, - "humidity": "Влажность улья", "wirelessSensorsBattery": "Батарея беспроводных датчиков", "sensorTypes": { "hiveHeart": "HiveHeart", @@ -307,178 +440,36 @@ } }, "diagram": { - "title": "Измерения", - "subtitle": "Вес, температура и активность у летка во времени", - "noMeasurements": "Нет измерений для выбранного диапазона.", - "noDataForSeries": "Нет данных в выбранном диапазоне.", - "meta": { - "device": "Устройство:", - "lastSeen": "Последняя связь:", - "lastData": "Последние данные:", - "firmware": "Прошивка:", - "hiveInsideFirmware": "HiveInside:" - }, "range": { "all": "Все", "from": "От", "to": "До" }, - "series": { - "weight": "Вес {{name}}", - "temp": "Температура {{name}}", - "micRms": "RMS микрофона {{name}}", - "vibration": "Вибрация {{name}}", - "swarmBand": "Роевая частота {{name}} (8–30 Гц)", - "beesIn": "{{name}} пчёл вошло", - "beesOut": "{{name}} пчёл вышло", - "netFlow": "Чистый поток {{name}}", - "ambientTemp": "Температура окружающей среды", - "ambientHumidity": "Влажность окружающей среды", - "batteryVoltage": "Напряжение батареи", - "batteryCharge": "Заряд батареи", - "solarVoltage": "Напряжение солнечной панели", - "solarCurrent": "Ток солнечной панели", - "solarPower": "Мощность солнечной панели", - "humidity": "Влажность {{name}}", - "pressure": "Давление {{name}}", - "fanningBand": "Частота вентилирования {{name}} (30–100 Гц)", - "activityBand": "Частота активности {{name}} (100–200 Гц)" - }, - "group": { - "ambient": "Окружающая среда", - "offGrid": "Автономный" - }, - "axis": { - "weight": "Вес", - "temperature": "Температура", - "humidity": "Влажность", - "voltage": "Напряжение", - "percent": "Процент", - "current": "Ток", - "power": "Мощность", - "sound": "Звук", - "beecount": "Количество пчёл", - "vibration": "Вибрация", - "pressure": "Давление" - }, "marker": { - "inspection": "Осмотр", - "boxAdded": "Добавлен корпус", - "action": { - "maintenance": "Обслуживание", - "feeding": "Подкормка", - "frames": "Рамки", - "treatment": "Обработка" - } - }, - "axisSettings": { - "title": "Настройки оси", - "reset": "Сбросить", - "side": "Сторона", - "left": "Слева", - "right": "Справа", - "scale": "Шкала", - "auto": "Авто", - "zeroToMax": "0 до макс", - "custom": "Своя", - "min": "Мин", - "max": "Макс", - "autoPlaceholder": "авто" + "inspection": "Осмотр" } }, "sound": { - "title": "Звук улья", - "description": "Энергия полос БПФ INMP441 (дБFS) — {{left}} (левый) · {{right}} (правый)", - "hide": "Скрыть звук", - "show": "Показать звук", - "micLeftFallback": "Микрофон слева", - "micRightFallback": "Микрофон справа", - "noDataForChannel": "Нет данных для {{name}} в этом диапазоне.", - "bandReference": "Справка по частотам", - "thresholds": "Пороги, используемые движком аналитики: писк/пение маток > −45 дБFS, стресс > −38 дБFS, гул без матки > −40 дБFS при тихом пении маток. Калибруйте по своей собственной базовой линии.", - "status": { - "noData": "Нет данных", - "micError": "Ошибка микрофона", - "bothActive": "{{left}} + {{right}} активны", - "oneActive": "{{name}} активен" - }, - "table": { - "band": "Частота", - "range": "Диапазон", - "significance": "Значение" - }, "bands": { "subBass": { - "label": "Суб-бас", - "description": "Структурная вибрация / низкий гул" + "label": "Суб-бас" }, "hum": { - "label": "Гул", - "description": "Нормальный гул семьи (~200 Гц основной тон)" + "label": "Гул" }, "piping": { - "label": "Писк/пение маток", - "description": "Писк/пение матки (сигнал перед роением)" + "label": "Писк/пение маток" }, "stress": { - "label": "Стресс", - "description": "Возбуждённая семья / воровство" + "label": "Стресс" }, "high": { - "label": "Высокие", - "description": "Гармонические обертоны" + "label": "Высокие" } - }, - "rmsToggleShow": "Показать уровень RMS", - "rmsToggleHide": "Скрыть уровень RMS", - "rmsTitle": "Уровень звука RMS", - "rmsSeries": "{{name}} RMS" - }, - "beecounter": { - "title": "Счётчик у летка", - "subtitle": "Пчёлы, входящие и выходящие за интервал измерения", - "noData": "Данные BeeCounter для этого устройства не найдены.", - "offline": "Не в сети", - "gatesOk": "{{count}} ворот в норме", - "glitches_one": "{{count}} сбой", - "glitches_other": "{{count}} сбоев", - "gatesRatio": "{{healthy}}/{{total}} ворот", - "inPeriod": "Вошло (период)", - "outPeriod": "Вышло (период)", - "netFlow": "Чистый поток", - "netInbound": "чистый приток", - "netOutbound": "чистый отток", - "balanced": "сбалансировано", - "view": { - "inOut": "Вход / Выход", - "netFlow": "Чистый поток", - "cumulative": "Накопительно" - }, - "series": { - "in": "{{name}} вошло", - "out": "{{name}} вышло", - "net": "{{name}} чистое", - "totalIn": "{{name}} всего вошло", - "totalOut": "{{name}} всего вышло" - }, - "diagnostics": { - "title": "Диагностика оборудования", - "gatesHealthy": "Ворота в норме", - "glitchCount": "Количество сбоев", - "totalInLifetime": "Всего вошло (за всё время)", - "totalOutLifetime": "Всего вышло (за всё время)" } }, "insights": { - "title": "Аналитика", - "description": "Оповещения на основе датчиков, полученные из трендов веса и температуры.", - "updated": "Обновлено {{ago}}.", - "refresh": "Обновить аналитику", - "loadError": "Не удалось загрузить аналитику: {{message}}", "allClear": "Всё в порядке", - "noActiveAlerts": "Нет активных оповещений за последние {{count}} дней.", - "activeAlerts_one": "{{count}} активное оповещение:", - "activeAlerts_other": "{{count}} активных оповещений:", "confidence": "Уверенность {{value}}%", "source": "Источник: {{source}}", "hiveChannel": "Улей {{channel}}", @@ -563,5 +554,760 @@ "description": "Для этого устройства не зарегистрировано ни одного оповещения.", "descriptionFiltered": "Для этого устройства не зарегистрировано ни одного оповещения, соответствующего этому фильтру." } + }, + "inspectionMode": { + "title": "Режим осмотра", + "description": "Пока включён режим осмотра, HiveHub продолжает измерения, но не включает показания в графики, аналитику и оповещения, чтобы извлечение рамок не было принято за роение.", + "status": { + "active": "Активен с {{time}}", + "pending": "Ожидает получения хабом", + "off": "Выключен" + }, + "pendingHint": "Запрошено — хаб получит команду при следующем сеансе связи.", + "timeoutNote_one": "Завершится автоматически через {{count}} минуту, если никто его не остановит.", + "timeoutNote_few": "Завершится автоматически через {{count}} минуты, если никто его не остановит.", + "timeoutNote_many": "Завершится автоматически через {{count}} минут, если никто его не остановит.", + "timeoutNote_other": "Завершится автоматически через {{count}} минуты, если никто его не остановит.", + "scope": { + "wholeHub": "Весь хаб", + "label": "Охват: {{hives}}" + }, + "start": "Включить режим осмотра", + "stop": "Выключить режим осмотра", + "starting": "Запуск…", + "stopping": "Остановка…", + "cancel": "Отмена", + "noteLabel": "Заметка (необязательно)", + "notePlaceholder": "напр. Поставил магазинную надставку", + "viewerHint": "Только владельцы и администраторы могут включать или выключать режим осмотра.", + "loadFailed": "Не удалось загрузить статус осмотра.", + "startDialog": { + "title": "Включить режим осмотра", + "description": "Показания выбранных ульев скрываются, пока вы не выключите режим осмотра или не истечёт его время.", + "hivesLabel": "Ульи", + "wholeHub": "Весь хаб (все ульи)", + "selectHive": "Выберите хотя бы один улей или весь хаб.", + "confirm": "Включить" + }, + "stopDialog": { + "title": "Выключить режим осмотра", + "description": "HiveHub вернётся к обычным показаниям и оповещениям.", + "confirm": "Выключить" + }, + "toast": { + "started": "Режим осмотра включён", + "stopped": "Режим осмотра выключен", + "noteSaved": "Заметка сохранена" + }, + "history": { + "title": "Недавние осмотры", + "empty": "Осмотры ещё не записаны.", + "ongoing": "идёт", + "startedVia": "Запущен через: {{source}}", + "ended": "Завершён: {{reason}}", + "editNote": "Изменить заметку", + "addNote": "Добавить заметку", + "saveNote": "Сохранить" + }, + "duration": { + "minutes_one": "{{count}} мин", + "minutes_few": "{{count}} мин", + "minutes_many": "{{count}} мин", + "minutes_other": "{{count}} мин", + "hoursMinutes": "{{hours}} ч {{minutes}} мин" + }, + "source": { + "device": "кнопка на хабе", + "api": "приложение", + "dashboard": "локальная панель" + }, + "endReason": { + "timeout": "истекло время", + "device": "кнопка на хабе", + "api": "остановлен в приложении", + "dashboard": "остановлен на локальной панели" + } + }, + "hiveLink": { + "prompt": { + "message": "Этот улей контролирует HiveHub {{device}} ({{hive}}). Включите режим осмотра, чтобы открытие улья не вызвало ложных оповещений.", + "active": "Режим осмотра включён для {{hive}} на HiveHub {{device}}.", + "pending": "Режим осмотра запрошен для {{hive}} на HiveHub {{device}} — хаб получит команду при следующем сеансе связи.", + "activeOther": "Режим осмотра включён для других ульев на HiveHub {{device}}, но не для {{hive}}. Сначала выключите его, чтобы включить режим для этого улья." + }, + "card": { + "title": "HiveHub", + "description": "Текущие показания связанных датчиков HiveHub.", + "weight": "Вес", + "weightChange24h": "Изменение за 24 ч", + "temperature": "Температура в улье", + "humidity": "Влажность", + "lastReading": "Последнее показание: {{time}}", + "openHiveHub": "Открыть HiveHub" + } + }, + "traffic": { + "title": "Счётчики пчёл HiveTraffic", + "description": "Ночной режим и группы излучателей для каждого счётчика HiveTraffic, сопряжённого с этим хабом. ИК-излучатели — самая крупная статья энергопотребления счётчика, поэтому от этих настроек зависит, хватит ли автономного питания.", + "readOnly": "Только владельцы устройств и администраторы могут изменять эти настройки. Ваша роль: «{{role}}».", + "hiveFallback": "Улей {{slot}}", + "noChanges": "Нет изменений для сохранения", + "saved": "Настройки счётчиков пчёл сохранены. Счётчики получат их при следующем сеансе связи.", + "appliesToAll": "Применяется ко всем счётчикам HiveTraffic, сопряжённым с этим устройством. Счётчик получит настройки в следующем цикле отправки хаба.", + "status": { + "title": "Счётчики сейчас", + "none": "В последнем показании нет данных ни от одного счётчика HiveTraffic.", + "counting": "Считает", + "nightIdle": "Ночной простой", + "nightIdleFor_one": "Ночной простой · осталась {{count}} мин", + "nightIdleFor_few": "Ночной простой · осталось {{count}} мин", + "nightIdleFor_many": "Ночной простой · осталось {{count}} мин", + "nightIdleFor_other": "Ночной простой · осталось {{count}} мин", + "banksRunning": "Работающие группы излучателей: {{banks}}", + "banksUnknown": "Группы излучателей не сообщаются (прошивка счётчика старше 0.3.0)", + "applied": "Группы применены", + "pending": "Группы ожидают следующего сеанса связи" + }, + "night": { + "title": "Ночной режим", + "description": "Медоносные пчёлы активны днём — для лёта нужен свет, а ниже примерно 10 °C он и так прекращается. В этом окне счётчик прекращает измерения для экономии энергии. Он продолжает рассылать объявления, поэтому показания и обновления прошивки по-прежнему работают.", + "enable": "Включить ночной режим", + "start": "Начало ночи (местное время)", + "end": "Конец ночи (местное время)", + "windowHelp": "Окно может переходить через полночь (напр. 20:00 → 06:00). Если начало и конец совпадают, окно пустое — оно никогда не действует, а не охватывает весь день. Оставьте запас вокруг сумерек и рассвета: активность достигает пика незадолго до заката и восхода.", + "emptyWindow": "Начало и конец совпадают, поэтому окно пустое и ночной режим никогда не включится.", + "wrapsMidnight": "Окно переходит через полночь: с {{start}} до {{end}} следующего утра.", + "timezone": "Часовой пояс", + "tzCustom": "Строка часового пояса POSIX TZ", + "timezoneHelp": "Часы хаба идут по UTC. Без правильного часового пояса окно смещается на ваше отклонение от UTC и сдвигается на час при каждом переходе на летнее/зимнее время.", + "tzPresets": { + "cet": "Центральная Европа (Берлин, Париж, Мадрид, Вена)", + "uk": "Великобритания / Ирландия", + "eet": "Восточная Европа (Хельсинки, Афины)", + "usEastern": "США, восточное время", + "usCentral": "США, центральное время", + "usMountain": "США, горное время", + "usPacific": "США, тихоокеанское время", + "utc": "UTC (без местного времени)", + "custom": "Свой…" + }, + "maxTraffic": "Откладывать выше (пересечений за цикл)", + "maxTrafficHelp": "Откладывать ночной режим, пока за последний цикл отправки насчитано больше пересечений (вход + выход), чем указано, чтобы не прерывать семью, которая ещё летает поздним летним вечером. 0 — только по часам.", + "save": "Сохранить ночной режим" + }, + "banks": { + "title": "Группы излучателей", + "description": "ИК-излучатели счётчика переключаются тремя группами по восемь проходов. Каждая группа потребляет около 80 мА. Отключите одну, если леток уже 24 проходов, часть его закрыта или источник питания не тянет всю плату.", + "bank": "Группа {{bank}} — проходы {{gates}}", + "current_one": "Включена {{count}} группа · около {{ma}} мА на шине 3,3 В счётчика", + "current_few": "Включены {{count}} группы · около {{ma}} мА на шине 3,3 В счётчика", + "current_many": "Включено {{count}} групп · около {{ma}} мА на шине 3,3 В счётчика", + "current_other": "Включено {{count}} группы · около {{ma}} мА на шине 3,3 В счётчика", + "help": "Отключённая группа совсем не считает свои проходы, поэтому итоги падают примерно пропорционально — не сравнивайте суженный счётчик с его собственной историей. Хотя бы одна группа должна оставаться включённой; счётчик, который ничего не должен считать, отсоедините.", + "firmwareNote": "Требуется прошивка счётчика 0.3.0 или новее. Настройка отправляется повторно после каждого сброса счётчика.", + "save": "Сохранить группы излучателей" + }, + "errors": { + "loadFailed": "Не удалось загрузить конфигурацию устройства.", + "saveFailed": "Не удалось сохранить настройки счётчиков пчёл", + "invalidTime": "Введите корректное время начала и конца (ЧЧ:ММ).", + "invalidMaxTraffic": "Порог откладывания должен быть целым числом не меньше 0.", + "emptyWindow": "Начало и конец ночного режима совпадают — окно никогда не будет действовать.", + "noBank": "Хотя бы одна группа излучателей должна оставаться включённой — вместо этого отсоедините счётчик." + } + }, + "hubAccess": { + "title": "Доступ к хабу", + "description": "Подключайтесь к хабу на месте, не открывая корпус, и задайте, как долго может длиться осмотр.", + "readOnly": "Только владельцы устройств и администраторы могут изменять эти настройки. Ваша роль: «{{role}}».", + "noChanges": "Нет изменений для сохранения", + "cancel": "Отмена", + "ap": { + "title": "Точка доступа для настройки", + "description": "Открывает Wi-Fi-сеть хаба HiveHub-Setup — так же, как короткое нажатие кнопки настройки: смена Wi-Fi, сопряжение датчиков или загрузка данных с SD-карты.", + "lastCheckIn": "Последний сеанс связи: {{time}}", + "open": "Открыть точку доступа для настройки", + "confirmTitle": "Открыть точку доступа для настройки?", + "confirmTiming_one": "Запрос поставлен в очередь. Между циклами хаб спит, поэтому точка доступа откроется после его следующего сеанса связи — не позднее чем через {{count}} минуту.", + "confirmTiming_few": "Запрос поставлен в очередь. Между циклами хаб спит, поэтому точка доступа откроется после его следующего сеанса связи — не позднее чем через {{count}} минуты.", + "confirmTiming_many": "Запрос поставлен в очередь. Между циклами хаб спит, поэтому точка доступа откроется после его следующего сеанса связи — не позднее чем через {{count}} минут.", + "confirmTiming_other": "Запрос поставлен в очередь. Между циклами хаб спит, поэтому точка доступа откроется после его следующего сеанса связи — не позднее чем через {{count}} минуты.", + "confirmTimingUnknown": "Запрос поставлен в очередь. Между циклами хаб спит, поэтому точка доступа откроется после его следующего сеанса связи — не позднее чем через один интервал отправки.", + "confirmOffline": "Пока точка доступа открыта, хаб отключён от Wi-Fi и не отправляет показания.", + "confirmTimeout": "Если никто не подключится, она закроется сама по истечении тайм-аута портала настройки, поэтому запускайте её, только когда кто-то находится у улья.", + "confirm": "Поставить точку доступа в очередь", + "queueing": "Постановка в очередь…", + "queued": "Точка доступа поставлена в очередь. Она откроется после следующего сеанса связи хаба.", + "failed": "Не удалось поставить точку доступа в очередь" + }, + "inspection": { + "title": "Тайм-аут осмотра", + "description": "Страховка: осмотр завершается сам через указанное число минут, чтобы забытое нажатие кнопки не скрывало данные улья бесконечно. По умолчанию 60.", + "label": "Тайм-аут (минуты, 1–1440)", + "save": "Сохранить тайм-аут", + "saved": "Тайм-аут осмотра сохранён", + "saveFailed": "Не удалось сохранить тайм-аут осмотра", + "invalid": "Введите целое число минут от 1 до 1440." + } + }, + "dataTools": { + "title": "Экспорт и очистка данных", + "description": "Сделайте резервную копию показаний этого хаба или удалите ошибочные.", + "hiveFallback": "Улей {{slot}}", + "cancel": "Отмена", + "export": { + "title": "Экспорт показаний", + "description": "Скачать сохранённые показания в виде резервной копии NDJSON.", + "from": "С (необязательно)", + "to": "По (необязательно)", + "rangeHelp": "Оставьте оба поля пустыми, чтобы экспортировать всё. Даты охватывают полные дни по вашему местному времени.", + "hives": "Ульи (необязательно)", + "allHives": "Включены все ульи.", + "someHives_one": "Включён только {{count}} выбранный улей. Число ниже учитывает все показания в диапазоне.", + "someHives_few": "Включены только {{count}} выбранных улья. Число ниже учитывает все показания в диапазоне.", + "someHives_many": "Включены только {{count}} выбранных ульев. Число ниже учитывает все показания в диапазоне.", + "someHives_other": "Включены только {{count}} выбранного улья. Число ниже учитывает все показания в диапазоне.", + "invalidRange": "Дата начала позже даты окончания.", + "summaryFailed": "Не удалось подсчитать показания.", + "count_one": "{{count}} показание", + "count_few": "{{count}} показания", + "count_many": "{{count}} показаний", + "count_other": "{{count}} показания", + "span": "С {{first}} по {{last}}", + "download": "Скачать", + "formatHelp": "Одно показание JSON на строку. Файл можно снова импортировать через импорт с SD-карты.", + "noPermission": "Только владельцы устройств и администраторы могут экспортировать данные. Ваша роль: «{{role}}»." + }, + "delete": { + "title": "Удалить показания за период", + "description": "Удаляет ложные показания — например, скачки веса, которые хаб записывает при загрузке до калибровки весов и которые иначе искажают графики и аналитику.", + "warningTitle": "Это действие необратимо", + "warning": "Удалённые показания исчезнут навсегда. Сначала экспортируйте период, если он может понадобиться.", + "start": "Начало", + "end": "Конец", + "claimCode": "Код привязки", + "claimCodeHelp": "Код привязки хаба — как второе подтверждение.", + "review": "Проверить удаление", + "confirmTitle": "Удалить эти показания?", + "confirmRange": "С {{start}} по {{end}}.", + "confirmCount_one": "{{count}} показание будет удалено безвозвратно.", + "confirmCount_few": "{{count}} показания будут удалены безвозвратно.", + "confirmCount_many": "{{count}} показаний будут удалены безвозвратно.", + "confirmCount_other": "{{count}} показания будут удалены безвозвратно.", + "confirmIrreversible": "Это действие необратимо.", + "countFailed": "Не удалось подсчитать показания в этом периоде.", + "confirm_one": "Удалить {{count}} показание", + "confirm_few": "Удалить {{count}} показания", + "confirm_many": "Удалить {{count}} показаний", + "confirm_other": "Удалить {{count}} показания", + "deleting": "Удаление…", + "success_one": "Удалено {{count}} показание.", + "success_few": "Удалено {{count}} показания.", + "success_many": "Удалено {{count}} показаний.", + "success_other": "Удалено {{count}} показания.", + "noPermission": "Удалять показания может только владелец устройства. Ваша роль: «{{role}}».", + "errors": { + "missingRange": "Выберите начало и конец.", + "invalidRange": "Конец должен совпадать с началом или быть позже.", + "missingClaimCode": "Введите код привязки хаба." + } + } + }, + "alertSettings": { + "title": "Оповещения HiveHub по эл. почте", + "description": "Получайте письмо, когда HiveHub замечает что-то в ваших ульях — роение, воровство, безматочность семьи и многое другое.", + "enable": "Присылать мне оповещения HiveHub по эл. почте", + "minSeverity": "Минимальная важность", + "minSeverityHelp": "Оповещения ниже этого уровня никогда не отправляются по почте.", + "severity": { + "watch": "Наблюдение и выше", + "warning": "Предупреждение и выше", + "critical": "Только критические" + }, + "help": { + "schedule": "HivePal проверяет новые оповещения каждые 15 минут.", + "once": "Каждое оповещение отправляется один раз при появлении и повторно — только если оно обостряется.", + "scope": "Охватывает все устройства HiveHub, к которым у вас есть доступ.", + "email": "Требуется настроенная электронная почта на сервере HivePal." + }, + "saved": "Настройки оповещений сохранены", + "saveFailed": "Не удалось сохранить настройки оповещений", + "loadFailed": "Не удалось загрузить ваши настройки." + }, + "health": { + "title": "Состояние датчиков", + "description": "Состояние каждого датчика по данным {{name}}.", + "descriptionAt": "Состояние датчиков по последнему сеансу связи · {{ago}}.", + "noData": "Данные о состоянии датчиков ещё не поступали — они появятся, когда устройство отправит первое показание.", + "hubHeading": "Хаб", + "hiveIndex": "Улей {{n}}", + "noHiveSensors": "Для этого улья датчики не сообщаются.", + "faultNote": "Хаб сообщает о своих подсистемах только «исправно/неисправно» — кода ошибки для них он не отправляет, поэтому каждая неисправность выше описывает, что именно отказало. Счётчик на летке сообщает код состояния, и он показывается вместе с неисправностью везде, где был считан.", + "summary": { + "noData": "Нет данных датчиков", + "allOk": "Всё в порядке", + "faults_one": "{{count}} неисправность", + "faults_few": "{{count}} неисправности", + "faults_many": "{{count}} неисправностей", + "faults_other": "{{count}} неисправности" + }, + "state": { + "ok": "OK", + "fault": "Неисправность", + "notFitted": "Не установлен", + "notHeard": "Нет сигнала" + }, + "hub": { + "sht_ok": "Внешняя температура/влажность (SHT40)", + "rtc_ok": "Часы реального времени", + "sd_ok": "SD-карта", + "mic_ok": "Микрофон", + "mic_left_ok": "Микрофон · левый", + "mic_right_ok": "Микрофон · правый", + "battery_monitor_ok": "Монитор аккумулятора", + "solar_monitor_ok": "Монитор солнечной панели" + }, + "hubReason": { + "sht_ok": "Датчик окружающей среды не выдал корректного показания в этом цикле, поэтому в этом сеансе связи нет внешней температуры и влажности. Обычно это неисправность шины I²C или проводки; датчик, не обнаруженный при загрузке, выглядит так же.", + "rtc_ok": "Часы реального времени не ответили по шине I²C. Метки времени берутся из сетевого времени, поэтому показание, снятое без связи, может получить неверную дату.", + "sd_ok": "SD-карта не была смонтирована в этом цикле — отсутствует, не читается или неисправность SPI/проводки. Пока это сохраняется, показания не буферизуются на карту.", + "mic_ok": "Запись с микрофона не дала пригодных отсчётов, поэтому в этом сеансе связи нет акустических полос.", + "mic_left_ok": "Левый микрофон не дал пригодных отсчётов.", + "mic_right_ok": "Правый микрофон не дал пригодных отсчётов.", + "battery_monitor_ok": "Монитор заряда аккумулятора не ответил по шине I²C — напряжение и уровень заряда аккумулятора в этом сеансе связи отсутствуют.", + "solar_monitor_ok": "Монитор солнечной панели не ответил по шине I²C — напряжение, ток и мощность солнечной панели в этом сеансе связи отсутствуют." + }, + "sensor": { + "scale": "Весы", + "inHive": "Датчик в улье", + "counter": "Счётчик HiveTraffic", + "hiveheart": "HiveHeart" + }, + "reason": { + "scale": "Для этого улья настроены весы, но в этом цикле они не выдали пригодного показания, поэтому в этом сеансе связи нет веса для улья.", + "inHive": "Во время BLE-сканирования в этом цикле не получено объявлений от сопряжённого {{kind}} — вне зоны действия, разряжена батарея или выключен.", + "inHiveKindFallback": "датчика в улье", + "node": "Узел {{node}}.", + "lastHeard": "Последний сигнал {{ago}}.", + "notHeardInRange": "В загруженном периоде сигнала не было.", + "counter": "Счётчик на летке не ответил на чтение GATT в этом цикле — вне зоны действия, выключен или занят в момент подключения HiveHub.", + "counterNode": "Счётчик {{node}}.", + "lastAnswered": "Последний ответ {{ago}}.", + "notAnsweredInRange": "В загруженном периоде он не отвечал.", + "statusThen": "Состояние тогда: {{status}}.", + "expandersThen_one": "Тогда отвечал {{count}} из 3 расширителей портов.", + "expandersThen_few": "Тогда отвечали {{count}} из 3 расширителей портов.", + "expandersThen_many": "Тогда отвечало {{count}} из 3 расширителей портов.", + "expandersThen_other": "Тогда отвечало {{count}} из 3 расширителей портов.", + "hiveheart": "HiveHeart не был слышен в этом цикле — вне зоны действия, разряжена батарея или выключен." + }, + "counter": { + "nightIdle": "ночной простой", + "nightIdleBadge": "Ночной простой", + "otherBits": "биты счётчика {{hex}}", + "noFlags": "флаги не установлены", + "statusCode": "состояние {{hex}}", + "expanders_one": "{{count}}/3 расширителей проходов", + "expanders_few": "{{count}}/3 расширителей проходов", + "expanders_many": "{{count}}/3 расширителей проходов", + "expanders_other": "{{count}}/3 расширителей проходов", + "glitches_one": "{{count}} сбой", + "glitches_few": "{{count}} сбоя", + "glitches_many": "{{count}} сбоев", + "glitches_other": "{{count}} сбоя", + "banks": "Группы {{list}}", + "noBanks": "Нет групп излучателей" + }, + "meta": { + "firmware": "v{{version}}", + "batteryPercent": "Аккумулятор {{value}} %", + "batteryMv": "Аккумулятор {{value}} мВ", + "batteryV": "Аккумулятор {{value}} В", + "rssi": "{{value}} дБм" + } + }, + "hubStatus": { + "title": "Состояние хаба", + "description": "Связь, часы и питание самого HiveHub.", + "noData": "От этого хаба ещё не получено ни одного показания.", + "staleWarning": "Нет показаний уже {{ago}} — хаб пропустил несколько сеансов связи.", + "badge": { + "live": "В сети", + "stale": "Устарело", + "inspection": "Идёт осмотр", + "calibration": "Режим калибровки" + }, + "section": { + "connectivity": "Связь", + "battery": "Аккумулятор", + "solar": "Солнечная панель" + }, + "row": { + "signal": "Сигнал", + "transport": "Канал связи", + "timeSource": "Источник времени", + "lastSeen": "Последний раз на связи", + "lastReading": "Последнее показание", + "firmware": "Прошивка", + "bootCount": "Число загрузок", + "stateOfCharge": "Уровень заряда", + "batteryVoltage": "Напряжение", + "batteryAlert": "Предупреждение аккумулятора", + "solarPower": "Солнечная мощность", + "solarCurrent": "Солнечный ток", + "solarBus": "Напряжение панели (шины)", + "solarLoad": "Напряжение нагрузки" + }, + "signalQuality": { + "excellent": "Отличный", + "good": "Хороший", + "fair": "Удовлетворительный", + "poor": "Слабый" + }, + "transport": { + "wifi": "Wi-Fi", + "cellular": "Мобильная сеть" + }, + "rtc": { + "ok": "RTC в норме", + "fault": "Неисправность RTC" + }, + "batteryAlertActive": "Низкий заряд аккумулятора" + }, + "hiveCards": { + "empty": "Это устройство ещё не сообщило ни об одном улье.", + "hiveIndex": "Улей {{n}}", + "weight": "Вес", + "temperature": "Температура", + "humidity": "Влажность", + "delta24h": "24 ч {{value}} кг", + "lastReported": "Последние данные {{ago}}", + "inspection": "Осмотр", + "insightsLoading": "Загрузка аналитики", + "insightsUnavailable": "Аналитика недоступна", + "noSensors": "Датчики не сообщаются", + "traffic": "Вход {{in}} · Выход {{out}}", + "trafficTitle": "Пчёлы вход/выход за последний интервал", + "alerts_one": "{{count}} активное оповещение", + "alerts_few": "{{count}} активных оповещения", + "alerts_many": "{{count}} активных оповещений", + "alerts_other": "{{count}} активного оповещения", + "sensor": { + "scale": "Весы", + "inHive": "Датчик в улье", + "counter": "Счётчик HiveTraffic", + "hiveheart": "HiveHeart" + }, + "state": { + "ok": "OK", + "fault": "Нет сигнала / неисправность" + } + }, + "audio": { + "title": "Звук улья", + "description": "Попросите узел HiveInside улья сделать запись, а затем прослушайте её здесь. Хаб забирает запрос при следующем пробуждении, поэтому запись появляется в пределах одного интервала отправки, а не сразу.", + "noNodes": "К этому хабу не подключён ни один узел HiveInside. Микрофон есть только у HiveInside — маяки HolyIot и RuuviTag записывать не могут.", + "hive": "Улей", + "hiveFallback": "Улей {{slot}}", + "length": "Длительность", + "durationOption_one": "{{count}} секунда", + "durationOption_few": "{{count}} секунды", + "durationOption_many": "{{count}} секунд", + "durationOption_other": "{{count}} секунды", + "gain": "Усиление", + "gainValue": "{{gain}} дБ", + "record": "Записать", + "nextWake": "Попадёт на хаб при его следующем пробуждении.", + "viewerNote": "Запросить запись может владелец или администратор.", + "lengthHint": "Короче обычно лучше: 10–20 секунд достаточно, чтобы услышать то, на что указала аналитика, а батареи и времени, когда улей не в эфире, уходит меньше — подключённый узел не рассылает объявления и не снимает показания.", + "requestSuccess": "Запись запрошена. Запрос попадёт на хаб при его следующем пробуждении.", + "requestError": "Не удалось поставить запрос в очередь: {{message}}", + "listTitle": "Записи", + "onlySelectedHive": "Только {{name}}", + "loadError": "Не удалось получить список записей: {{message}}", + "empty": "Записей пока нет. Попросите улей сделать запись, и она появится здесь. Сеанс — это 10–60 секунд звука 16 кГц, скачанного с узла по BLE.", + "requestedLength_one": "Запрошено {{count}} с", + "requestedLength_few": "Запрошено {{count}} с", + "requestedLength_many": "Запрошено {{count}} с", + "requestedLength_other": "Запрошено {{count}} с", + "requestedBy": "Запросил(а): {{name}}", + "status": { + "requested": "Ожидание хаба", + "streaming": "Запись…", + "ready": "Готово", + "readyIncomplete": "Готово, не полностью", + "failed": "Ошибка" + }, + "waitingNote": "Ожидание, пока хаб проснётся и заберёт запрос — пока ничего не записано. Запрос, который хаб не забрал в течение 45 минут, помечается как неудавшийся.", + "streamingNote": "Идёт запись — звук появляется здесь по мере поступления.", + "streamingPartial": "Запись ещё идёт — это то, что уже поступило. Воспроизведите снова после окончания сеанса, чтобы прослушать всё целиком.", + "failedFallback": "Эта запись не удалась.", + "seconds": "{{value}} с", + "bytes": "{{value}} Б", + "percent": "{{value}} %", + "download": "Скачать WAV", + "delete": "Удалить", + "deleteConfirm": "Удалить эту запись? Больше её никто не удалит — автоматической очистки нет.", + "deleteSuccess": "Запись удалена.", + "deleteError": "Не удалось удалить запись: {{message}}", + "confirmation": { + "verified": "проверено", + "reported": "не проверено", + "confirmed": "подтверждено хабом", + "unknown": "не подтверждено" + }, + "quality": { + "length": "Длительность", + "dropped": "Потеряно", + "gaps": "Пропуски", + "hubBuffer": "Буфер хаба", + "clipping": "Перегрузка", + "checksum": "Контрольная сумма", + "cause": { + "dropped": "узел не успевал выдавать звук", + "gaps": "пакеты потерялись в эфире", + "hubBuffer": "отправка хаба отставала от его буфера" + }, + "gapNote": "Звук по обе стороны пропуска настоящий, но стык — нет. Причина: {{causes}}.", + "clippingNote": "Уменьшите усиление для более чистой записи.", + "unknownNote": "Хаб так и не сообщил об этом сеансе, поэтому никто не проверил, целая ли запись. То, что есть, воспроизводится." + } + }, + "dashboard": { + "hiveFallback": "Улей {{index}}", + "units": { + "bees": "пчёл" + }, + "range": { + "24h": "24 ч", + "7d": "7 дн", + "30d": "30 дн", + "365d": "365 дн", + "custom": "Свой", + "invalid": "Дата начала не должна быть позже даты окончания." + }, + "header": { + "title": "Моя панель", + "description": "Настраиваемые виджеты. Выбранные выше ульи управляют графиками по ульям.", + "editDescription": "Режим редактирования: перетаскивайте маркер, чтобы перемещать виджеты, и угол, чтобы менять их размер на сетке из 4 колонок.", + "edit": "Редактировать панель", + "done": "Готово", + "addWidget": "Добавить виджет", + "reset": "Сбросить", + "editBanner": "Режим редактирования панели включён. Используйте маркер в заголовке каждого виджета для перемещения и тяните правый нижний угол, чтобы изменить ширину или высоту. Изменения раскладки сохраняются автоматически для этого устройства HiveHub.", + "dropEnd": "Отпустите здесь, чтобы переместить виджет в конец панели.", + "noWidgets": "Виджетов пока нет. Добавьте шаблон, чтобы собрать свою панель.", + "restoreDefaults": "Восстановить по умолчанию" + }, + "overview": { + "title": "Обзор ульев", + "description": "Краткий статус сопоставленных ульев. Выберите плитки, чтобы управлять виджетами панели ниже.", + "lastData": "Последние данные", + "hivesOnline": "Ульев на связи", + "hubFirmware": "Прошивка HiveHub", + "hiveInsideFirmware": "Прошивка HiveInside", + "selected_one": "Выбран {{count}}", + "selected_few": "Выбрано {{count}}", + "selected_many": "Выбрано {{count}}", + "selected_other": "Выбрано {{count}}", + "hide": "Скрыть обзор", + "show": "Показать обзор", + "slot": "Слот {{index}}", + "ok": "OK", + "noData": "Нет данных", + "weight": "Вес", + "temperature": "Темп.", + "humidity": "Влажн.", + "empty": "Сопоставьте слоты HiveHub с ульями HivePal в панели настройки устройства, чтобы здесь появились плитки обзора." + }, + "sensors": { + "scale": "весы", + "inHive": "в улье", + "accel": "акселер.", + "counter": "счётчик", + "none": "нет датчиков" + }, + "widgetShell": { + "move": "Переместить {{title}}", + "moveHint": "Перетащите, чтобы переместить виджет", + "remove": "Удалить {{title}}", + "resize": "Изменить размер {{title}}", + "resizeHint": "Перетащите, чтобы изменить размер виджета" + }, + "addWidget": { + "title": "Добавить виджет", + "description": "Начните с шаблона. Где применимо, виджет будет использовать ульи, выбранные в сетке обзора.", + "defaultSize": "Размер по умолчанию {{w}} x {{h}}" + }, + "widgets": { + "weightComparison": { + "title": "Сравнение веса", + "description": "Сравнение выбранных ульев за текущий период." + }, + "dailyMaxWeight": { + "title": "Максимальный вес за день", + "description": "Наибольший скомпенсированный вес за день для выбранных ульев." + }, + "climate": { + "title": "Микроклимат улья", + "description": "Температура и влажность выбранных ульев." + }, + "power": { + "title": "Состояние питания", + "description": "Заряд и напряжение аккумулятора, солнечная энергия." + }, + "beeTraffic": { + "title": "Лёт пчёл", + "description": "Суммарный лёт на вход/выход и чистый поток." + }, + "soundRms": { + "title": "Звук / акустические полосы", + "description": "Акустический RMS по ульям и полосы FFT/HiveHeart, если доступны." + }, + "hiveHeartSpectrum": { + "title": "Спектр HiveHeart", + "description": "Последние уровни FFT HiveHeart (0–15) в 16 частотных диапазонах для выбранных ульев." + }, + "vibration": { + "title": "Полосы вибрации", + "description": "RMS вибрации, полосы роения, вентилирования и активности." + }, + "configurableDiagram": { + "title": "Настраиваемый график", + "description": "Выберите показатели улья, звука, лёта, микроклимата и устройства для построения." + }, + "temperatureHeatmap": { + "title": "Тепловая карта температуры", + "description": "Компактный обзор температуры всех ульев." + }, + "insights": { + "title": "Лента аналитики", + "description": "Активные оповещения и фрагменты данных-обоснований." + }, + "dataQuality": { + "title": "Качество данных", + "description": "Доступность датчиков и пропущенные показания." + } + }, + "metricGroups": { + "scale": "Весы", + "climate": "Микроклимат", + "beeTraffic": "Лёт пчёл", + "vibration": "Вибрация", + "sound": "Звук", + "device": "Устройство" + }, + "metrics": { + "weight": "Вес", + "temperature": "Температура", + "humidity": "Влажность", + "pressure": "Давление", + "beeIn": "Пчёлы на вход", + "beeOut": "Пчёлы на выход", + "beeNet": "Чистый поток", + "vibration": "RMS вибрации", + "swarmBand": "Полоса роения", + "fanningBand": "Полоса вентилирования", + "activityBand": "Полоса активности", + "soundRms": "RMS звука", + "soundHum": "Полоса гула", + "soundPiping": "Полоса пения маток", + "soundStress": "Полоса стресса", + "hiveHeartFrequency": "Частота HiveHeart", + "hiveHeartEnergy": "Энергия HiveHeart", + "batterySoc": "Заряд аккумулятора", + "batteryVoltage": "Напряжение аккумулятора", + "solarPower": "Солнечная мощность" + }, + "axes": { + "weight": "Вес", + "temperature": "Температура", + "humidity": "Влажность", + "pressure": "Давление", + "beecount": "Число пчёл", + "vibration": "Вибрация", + "dbfs": "Уровень звука", + "frequency": "Частота", + "energy": "Энергия", + "percent": "Заряд", + "voltage": "Напряжение", + "power": "Мощность" + }, + "soundMetrics": { + "rms": "RMS", + "hiveHeartFrequency": "Частота HiveHeart", + "hiveHeartEnergy": "Энергия HiveHeart", + "hiveHeartPeak": "Пик HiveHeart" + }, + "vibrationMetrics": { + "rms": "RMS", + "swarmBand": "Полоса роения", + "fanningBand": "Полоса вентилирования", + "activityBand": "Полоса активности" + }, + "series": { + "temperature": "{{name}} темп.", + "humidity": "{{name}} влажн.", + "beeIn": "Вход", + "beeOut": "Выход", + "beeNet": "Итог" + }, + "axisScale": { + "auto": "авто", + "setBound": "Задать {{axis}} {{bound}}", + "clearHint": "Оставьте пустым и нажмите Enter для авто", + "tickTitle": "Нажмите, чтобы задать {{bound}}; очистите для авто", + "reset": "сброс", + "help": "Нажмите на нижнюю или верхнюю подпись оси Y, чтобы изменить. Очистите значение для авто." + }, + "chart": { + "downloadCsv": "Скачать CSV" + }, + "inspection": { + "legendHint_one": "Затенено {{count}} окно осмотра; показания могут быть искажены", + "legendHint_few": "Затенено {{count}} окна осмотра; показания могут быть искажены", + "legendHint_many": "Затенено {{count}} окон осмотра; показания могут быть искажены", + "legendHint_other": "Затенено {{count}} окна осмотра; показания могут быть искажены" + }, + "empty": { + "noData": "Нет данных для выбранных ульев и периода.", + "climate": "Нет данных микроклимата за выбранный период.", + "power": "Нет данных питания за выбранный период.", + "beeTraffic": "Нет данных счётчика пчёл для выбранных ульев.", + "sound": "Нет акустических данных по ульям для выбранных ульев.", + "vibration": "Нет данных вибрации или полос акселерометра для выбранных ульев.", + "configurable": "Выберите хотя бы один показатель с данными для выбранных ульев и периода.", + "dailyMaxWeight": "Нет данных веса для выбранных ульев и периода.", + "hiveHeart": "Нет данных FFT HiveHeart для выбранных ульев и периода.", + "unknownWidget": "Неизвестный тип виджета." + }, + "soundFootnote": "Сначала показываются акустические показания по ульям. Устаревшие данные левого/правого микрофонов сопоставляются с настроенными названиями ульев для слотов 1 и 2, если блока акустики по ульям нет.", + "heatmap": { + "low": "низкая <33 °C", + "optimal": "оптимум для расплода 33–36 °C", + "high": "высокая >36 °C" + }, + "insights": { + "loading": "Загрузка аналитики…", + "unavailable": "Аналитика недоступна.", + "noAlerts": "Нет активных оповещений для выбранных ульев.", + "showing_one": "Показано {{count}} активное оповещение для выбранных ульев.", + "showing_few": "Показано {{count}} активных оповещения для выбранных ульев.", + "showing_many": "Показано {{count}} активных оповещений для выбранных ульев.", + "showing_other": "Показано {{count}} активного оповещения для выбранных ульев.", + "confidence": "достоверность {{percent}}%" + }, + "dataQuality": { + "activeSlots": "Активные слоты", + "noScale": "Нет весов", + "noClimate": "Нет микроклимата", + "noRecentData": "нет свежих данных" + }, + "hiveHeart": { + "level": "уровень", + "dominant": "доминирующая частота {{hz}} Гц", + "noDominant": "доминирующая частота не сообщается", + "footnote": "Относительные уровни 0–15 (не dBFS) последнего показания в периоде. Пунктирные линии отмечают доминирующую частоту, о которой сообщает HiveHeart; заголовки «Суб-бас/Гул/Писк/пение маток/Стресс» приблизительны и охватывают несколько диапазонов." + } } } diff --git a/apps/frontend/public/locales/sk/common.json b/apps/frontend/public/locales/sk/common.json index ea66a701c..e858a9040 100644 --- a/apps/frontend/public/locales/sk/common.json +++ b/apps/frontend/public/locales/sk/common.json @@ -29,7 +29,8 @@ "tools": "Nástroje", "syrupCalculator": "Kalkulačka sirupu", "calendar": "Kalendár", - "actions": "Hromadné pridanie" + "actions": "Hromadné pridanie", + "hivehub": "HiveHub" }, "actions": { "save": "Uložiť", diff --git a/apps/frontend/public/locales/sk/hivescale.json b/apps/frontend/public/locales/sk/hivescale.json index 6b2bcb33d..9c868a3c9 100644 --- a/apps/frontend/public/locales/sk/hivescale.json +++ b/apps/frontend/public/locales/sk/hivescale.json @@ -35,7 +35,7 @@ }, "page": { "title": "HiveHub", - "subtitle": "Live weight and temperature visualisation from your separate HiveHub backend.", + "subtitle": "Hmotnosť, mikroklíma, zvuk, prelet a upozornenia z vašich zariadení HiveHub.", "refresh": "Refresh", "refreshing": "Refreshing…", "refreshError": "Could not refresh HiveHub data.", @@ -43,16 +43,26 @@ "removeSuccess": "HiveHub device removed from your account.", "removeSuccessShared": "K tomuto zariadeniu HiveHub už nemáte prístup. Ostatní používatelia ho stále majú, takže jeho kód nároku zostáva používaný.", "releaseConfirm": "Úplne uvoľniť {{name}}? Všetci používatelia stratia prístup a zariadenie si možno znova nárokovať jeho kódom. Merania sa zachovajú.", - "releaseSuccess": "Zariadenie HiveHub uvoľnené. Možno si ho znova nárokovať jeho kódom nároku." + "releaseSuccess": "Zariadenie HiveHub uvoľnené. Možno si ho znova nárokovať jeho kódom nároku.", + "unavailableTitle": "HiveHub nie je dostupný", + "inspectionActiveTitle": "Režim prehliadky je zapnutý", + "inspectionActiveBody": "Kým sú úle otvorené, merania sa skryjú, aby vyberanie rámikov nevyvolalo falošné upozornenia.", + "openInspections": "Otvoriť prehliadky", + "tabs": { + "overview": "Prehľad", + "inspections": "Prehliadky", + "audio": "Zvuk", + "health": "Stav", + "setup": "Nastavenie" + } }, "setup": { "title": "Devices", - "description": "Select or remove a claimed HiveHub device. Expand the setup to claim devices, check status, map scales, and calibrate the raw readings.", "removeScale": "Remove scale", "releaseScale": "Uvoľniť zariadenie", "noDevices": "No devices claimed yet — open the setup to add one.", - "hide": "Hide setup", - "show": "Show setup" + "selectDevice": "Zariadenie HiveHub", + "noDevicesYet": "Zatiaľ nie je nárokované žiadne zariadenie HiveHub. Nárokujte si ho kódom, ktorý hub zobrazuje vo svojom portáli nastavenia." }, "claim": { "title": "Claim HiveHub device", @@ -65,16 +75,30 @@ "success": "HiveHub device claimed.", "errors": { "missingCode": "Enter the claim code printed/configured for the device." - } + }, + "hive1Name": "Názov úľa 1", + "hive2Name": "Názov úľa 2", + "optional": "Voliteľné" }, "mapping": { - "title": "Scale mapping", - "description": "These labels stay in HiveHub. Later, this is the right place to map Scale 1/2 to HivePal hive IDs for inspection markers.", - "scale1Label": "Display name for Scale 1", - "scale2Label": "Display name for Scale 2", + "title": "Úle", "hiveNamePlaceholder": "Select or type a hive name", - "save": "Save mapping", - "success": "Scale mapping updated." + "save": "Uložiť úle", + "success": "Sloty úľov uložené do HiveHub.", + "slotsDescription": "Pomenujte každý slot úľa a prepojte ho s úľom v HivePal. Vďaka prepojeniu prehliadky, stránka úľa a e-maily s upozorneniami vedia, ktorému včelstvu slot patrí. Zelená bodka označuje sloty, ktoré hub práve hlási.", + "mapSlot": "Priradiť slot {{slot}}", + "add": "Pridať", + "reporting": "Hlásené hubom", + "unknownHive": "Úľ sa nenašiel", + "mappedCount": "Priradené sloty: {{count}}/{{total}}", + "dialogTitle": "Slot úľa {{slot}}", + "dialogDescription": "Prepojte slot s jedným z vašich úľov v HivePal a zvoľte názov, ktorý preň HiveHub zobrazí.", + "linkedHive": "Úľ v HivePal", + "notLinked": "Neprepojené", + "linkHint": "Prepojenie zostane zachované aj po premenovaní úľa. Výber úľa doplní jeho názov, ak je názov prázdny.", + "displayName": "Zobrazovaný názov", + "clearSlot": "Vymazať slot", + "applySlot": "Použiť" }, "status": { "title": "Device status", @@ -94,6 +118,13 @@ "revokeAccess": "Revoke access", "errors": { "missingEmail": "Enter the email address of an existing HivePal user." + }, + "lastSeen": "Posledné hlásenie", + "claimedAt": "Nárokované", + "roles": { + "owner": "Vlastník", + "admin": "Administrátor", + "viewer": "Pozorovateľ" } }, "calibration": { @@ -203,11 +234,9 @@ }, "sourceHint": "Okolitá teplota v ustálenom stave úzko sleduje telo snímača a je odporúčaným predvoleným nastavením.", "refLabel": "Referenčná teplota / °C", - "refHint": "Teplota, pri ktorej je korekcia nulová.", "coeffLabel": "Koeficient {{scaleName}} / kg na °C", "autoFit": "Automaticky prispôsobiť z histórie", "fitting": "Prispôsobuje sa…", - "autoFitHint": "Automatické prispôsobenie regresne porovná surovú hmotnosť za posledné 3 dni so zvolenou teplotou vyhladenou pomocou EMA a potom výsledok použije a zapne. Pre čisté prispôsobenie zachyťte okno, v ktorom je fyzické zaťaženie konštantné a teplota kolíše.", "save": "Uložiť teplotnú kompenzáciu", "errors": { "refTemp": "Referenčná teplota musí byť číslo (°C).", @@ -219,11 +248,33 @@ "noSignal": "nedostatočný teplotný/hmotnostný signál.", "fitApplied": "{{scaleName}}: {{coeff}} kg/°C pri {{refTemp}}°C (R²={{r2}}, n={{n}}). Použité a zapnuté." } + }, + "perHive": { + "hive": "Úľ {{index}}", + "hiveNamed": "Úľ {{index}} · {{name}}", + "hiveLabel": "Úľ", + "knownWeightPlaceholder": "napr. 10", + "manualSaveHint": "Prepnite úľ, ak chcete upraviť inú váhu; všetky úpravy sa pri uložení odošlú naraz. Úle od 3 vyššie sa zapisujú, len ak sa zmenili.", + "tempco": { + "refHint": "Teplota, pri ktorej váhy vážia presne, t. j. pri ktorej boli tárované a kalibrované. Korekcia je pri nej nulová; prispôsobenie ju nemení, pokiaľ o to nepožiadate.", + "lookbackLabel": "Obdobie prispôsobenia / dni", + "setRefTemp": "Nastaviť aj referenčnú teplotu", + "setRefTempHint": "Nahradí referenčnú teplotu priemernou teplotou prispôsobeného obdobia. Nechajte vypnuté, ak bola váha kalibrovaná pri známej teplote.", + "fitOnly": "Len prispôsobiť (náhľad)", + "fitAndApply": "Prispôsobiť a použiť", + "fitSummary": "{{scaleName}}: {{coeff}} kg/°C, R² {{r2}} z {{n}} meraní v rozsahu {{min}}–{{max}} °C (priemer obdobia {{mean}} °C).", + "lowRSquared": "R² pod 0,5: teplota vysvetľuje len málo z tohto posunu. Prispôsobte na úseku s konštantnou záťažou a veľkým rozdielom teplôt medzi dňom a nocou.", + "autoFitHint": "Automatické prispôsobenie vypočíta regresiu uloženej hmotnosti vybraného úľa za zvolené obdobie voči vybranej teplote vyhladenej EMA. „Len prispôsobiť“ vyplní koeficient na kontrolu pred uložením; „Prispôsobiť a použiť“ ho uloží a zapne kompenzáciu. Pre čisté prispôsobenie zvoľte obdobie, keď je fyzická záťaž konštantná a teplota kolíše.", + "errors": { + "lookback": "Obdobie musí byť celý počet dní od 1 do 90." + }, + "toasts": { + "fitFilled": "{{scaleName}}: {{coeff}} kg/°C (R²={{r2}}, n={{n}}). Vyplnené — skontrolujte a uložte." + } + } } }, "firmware": { - "title": "Firmware upload", - "description": "Upload a firmware binary and register it as a release. New devices of the matching type pick it up on their next update check.", "type": "Firmware type", "selectType": "Select firmware type", "version": "Version", @@ -238,6 +289,102 @@ "errors": { "missingFile": "Choose a firmware binary (.bin) to upload.", "missingVersion": "Enter a version, e.g. 0.6.3." + }, + "update": { + "title": "Aktualizácia firmvéru", + "description": "Váhy sa nikdy neaktualizujú samy — každé nové zostavenie tu schválite skôr, ako ho táto váha nainštaluje.", + "unavailable": "Stav firmvéru momentálne nie je k dispozícii.", + "availableTitle": "K dispozícii je firmvér {{version}}", + "availableBody": "Táto váha beží na {{current}}. Použite aktualizáciu na {{version}}.", + "officialSuffix": "Toto je oficiálne zostavenie.", + "apply": "Použiť aktualizáciu", + "applying": "Používa sa…", + "applied": "Firmvér {{version}} schválený — váha sa aktualizuje pri ďalšom hlásení.", + "queued": "Aktualizácia {{version}} schválená. Váha ju nainštaluje pri ďalšom hlásení.", + "upToDate": "Firmvér je aktuálny ({{version}}).", + "viewerNotice": "Aktualizácie firmvéru môžu použiť len vlastníci a administrátori zariadenia." + }, + "board": { + "cardTitle": "Firmvér", + "cardDescription": "Firmvér tohto HiveHub a uzlov HiveInside a počítadiel HiveTraffic, ktoré obsluhuje. Nahratím sa zaregistruje vydanie; nainštaluje sa schválením (hub) alebo prenosom (uzly).", + "current": "Aktuálny", + "latest": "Najnovší", + "approved": "Schválený", + "board": "Doska", + "badgePending": "Aktualizácia čaká na schválenie", + "badgeApproved": "Aktualizácia schválená", + "badgeUpToDate": "Aktuálny", + "approveFlash": "Schváliť a nahrať {{version}}", + "approveConfirm": "Schváliť firmvér {{version}} a nahrať ho bezdrôtovo?\n\nHiveHub ho nainštaluje pri ďalšom hlásení a reštartuje sa. Vzdialené zariadenie, ktorému aktualizácia zlyhá uprostred, môže na obnovu vyžadovať fyzický prístup.", + "otherBoards": "Nahraté aj pre inú dosku (nepoužije sa na toto zariadenie {{board}}): {{releases}}. Zostavenie sa dostane len do zariadenia so zhodnou doskou — pri nahrávaní skontrolujte dosku.", + "otherBoardsUnknown": "Nahraté aj pre inú dosku (nepoužije sa na toto zariadenie): {{releases}}. Zostavenie sa dostane len do zariadenia so zhodnou doskou — pri nahrávaní skontrolujte dosku." + }, + "relays": { + "hiveFallback": "Úľ {{slot}}", + "latest": "najnovší {{version}}", + "updateAvailable": "dostupná aktualizácia: {{version}}", + "status": { + "pending": "prenos vo fronte", + "running": "prenáša sa…", + "completed": "prenos dokončený", + "failed": "prenos zlyhal", + "expired": "čas prenosu vypršal" + }, + "statusTitle": "Prenos {{version}}: {{status}} ({{date}})", + "failedDetail": "Posledný prenos zlyhal — {{message}} ({{date}})", + "noReason": "dôvod nebol nahlásený", + "alreadyQueued": "Pre tento uzol je už prenos vo fronte", + "queueing": "Zaraďuje sa…", + "relayAnyway": "Preniesť aj tak", + "confirm": "Preniesť {{name}} {{version}} do {{hive}}?\n\nHiveHub to prevezme v ďalšom cykle odosielania — zvyčajne asi do 10 minút, prípadne podľa intervalu odosielania tohto zariadenia. Potom niekoľko minút posiela obraz cez BLE a uzol sa reštartuje do nového firmvéru. Uzol musí byť prebudený a v dosahu, inak prenos zlyhá a treba ho znova zaradiť do frontu.", + "queued": "Prenos {{version}} zaradený do frontu pre {{hive}}.", + "queuedFrom": "Prenos zaradený do frontu pre {{hive}} ({{current}} → {{version}}).", + "hiveinside": { + "name": "HiveInside", + "title": "Uzly HiveInside", + "description": "Firmvér, ktorý hlási každý uzol v úli. Nahratie obrazu HiveInside len zaregistruje vydanie — do uzla sa nič nedostane, kým nestlačíte „Preniesť do uzla“. Tu zobrazená verzia potvrdzuje, že sa aktualizácia uchytila.", + "relay": "Preniesť do uzla", + "unnamed": "Nehlási vlastný názov (HiveInside pred 0.5.0)", + "noRelease": "Zatiaľ nie je nahraté žiadne vydanie HiveInside — nahrajte ho nižšie, aby sa dalo preniesť." + }, + "beecounter": { + "name": "HiveTraffic", + "title": "Počítadlá HiveTraffic", + "description": "Firmvér, ktorý hlási každé počítadlo HiveTraffic. Nahratie obrazu HiveTraffic len zaregistruje vydanie — do počítadla sa nič nedostane, kým nestlačíte „Preniesť do počítadla“. Počítadlá staršie ako približne v0.1.0 nehlásia verziu, ale dajú sa aktualizovať.", + "relay": "Preniesť do počítadla", + "unnamed": "Nehlási názov ani adresu", + "noRelease": "Zatiaľ nie je nahraté žiadne vydanie HiveTraffic — nahrajte ho nižšie, aby sa dalo preniesť.", + "countingWarning": "Počas celého prenosu počítadlo nepočíta včely, preto prenášajte, keď je prelet slabý.", + "confirmExtra": "Počas celého prenosu počítadlo nepočíta včely — prelet počas týchto niekoľkých minút sa stratí." + } + }, + "uploadForm": { + "title": "Nahrať firmvér", + "description": "Nahratie len zaregistruje vydanie — samo nikdy nič nenainštaluje. Schváľte aktualizáciu hubu vyššie alebo preneste vydanie pre uzol či počítadlo z jeho riadku.", + "board": "Doska", + "targets": { + "hivehub": "Hub HiveHub", + "hiveinside": "HiveInside", + "beecounter": "Počítadlo HiveTraffic" + }, + "boards": { + "auto": "Automaticky (z názvu súboru)", + "esp32": "ESP32 (klasická, 30 pinov)", + "esp32-c6": "ESP32-C6", + "nrf54lm20a": "nRF54LM20A" + }, + "boardHint": { + "hivehub": "Firmvér hubu musí uvádzať dosku: vyberte ju, alebo ponechajte automaticky, ak má súbor názov ako hivehub_esp32-c6_0.30.5.bin.", + "hiveinside": "HiveInside sa dodáva len pre nRF54LM20A. Nahrajte artefakt zostavenia bez zmeny (napr. hiveinside_nrf54lm20a_1.4.0.bin) a cieľ aj verzia sa doplnia z jeho názvu.", + "beecounter": "Počítadlo HiveTraffic je ESP32-C6. Nahrajte obraz aplikácie (napr. beecounter_esp32-c6_0.7.1.bin), nie zlúčený výrobný obraz." + }, + "detected": "{{name}} — doplnené: {{fields}}.", + "nothingDetected": "{{name}} — v názve sa nerozpoznal cieľ, doska ani verzia; nastavte ich nižšie.", + "detectedTarget": "cieľ „{{target}}“", + "detectedBoard": "doska {{board}}", + "detectedVersion": "verzia {{version}}", + "autoQueued": "Prenos automaticky zaradený do frontu pre {{hives}}.", + "autoQueueFailed": "Prenos pre {{hives}} sa nepodarilo automaticky zaradiť do frontu." } }, "sdData": { @@ -262,12 +409,20 @@ "parsed_other": "Parsed {{count}} records", "skipped_one": " · {{count}} unreadable line skipped.", "skipped_other": " · {{count}} unreadable lines skipped." + }, + "mismatch": { + "title": "Súbor z iného hubu", + "description": "Tento súbor zaznamenal iný hub, nie {{device}}.", + "fileDevices_one": "ID zariadenia v súbore:", + "fileDevices_few": "ID zariadení v súbore:", + "fileDevices_many": "ID zariadení v súbore:", + "fileDevices_other": "ID zariadení v súbore:", + "hint": "Importujte ho aj tak, len ak SD karta naozaj patrí tomuto zariadeniu (napríklad po výmene dosky hubu).", + "cancel": "Zrušiť", + "force": "Aj tak importovať do tohto zariadenia" } }, "panel": { - "weight": "Weight", - "hiveTemp": "Hive temp", - "rmsSound": "RMS sound", "ambientTemperature": "Ambient temperature", "ambientHumidity": "Ambient humidity", "batteryCharge": "Battery charge", @@ -285,168 +440,36 @@ } }, "diagram": { - "title": "Measurements", - "subtitle": "Weight, temperature, and entrance activity over time", - "noMeasurements": "No measurements for the selected range.", - "noDataForSeries": "No data in the selected range.", - "meta": { - "device": "Device:", - "lastSeen": "Last seen:", - "lastData": "Last data:", - "firmware": "Firmware:" - }, "range": { "all": "All", "from": "From", "to": "To" }, - "series": { - "weight": "{{name}} weight", - "temp": "{{name}} temp", - "micRms": "{{name}} mic RMS", - "beesIn": "{{name}} bees in", - "beesOut": "{{name}} bees out", - "netFlow": "{{name}} net flow", - "ambientTemp": "Ambient temp", - "ambientHumidity": "Ambient humidity", - "batteryVoltage": "Battery voltage", - "batteryCharge": "Battery charge", - "solarVoltage": "Solar voltage", - "solarCurrent": "Solar current", - "solarPower": "Solar power", - "humidity": "{{name}} vlhkosť", - "pressure": "{{name}} tlak" - }, - "group": { - "ambient": "Ambient", - "offGrid": "Off-grid" - }, - "axis": { - "weight": "Weight", - "temperature": "Temperature", - "humidity": "Humidity", - "voltage": "Voltage", - "percent": "Percent", - "current": "Current", - "power": "Power", - "sound": "Sound", - "beecount": "Bee count", - "pressure": "Tlak" - }, "marker": { - "inspection": "Inspection", - "boxAdded": "Box added", - "action": { - "maintenance": "Maintenance", - "feeding": "Feeding", - "frames": "Frames", - "treatment": "Treatment" - } - }, - "axisSettings": { - "title": "Axis settings", - "reset": "Reset", - "side": "Side", - "left": "Left", - "right": "Right", - "scale": "Scale", - "auto": "Auto", - "zeroToMax": "0 to max", - "custom": "Custom", - "min": "Min", - "max": "Max", - "autoPlaceholder": "auto" + "inspection": "Inspection" } }, "sound": { - "title": "Hive sound", - "description": "INMP441 FFT band energy (dBFS) — {{left}} (left) · {{right}} (right)", - "hide": "Hide sound", - "show": "Show sound", - "micLeftFallback": "Mic left", - "micRightFallback": "Mic right", - "noDataForChannel": "No data for {{name}} in this range.", - "bandReference": "Band reference", - "thresholds": "Thresholds used by the Insights engine: piping > −45 dBFS, stress > −38 dBFS, queenless hum > −40 dBFS with quiet piping. Calibrate against your own baseline.", - "status": { - "noData": "No data", - "micError": "Mic error", - "bothActive": "{{left}} + {{right}} active", - "oneActive": "{{name}} active" - }, - "table": { - "band": "Band", - "range": "Range", - "significance": "Significance" - }, "bands": { "subBass": { - "label": "Sub-bass", - "description": "Structural vibration / low rumble" + "label": "Sub-bass" }, "hum": { - "label": "Hum", - "description": "Normal colony hum (~200 Hz fundamental)" + "label": "Hum" }, "piping": { - "label": "Piping", - "description": "Queen piping / tooting (pre-swarm signal)" + "label": "Piping" }, "stress": { - "label": "Stress", - "description": "Agitated colony / robbing" + "label": "Stress" }, "high": { - "label": "High", - "description": "Harmonic overtones" + "label": "High" } } }, - "beecounter": { - "title": "Entrance Counter", - "subtitle": "Bees entering and exiting per measurement interval", - "noData": "No BeeCounter data found for this device.", - "offline": "Offline", - "gatesOk": "{{count}} gates OK", - "glitches_one": "{{count}} glitch", - "glitches_other": "{{count}} glitches", - "gatesRatio": "{{healthy}}/{{total}} gates", - "inPeriod": "In (period)", - "outPeriod": "Out (period)", - "netFlow": "Net flow", - "netInbound": "net inbound", - "netOutbound": "net outbound", - "balanced": "balanced", - "view": { - "inOut": "In / Out", - "netFlow": "Net flow", - "cumulative": "Cumulative" - }, - "series": { - "in": "{{name}} in", - "out": "{{name}} out", - "net": "{{name}} net", - "totalIn": "{{name}} total in", - "totalOut": "{{name}} total out" - }, - "diagnostics": { - "title": "Hardware diagnostics", - "gatesHealthy": "Gates healthy", - "glitchCount": "Glitch count", - "totalInLifetime": "Total in (lifetime)", - "totalOutLifetime": "Total out (lifetime)" - } - }, "insights": { - "title": "Insights", - "description": "Sensor-based alerts derived from weight and temperature trends.", - "updated": "Updated {{ago}}.", - "refresh": "Refresh insights", - "loadError": "Failed to load insights: {{message}}", "allClear": "All clear", - "noActiveAlerts": "No active alerts in the last {{count}} days.", - "activeAlerts_one": "{{count}} active alert:", - "activeAlerts_other": "{{count}} active alerts:", "confidence": "Confidence {{value}}%", "source": "Source: {{source}}", "hiveChannel": "Hive {{channel}}", @@ -531,5 +554,760 @@ "description": "No alerts have been recorded for this device.", "descriptionFiltered": "No alerts have been recorded for this device matching this filter." } + }, + "inspectionMode": { + "title": "Režim prehliadky", + "description": "Kým je zapnutý režim prehliadky, HiveHub ďalej meria, ale merania nezahŕňa do grafov, analýz ani upozornení, aby sa vyberanie rámikov nepovažovalo za rojenie.", + "status": { + "active": "Aktívny od {{time}}", + "pending": "Čaká na prevzatie hubom", + "off": "Vypnutý" + }, + "pendingHint": "Vyžiadané — hub to prevezme pri ďalšom hlásení.", + "timeoutNote_one": "Automaticky skončí po {{count}} minúte, ak ho nikto nezastaví.", + "timeoutNote_few": "Automaticky skončí po {{count}} minútach, ak ho nikto nezastaví.", + "timeoutNote_many": "Automaticky skončí po {{count}} minútach, ak ho nikto nezastaví.", + "timeoutNote_other": "Automaticky skončí po {{count}} minútach, ak ho nikto nezastaví.", + "scope": { + "wholeHub": "Celý hub", + "label": "Pokrýva: {{hives}}" + }, + "start": "Spustiť režim prehliadky", + "stop": "Zastaviť režim prehliadky", + "starting": "Spúšťa sa…", + "stopping": "Zastavuje sa…", + "cancel": "Zrušiť", + "noteLabel": "Poznámka (voliteľné)", + "notePlaceholder": "napr. Pridaný medník", + "viewerHint": "Režim prehliadky môžu spustiť alebo zastaviť len vlastníci a administrátori.", + "loadFailed": "Stav prehliadky sa nepodarilo načítať.", + "startDialog": { + "title": "Spustiť režim prehliadky", + "description": "Merania z vybraných úľov sa skryjú, kým režim prehliadky nezastavíte alebo kým nevyprší jeho čas.", + "hivesLabel": "Úle", + "wholeHub": "Celý hub (všetky úle)", + "selectHive": "Vyberte aspoň jeden úľ alebo celý hub.", + "confirm": "Spustiť" + }, + "stopDialog": { + "title": "Zastaviť režim prehliadky", + "description": "HiveHub sa vráti k bežným meraniam a upozorneniam.", + "confirm": "Zastaviť" + }, + "toast": { + "started": "Režim prehliadky spustený", + "stopped": "Režim prehliadky zastavený", + "noteSaved": "Poznámka uložená" + }, + "history": { + "title": "Nedávne prehliadky", + "empty": "Zatiaľ nie sú zaznamenané žiadne prehliadky.", + "ongoing": "prebieha", + "startedVia": "Spustené cez: {{source}}", + "ended": "Ukončené: {{reason}}", + "editNote": "Upraviť poznámku", + "addNote": "Pridať poznámku", + "saveNote": "Uložiť" + }, + "duration": { + "minutes_one": "{{count}} min", + "minutes_few": "{{count}} min", + "minutes_many": "{{count}} min", + "minutes_other": "{{count}} min", + "hoursMinutes": "{{hours}} h {{minutes}} min" + }, + "source": { + "device": "tlačidlo na hube", + "api": "aplikácia", + "dashboard": "lokálna nástenka" + }, + "endReason": { + "timeout": "vypršal čas", + "device": "tlačidlo na hube", + "api": "zastavené v aplikácii", + "dashboard": "zastavené na lokálnej nástenke" + } + }, + "hiveLink": { + "prompt": { + "message": "Tento úľ monitoruje HiveHub {{device}} ({{hive}}). Spustite režim prehliadky, aby otvorenie úľa nevyvolalo falošné upozornenia.", + "active": "Režim prehliadky je zapnutý pre {{hive}} na HiveHub {{device}}.", + "pending": "Režim prehliadky vyžiadaný pre {{hive}} na HiveHub {{device}} — hub ho prevezme pri ďalšom hlásení.", + "activeOther": "Režim prehliadky je zapnutý pre iné úle na HiveHub {{device}}, nie pre {{hive}}. Najprv ho zastavte, ak ho chcete spustiť pre tento úľ." + }, + "card": { + "title": "HiveHub", + "description": "Aktuálne merania z prepojených snímačov HiveHub.", + "weight": "Hmotnosť", + "weightChange24h": "Zmena za 24 h", + "temperature": "Teplota v úli", + "humidity": "Vlhkosť", + "lastReading": "Posledné meranie: {{time}}", + "openHiveHub": "Otvoriť HiveHub" + } + }, + "traffic": { + "title": "Počítadlá včiel HiveTraffic", + "description": "Nočný režim a skupiny vysielačov pre každé počítadlo HiveTraffic spárované s týmto hubom. IR vysielače sú zďaleka najväčšou položkou v energetickej bilancii počítadla, takže tieto nastavenia rozhodujú, či vystačí autonómne napájanie.", + "readOnly": "Tieto nastavenia môžu meniť len vlastníci a administrátori zariadenia. Vaša rola je „{{role}}“.", + "hiveFallback": "Úľ {{slot}}", + "noChanges": "Žiadne zmeny na uloženie", + "saved": "Nastavenia počítadiel včiel uložené. Počítadlá ich prevezmú pri ďalšom hlásení.", + "appliesToAll": "Platí pre každé počítadlo HiveTraffic spárované s týmto zariadením. Počítadlo sa to dozvie v ďalšom cykle odosielania hubu.", + "status": { + "title": "Počítadlá práve teraz", + "none": "V poslednom meraní nie je hlásené žiadne počítadlo HiveTraffic.", + "counting": "Počíta", + "nightIdle": "Nočný kľud", + "nightIdleFor_one": "Nočný kľud · zostáva {{count}} min", + "nightIdleFor_few": "Nočný kľud · zostávajú {{count}} min", + "nightIdleFor_many": "Nočný kľud · zostáva {{count}} min", + "nightIdleFor_other": "Nočný kľud · zostáva {{count}} min", + "banksRunning": "Bežiace skupiny vysielačov: {{banks}}", + "banksUnknown": "Skupiny vysielačov nie sú hlásené (firmvér počítadla starší ako 0.3.0)", + "applied": "Skupiny použité", + "pending": "Skupiny čakajú na ďalšie hlásenie" + }, + "night": { + "title": "Nočný režim", + "description": "Včely medonosné sú denné — let potrebuje svetlo a pod asi 10 °C aj tak ustáva. V tomto okne počítadlo prestane snímať, aby šetrilo energiu. Naďalej vysiela oznámenia, takže merania aj aktualizácie firmvéru fungujú.", + "enable": "Zapnúť nočný režim", + "start": "Začiatok noci (miestny čas)", + "end": "Koniec noci (miestny čas)", + "windowHelp": "Okno môže prechádzať cez polnoc (napr. 20:00 → 06:00). Ak nastavíte začiatok a koniec na rovnaký čas, okno bude prázdne — nikdy neplatí — a nepokryje celý deň. Nechajte rezervu okolo súmraku a svitania: aktivita vrcholí tesne pred západom a východom slnka.", + "emptyWindow": "Začiatok a koniec sú rovnaké, takže okno je prázdne a nočný režim by sa nikdy nepoužil.", + "wrapsMidnight": "Okno prechádza cez polnoc: od {{start}} do {{end}} nasledujúceho rána.", + "timezone": "Časové pásmo", + "tzCustom": "Reťazec POSIX TZ", + "timezoneHelp": "Hodiny hubu bežia v UTC. Bez správneho časového pásma je okno posunuté o váš posun od UTC a pri každej zmene letného času sa posunie o hodinu.", + "tzPresets": { + "cet": "Stredná Európa (Berlín, Paríž, Madrid, Viedeň)", + "uk": "Spojené kráľovstvo / Írsko", + "eet": "Východná Európa (Helsinki, Atény)", + "usEastern": "USA – východný čas", + "usCentral": "USA – centrálny čas", + "usMountain": "USA – horský čas", + "usPacific": "USA – tichomorský čas", + "utc": "UTC (bez miestneho času)", + "custom": "Vlastné…" + }, + "maxTraffic": "Odložiť nad (prechodov za cyklus)", + "maxTrafficHelp": "Odložiť nočný režim, kým sa v poslednom cykle odosielania napočítalo viac prechodov (dnu + von), aby sa neprerušilo včelstvo, ktoré ešte lieta neskoro v letný večer. 0 znamená len podľa hodín.", + "save": "Uložiť nočný režim" + }, + "banks": { + "title": "Skupiny vysielačov", + "description": "IR vysielače počítadla sa spínajú v troch skupinách po ôsmich priechodoch. Každá skupina odoberá asi 80 mA. Jednu vypnite, ak je česno užšie ako 24 priechodov, jeho časť je zatvorená alebo napájanie neutiahne celú dosku.", + "bank": "Skupina {{bank}} — priechody {{gates}}", + "current_one": "{{count}} skupina zapnutá · asi {{ma}} mA na 3,3 V vetve počítadla", + "current_few": "{{count}} skupiny zapnuté · asi {{ma}} mA na 3,3 V vetve počítadla", + "current_many": "{{count}} skupiny zapnutej · asi {{ma}} mA na 3,3 V vetve počítadla", + "current_other": "{{count}} skupín zapnutých · asi {{ma}} mA na 3,3 V vetve počítadla", + "help": "Vypnutá skupina svoje priechody vôbec nepočíta, takže súčty klesnú zhruba úmerne — neporovnávajte zúžené počítadlo s jeho vlastnou históriou. Aspoň jedna skupina musí zostať zapnutá; počítadlo, ktoré nemá nič počítať, odpárujte.", + "firmwareNote": "Vyžaduje firmvér počítadla 0.3.0 alebo novší. Nastavenie sa odošle znova po každom resete počítadla.", + "save": "Uložiť skupiny vysielačov" + }, + "errors": { + "loadFailed": "Konfiguráciu zariadenia sa nepodarilo načítať.", + "saveFailed": "Nastavenia počítadiel včiel sa nepodarilo uložiť", + "invalidTime": "Zadajte platný čas začiatku a konca (HH:MM).", + "invalidMaxTraffic": "Prah odloženia musí byť celé číslo 0 alebo viac.", + "emptyWindow": "Začiatok a koniec nočného režimu sú rovnaké — okno by nikdy neplatilo.", + "noBank": "Aspoň jedna skupina vysielačov musí zostať zapnutá — radšej počítadlo odpárujte." + } + }, + "hubAccess": { + "title": "Prístup k hubu", + "description": "Pripojte sa k hubu na mieste bez otvárania krytu a nastavte, ako dlho môže prehliadka trvať.", + "readOnly": "Tieto nastavenia môžu meniť len vlastníci a administrátori zariadenia. Vaša rola je „{{role}}“.", + "noChanges": "Žiadne zmeny na uloženie", + "cancel": "Zrušiť", + "ap": { + "title": "Prístupový bod nastavenia", + "description": "Otvorí Wi-Fi sieť hubu HiveHub-Setup, rovnako ako krátke stlačenie tlačidla nastavenia: zmena Wi-Fi, párovanie snímačov alebo stiahnutie údajov zo SD karty.", + "lastCheckIn": "Posledné hlásenie: {{time}}", + "open": "Otvoriť prístupový bod nastavenia", + "confirmTitle": "Otvoriť prístupový bod nastavenia?", + "confirmTiming_one": "Požiadavka je vo fronte. Hub medzi cyklami spí, takže prístupový bod sa otvorí po jeho ďalšom hlásení — najneskôr o {{count}} minútu.", + "confirmTiming_few": "Požiadavka je vo fronte. Hub medzi cyklami spí, takže prístupový bod sa otvorí po jeho ďalšom hlásení — najneskôr o {{count}} minúty.", + "confirmTiming_many": "Požiadavka je vo fronte. Hub medzi cyklami spí, takže prístupový bod sa otvorí po jeho ďalšom hlásení — najneskôr o {{count}} minúty.", + "confirmTiming_other": "Požiadavka je vo fronte. Hub medzi cyklami spí, takže prístupový bod sa otvorí po jeho ďalšom hlásení — najneskôr o {{count}} minút.", + "confirmTimingUnknown": "Požiadavka je vo fronte. Hub medzi cyklami spí, takže prístupový bod sa otvorí po jeho ďalšom hlásení — najneskôr o jeden interval odosielania.", + "confirmOffline": "Kým je prístupový bod otvorený, hub nie je pripojený k Wi-Fi a neposiela žiadne merania.", + "confirmTimeout": "Ak sa nikto nepripojí, zatvorí sa sám po uplynutí časového limitu portálu nastavenia, preto ho spúšťajte, len keď je niekto pri úli.", + "confirm": "Zaradiť prístupový bod", + "queueing": "Zaraďuje sa…", + "queued": "Prístupový bod zaradený do frontu. Otvorí sa po ďalšom hlásení hubu.", + "failed": "Prístupový bod sa nepodarilo zaradiť do frontu" + }, + "inspection": { + "title": "Časový limit prehliadky", + "description": "Poistka: prehliadka sa po tomto počte minút skončí sama, aby zabudnuté stlačenie tlačidla neskrývalo údaje úľa donekonečna. Predvolene 60.", + "label": "Časový limit (minúty, 1–1440)", + "save": "Uložiť časový limit", + "saved": "Časový limit prehliadky uložený", + "saveFailed": "Časový limit prehliadky sa nepodarilo uložiť", + "invalid": "Zadajte celý počet minút od 1 do 1440." + } + }, + "dataTools": { + "title": "Export a čistenie údajov", + "description": "Zálohujte merania tohto hubu alebo odstráňte chybné.", + "hiveFallback": "Úľ {{slot}}", + "cancel": "Zrušiť", + "export": { + "title": "Exportovať merania", + "description": "Stiahnite uložené merania ako zálohu NDJSON.", + "from": "Od (voliteľné)", + "to": "Do (voliteľné)", + "rangeHelp": "Nechajte obe polia prázdne, ak chcete exportovať všetko. Dátumy pokrývajú celé dni vo vašom miestnom čase.", + "hives": "Úle (voliteľné)", + "allHives": "Zahrnuté sú všetky úle.", + "someHives_one": "Zahrnutý je len {{count}} vybraný úľ. Počet nižšie zahŕňa všetky merania v rozsahu.", + "someHives_few": "Zahrnuté sú len {{count}} vybrané úle. Počet nižšie zahŕňa všetky merania v rozsahu.", + "someHives_many": "Zahrnuté je len {{count}} vybraného úľa. Počet nižšie zahŕňa všetky merania v rozsahu.", + "someHives_other": "Zahrnutých je len {{count}} vybraných úľov. Počet nižšie zahŕňa všetky merania v rozsahu.", + "invalidRange": "Dátum začiatku je po dátume konca.", + "summaryFailed": "Merania sa nepodarilo spočítať.", + "count_one": "{{count}} meranie", + "count_few": "{{count}} merania", + "count_many": "{{count}} merania", + "count_other": "{{count}} meraní", + "span": "Od {{first}} do {{last}}", + "download": "Stiahnuť", + "formatHelp": "Jedno meranie JSON na riadok. Súbor možno znova importovať cez import SD karty.", + "noPermission": "Údaje môžu exportovať len vlastníci a administrátori zariadenia. Vaša rola je „{{role}}“." + }, + "delete": { + "title": "Vymazať merania v rozsahu", + "description": "Odstráni chybné merania — napríklad skoky hmotnosti, ktoré hub zaznamená pri štarte pred kalibráciou váh a ktoré inak skresľujú grafy a analýzy.", + "warningTitle": "Toto sa nedá vrátiť späť", + "warning": "Vymazané merania sú nenávratne preč. Ak by ste rozsah mohli potrebovať, najprv ho exportujte.", + "start": "Začiatok", + "end": "Koniec", + "claimCode": "Kód nároku", + "claimCodeHelp": "Kód nároku hubu ako druhé potvrdenie.", + "review": "Skontrolovať vymazanie", + "confirmTitle": "Vymazať tieto merania?", + "confirmRange": "Od {{start}} do {{end}}.", + "confirmCount_one": "{{count}} meranie bude natrvalo vymazané.", + "confirmCount_few": "{{count}} merania budú natrvalo vymazané.", + "confirmCount_many": "{{count}} merania budú natrvalo vymazané.", + "confirmCount_other": "{{count}} meraní bude natrvalo vymazaných.", + "confirmIrreversible": "Toto sa nedá vrátiť späť.", + "countFailed": "Merania v tomto rozsahu sa nepodarilo spočítať.", + "confirm_one": "Vymazať {{count}} meranie", + "confirm_few": "Vymazať {{count}} merania", + "confirm_many": "Vymazať {{count}} merania", + "confirm_other": "Vymazať {{count}} meraní", + "deleting": "Vymazáva sa…", + "success_one": "Vymazané {{count}} meranie.", + "success_few": "Vymazané {{count}} merania.", + "success_many": "Vymazané {{count}} merania.", + "success_other": "Vymazaných {{count}} meraní.", + "noPermission": "Merania môže vymazať len vlastník zariadenia. Vaša rola je „{{role}}“.", + "errors": { + "missingRange": "Vyberte začiatok a koniec.", + "invalidRange": "Koniec musí byť v čase začiatku alebo neskôr.", + "missingClaimCode": "Zadajte kód nároku hubu." + } + } + }, + "alertSettings": { + "title": "E-mailové upozornenia HiveHub", + "description": "Dostaňte e-mail, keď HiveHub zistí niečo vo vašich úľoch — rojenie, rabovku, bezmatečné včelstvo a ďalšie.", + "enable": "Posielať mi e-mailom upozornenia HiveHub", + "minSeverity": "Minimálna závažnosť", + "minSeverityHelp": "Upozornenia pod touto úrovňou sa nikdy neposielajú e-mailom.", + "severity": { + "watch": "Sledovať a vyššie", + "warning": "Varovanie a vyššie", + "critical": "Len kritické" + }, + "help": { + "schedule": "HivePal kontroluje nové upozornenia každých 15 minút.", + "once": "Každé upozornenie sa pošle e-mailom raz, keď sa objaví, a znova len vtedy, ak sa zhorší.", + "scope": "Pokrýva všetky zariadenia HiveHub, ku ktorým máte prístup.", + "email": "Vyžaduje, aby bol na serveri HivePal nakonfigurovaný e-mail." + }, + "saved": "Nastavenia upozornení uložené", + "saveFailed": "Nastavenia upozornení sa nepodarilo uložiť", + "loadFailed": "Vaše predvoľby sa nepodarilo načítať." + }, + "health": { + "title": "Stav snímačov", + "description": "Stav jednotlivých snímačov podľa hlásenia {{name}}.", + "descriptionAt": "Stav snímačov z posledného hlásenia · {{ago}}.", + "noData": "Zatiaľ nie je hlásený žiadny stav snímačov — zobrazí sa, keď zariadenie pošle prvé meranie.", + "hubHeading": "Hub", + "hiveIndex": "Úľ {{n}}", + "noHiveSensors": "Pre tento úľ nie sú hlásené žiadne snímače.", + "faultNote": "Hub hlási svoje vlastné subsystémy len ako v poriadku/chyba — neposiela pre ne žiadny chybový kód, preto každá chyba vyššie popisuje, čo zlyhalo. Počítadlo v česne stavový kód hlási a zobrazuje sa pri chybe všade, kde bol načítaný.", + "summary": { + "noData": "Žiadne údaje snímačov", + "allOk": "Všetko v poriadku", + "faults_one": "{{count}} chyba", + "faults_few": "{{count}} chyby", + "faults_many": "{{count}} chyby", + "faults_other": "{{count}} chýb" + }, + "state": { + "ok": "OK", + "fault": "Chyba", + "notFitted": "Nenainštalované", + "notHeard": "Bez signálu" + }, + "hub": { + "sht_ok": "Vonkajšia teplota/vlhkosť (SHT40)", + "rtc_ok": "Hodiny reálneho času", + "sd_ok": "SD karta", + "mic_ok": "Mikrofón", + "mic_left_ok": "Mikrofón · ľavý", + "mic_right_ok": "Mikrofón · pravý", + "battery_monitor_ok": "Monitor batérie", + "solar_monitor_ok": "Monitor solárneho panelu" + }, + "hubReason": { + "sht_ok": "Snímač okolia v tomto cykle nevrátil platné meranie, takže toto hlásenie neobsahuje vonkajšiu teplotu ani vlhkosť. Zvyčajne ide o chybu zbernice I²C alebo zapojenia; snímač, ktorý pri štarte nebol vôbec zistený, sa prejavuje rovnako.", + "rtc_ok": "Hodiny reálneho času neodpovedali na zbernici I²C. Časové značky sa preberajú zo sieťového času, takže meranie urobené offline môže mať nesprávny dátum.", + "sd_ok": "SD karta nebola v tomto cykle pripojená — chýba, je nečitateľná alebo ide o chybu SPI/zapojenia. Kým to trvá, merania sa na kartu neukladajú do vyrovnávacej pamäte.", + "mic_ok": "Záznam z mikrofónu nevrátil žiadne použiteľné vzorky, takže toto hlásenie neobsahuje akustické pásma.", + "mic_left_ok": "Ľavý mikrofón nevrátil žiadne použiteľné vzorky.", + "mic_right_ok": "Pravý mikrofón nevrátil žiadne použiteľné vzorky.", + "battery_monitor_ok": "Merač nabitia batérie neodpovedal na zbernici I²C — napätie a stav nabitia batérie v tomto hlásení chýbajú.", + "solar_monitor_ok": "Monitor solárneho panelu neodpovedal na zbernici I²C — solárne napätie, prúd a výkon v tomto hlásení chýbajú." + }, + "sensor": { + "scale": "Váha", + "inHive": "Snímač v úli", + "counter": "Počítadlo HiveTraffic", + "hiveheart": "HiveHeart" + }, + "reason": { + "scale": "Pre tento úľ je nakonfigurovaná váha, ale v tomto cykle neposkytla použiteľné meranie, takže toto hlásenie preň neobsahuje hmotnosť.", + "inHive": "Počas BLE skenovania v tomto cykle neprišlo žiadne oznámenie od spárovaného zariadenia {{kind}} — mimo dosahu, vybitá batéria alebo vypnuté.", + "inHiveKindFallback": "snímač v úli", + "node": "Uzol {{node}}.", + "lastHeard": "Naposledy zachytený {{ago}}.", + "notHeardInRange": "V načítanom rozsahu nebol zachytený.", + "counter": "Počítadlo v česne v tomto cykle neodpovedalo na čítanie GATT — mimo dosahu, vypnuté alebo zaneprázdnené, keď sa HiveHub pripájal.", + "counterNode": "Počítadlo {{node}}.", + "lastAnswered": "Naposledy odpovedalo {{ago}}.", + "notAnsweredInRange": "V načítanom rozsahu neodpovedalo.", + "statusThen": "Stav vtedy: {{status}}.", + "expandersThen_one": "Vtedy odpovedal {{count}} z 3 rozširovačov portov.", + "expandersThen_few": "Vtedy odpovedali {{count}} z 3 rozširovačov portov.", + "expandersThen_many": "Vtedy odpovedalo {{count}} z 3 rozširovačov portov.", + "expandersThen_other": "Vtedy odpovedalo {{count}} z 3 rozširovačov portov.", + "hiveheart": "HiveHeart nebol v tomto cykle zachytený — mimo dosahu, vybitá batéria alebo vypnutý." + }, + "counter": { + "nightIdle": "nočný kľud", + "nightIdleBadge": "Nočný kľud", + "otherBits": "bity špecifické pre počítadlo {{hex}}", + "noFlags": "žiadne príznaky", + "statusCode": "stav {{hex}}", + "expanders_one": "{{count}}/3 rozširovačov priechodov", + "expanders_few": "{{count}}/3 rozširovačov priechodov", + "expanders_many": "{{count}}/3 rozširovačov priechodov", + "expanders_other": "{{count}}/3 rozširovačov priechodov", + "glitches_one": "{{count}} výpadok", + "glitches_few": "{{count}} výpadky", + "glitches_many": "{{count}} výpadku", + "glitches_other": "{{count}} výpadkov", + "banks": "Skupiny {{list}}", + "noBanks": "Žiadne skupiny vysielačov" + }, + "meta": { + "firmware": "v{{version}}", + "batteryPercent": "Batéria {{value}} %", + "batteryMv": "Batéria {{value}} mV", + "batteryV": "Batéria {{value}} V", + "rssi": "{{value}} dBm" + } + }, + "hubStatus": { + "title": "Stav hubu", + "description": "Pripojenie, hodiny a napájanie samotného HiveHub.", + "noData": "Z tohto hubu zatiaľ neprišlo žiadne meranie.", + "staleWarning": "Žiadne meranie už {{ago}} — hub vynechal niekoľko hlásení.", + "badge": { + "live": "Online", + "stale": "Neaktuálne", + "inspection": "Prebieha prehliadka", + "calibration": "Režim kalibrácie" + }, + "section": { + "connectivity": "Pripojenie", + "battery": "Batéria", + "solar": "Solárny panel" + }, + "row": { + "signal": "Signál", + "transport": "Prenos", + "timeSource": "Zdroj času", + "lastSeen": "Naposledy videný", + "lastReading": "Posledné meranie", + "firmware": "Firmvér", + "bootCount": "Počet štartov", + "stateOfCharge": "Stav nabitia", + "batteryVoltage": "Napätie", + "batteryAlert": "Upozornenie batérie", + "solarPower": "Solárny výkon", + "solarCurrent": "Solárny prúd", + "solarBus": "Napätie panelu (zbernice)", + "solarLoad": "Napätie záťaže" + }, + "signalQuality": { + "excellent": "Výborný", + "good": "Dobrý", + "fair": "Priemerný", + "poor": "Slabý" + }, + "transport": { + "wifi": "Wi-Fi", + "cellular": "Mobilná sieť" + }, + "rtc": { + "ok": "RTC v poriadku", + "fault": "Chyba RTC" + }, + "batteryAlertActive": "Slabá batéria" + }, + "hiveCards": { + "empty": "Toto zariadenie zatiaľ nehlási žiadne úle.", + "hiveIndex": "Úľ {{n}}", + "weight": "Hmotnosť", + "temperature": "Teplota", + "humidity": "Vlhkosť", + "delta24h": "24 h {{value}} kg", + "lastReported": "Naposledy hlásené {{ago}}", + "inspection": "Prehliadka", + "insightsLoading": "Načítavajú sa analýzy", + "insightsUnavailable": "Analýzy nie sú k dispozícii", + "noSensors": "Žiadne hlásené snímače", + "traffic": "Dnu {{in}} · Von {{out}}", + "trafficTitle": "Včely dnu/von za posledný interval", + "alerts_one": "{{count}} aktívne upozornenie", + "alerts_few": "{{count}} aktívne upozornenia", + "alerts_many": "{{count}} aktívneho upozornenia", + "alerts_other": "{{count}} aktívnych upozornení", + "sensor": { + "scale": "Váha", + "inHive": "Snímač v úli", + "counter": "Počítadlo HiveTraffic", + "hiveheart": "HiveHeart" + }, + "state": { + "ok": "OK", + "fault": "Bez signálu / chyba" + } + }, + "audio": { + "title": "Zvuk úľa", + "description": "Požiadajte uzol HiveInside v úli o nahrávku a potom si ju tu vypočujte. Hub prevezme požiadavku pri ďalšom prebudení, takže nahrávka sa objaví do jedného intervalu hlásenia, nie okamžite.", + "noNodes": "K tomuto hubu nie je spárovaný žiadny uzol HiveInside. Mikrofón má len HiveInside — maják HolyIot ani RuuviTag nahrávať nevie.", + "hive": "Úľ", + "hiveFallback": "Úľ {{slot}}", + "length": "Dĺžka", + "durationOption_one": "{{count}} sekunda", + "durationOption_few": "{{count}} sekundy", + "durationOption_many": "{{count}} sekundy", + "durationOption_other": "{{count}} sekúnd", + "gain": "Zosilnenie", + "gainValue": "{{gain}} dB", + "record": "Nahrať", + "nextWake": "Dostane sa do hubu pri jeho ďalšom prebudení.", + "viewerNote": "Nahrávku môže vyžiadať vlastník alebo administrátor.", + "lengthHint": "Kratšie je zvyčajne lepšie: 10–20 sekúnd stačí na to, aby ste počuli, na čo analýza upozornila, a stojí to menej batérie a menej času, keď je úľ mimo éteru — pripojený uzol ani nevysiela oznámenia, ani nesníma.", + "requestSuccess": "Nahrávka vyžiadaná. Do hubu sa dostane pri jeho ďalšom prebudení.", + "requestError": "Požiadavku sa nepodarilo zaradiť do frontu: {{message}}", + "listTitle": "Nahrávky", + "onlySelectedHive": "Len {{name}}", + "loadError": "Nahrávky sa nepodarilo načítať: {{message}}", + "empty": "Zatiaľ žiadne nahrávky. Požiadajte úľ o nahrávku a objaví sa tu. Relácia je 10–60 sekúnd zvuku 16 kHz stiahnutého z uzla cez BLE.", + "requestedLength_one": "Vyžiadané {{count}} s", + "requestedLength_few": "Vyžiadané {{count}} s", + "requestedLength_many": "Vyžiadané {{count}} s", + "requestedLength_other": "Vyžiadané {{count}} s", + "requestedBy": "Vyžiadal(a): {{name}}", + "status": { + "requested": "Čaká sa na hub", + "streaming": "Nahráva sa…", + "ready": "Pripravené", + "readyIncomplete": "Pripravené, neúplné", + "failed": "Zlyhalo" + }, + "waitingNote": "Čaká sa, kým sa hub prebudí a požiadavku prevezme — zatiaľ nič nenahrané. Požiadavka, ktorú hub neprevezme do 45 minút, sa označí ako neúspešná.", + "streamingNote": "Nahráva sa — zvuk sa tu objavuje priebežne, ako prichádza.", + "streamingPartial": "Stále sa nahráva — toto je to, čo zatiaľ prišlo. Po skončení relácie si to prehrajte znova a vypočujete si celé.", + "failedFallback": "Táto nahrávka zlyhala.", + "seconds": "{{value}} s", + "bytes": "{{value}} B", + "percent": "{{value}} %", + "download": "Stiahnuť WAV", + "delete": "Vymazať", + "deleteConfirm": "Vymazať túto nahrávku? Nič iné ju nevymaže — žiadne automatické čistenie neexistuje.", + "deleteSuccess": "Nahrávka vymazaná.", + "deleteError": "Nahrávku sa nepodarilo vymazať: {{message}}", + "confirmation": { + "verified": "overené", + "reported": "neoverené", + "confirmed": "potvrdené hubom", + "unknown": "nepotvrdené" + }, + "quality": { + "length": "Dĺžka", + "dropped": "Zahodené", + "gaps": "Medzery", + "hubBuffer": "Vyrovnávacia pamäť hubu", + "clipping": "Orezanie", + "checksum": "Kontrolný súčet", + "cause": { + "dropped": "uzol nestíhal odosielať zvuk dostatočne rýchlo", + "gaps": "pakety sa stratili vo vzduchu", + "hubBuffer": "odosielanie hubu nestíhalo jeho vyrovnávaciu pamäť" + }, + "gapNote": "Zvuk na oboch stranách medzery je skutočný, spoj však nie. Príčina: {{causes}}.", + "clippingNote": "Pre čistejšiu nahrávku znížte zosilnenie.", + "unknownNote": "Hub o tejto relácii nikdy nepodal hlásenie, takže nikto neoveril, či je zvuk úplný. To, čo tu je, sa dá prehrať." + } + }, + "dashboard": { + "hiveFallback": "Úľ {{index}}", + "units": { + "bees": "včiel" + }, + "range": { + "24h": "24 h", + "7d": "7 d", + "30d": "30 d", + "365d": "365 d", + "custom": "Vlastné", + "invalid": "Dátum začiatku nesmie byť po dátume konca." + }, + "header": { + "title": "Moja nástenka", + "description": "Používateľsky nastaviteľné widgety. Úle vybrané vyššie určujú grafy podľa úľov.", + "editDescription": "Režim úprav: potiahnutím úchytu presúvate widgety a potiahnutím rohu meníte ich veľkosť v mriežke so 4 stĺpcami.", + "edit": "Upraviť nástenku", + "done": "Hotovo", + "addWidget": "Pridať widget", + "reset": "Obnoviť", + "editBanner": "Režim úprav nástenky je aktívny. Úchytom v hlavičke každého widgetu ho presuniete a potiahnutím pravého dolného rohu zmeníte šírku alebo výšku. Zmeny rozloženia sa pre toto zariadenie HiveHub ukladajú automaticky.", + "dropEnd": "Pustite sem, ak chcete widget presunúť na koniec nástenky.", + "noWidgets": "Zatiaľ žiadne widgety. Pridajte šablónu a zostavte si nástenku.", + "restoreDefaults": "Obnoviť predvolené" + }, + "overview": { + "title": "Prehľad úľov", + "description": "Kompaktný stav priradených úľov. Výberom dlaždíc riadite widgety nástenky nižšie.", + "lastData": "Posledné údaje", + "hivesOnline": "Úle online", + "hubFirmware": "FW HiveHub", + "hiveInsideFirmware": "FW HiveInside", + "selected_one": "{{count}} vybraný", + "selected_few": "{{count}} vybrané", + "selected_many": "{{count}} vybraného", + "selected_other": "{{count}} vybraných", + "hide": "Skryť prehľad", + "show": "Zobraziť prehľad", + "slot": "Slot {{index}}", + "ok": "OK", + "noData": "Žiadne údaje", + "weight": "Hmotnosť", + "temperature": "Tepl.", + "humidity": "RV", + "empty": "Priraďte sloty HiveHub k úľom HivePal v paneli nastavenia zariadenia, aby sa tu zobrazili dlaždice prehľadu." + }, + "sensors": { + "scale": "váha", + "inHive": "v úli", + "accel": "akcel.", + "counter": "počítadlo", + "none": "žiadne snímače" + }, + "widgetShell": { + "move": "Presunúť {{title}}", + "moveHint": "Potiahnutím presuniete tento widget", + "remove": "Odstrániť {{title}}", + "resize": "Zmeniť veľkosť {{title}}", + "resizeHint": "Potiahnutím zmeníte veľkosť tohto widgetu" + }, + "addWidget": { + "title": "Pridať widget", + "description": "Začnite zo šablóny. Widget použije, kde je to vhodné, úle vybrané v mriežke prehľadu.", + "defaultSize": "Predvolená veľkosť {{w}} x {{h}}" + }, + "widgets": { + "weightComparison": { + "title": "Porovnanie hmotnosti", + "description": "Porovnajte vybrané úle za aktuálne obdobie." + }, + "dailyMaxWeight": { + "title": "Denná max. hmotnosť", + "description": "Najvyššia kompenzovaná hmotnosť za deň pre vybrané úle." + }, + "climate": { + "title": "Mikroklíma úľa", + "description": "Teplota a vlhkosť vybraných úľov." + }, + "power": { + "title": "Stav napájania", + "description": "Nabitie batérie, napätie batérie a solárny príkon." + }, + "beeTraffic": { + "title": "Prelet včiel", + "description": "Súhrnný prelet dnu/von a čistý tok." + }, + "soundRms": { + "title": "Zvuk / akustické pásma", + "description": "Akustické RMS podľa úľov a pásma FFT/HiveHeart, ak sú k dispozícii." + }, + "hiveHeartSpectrum": { + "title": "Spektrum HiveHeart", + "description": "Najnovšie úrovne FFT HiveHeart (0–15) v 16 frekvenčných rozsahoch pre vybrané úle." + }, + "vibration": { + "title": "Vibračné pásma", + "description": "RMS vibrácií, pásma rojenia, ovievania a aktivity." + }, + "configurableDiagram": { + "title": "Nastaviteľný graf", + "description": "Vyberte metriky úľa, zvuku, preletu, mikroklímy a zariadenia, ktoré sa majú vykresliť." + }, + "temperatureHeatmap": { + "title": "Tepelná mapa teploty", + "description": "Kompaktný prehľad teploty všetkých úľov." + }, + "insights": { + "title": "Kanál analýz", + "description": "Aktívne upozornenia a úryvky dôkazov." + }, + "dataQuality": { + "title": "Kvalita údajov", + "description": "Dostupnosť snímačov a chýbajúce merania." + } + }, + "metricGroups": { + "scale": "Váha", + "climate": "Mikroklíma", + "beeTraffic": "Prelet včiel", + "vibration": "Vibrácie", + "sound": "Zvuk", + "device": "Zariadenie" + }, + "metrics": { + "weight": "Hmotnosť", + "temperature": "Teplota", + "humidity": "Vlhkosť", + "pressure": "Tlak", + "beeIn": "Včely dnu", + "beeOut": "Včely von", + "beeNet": "Čistý tok", + "vibration": "RMS vibrácií", + "swarmBand": "Pásmo rojenia", + "fanningBand": "Pásmo ovievania", + "activityBand": "Pásmo aktivity", + "soundRms": "RMS zvuku", + "soundHum": "Pásmo bzukotu", + "soundPiping": "Pásmo tútania", + "soundStress": "Pásmo stresu", + "hiveHeartFrequency": "Frekvencia HiveHeart", + "hiveHeartEnergy": "Energia HiveHeart", + "batterySoc": "Nabitie batérie", + "batteryVoltage": "Napätie batérie", + "solarPower": "Solárny výkon" + }, + "axes": { + "weight": "Hmotnosť", + "temperature": "Teplota", + "humidity": "Vlhkosť", + "pressure": "Tlak", + "beecount": "Počet včiel", + "vibration": "Vibrácie", + "dbfs": "Hladina zvuku", + "frequency": "Frekvencia", + "energy": "Energia", + "percent": "Nabitie", + "voltage": "Napätie", + "power": "Výkon" + }, + "soundMetrics": { + "rms": "RMS", + "hiveHeartFrequency": "Frekvencia HiveHeart", + "hiveHeartEnergy": "Energia HiveHeart", + "hiveHeartPeak": "Špička HiveHeart" + }, + "vibrationMetrics": { + "rms": "RMS", + "swarmBand": "Pásmo rojenia", + "fanningBand": "Pásmo ovievania", + "activityBand": "Pásmo aktivity" + }, + "series": { + "temperature": "{{name}} tepl.", + "humidity": "{{name}} RV", + "beeIn": "Dnu", + "beeOut": "Von", + "beeNet": "Čisté" + }, + "axisScale": { + "auto": "auto", + "setBound": "Nastaviť {{axis}} {{bound}}", + "clearHint": "Nechajte prázdne a stlačte Enter pre auto", + "tickTitle": "Kliknutím nastavíte {{bound}}; vymazaním auto", + "reset": "obnoviť", + "help": "Kliknutím na najnižší alebo najvyšší popis osi Y ho upravíte. Vymazaním hodnoty nastavíte auto." + }, + "chart": { + "downloadCsv": "Stiahnuť CSV" + }, + "inspection": { + "legendHint_one": "{{count}} okno prehliadky vytieňované; merania môžu byť narušené", + "legendHint_few": "{{count}} okná prehliadky vytieňované; merania môžu byť narušené", + "legendHint_many": "{{count}} okna prehliadky vytieňovaného; merania môžu byť narušené", + "legendHint_other": "{{count}} okien prehliadky vytieňovaných; merania môžu byť narušené" + }, + "empty": { + "noData": "Žiadne údaje pre vybrané úle a obdobie.", + "climate": "Žiadne údaje o mikroklíme za vybrané obdobie.", + "power": "Žiadne údaje o napájaní za vybrané obdobie.", + "beeTraffic": "Žiadne údaje počítadla včiel pre vybrané úle.", + "sound": "Žiadne akustické údaje podľa úľov pre vybrané úle.", + "vibration": "Žiadne údaje o vibráciách ani pásmach akcelerometra pre vybrané úle.", + "configurable": "Vyberte aspoň jednu metriku s údajmi pre vybrané úle a obdobie.", + "dailyMaxWeight": "Žiadne údaje o hmotnosti pre vybrané úle a obdobie.", + "hiveHeart": "Žiadne údaje FFT HiveHeart pre vybrané úle a obdobie.", + "unknownWidget": "Neznámy typ widgetu." + }, + "soundFootnote": "Najprv zobrazuje akustické merania podľa úľov. Staršie údaje ľavého/pravého mikrofónu sa priradia k nastaveným názvom úľov pre sloty 1 a 2, ak nie je k dispozícii akustický blok podľa úľov.", + "heatmap": { + "low": "nízka <33 °C", + "optimal": "optimum pre plod 33–36 °C", + "high": "vysoká >36 °C" + }, + "insights": { + "loading": "Načítavajú sa analýzy…", + "unavailable": "Analýzy nie sú k dispozícii.", + "noAlerts": "Žiadne aktívne upozornenia pre vybrané úle.", + "showing_one": "Zobrazuje sa {{count}} aktívne upozornenie pre vybrané úle.", + "showing_few": "Zobrazujú sa {{count}} aktívne upozornenia pre vybrané úle.", + "showing_many": "Zobrazuje sa {{count}} aktívneho upozornenia pre vybrané úle.", + "showing_other": "Zobrazuje sa {{count}} aktívnych upozornení pre vybrané úle.", + "confidence": "spoľahlivosť {{percent}}%" + }, + "dataQuality": { + "activeSlots": "Aktívne sloty", + "noScale": "Bez váhy", + "noClimate": "Bez mikroklímy", + "noRecentData": "žiadne nedávne údaje" + }, + "hiveHeart": { + "level": "úroveň", + "dominant": "dominantná frekvencia {{hz}} Hz", + "noDominant": "dominantná frekvencia nie je hlásená", + "footnote": "Relatívne úrovne 0–15 (nie dBFS) posledného merania v rozsahu. Prerušované čiary označujú dominantnú frekvenciu, ktorú hlási HiveHeart; nadpisy Sub-bass/Hum/Piping/Stress sú približné a pokrývajú viac rozsahov." + } } } diff --git a/apps/frontend/public/locales/sr/common.json b/apps/frontend/public/locales/sr/common.json index 47d09d6e3..5eca38873 100644 --- a/apps/frontend/public/locales/sr/common.json +++ b/apps/frontend/public/locales/sr/common.json @@ -80,7 +80,8 @@ "syrupCalculator": "Калкулатор сирупа", "broodTimeline": "Временска линија легла", "todos": "Задаци", - "allTodos": "Сви задаци" + "allTodos": "Сви задаци", + "hivehub": "HiveHub" }, "messages": { "confirmDelete": "Да ли сте сигурни да желите да обришете ову ставку?", diff --git a/apps/frontend/public/locales/sr/hivescale.json b/apps/frontend/public/locales/sr/hivescale.json index 29785a1bd..f61284a24 100644 --- a/apps/frontend/public/locales/sr/hivescale.json +++ b/apps/frontend/public/locales/sr/hivescale.json @@ -35,7 +35,7 @@ }, "page": { "title": "HiveHub", - "subtitle": "Live weight and temperature visualisation from your separate HiveHub backend.", + "subtitle": "Тежина, микроклима, звук, лет пчела и упозорења са ваших HiveHub уређаја.", "refresh": "Refresh", "refreshing": "Refreshing…", "refreshError": "Could not refresh HiveHub data.", @@ -43,16 +43,26 @@ "removeSuccess": "HiveHub device removed from your account.", "removeSuccessShared": "Више немате приступ овом HiveHub уређају. Други корисници га и даље имају, па његов код за преузимање остаје у употреби.", "releaseConfirm": "Потпуно ослободити {{name}}? Сви корисници губе приступ, а уређај се поново може преузети својим кодом. Очитавања се задржавају.", - "releaseSuccess": "HiveHub уређај је ослобођен. Може се поново преузети својим кодом за преузимање." + "releaseSuccess": "HiveHub уређај је ослобођен. Може се поново преузети својим кодом за преузимање.", + "unavailableTitle": "HiveHub није доступан", + "inspectionActiveTitle": "Режим прегледа је укључен", + "inspectionActiveBody": "Док су кошнице отворене, очитавања се скривају, тако да вађење рамова не изазива лажна упозорења.", + "openInspections": "Отвори прегледе", + "tabs": { + "overview": "Преглед стања", + "inspections": "Прегледи", + "audio": "Звук", + "health": "Стање", + "setup": "Подешавање" + } }, "setup": { "title": "Devices", - "description": "Select or remove a claimed HiveHub device. Expand the setup to claim devices, check status, map scales, and calibrate the raw readings.", "removeScale": "Remove scale", "releaseScale": "Ослободи уређај", "noDevices": "No devices claimed yet — open the setup to add one.", - "hide": "Hide setup", - "show": "Show setup" + "selectDevice": "HiveHub уређај", + "noDevicesYet": "Још није преузет ниједан HiveHub уређај. Преузмите га помоћу кода који хаб приказује на свом порталу за подешавање." }, "claim": { "title": "Claim HiveHub device", @@ -65,16 +75,30 @@ "success": "HiveHub device claimed.", "errors": { "missingCode": "Enter the claim code printed/configured for the device." - } + }, + "hive1Name": "Назив кошнице 1", + "hive2Name": "Назив кошнице 2", + "optional": "Опционо" }, "mapping": { - "title": "Scale mapping", - "description": "These labels stay in HiveHub. Later, this is the right place to map Scale 1/2 to HivePal hive IDs for inspection markers.", - "scale1Label": "Display name for Scale 1", - "scale2Label": "Display name for Scale 2", + "title": "Кошнице", "hiveNamePlaceholder": "Select or type a hive name", - "save": "Save mapping", - "success": "Scale mapping updated." + "save": "Сачувај кошнице", + "success": "Слотови кошница су сачувани у HiveHub.", + "slotsDescription": "Дајте назив сваком слоту кошнице и повежите га са кошницом у HivePal-у. Захваљујући вези, прегледи, страница кошнице и имејлови са упозорењима знају ком друштву слот припада. Зелена тачка означава слотове које хаб тренутно пријављује.", + "mapSlot": "Додели слот {{slot}}", + "add": "Додај", + "reporting": "Пријављује хаб", + "unknownHive": "Кошница није пронађена", + "mappedCount": "Додељено слотова: {{count}}/{{total}}", + "dialogTitle": "Слот кошнице {{slot}}", + "dialogDescription": "Повежите слот са једном од ваших кошница у HivePal-у и изаберите назив који ће HiveHub приказивати за њега.", + "linkedHive": "Кошница у HivePal-у", + "notLinked": "Није повезано", + "linkHint": "Веза остаје и након преименовања кошнице. Избор кошнице попуњава њен назив ако је назив празан.", + "displayName": "Приказни назив", + "clearSlot": "Очисти слот", + "applySlot": "Примени" }, "status": { "title": "Device status", @@ -94,6 +118,13 @@ "revokeAccess": "Revoke access", "errors": { "missingEmail": "Enter the email address of an existing HivePal user." + }, + "lastSeen": "Последње јављање", + "claimedAt": "Преузето", + "roles": { + "owner": "Власник", + "admin": "Администратор", + "viewer": "Посматрач" } }, "calibration": { @@ -203,11 +234,9 @@ }, "sourceHint": "Ambient tracks the cell body closely in steady state and is the recommended default.", "refLabel": "Reference temperature / °C", - "refHint": "Temperature at which the correction is zero.", "coeffLabel": "{{scaleName}} coefficient / kg per °C", "autoFit": "Auto-fit from history", "fitting": "Fitting…", - "autoFitHint": "Auto-fit regresses the last 3 days of raw weight against the EMA-smoothed selected temperature, then applies and enables the result. For a clean fit, capture a window where the physical load is constant and the temperature swings.", "save": "Save temperature compensation", "errors": { "refTemp": "Reference temperature must be a number (°C).", @@ -219,11 +248,33 @@ "noSignal": "not enough temperature/weight signal.", "fitApplied": "{{scaleName}}: {{coeff}} kg/°C @ {{refTemp}}°C (R²={{r2}}, n={{n}}). Applied and enabled." } + }, + "perHive": { + "hive": "Кошница {{index}}", + "hiveNamed": "Кошница {{index}} · {{name}}", + "hiveLabel": "Кошница", + "knownWeightPlaceholder": "нпр. 10", + "manualSaveHint": "Промените кошницу да бисте уредили другу вагу; све измене се шаљу заједно када сачувате. Кошнице од 3 навише уписују се само ако су измењене.", + "tempco": { + "refHint": "Температура на којој ваге мере тачно, тј. на којој су тариране и калибрисане. На њој је корекција нула; прилагођавање је не мења осим ако то не затражите.", + "lookbackLabel": "Период прилагођавања / дани", + "setRefTemp": "Подеси и референтну температуру", + "setRefTempHint": "Замењује референтну температуру средњом температуром прилагођеног периода. Оставите искључено ако је вага калибрисана на познатој температури.", + "fitOnly": "Само прилагоди (преглед)", + "fitAndApply": "Прилагоди и примени", + "fitSummary": "{{scaleName}}: {{coeff}} kg/°C, R² {{r2}} из {{n}} очитавања у распону {{min}}–{{max}} °C (просек периода {{mean}} °C).", + "lowRSquared": "R² испод 0,5: температура слабо објашњава ово одступање. Прилагодите на периоду са сталним оптерећењем и великом разликом температура између дана и ноћи.", + "autoFitHint": "Аутоматско прилагођавање рачуна регресију сачуване тежине изабране кошнице у изабраном периоду у односу на изабрану температуру изглађену EMA. „Само прилагоди“ попуњава коефицијент ради провере пре чувања; „Прилагоди и примени“ га чува и укључује компензацију. За чисто прилагођавање изаберите период у коме је физичко оптерећење стално, а температура се мења.", + "errors": { + "lookback": "Период мора бити цео број дана између 1 и 90." + }, + "toasts": { + "fitFilled": "{{scaleName}}: {{coeff}} kg/°C (R²={{r2}}, n={{n}}). Попуњено — проверите и сачувајте." + } + } } }, "firmware": { - "title": "Firmware upload", - "description": "Upload a firmware binary and register it as a release. New devices of the matching type pick it up on their next update check.", "type": "Firmware type", "selectType": "Select firmware type", "version": "Version", @@ -238,6 +289,102 @@ "errors": { "missingFile": "Choose a firmware binary (.bin) to upload.", "missingVersion": "Enter a version, e.g. 0.6.3." + }, + "update": { + "title": "Ажурирање фирмвера", + "description": "Ваге се никада не ажурирају саме — сваку нову верзију одобравате овде пре него што је ова вага инсталира.", + "unavailable": "Стање фирмвера тренутно није доступно.", + "availableTitle": "Доступан је фирмвер {{version}}", + "availableBody": "Ова вага користи {{current}}. Примените да бисте је ажурирали на {{version}}.", + "officialSuffix": "Ово је званична верзија.", + "apply": "Примени ажурирање", + "applying": "Примењује се…", + "applied": "Фирмвер {{version}} је одобрен — вага ће се ажурирати при следећем јављању.", + "queued": "Ажурирање {{version}} је одобрено. Вага ће га инсталирати при следећем јављању.", + "upToDate": "Фирмвер је ажуран ({{version}}).", + "viewerNotice": "Само власници и администратори уређаја могу да примењују ажурирања фирмвера." + }, + "board": { + "cardTitle": "Фирмвер", + "cardDescription": "Фирмвер овог HiveHub-а и HiveInside чворова и HiveTraffic бројача које опслужује. Отпремање региструје издање; инсталира се одобравањем (хаб) или преносом (чворови).", + "current": "Тренутни", + "latest": "Најновији", + "approved": "Одобрен", + "board": "Плоча", + "badgePending": "Ажурирање чека одобрење", + "badgeApproved": "Ажурирање одобрено", + "badgeUpToDate": "Ажурно", + "approveFlash": "Одобри и упиши {{version}}", + "approveConfirm": "Одобрити фирмвер {{version}} и упиcати га бежично?\n\nHiveHub ће га инсталирати при следећем јављању и поново се покренути. Удаљеном уређају коме ажурирање не успе усред поступка може бити потребан физички приступ ради опоравка.", + "otherBoards": "Отпремљено и за другу плочу (не примењује се на овај {{board}} уређај): {{releases}}. Верзија стиже само до уређаја са одговарајућом плочом — проверите плочу при отпремању.", + "otherBoardsUnknown": "Отпремљено и за другу плочу (не примењује се на овај уређај): {{releases}}. Верзија стиже само до уређаја са одговарајућом плочом — проверите плочу при отпремању." + }, + "relays": { + "hiveFallback": "Кошница {{slot}}", + "latest": "најновији {{version}}", + "updateAvailable": "доступно ажурирање: {{version}}", + "status": { + "pending": "пренос у реду чекања", + "running": "пренос у току…", + "completed": "пренос завршен", + "failed": "пренос није успео", + "expired": "време преноса је истекло" + }, + "statusTitle": "Пренос {{version}}: {{status}} ({{date}})", + "failedDetail": "Последњи пренос није успео — {{message}} ({{date}})", + "noReason": "разлог није пријављен", + "alreadyQueued": "Пренос за овај чвор је већ у реду чекања", + "queueing": "Ставља се у ред…", + "relayAnyway": "Ипак пренеси", + "confirm": "Пренети {{name}} {{version}} на {{hive}}?\n\nHiveHub ће ово преузети у следећем циклусу слања — обично у року од око 10 минута, или у интервалу слања који овај уређај користи. Затим неколико минута шаље слику преко BLE, а чвор се поново покреће са њом. Чвор мора бити будан и у домету, иначе пренос не успева и мора се поново ставити у ред.", + "queued": "Пренос {{version}} стављен у ред за {{hive}}.", + "queuedFrom": "Пренос стављен у ред за {{hive}} ({{current}} → {{version}}).", + "hiveinside": { + "name": "HiveInside", + "title": "HiveInside чворови", + "description": "Фирмвер који оглашава сваки чвор у кошници. Отпремање HiveInside слике само региструје издање — ништа не стиже до чвора док не притиснете „Пренеси на чвор“. Верзија приказана овде потврђује да је ажурирање успело.", + "relay": "Пренеси на чвор", + "unnamed": "Не оглашава сопствени назив (HiveInside пре 0.5.0)", + "noRelease": "Још није отпремљено ниједно HiveInside издање — отпремите га испод да бисте га пренели." + }, + "beecounter": { + "name": "HiveTraffic", + "title": "HiveTraffic бројачи", + "description": "Фирмвер који пријављује сваки HiveTraffic бројач. Отпремање HiveTraffic слике само региструје издање — ништа не стиже до бројача док не притиснете „Пренеси на бројач“. Бројачи старији од отприлике v0.1.0 не пријављују верзију, али се и даље могу ажурирати.", + "relay": "Пренеси на бројач", + "unnamed": "Не пријављује назив ни адресу", + "noRelease": "Још није отпремљено ниједно HiveTraffic издање — отпремите га испод да бисте га пренели.", + "countingWarning": "Током целог преноса бројач не броји пчеле, зато преносите када је лет слаб.", + "confirmExtra": "Током целог преноса бројач не броји пчеле — сав лет током тих неколико минута се губи." + } + }, + "uploadForm": { + "title": "Отпреми фирмвер", + "description": "Отпремање само региструје издање — само по себи никада ништа не инсталира. Одобрите ажурирање хаба изнад или пренесите издање за чвор или бројач из његовог реда.", + "board": "Плоча", + "targets": { + "hivehub": "HiveHub хаб", + "hiveinside": "HiveInside", + "beecounter": "HiveTraffic бројач" + }, + "boards": { + "auto": "Аутоматски (из назива датотеке)", + "esp32": "ESP32 (класична, 30 пинова)", + "esp32-c6": "ESP32-C6", + "nrf54lm20a": "nRF54LM20A" + }, + "boardHint": { + "hivehub": "Фирмвер хаба мора навести плочу: изаберите је или оставите аутоматски када је датотека названа нпр. hivehub_esp32-c6_0.30.5.bin.", + "hiveinside": "HiveInside постоји само за nRF54LM20A. Отпремите артефакт верзије непромењен (нпр. hiveinside_nrf54lm20a_1.4.0.bin), а циљ и верзија се попуњавају из његовог назива.", + "beecounter": "HiveTraffic бројач је ESP32-C6. Отпремите слику апликације (нпр. beecounter_esp32-c6_0.7.1.bin), а не спојену фабричку слику." + }, + "detected": "{{name}} — попуњено: {{fields}}.", + "nothingDetected": "{{name}} — у називу није препознат циљ, плоча ни верзија; подесите их испод.", + "detectedTarget": "циљ „{{target}}“", + "detectedBoard": "плоча {{board}}", + "detectedVersion": "верзија {{version}}", + "autoQueued": "Пренос је аутоматски стављен у ред за {{hives}}.", + "autoQueueFailed": "Није могуће аутоматски ставити пренос у ред за {{hives}}." } }, "sdData": { @@ -262,12 +409,19 @@ "parsed_other": "Parsed {{count}} records", "skipped_one": " · {{count}} unreadable line skipped.", "skipped_other": " · {{count}} unreadable lines skipped." + }, + "mismatch": { + "title": "Датотека са другог хаба", + "description": "Ову датотеку је снимио други хаб, а не {{device}}.", + "fileDevices_one": "ID уређаја у датотеци:", + "fileDevices_few": "ID-јеви уређаја у датотеци:", + "fileDevices_other": "ID-јеви уређаја у датотеци:", + "hint": "Ипак је увезите само ако SD картица заиста припада овом уређају (на пример након замене плоче хаба).", + "cancel": "Откажи", + "force": "Ипак увези у овај уређај" } }, "panel": { - "weight": "Weight", - "hiveTemp": "Hive temp", - "rmsSound": "RMS sound", "ambientTemperature": "Ambient temperature", "ambientHumidity": "Ambient humidity", "batteryCharge": "Battery charge", @@ -277,7 +431,6 @@ "title": "General", "description": "Ambient & power" }, - "humidity": "Hive humidity", "wirelessSensorsBattery": "Wireless sensors battery", "sensorTypes": { "hiveHeart": "HiveHeart", @@ -286,174 +439,36 @@ } }, "diagram": { - "title": "Measurements", - "subtitle": "Weight, temperature, and entrance activity over time", - "noMeasurements": "No measurements for the selected range.", - "noDataForSeries": "No data in the selected range.", - "meta": { - "device": "Device:", - "lastSeen": "Last seen:", - "lastData": "Last data:", - "firmware": "Firmware:" - }, "range": { "all": "All", "from": "From", "to": "To" }, - "series": { - "weight": "{{name}} weight", - "temp": "{{name}} temp", - "micRms": "{{name}} mic RMS", - "beesIn": "{{name}} bees in", - "beesOut": "{{name}} bees out", - "netFlow": "{{name}} net flow", - "ambientTemp": "Ambient temp", - "ambientHumidity": "Ambient humidity", - "batteryVoltage": "Battery voltage", - "batteryCharge": "Battery charge", - "solarVoltage": "Solar voltage", - "solarCurrent": "Solar current", - "solarPower": "Solar power", - "humidity": "{{name}} vlažnost", - "pressure": "{{name}} pritisak", - "fanningBand": "{{name}} fanning band (30–100 Hz)", - "activityBand": "{{name}} activity band (100–200 Hz)" - }, - "group": { - "ambient": "Ambient", - "offGrid": "Off-grid" - }, - "axis": { - "weight": "Weight", - "temperature": "Temperature", - "humidity": "Humidity", - "voltage": "Voltage", - "percent": "Percent", - "current": "Current", - "power": "Power", - "sound": "Sound", - "beecount": "Bee count", - "pressure": "Pritisak" - }, "marker": { - "inspection": "Inspection", - "boxAdded": "Box added", - "action": { - "maintenance": "Maintenance", - "feeding": "Feeding", - "frames": "Frames", - "treatment": "Treatment" - } - }, - "axisSettings": { - "title": "Axis settings", - "reset": "Reset", - "side": "Side", - "left": "Left", - "right": "Right", - "scale": "Scale", - "auto": "Auto", - "zeroToMax": "0 to max", - "custom": "Custom", - "min": "Min", - "max": "Max", - "autoPlaceholder": "auto" + "inspection": "Inspection" } }, "sound": { - "title": "Hive sound", - "description": "INMP441 FFT band energy (dBFS) — {{left}} (left) · {{right}} (right)", - "hide": "Hide sound", - "show": "Show sound", - "micLeftFallback": "Mic left", - "micRightFallback": "Mic right", - "noDataForChannel": "No data for {{name}} in this range.", - "bandReference": "Band reference", - "thresholds": "Thresholds used by the Insights engine: piping > −45 dBFS, stress > −38 dBFS, queenless hum > −40 dBFS with quiet piping. Calibrate against your own baseline.", - "status": { - "noData": "No data", - "micError": "Mic error", - "bothActive": "{{left}} + {{right}} active", - "oneActive": "{{name}} active" - }, - "table": { - "band": "Band", - "range": "Range", - "significance": "Significance" - }, "bands": { "subBass": { - "label": "Sub-bass", - "description": "Structural vibration / low rumble" + "label": "Sub-bass" }, "hum": { - "label": "Hum", - "description": "Normal colony hum (~200 Hz fundamental)" + "label": "Hum" }, "piping": { - "label": "Piping", - "description": "Queen piping / tooting (pre-swarm signal)" + "label": "Piping" }, "stress": { - "label": "Stress", - "description": "Agitated colony / robbing" + "label": "Stress" }, "high": { - "label": "High", - "description": "Harmonic overtones" + "label": "High" } - }, - "rmsToggleShow": "Show RMS level", - "rmsToggleHide": "Hide RMS level", - "rmsTitle": "RMS sound level", - "rmsSeries": "{{name}} RMS" - }, - "beecounter": { - "title": "Entrance Counter", - "subtitle": "Bees entering and exiting per measurement interval", - "noData": "No BeeCounter data found for this device.", - "offline": "Offline", - "gatesOk": "{{count}} gates OK", - "glitches_one": "{{count}} glitch", - "glitches_other": "{{count}} glitches", - "gatesRatio": "{{healthy}}/{{total}} gates", - "inPeriod": "In (period)", - "outPeriod": "Out (period)", - "netFlow": "Net flow", - "netInbound": "net inbound", - "netOutbound": "net outbound", - "balanced": "balanced", - "view": { - "inOut": "In / Out", - "netFlow": "Net flow", - "cumulative": "Cumulative" - }, - "series": { - "in": "{{name}} in", - "out": "{{name}} out", - "net": "{{name}} net", - "totalIn": "{{name}} total in", - "totalOut": "{{name}} total out" - }, - "diagnostics": { - "title": "Hardware diagnostics", - "gatesHealthy": "Gates healthy", - "glitchCount": "Glitch count", - "totalInLifetime": "Total in (lifetime)", - "totalOutLifetime": "Total out (lifetime)" } }, "insights": { - "title": "Insights", - "description": "Sensor-based alerts derived from weight and temperature trends.", - "updated": "Updated {{ago}}.", - "refresh": "Refresh insights", - "loadError": "Failed to load insights: {{message}}", "allClear": "All clear", - "noActiveAlerts": "No active alerts in the last {{count}} days.", - "activeAlerts_one": "{{count}} active alert:", - "activeAlerts_other": "{{count}} active alerts:", "confidence": "Confidence {{value}}%", "source": "Source: {{source}}", "hiveChannel": "Hive {{channel}}", @@ -538,5 +553,740 @@ "description": "No alerts have been recorded for this device.", "descriptionFiltered": "No alerts have been recorded for this device matching this filter." } + }, + "inspectionMode": { + "title": "Режим прегледа", + "description": "Док је режим прегледа укључен, HiveHub наставља да мери, али очитавања не укључује у графиконе, анализе и упозорења, тако да се вађење рамова не сматра ројењем.", + "status": { + "active": "Активно од {{time}}", + "pending": "Чека да га хаб преузме", + "off": "Искључено" + }, + "pendingHint": "Затражено — хаб ће га преузети при следећем јављању.", + "timeoutNote_one": "Аутоматски се завршава након {{count}} минута ако га нико не заустави.", + "timeoutNote_few": "Аутоматски се завршава након {{count}} минута ако га нико не заустави.", + "timeoutNote_other": "Аутоматски се завршава након {{count}} минута ако га нико не заустави.", + "scope": { + "wholeHub": "Цео хаб", + "label": "Обухвата: {{hives}}" + }, + "start": "Покрени режим прегледа", + "stop": "Заустави режим прегледа", + "starting": "Покреће се…", + "stopping": "Зауставља се…", + "cancel": "Откажи", + "noteLabel": "Белешка (опционо)", + "notePlaceholder": "нпр. Додат медишни наставак", + "viewerHint": "Само власници и администратори могу да покрену или зауставе режим прегледа.", + "loadFailed": "Није могуће учитати стање прегледа.", + "startDialog": { + "title": "Покрени режим прегледа", + "description": "Очитавања изабраних кошница се скривају док не зауставите режим прегледа или не истекне његово време.", + "hivesLabel": "Кошнице", + "wholeHub": "Цео хаб (све кошнице)", + "selectHive": "Изаберите бар једну кошницу или цео хаб.", + "confirm": "Покрени" + }, + "stopDialog": { + "title": "Заустави режим прегледа", + "description": "HiveHub се враћа на уобичајена очитавања и упозорења.", + "confirm": "Заустави" + }, + "toast": { + "started": "Режим прегледа је покренут", + "stopped": "Режим прегледа је заустављен", + "noteSaved": "Белешка је сачувана" + }, + "history": { + "title": "Недавни прегледи", + "empty": "Још нема забележених прегледа.", + "ongoing": "у току", + "startedVia": "Покренуто преко: {{source}}", + "ended": "Завршено: {{reason}}", + "editNote": "Измени белешку", + "addNote": "Додај белешку", + "saveNote": "Сачувај" + }, + "duration": { + "minutes_one": "{{count}} мин", + "minutes_few": "{{count}} мин", + "minutes_other": "{{count}} мин", + "hoursMinutes": "{{hours}} ч {{minutes}} мин" + }, + "source": { + "device": "дугме на хабу", + "api": "апликација", + "dashboard": "локална контролна табла" + }, + "endReason": { + "timeout": "истекло време", + "device": "дугме на хабу", + "api": "заустављено у апликацији", + "dashboard": "заустављено на локалној контролној табли" + } + }, + "hiveLink": { + "prompt": { + "message": "Ову кошницу надгледа HiveHub {{device}} ({{hive}}). Покрените режим прегледа да отварање кошнице не би изазвало лажна упозорења.", + "active": "Режим прегледа је укључен за {{hive}} на HiveHub {{device}}.", + "pending": "Режим прегледа је затражен за {{hive}} на HiveHub {{device}} — хаб ће га преузети при следећем јављању.", + "activeOther": "Режим прегледа је укључен за друге кошнице на HiveHub {{device}}, а не за {{hive}}. Прво га зауставите да бисте га покренули за ову кошницу." + }, + "card": { + "title": "HiveHub", + "description": "Тренутна очитавања повезаних HiveHub сензора.", + "weight": "Тежина", + "weightChange24h": "Промена за 24 ч", + "temperature": "Температура у кошници", + "humidity": "Влажност", + "lastReading": "Последње очитавање: {{time}}", + "openHiveHub": "Отвори HiveHub" + } + }, + "traffic": { + "title": "HiveTraffic бројачи пчела", + "description": "Ноћни режим и групе емитера за сваки HiveTraffic бројач упарен са овим хабом. IR емитери су убедљиво највећа ставка у потрошњи енергије бројача, па ова подешавања одлучују да ли ће бити довољно самостално напајање.", + "readOnly": "Само власници и администратори уређаја могу да мењају ова подешавања. Ваша улога је „{{role}}“.", + "hiveFallback": "Кошница {{slot}}", + "noChanges": "Нема измена за чување", + "saved": "Подешавања бројача пчела су сачувана. Бројачи ће их преузети при следећем јављању.", + "appliesToAll": "Важи за сваки HiveTraffic бројач упарен са овим уређајем. Бројач добија подешавање у следећем циклусу слања хаба.", + "status": { + "title": "Бројачи тренутно", + "none": "У последњем очитавању није пријављен ниједан HiveTraffic бројач.", + "counting": "Броји", + "nightIdle": "Ноћно мировање", + "nightIdleFor_one": "Ноћно мировање · још {{count}} мин", + "nightIdleFor_few": "Ноћно мировање · још {{count}} мин", + "nightIdleFor_other": "Ноћно мировање · још {{count}} мин", + "banksRunning": "Активне групе емитера: {{banks}}", + "banksUnknown": "Групе емитера нису пријављене (фирмвер бројача старији од 0.3.0)", + "applied": "Групе примењене", + "pending": "Групе чекају следеће јављање" + }, + "night": { + "title": "Ноћни режим", + "description": "Медоносне пчеле су дневне — за лет им је потребна светлост, а испод око 10 °C лет ионако престаје. Током овог периода бројач престаје да мери како би штедео енергију. Наставља да се оглашава, тако да очитавања и ажурирања фирмвера и даље раде.", + "enable": "Укључи ноћни режим", + "start": "Почетак ноћи (локално време)", + "end": "Крај ноћи (локално време)", + "windowHelp": "Период може прелазити преко поноћи (нпр. 20:00 → 06:00). Ако поставите исто време за почетак и крај, период је празан — никада се не примењује — уместо да обухвата цео дан. Оставите резерву око сумрака и зоре: активност је највећа непосредно пре заласка и изласка сунца.", + "emptyWindow": "Почетак и крај су исти, па је период празан и ноћни режим се никада не би применио.", + "wrapsMidnight": "Период прелази преко поноћи: од {{start}} до {{end}} следећег јутра.", + "timezone": "Временска зона", + "tzCustom": "POSIX TZ низ", + "timezoneHelp": "Сат хаба ради по UTC. Без исправне временске зоне период је померен за вашу разлику у односу на UTC и помера се за сат при свакој промени летњег рачунања времена.", + "tzPresets": { + "cet": "Средња Европа (Берлин, Париз, Мадрид, Беч)", + "uk": "Уједињено Краљевство / Ирска", + "eet": "Источна Европа (Хелсинки, Атина)", + "usEastern": "САД – источно време", + "usCentral": "САД – централно време", + "usMountain": "САД – планинско време", + "usPacific": "САД – пацифичко време", + "utc": "UTC (без локалног времена)", + "custom": "Прилагођено…" + }, + "maxTraffic": "Одложи изнад (пролаза по циклусу)", + "maxTrafficHelp": "Одлаже ноћни режим док је у последњем циклусу слања избројано више пролаза од овог броја (унутра + напоље), тако да друштво које још лети касно летње вече не буде прекинуто. 0 значи само по сату.", + "save": "Сачувај ноћни режим" + }, + "banks": { + "title": "Групе емитера", + "description": "IR емитери бројача укључују се у три групе од по осам пролаза. Свака група троши око 80 mA. Искључите једну ако је улаз у кошницу ужи од 24 пролаза, ако је део затворен или ако напајање не може да издржи целу плочу.", + "bank": "Група {{bank}} — пролази {{gates}}", + "current_one": "{{count}} група укључена · око {{ma}} mA на 3,3 V линији бројача", + "current_few": "{{count}} групе укључене · око {{ma}} mA на 3,3 V линији бројача", + "current_other": "{{count}} група укључено · око {{ma}} mA на 3,3 V линији бројача", + "help": "Искључена група уопште не броји своје пролазе, па укупни збирови падају отприлике сразмерно — не поредите сужени бројач са његовом сопственом историјом. Бар једна група мора остати укључена; бројач који не треба ништа да броји распарите.", + "firmwareNote": "Потребан је фирмвер бројача 0.3.0 или новији. Подешавање се поново шаље након сваког ресетовања бројача.", + "save": "Сачувај групе емитера" + }, + "errors": { + "loadFailed": "Није могуће учитати конфигурацију уређаја.", + "saveFailed": "Није могуће сачувати подешавања бројача пчела", + "invalidTime": "Унесите исправно време почетка и краја (ЧЧ:ММ).", + "invalidMaxTraffic": "Праг одлагања мора бити цео број, 0 или већи.", + "emptyWindow": "Почетак и крај ноћног режима су исти — период се никада не би применио.", + "noBank": "Бар једна група емитера мора остати укључена — уместо тога распарите бројач." + } + }, + "hubAccess": { + "title": "Приступ хабу", + "description": "Приступите хабу на лицу места без отварања кућишта и подесите колико дуго преглед сме да траје.", + "readOnly": "Само власници и администратори уређаја могу да мењају ова подешавања. Ваша улога је „{{role}}“.", + "noChanges": "Нема измена за чување", + "cancel": "Откажи", + "ap": { + "title": "Приступна тачка за подешавање", + "description": "Отвара Wi-Fi мрежу хаба HiveHub-Setup, исто као кратак притисак на дугме за подешавање: промена Wi-Fi мреже, упаривање сензора или преузимање података са SD картице.", + "lastCheckIn": "Последње јављање: {{time}}", + "open": "Отвори приступну тачку за подешавање", + "confirmTitle": "Отворити приступну тачку за подешавање?", + "confirmTiming_one": "Захтев је у реду чекања. Хаб спава између циклуса, па се приступна тачка отвара после његовог следећег јављања — најкасније за {{count}} минут.", + "confirmTiming_few": "Захтев је у реду чекања. Хаб спава између циклуса, па се приступна тачка отвара после његовог следећег јављања — најкасније за {{count}} минута.", + "confirmTiming_other": "Захтев је у реду чекања. Хаб спава између циклуса, па се приступна тачка отвара после његовог следећег јављања — најкасније за {{count}} минута.", + "confirmTimingUnknown": "Захтев је у реду чекања. Хаб спава између циклуса, па се приступна тачка отвара после његовог следећег јављања — најкасније за један интервал слања.", + "confirmOffline": "Док је приступна тачка отворена, хаб није на Wi-Fi мрежи и не шаље очитавања.", + "confirmTimeout": "Ако се нико не повеже, сама се затвара по истеку времена портала за подешавање, зато је покрените само када је неко код кошнице.", + "confirm": "Стави приступну тачку у ред", + "queueing": "Ставља се у ред…", + "queued": "Приступна тачка је у реду чекања. Отвара се после следећег јављања хаба.", + "failed": "Није могуће ставити приступну тачку у ред" + }, + "inspection": { + "title": "Временско ограничење прегледа", + "description": "Сигурносна мрежа: преглед се сам завршава после овог броја минута, тако да заборављени притисак на дугме не скрива податке кошнице бесконачно. Подразумевано 60.", + "label": "Временско ограничење (минути, 1–1440)", + "save": "Сачувај временско ограничење", + "saved": "Временско ограничење прегледа је сачувано", + "saveFailed": "Није могуће сачувати временско ограничење прегледа", + "invalid": "Унесите цео број минута између 1 и 1440." + } + }, + "dataTools": { + "title": "Извоз и чишћење података", + "description": "Направите резервну копију очитавања овог хаба или уклоните погрешна.", + "hiveFallback": "Кошница {{slot}}", + "cancel": "Откажи", + "export": { + "title": "Извези очитавања", + "description": "Преузмите сачувана очитавања као NDJSON резервну копију.", + "from": "Од (опционо)", + "to": "До (опционо)", + "rangeHelp": "Оставите оба поља празна да бисте извезли све. Датуми обухватају целе дане по вашем локалном времену.", + "hives": "Кошнице (опционо)", + "allHives": "Укључене су све кошнице.", + "someHives_one": "Укључена је само {{count}} изабрана кошница. Број испод обухвата сва очитавања у распону.", + "someHives_few": "Укључене су само {{count}} изабране кошнице. Број испод обухвата сва очитавања у распону.", + "someHives_other": "Укључено је само {{count}} изабраних кошница. Број испод обухвата сва очитавања у распону.", + "invalidRange": "Датум почетка је после датума краја.", + "summaryFailed": "Није могуће пребројати очитавања.", + "count_one": "{{count}} очитавање", + "count_few": "{{count}} очитавања", + "count_other": "{{count}} очитавања", + "span": "Од {{first}} до {{last}}", + "download": "Преузми", + "formatHelp": "Једно JSON очитавање по реду. Датотека се може поново увести преко увоза са SD картице.", + "noPermission": "Само власници и администратори уређаја могу да извозе податке. Ваша улога је „{{role}}“." + }, + "delete": { + "title": "Обриши очитавања у распону", + "description": "Уклања лажна очитавања — на пример скокове тежине које хаб забележи при покретању пре калибрације вага, а који иначе искривљују графиконе и анализе.", + "warningTitle": "Ово се не може опозвати", + "warning": "Обрисана очитавања нестају заувек. Ако би вам распон могао затребати, прво га извезите.", + "start": "Почетак", + "end": "Крај", + "claimCode": "Код за преузимање", + "claimCodeHelp": "Код за преузимање хаба, као друга потврда.", + "review": "Провери брисање", + "confirmTitle": "Обрисати ова очитавања?", + "confirmRange": "Од {{start}} до {{end}}.", + "confirmCount_one": "{{count}} очитавање биће трајно обрисано.", + "confirmCount_few": "{{count}} очитавања биће трајно обрисана.", + "confirmCount_other": "{{count}} очитавања биће трајно обрисано.", + "confirmIrreversible": "Ово се не може опозвати.", + "countFailed": "Није могуће пребројати очитавања у овом распону.", + "confirm_one": "Обриши {{count}} очитавање", + "confirm_few": "Обриши {{count}} очитавања", + "confirm_other": "Обриши {{count}} очитавања", + "deleting": "Брише се…", + "success_one": "Обрисано је {{count}} очитавање.", + "success_few": "Обрисана су {{count}} очитавања.", + "success_other": "Обрисано је {{count}} очитавања.", + "noPermission": "Само власник уређаја може да брише очитавања. Ваша улога је „{{role}}“.", + "errors": { + "missingRange": "Изаберите почетак и крај.", + "invalidRange": "Крај мора бити у исто време као почетак или после њега.", + "missingClaimCode": "Унесите код за преузимање хаба." + } + } + }, + "alertSettings": { + "title": "HiveHub упозорења имејлом", + "description": "Примите имејл када HiveHub примети нешто у вашим кошницама — ројење, грабеж, друштво без матице и друго.", + "enable": "Шаљи ми HiveHub упозорења имејлом", + "minSeverity": "Минимална озбиљност", + "minSeverityHelp": "Упозорења испод овог нивоа никада се не шаљу имејлом.", + "severity": { + "watch": "Праћење и више", + "warning": "Упозорење и више", + "critical": "Само критична" + }, + "help": { + "schedule": "HivePal проверава нова упозорења сваких 15 минута.", + "once": "Свако упозорење се шаље имејлом једном када се појави, и поново само ако се погорша.", + "scope": "Обухвата све HiveHub уређаје којима имате приступ.", + "email": "Захтева да имејл буде подешен на HivePal серверу." + }, + "saved": "Подешавања упозорења су сачувана", + "saveFailed": "Није могуће сачувати подешавања упозорења", + "loadFailed": "Није могуће учитати ваша подешавања." + }, + "health": { + "title": "Стање сензора", + "description": "Стање сваког сензора према извештају уређаја {{name}}.", + "descriptionAt": "Стање сензора из последњег јављања · {{ago}}.", + "noData": "Стање сензора још није пријављено — појављује се када уређај пошаље прво очитавање.", + "hubHeading": "Хаб", + "hiveIndex": "Кошница {{n}}", + "noHiveSensors": "За ову кошницу нису пријављени сензори.", + "faultNote": "Хаб своје подсистеме пријављује само као исправно/неисправно — за њих не шаље шифру грешке, па сваки квар изнад описује шта није успело. Бројач на улазу у кошницу пријављује шифру стања, која се приказује уз квар свуда где је очитана.", + "summary": { + "noData": "Нема података сензора", + "allOk": "Све у реду", + "faults_one": "{{count}} квар", + "faults_few": "{{count}} квара", + "faults_other": "{{count}} кварова" + }, + "state": { + "ok": "OK", + "fault": "Квар", + "notFitted": "Није уграђено", + "notHeard": "Нема сигнала" + }, + "hub": { + "sht_ok": "Спољна температура/влажност (SHT40)", + "rtc_ok": "Сат реалног времена", + "sd_ok": "SD картица", + "mic_ok": "Микрофон", + "mic_left_ok": "Микрофон · леви", + "mic_right_ok": "Микрофон · десни", + "battery_monitor_ok": "Надзор батерије", + "solar_monitor_ok": "Надзор соларног панела" + }, + "hubReason": { + "sht_ok": "Сензор околине у овом циклусу није вратио исправно очитавање, па ово јављање не садржи спољну температуру ни влажност. Обично је у питању квар I²C магистрале или ожичења; сензор који уопште није откривен при покретању изгледа исто.", + "rtc_ok": "Сат реалног времена није одговорио на I²C магистрали. Временске ознаке се узимају из мрежног времена, па очитавање снимљено ван мреже може имати погрешан датум.", + "sd_ok": "SD картица у овом циклусу није монтирана — недостаје, не може да се прочита или је квар SPI/ожичења. Док ово траје, очитавања се не баферују на картицу.", + "mic_ok": "Снимање микрофоном није вратило употребљиве узорке, па ово јављање не садржи акустичне опсеге.", + "mic_left_ok": "Леви микрофон није вратио употребљиве узорке.", + "mic_right_ok": "Десни микрофон није вратио употребљиве узорке.", + "battery_monitor_ok": "Мерач напуњености батерије није одговорио на I²C магистрали — напон и ниво напуњености батерије недостају у овом јављању.", + "solar_monitor_ok": "Надзор соларног панела није одговорио на I²C магистрали — соларни напон, струја и снага недостају у овом јављању." + }, + "sensor": { + "scale": "Вага", + "inHive": "Сензор у кошници", + "counter": "HiveTraffic бројач", + "hiveheart": "HiveHeart" + }, + "reason": { + "scale": "За ову кошницу је подешена вага, али у овом циклусу није дала употребљиво очитавање, па ово јављање не садржи тежину за њу.", + "inHive": "Током BLE скенирања у овом циклусу није примљен оглас од упареног уређаја {{kind}} — ван домета, празна батерија или искључен.", + "inHiveKindFallback": "сензор у кошници", + "node": "Чвор {{node}}.", + "lastHeard": "Последњи пут чут {{ago}}.", + "notHeardInRange": "Није чут у учитаном распону.", + "counter": "Бројач на улазу у кошницу није одговорио на GATT читање у овом циклусу — ван домета, искључен или заузет када се HiveHub повезивао.", + "counterNode": "Бројач {{node}}.", + "lastAnswered": "Последњи одговор {{ago}}.", + "notAnsweredInRange": "Није одговорио у учитаном распону.", + "statusThen": "Стање тада: {{status}}.", + "expandersThen_one": "Тада је одговарао {{count}} од 3 проширивача портова.", + "expandersThen_few": "Тада су одговарала {{count}} од 3 проширивача портова.", + "expandersThen_other": "Тада је одговарало {{count}} од 3 проширивача портова.", + "hiveheart": "HiveHeart није чут у овом циклусу — ван домета, празна батерија или искључен." + }, + "counter": { + "nightIdle": "ноћно мировање", + "nightIdleBadge": "Ноћно мировање", + "otherBits": "битови специфични за бројач {{hex}}", + "noFlags": "нема постављених заставица", + "statusCode": "стање {{hex}}", + "expanders_one": "{{count}}/3 проширивача пролаза", + "expanders_few": "{{count}}/3 проширивача пролаза", + "expanders_other": "{{count}}/3 проширивача пролаза", + "glitches_one": "{{count}} сметња", + "glitches_few": "{{count}} сметње", + "glitches_other": "{{count}} сметњи", + "banks": "Групе {{list}}", + "noBanks": "Нема група емитера" + }, + "meta": { + "firmware": "v{{version}}", + "batteryPercent": "Батерија {{value}} %", + "batteryMv": "Батерија {{value}} mV", + "batteryV": "Батерија {{value}} V", + "rssi": "{{value}} dBm" + } + }, + "hubStatus": { + "title": "Стање хаба", + "description": "Веза, сат и напајање самог HiveHub-а.", + "noData": "Са овог хаба још није примљено ниједно очитавање.", + "staleWarning": "Нема очитавања већ {{ago}} — хаб је пропустио неколико јављања.", + "badge": { + "live": "Уживо", + "stale": "Застарело", + "inspection": "Преглед у току", + "calibration": "Режим калибрације" + }, + "section": { + "connectivity": "Повезаност", + "battery": "Батерија", + "solar": "Соларни панел" + }, + "row": { + "signal": "Сигнал", + "transport": "Пренос", + "timeSource": "Извор времена", + "lastSeen": "Последњи пут виђен", + "lastReading": "Последње очитавање", + "firmware": "Фирмвер", + "bootCount": "Број покретања", + "stateOfCharge": "Ниво напуњености", + "batteryVoltage": "Напон", + "batteryAlert": "Упозорење батерије", + "solarPower": "Соларна снага", + "solarCurrent": "Соларна струја", + "solarBus": "Напон панела (магистрале)", + "solarLoad": "Напон оптерећења" + }, + "signalQuality": { + "excellent": "Одличан", + "good": "Добар", + "fair": "Осредњи", + "poor": "Слаб" + }, + "transport": { + "wifi": "Wi-Fi", + "cellular": "Мобилна мрежа" + }, + "rtc": { + "ok": "RTC у реду", + "fault": "Квар RTC-а" + }, + "batteryAlertActive": "Слаба батерија" + }, + "hiveCards": { + "empty": "Овај уређај још није пријавио ниједну кошницу.", + "hiveIndex": "Кошница {{n}}", + "weight": "Тежина", + "temperature": "Температура", + "humidity": "Влажност", + "delta24h": "24 ч {{value}} kg", + "lastReported": "Последње пријављено {{ago}}", + "inspection": "Преглед", + "insightsLoading": "Учитавање анализа", + "insightsUnavailable": "Анализе нису доступне", + "noSensors": "Нема пријављених сензора", + "traffic": "Унутра {{in}} · Напоље {{out}}", + "trafficTitle": "Пчеле унутра/напоље током последњег интервала", + "alerts_one": "{{count}} активно упозорење", + "alerts_few": "{{count}} активна упозорења", + "alerts_other": "{{count}} активних упозорења", + "sensor": { + "scale": "Вага", + "inHive": "Сензор у кошници", + "counter": "HiveTraffic бројач", + "hiveheart": "HiveHeart" + }, + "state": { + "ok": "OK", + "fault": "Нема сигнала / квар" + } + }, + "audio": { + "title": "Звук кошнице", + "description": "Затражите од HiveInside чвора кошнице да сними звук, а затим га преслушајте овде. Хаб преузима захтев при следећем буђењу, па се снимак појављује у року од једног интервала извештавања, а не одмах.", + "noNodes": "На овом хабу није упарен ниједан HiveInside чвор. Само HiveInside има микрофон — HolyIot или RuuviTag бикон не може да снима.", + "hive": "Кошница", + "hiveFallback": "Кошница {{slot}}", + "length": "Трајање", + "durationOption_one": "{{count}} секунда", + "durationOption_few": "{{count}} секунде", + "durationOption_other": "{{count}} секунди", + "gain": "Појачање", + "gainValue": "{{gain}} dB", + "record": "Сними", + "nextWake": "Стиже до хаба при његовом следећем буђењу.", + "viewerNote": "Снимак може да затражи власник или администратор.", + "lengthHint": "Краће је обично боље: 10–20 секунди је довољно да чујете оно на шта је анализа указала, а троши мање батерије и мање времена док је кошница ван етра — повезани чвор нити се оглашава нити мери.", + "requestSuccess": "Снимак је затражен. Стиже до хаба при његовом следећем буђењу.", + "requestError": "Захтев није могуће ставити у ред: {{message}}", + "listTitle": "Снимци", + "onlySelectedHive": "Само {{name}}", + "loadError": "Није могуће учитати снимке: {{message}}", + "empty": "Још нема снимака. Затражите од кошнице да сними и снимак ће се појавити овде. Сесија је 10–60 секунди звука од 16 kHz преузетог са чвора преко BLE.", + "requestedLength_one": "Затражено {{count}} s", + "requestedLength_few": "Затражено {{count}} s", + "requestedLength_other": "Затражено {{count}} s", + "requestedBy": "Затражио/ла: {{name}}", + "status": { + "requested": "Чека се хаб", + "streaming": "Снимање…", + "ready": "Спремно", + "readyIncomplete": "Спремно, непотпуно", + "failed": "Неуспешно" + }, + "waitingNote": "Чека се да се хаб пробуди и преузме захтев — још ништа није снимљено. Захтев који хаб не преузме у року од 45 минута означава се као неуспешан.", + "streamingNote": "Снимање — звук се појављује овде како стиже.", + "streamingPartial": "Снимање је још у току — ово је оно што је до сада стигло. Пустите поново када се сесија заврши да бисте чули цео снимак.", + "failedFallback": "Овај снимак није успео.", + "seconds": "{{value}} s", + "bytes": "{{value}} B", + "percent": "{{value}} %", + "download": "Преузми WAV", + "delete": "Обриши", + "deleteConfirm": "Обрисати овај снимак? Ништа друго га неће обрисати — нема аутоматског чишћења.", + "deleteSuccess": "Снимак је обрисан.", + "deleteError": "Није могуће обрисати снимак: {{message}}", + "confirmation": { + "verified": "проверено", + "reported": "непроверено", + "confirmed": "хаб потврдио", + "unknown": "није потврђено" + }, + "quality": { + "length": "Трајање", + "dropped": "Одбачено", + "gaps": "Прекиди", + "hubBuffer": "Бафер хаба", + "clipping": "Одсецање", + "checksum": "Контролни збир", + "cause": { + "dropped": "чвор није могао довољно брзо да шаље звук", + "gaps": "пакети су изгубљени у етру", + "hubBuffer": "слање хаба је заостајало за његовим бафером" + }, + "gapNote": "Звук са обе стране прекида је стваран, али спој није. Узрок: {{causes}}.", + "clippingNote": "Смањите појачање за чистији снимак.", + "unknownNote": "Хаб никада није пријавио ову сесију, па нико није проверио да ли је звук потпун. Оно што је ту може се пустити." + } + }, + "dashboard": { + "hiveFallback": "Кошница {{index}}", + "units": { + "bees": "пчела" + }, + "range": { + "24h": "24 ч", + "7d": "7 д", + "30d": "30 д", + "365d": "365 д", + "custom": "Прилагођено", + "invalid": "Датум почетка не сме бити после датума краја." + }, + "header": { + "title": "Моја контролна табла", + "description": "Виџети које подешава корисник. Кошнице изабране изнад управљају графиконима по кошницама.", + "editDescription": "Режим уређивања: превуците ручку да померите виџете и превуците угао да им промените величину на мрежи од 4 колоне.", + "edit": "Уреди контролну таблу", + "done": "Готово", + "addWidget": "Додај виџет", + "reset": "Ресетуј", + "editBanner": "Режим уређивања контролне табле је активан. Ручком у заглављу сваког виџета га померате, а превлачењем доњег десног угла мењате ширину или висину. Промене распореда се аутоматски чувају за овај HiveHub уређај.", + "dropEnd": "Отпустите овде да бисте преместили виџет на крај контролне табле.", + "noWidgets": "Још нема виџета. Додајте шаблон да бисте направили своју контролну таблу.", + "restoreDefaults": "Врати подразумевано" + }, + "overview": { + "title": "Преглед кошница", + "description": "Сажето стање додељених кошница. Изаберите плочице да бисте управљали виџетима контролне табле испод.", + "lastData": "Последњи подаци", + "hivesOnline": "Кошнице на мрежи", + "hubFirmware": "HiveHub FW", + "hiveInsideFirmware": "HiveInside FW", + "selected_one": "Изабрана: {{count}}", + "selected_few": "Изабране: {{count}}", + "selected_other": "Изабрано: {{count}}", + "hide": "Сакриј преглед", + "show": "Прикажи преглед", + "slot": "Слот {{index}}", + "ok": "OK", + "noData": "Нема података", + "weight": "Тежина", + "temperature": "Темп.", + "humidity": "RV", + "empty": "Доделите HiveHub слотове HivePal кошницама у панелу за подешавање уређаја да би се овде приказале плочице прегледа." + }, + "sensors": { + "scale": "вага", + "inHive": "у кошници", + "accel": "акцел.", + "counter": "бројач", + "none": "нема сензора" + }, + "widgetShell": { + "move": "Помери {{title}}", + "moveHint": "Превуците да померите овај виџет", + "remove": "Уклони {{title}}", + "resize": "Промени величину {{title}}", + "resizeHint": "Превуците да промените величину овог виџета" + }, + "addWidget": { + "title": "Додај виџет", + "description": "Почните од шаблона. Виџет ће, где је то примењиво, користити кошнице изабране у мрежи прегледа.", + "defaultSize": "Подразумевана величина {{w}} x {{h}}" + }, + "widgets": { + "weightComparison": { + "title": "Поређење тежине", + "description": "Упоредите изабране кошнице за тренутни период." + }, + "dailyMaxWeight": { + "title": "Дневна максимална тежина", + "description": "Највећа компензована тежина по дану за изабране кошнице." + }, + "climate": { + "title": "Микроклима кошнице", + "description": "Температура и влажност изабраних кошница." + }, + "power": { + "title": "Стање напајања", + "description": "Напуњеност батерије, напон батерије и соларни улаз." + }, + "beeTraffic": { + "title": "Лет пчела", + "description": "Укупан лет унутра/напоље и нето ток." + }, + "soundRms": { + "title": "Звук / акустични опсези", + "description": "Акустични RMS по кошници и FFT/HiveHeart опсези када су доступни." + }, + "hiveHeartSpectrum": { + "title": "HiveHeart спектар", + "description": "Најновији HiveHeart FFT нивои (0–15) у 16 фреквентних опсега за изабране кошнице." + }, + "vibration": { + "title": "Вибрациони опсези", + "description": "RMS вибрација, опсези ројења, вентилације и активности." + }, + "configurableDiagram": { + "title": "Прилагодљиви дијаграм", + "description": "Изаберите метрике кошнице, звука, лета пчела, микроклиме и уређаја за приказ." + }, + "temperatureHeatmap": { + "title": "Топлотна мапа температуре", + "description": "Сажет преглед температуре свих кошница." + }, + "insights": { + "title": "Низ анализа", + "description": "Активна упозорења и исечци доказа." + }, + "dataQuality": { + "title": "Квалитет података", + "description": "Доступност сензора и недостајућа очитавања." + } + }, + "metricGroups": { + "scale": "Вага", + "climate": "Микроклима", + "beeTraffic": "Лет пчела", + "vibration": "Вибрације", + "sound": "Звук", + "device": "Уређај" + }, + "metrics": { + "weight": "Тежина", + "temperature": "Температура", + "humidity": "Влажност", + "pressure": "Притисак", + "beeIn": "Пчеле унутра", + "beeOut": "Пчеле напоље", + "beeNet": "Нето ток", + "vibration": "RMS вибрација", + "swarmBand": "Опсег ројења", + "fanningBand": "Опсег вентилације", + "activityBand": "Опсег активности", + "soundRms": "RMS звука", + "soundHum": "Опсег зујања", + "soundPiping": "Опсег оглашавања матице", + "soundStress": "Опсег стреса", + "hiveHeartFrequency": "HiveHeart фреквенција", + "hiveHeartEnergy": "HiveHeart енергија", + "batterySoc": "Напуњеност батерије", + "batteryVoltage": "Напон батерије", + "solarPower": "Соларна снага" + }, + "axes": { + "weight": "Тежина", + "temperature": "Температура", + "humidity": "Влажност", + "pressure": "Притисак", + "beecount": "Број пчела", + "vibration": "Вибрације", + "dbfs": "Ниво звука", + "frequency": "Фреквенција", + "energy": "Енергија", + "percent": "Напуњеност", + "voltage": "Напон", + "power": "Снага" + }, + "soundMetrics": { + "rms": "RMS", + "hiveHeartFrequency": "HiveHeart фреквенција", + "hiveHeartEnergy": "HiveHeart енергија", + "hiveHeartPeak": "HiveHeart врх" + }, + "vibrationMetrics": { + "rms": "RMS", + "swarmBand": "Опсег ројења", + "fanningBand": "Опсег вентилације", + "activityBand": "Опсег активности" + }, + "series": { + "temperature": "{{name}} темп.", + "humidity": "{{name}} RV", + "beeIn": "Унутра", + "beeOut": "Напоље", + "beeNet": "Нето" + }, + "axisScale": { + "auto": "аутоматски", + "setBound": "Подеси {{axis}} {{bound}}", + "clearHint": "Оставите празно и притисните Enter за аутоматски", + "tickTitle": "Кликните да подесите {{bound}}; обришите за аутоматски", + "reset": "ресетуј", + "help": "Кликните на најнижу или највишу ознаку Y осе да бисте је изменили. Обришите вредност за аутоматски." + }, + "chart": { + "downloadCsv": "Преузми CSV" + }, + "inspection": { + "legendHint_one": "{{count}} период прегледа је осенчен; очитавања могу бити поремећена", + "legendHint_few": "{{count}} периода прегледа су осенчена; очитавања могу бити поремећена", + "legendHint_other": "{{count}} периода прегледа је осенчено; очитавања могу бити поремећена" + }, + "empty": { + "noData": "Нема података за изабране кошнице и период.", + "climate": "Нема података о микроклими за изабрани период.", + "power": "Нема података о напајању за изабрани период.", + "beeTraffic": "Нема података бројача пчела за изабране кошнице.", + "sound": "Нема акустичних података по кошници за изабране кошнице.", + "vibration": "Нема података о вибрацијама или опсезима акцелерометра за изабране кошнице.", + "configurable": "Изаберите бар једну метрику са подацима за изабране кошнице и период.", + "dailyMaxWeight": "Нема података о тежини за изабране кошнице и период.", + "hiveHeart": "Нема HiveHeart FFT података за изабране кошнице и период.", + "unknownWidget": "Непознат тип виџета." + }, + "soundFootnote": "Прво приказује акустична очитавања по кошници. Застарели подаци левог/десног микрофона додељују се подешеним називима кошница за слотове 1 и 2 када не постоји акустични блок по кошници.", + "heatmap": { + "low": "ниска <33 °C", + "optimal": "оптимално за легло 33–36 °C", + "high": "висока >36 °C" + }, + "insights": { + "loading": "Учитавање анализа…", + "unavailable": "Анализе нису доступне.", + "noAlerts": "Нема активних упозорења за изабране кошнице.", + "showing_one": "Приказано је {{count}} активно упозорење за изабране кошнице.", + "showing_few": "Приказана су {{count}} активна упозорења за изабране кошнице.", + "showing_other": "Приказано је {{count}} активних упозорења за изабране кошнице.", + "confidence": "поузданост {{percent}}%" + }, + "dataQuality": { + "activeSlots": "Активни слотови", + "noScale": "Нема ваге", + "noClimate": "Нема микроклиме", + "noRecentData": "нема скорашњих података" + }, + "hiveHeart": { + "level": "ниво", + "dominant": "доминантна фреквенција {{hz}} Hz", + "noDominant": "доминантна фреквенција није пријављена", + "footnote": "Релативни нивои 0–15 (не dBFS) последњег очитавања у распону. Испрекидане линије означавају доминантну фреквенцију коју пријављује HiveHeart; наслови Sub-bass/Hum/Piping/Stress су приближни и обухватају више опсега." + } } } diff --git a/apps/frontend/src/api/hooks/useHiveScale.ts b/apps/frontend/src/api/hooks/useHiveScale.ts index ba6c329ad..a1d5edb7f 100644 --- a/apps/frontend/src/api/hooks/useHiveScale.ts +++ b/apps/frontend/src/api/hooks/useHiveScale.ts @@ -1,11 +1,31 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { isAxiosError } from 'axios'; +import type { + HiveHubChannelsPatch, + HiveHubConfigPatch, + HiveHubTempCompensationFit, +} from 'shared-schemas'; import { apiClient } from '../client'; -export interface HiveScaleChannelMapping { - index: number; - display_name?: string | null; - hive_id?: string | null; +/** + * Pull the backend message out of an Axios error so a toast shows e.g. "No + * active hiveinside firmware release" instead of "Request failed with status + * code 404". + */ +export function hiveHubErrorMessage(error: unknown, fallback: string): string { + if (isAxiosError<{ message?: string | string[] }>(error)) { + const data = error.response?.data; + const message = + typeof data === 'object' && data !== null + ? Array.isArray(data.message) + ? data.message.join(', ') + : data.message + : typeof data === 'string' + ? data + : undefined; + return message || error.message || fallback; + } + return error instanceof Error ? error.message : fallback; } export interface HiveScaleDevice { @@ -18,7 +38,10 @@ export interface HiveScaleDevice { channels: { scale_1: string | null; scale_2: string | null; - hives?: HiveScaleChannelMapping[] | null; + /** Hive display names keyed by hive index ("1".."18"). */ + names?: Record | null; + /** HivePal hive id each hive index is linked to ("1".."18" -> id). */ + hive_ids?: Record | null; }; } @@ -36,12 +59,16 @@ export interface HiveScaleHiveReading { name?: string | null; weight_kg: number | null; raw_weight: number | null; - scale_source?: string | null; // hx711 | nau7802 | ... + scale_source?: string | null; // hx711 | nau7802 | hivescale_gatt | ... + /** False when a configured scale produced no usable reading this cycle. */ + scale_ok?: boolean | null; temp_c: number | null; temp_source?: string | null; // ds18b20 | ble | hiveheart humidity_percent: number | null; accel?: { + /** Set when the paired in-hive BLE node was heard this scan. */ ok: boolean | null; + sample_rate_hz?: number | null; sample_count?: number | null; range_g?: number | null; rms_mg: number | null; @@ -50,6 +77,8 @@ export interface HiveScaleHiveReading { band_fanning_mg: number | null; band_activity_mg: number | null; } | null; + /** HiveInside in-hive audio metrics (the canonical HiveHub key). */ + mic?: HiveScaleHiveSound | null; sound?: { ok?: boolean | null; rms_dbfs?: number | null; @@ -64,22 +93,57 @@ export interface HiveScaleHiveReading { peak?: number | null; } | null; hiveheart?: { + present?: boolean | null; + temp_c?: number | null; + humidity_percent?: number | null; frequency_hz?: number | null; energy?: number | null; peak?: number | null; battery_v?: number | null; + rssi_dbm?: number | null; + /** 16 relative spectrum levels (0–15), decoded by HiveHub. */ + fft_bins?: number[] | null; + } | null; + hivescale?: { + present?: boolean | null; + weight_kg?: number | null; + battery_v?: number | null; + rssi_dbm?: number | null; } | null; ble?: { present?: boolean | null; - sensor_type?: string | null; + sensor_type?: string | null; // HiveInside | RuuviTag | HolyIot 25015 firmware_version?: string | null; + board?: string | null; + /** Advertised node name, e.g. "HiveInside-8A3F". */ + device_name?: string | null; + mac?: string | null; humidity_percent: number | null; pressure_hpa: number | null; + accel_x_mg?: number | null; + accel_y_mg?: number | null; + accel_z_mg?: number | null; battery_percent?: number | null; + battery_mv?: number | null; rssi_dbm?: number | null; } | null; bee_counter?: { ok: boolean | null; + device_name?: string | null; + mac?: string | null; + version?: string | null; + protocol_version?: number | null; + /** Bit 0x80 = night idle. */ + status_flags?: number | null; + uptime_s?: number | null; + num_gates?: number | null; + /** How many of the three gate expanders answer (0..3). */ + mcps_healthy?: number | null; + glitch_count?: number | null; + /** Seconds until the night-mode window ends (wire rev 4+). */ + idle_s?: number | null; + /** Emitter-bank bitmask the counter runs with (wire rev 5+). */ + banks?: number | null; total_in: number | null; total_out: number | null; interval_in: number | null; @@ -87,6 +151,17 @@ export interface HiveScaleHiveReading { } | null; } +export interface HiveScaleHiveSound { + ok?: boolean | null; + rms_dbfs?: number | null; + peak_dbfs?: number | null; + band_sub_bass_dbfs?: number | null; + band_hum_dbfs?: number | null; + band_piping_dbfs?: number | null; + band_stress_dbfs?: number | null; + band_high_dbfs?: number | null; +} + export interface HiveScaleMeasurement { id: number; device_id: string; @@ -241,10 +316,45 @@ export interface HiveScaleMeasurement { // rows, so it is present on every measurement (only `undefined` on the rare // row with no data at all). hives?: HiveScaleHiveReading[]; + // ── Hub status ── + rssi_dbm?: number | null; + network_transport?: string | null; // "wifi" | "cellular" + hive_count?: number | null; + // ── Inspection mode (readings taken while it is on are masked) ── + inspection?: boolean | null; + inspection_id?: number | null; + inspection_hives?: number[] | null; +} + +/** + * Read one of the flat per-hive aliases HiveHub synthesizes for every hive + * (`scale_7_weight_kg`, `hiveheart_3_fft_bins`, …), which the fixed interface + * above only lists for hives 1–2. + */ +export function flatHiveField( + measurement: HiveScaleMeasurement | null | undefined, + prefix: string, + index: number, + suffix: string, +): T | null { + if (!measurement) return null; + const value = (measurement as unknown as Record)[ + `${prefix}_${index}_${suffix}` + ]; + return value === undefined ? null : (value as T); } export type HiveScaleTempcoSource = 'ambient' | 'hive_1' | 'hive_2'; +/** Calibration for hives 3..18 (hives 1–2 use the scale1/2_* fields). */ +export interface HiveScaleHiveCalibration { + index: number; + scale?: number; + offset: number; + factor: number; + tempco_kg_per_c: number; +} + export interface HiveScaleDeviceConfig { device_id: string; send_interval_seconds: number; @@ -252,13 +362,26 @@ export interface HiveScaleDeviceConfig { scale1_factor: number; scale2_offset: number; scale2_factor: number; + hive_scales?: HiveScaleHiveCalibration[]; config_version: number; - // Load-cell temperature compensation (corrected in the HiveScale backend). + // Load-cell temperature compensation (corrected in the HiveHub backend). tempco_enabled: boolean; tempco_source: HiveScaleTempcoSource; tempco_ref_temp_c: number; scale1_tempco_kg_per_c: number; scale2_tempco_kg_per_c: number; + // HiveTraffic night mode (local minutes since midnight, POSIX TZ). + beecounter_night_mode_enabled?: boolean; + beecounter_night_start_minute?: number; + beecounter_night_end_minute?: number; + beecounter_night_max_traffic?: number; + timezone?: string; + // HiveTraffic emitter banks (gates 00–07, 10–17, 20–27). + beecounter_bank1_enabled?: boolean; + beecounter_bank2_enabled?: boolean; + beecounter_bank3_enabled?: boolean; + /** Safety net: an inspection ends by itself after this many minutes. */ + inspection_timeout_minutes?: number; } export interface ClaimHiveScaleDeviceInput { @@ -268,31 +391,14 @@ export interface ClaimHiveScaleDeviceInput { scale_2_display_name?: string; } -export interface HiveScaleConfigPatch { - send_interval_seconds?: number; - scale1_offset?: number; - scale1_factor?: number; - scale2_offset?: number; - scale2_factor?: number; - tempco_enabled?: boolean; - tempco_source?: HiveScaleTempcoSource; - tempco_ref_temp_c?: number; - scale1_tempco_kg_per_c?: number; - scale2_tempco_kg_per_c?: number; -} +export type HiveScaleConfigPatch = HiveHubConfigPatch; -export interface HiveScaleTempCompensationFitInput { - scale: 1 | 2; - lookback_days?: number; - temp_source?: HiveScaleTempcoSource; - calibration_mode_only?: boolean; - apply?: boolean; -} +export type HiveScaleTempCompensationFitInput = HiveHubTempCompensationFit; export interface HiveScaleTempCompensationFitResult { ok: boolean; reason?: string; - scale: 1 | 2; + scale: number; temp_source: HiveScaleTempcoSource; applied: boolean; coeff_kg_per_c: number; @@ -306,11 +412,7 @@ export interface HiveScaleTempCompensationFitResult { window_end?: string; } -export interface HiveScaleChannelsPatch { - scale_1_display_name?: string; - scale_2_display_name?: string; - hives?: HiveScaleChannelMapping[]; -} +export type HiveScaleChannelsPatch = HiveHubChannelsPatch; export interface HiveScaleCalibrationModeStartInput { interval_seconds?: number; @@ -335,6 +437,8 @@ export interface HiveScaleMeasurementQuery { limit?: number; start_at?: string; end_at?: string; + /** Server-side down-sampling for wide chart ranges. */ + max_points?: number; } const DEFAULT_MEASUREMENT_QUERY: HiveScaleMeasurementQuery = { limit: 200 }; @@ -362,9 +466,25 @@ const HIVESCALE_KEYS = { ) => [...HIVESCALE_KEYS.all, 'insightsHistory', deviceId, query] as const, firmwareStatus: (deviceId: string | undefined) => [...HIVESCALE_KEYS.all, 'firmwareStatus', deviceId] as const, + inspectionStatus: (deviceId: string | undefined) => + [...HIVESCALE_KEYS.all, 'inspectionStatus', deviceId] as const, + inspections: ( + deviceId: string | undefined, + query: HiveScaleInspectionsQuery | undefined, + ) => [...HIVESCALE_KEYS.all, 'inspections', deviceId, query] as const, + recordings: (deviceId: string | undefined, hive: number | undefined) => + [...HIVESCALE_KEYS.all, 'recordings', deviceId, hive] as const, + exportSummary: ( + deviceId: string | undefined, + query: HiveScaleExportQuery | undefined, + ) => [...HIVESCALE_KEYS.all, 'exportSummary', deviceId, query] as const, }; -export const useHiveScaleDevices = () => { +export { HIVESCALE_KEYS }; + +export const useHiveScaleDevices = ( + options: { enabled?: boolean; retry?: boolean } = {}, +) => { return useQuery({ queryKey: HIVESCALE_KEYS.devices(), queryFn: async () => { @@ -373,7 +493,9 @@ export const useHiveScaleDevices = () => { ); return response.data; }, + enabled: options.enabled ?? true, staleTime: 30000, + ...(options.retry === false ? { retry: false } : {}), }); }; @@ -402,13 +524,101 @@ export const useHiveScaleMeasurements = ( `/api/hivescale/devices/${deviceId}/measurements`, { params: query }, ); - return response.data; + return response.data.map(withHiveReadings); }, enabled: !!deviceId, refetchInterval: options.refetchInterval ?? 60000, }); }; +const NESTED_HIVE_SENSORS = [ + 'accel', + 'ble', + 'bee_counter', + 'mic', + 'hiveheart', + 'hivescale', +] as const; + +/** + * Make sure a measurement carries `hives[]`. + * + * Current HiveHub servers always send it, synthesized from the flat columns + * for old rows. Older servers (and the HiveHub mock) send only the flat + * `scale_N_*` / `hive_N_*` / `ble_N_*` … fields, so rebuild the per-hive + * readings from those the way HiveHub's `_synthesize_hives_from_flat` does. + * Every component can then read hives[] without its own fallback. + */ +export function withHiveReadings( + measurement: HiveScaleMeasurement, +): HiveScaleMeasurement { + if (measurement.hives?.length) return measurement; + const flat = measurement as unknown as Record; + const byHive = new Map>(); + const hive = (index: number) => { + let entry = byHive.get(index); + if (!entry) { + entry = { index }; + byHive.set(index, entry); + } + return entry; + }; + const nested = (index: number, sensor: string) => { + const entry = hive(index); + entry[sensor] ??= {}; + return entry[sensor] as Record; + }; + + for (const [key, value] of Object.entries(flat)) { + if (value === null || value === undefined) continue; + const match = /^([a-z_]+?)_(\d{1,2})_(.+)$/.exec(key); + if (!match) continue; + const [, prefix, rawIndex, field] = match; + const index = Number(rawIndex); + if (index < 1 || index > 18) continue; + if (prefix === 'scale') { + if (field === 'weight_kg') hive(index).weight_kg = value; + else if (field === 'raw') hive(index).raw_weight = value; + } else if (prefix === 'hive') { + if (field === 'temp_c') hive(index).temp_c = value; + else if (field === 'humidity_percent') + hive(index).humidity_percent = value; + } else if ((NESTED_HIVE_SENSORS as readonly string[]).includes(prefix)) { + nested(index, prefix)[field] = value; + } + } + + // The legacy stereo mic belongs to hives 1 (left) and 2 (right). + for (const [index, side] of [ + [1, 'left'], + [2, 'right'], + ] as const) { + for (const [key, value] of Object.entries(flat)) { + const prefix = `mic_${side}_`; + if (value === null || value === undefined || !key.startsWith(prefix)) + continue; + const mic = nested(index, 'mic'); + mic[key.slice(prefix.length)] ??= value; + } + } + + for (const entry of byHive.values()) { + entry.weight_kg ??= null; + entry.raw_weight ??= null; + entry.temp_c ??= null; + entry.humidity_percent ??= + (entry.ble as { humidity_percent?: number } | undefined) + ?.humidity_percent ?? null; + const ble = entry.ble as Record | undefined; + if (ble) ble.present ??= true; + } + + const hives = [...byHive.values()].sort( + (a, b) => (a.index as number) - (b.index as number), + ) as unknown as HiveScaleHiveReading[]; + return hives.length ? { ...measurement, hives } : measurement; +} + export const useHiveScaleMembers = ( deviceId: string | undefined, enabled = true, @@ -506,7 +716,8 @@ export type HiveScaleInsightCategory = | 'brood' | 'decline' | 'winter' - | 'harvest'; + | 'harvest' + | 'acoustic'; export interface HiveScaleInsightAlert { id: string; @@ -806,15 +1017,19 @@ export type HiveScaleFirmwareTarget = | 'beecounter' | 'hiveinside'; +export type HiveScaleFirmwareBoard = 'esp32' | 'esp32-c6' | 'nrf54lm20a'; + export interface HiveScaleFirmwareUploadInput { file: File; version: string; target: HiveScaleFirmwareTarget; + /** Omit to let HiveHub take the board from the filename. */ + board?: HiveScaleFirmwareBoard; active?: boolean; } export interface HiveScaleAutoQueuedUpdate { - slot: 1 | 2; + slot: number; status: 'queued' | 'failed'; command_id?: number; error?: string; @@ -829,8 +1044,8 @@ export interface HiveScaleFirmwareUploadResult { size_bytes: number; crc32: number; /** - * For HiveInside uploads, the backend also auto-queues the OTA relay to both - * sensor slots (1 & 2). One entry per slot; absent for other targets. + * For HiveInside uploads, the backend also queues the OTA relay to every hive + * that reports a HiveInside node. One entry per slot; absent for other targets. */ auto_queued_updates?: HiveScaleAutoQueuedUpdate[]; } @@ -850,15 +1065,36 @@ export interface HiveScaleSdImportResult { duplicates: number; } +/** + * Thrown when the SD file was recorded by a different hub than the selected + * one. The caller can ask the user and retry with `force: true`. + */ +export class HiveScaleSdDeviceMismatchError extends Error { + constructor( + message: string, + readonly fileDeviceIds: string[], + ) { + super(message); + this.name = 'HiveScaleSdDeviceMismatchError'; + } +} + +export interface HiveScaleSdImportInput { + file: File; + /** Import even though the file names a different device. */ + force?: boolean; +} + export const useImportHiveScaleSdData = (deviceId: string | undefined) => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async file => { + return useMutation({ + mutationFn: async ({ file, force }) => { const formData = new FormData(); // The apiClient request interceptor strips Content-Type for FormData so // the browser sets the multipart boundary itself. formData.append('file', file); + if (force) formData.append('force', 'true'); try { const response = await apiClient.post( @@ -870,17 +1106,17 @@ export const useImportHiveScaleSdData = (deviceId: string | undefined) => { // Surface the backend message (e.g. "No measurements found…") instead // of Axios' generic "Request failed with status code 400" so callers // that toast error.message show actionable feedback. - if (isAxiosError<{ message?: string }>(error)) { - const data = error.response?.data; - const message = - (typeof data === 'object' && data !== null - ? data.message - : typeof data === 'string' - ? data - : undefined) ?? error.message; - throw new Error(message || 'SD import failed'); + if ( + isAxiosError<{ code?: string; file_device_ids?: string[] }>(error) && + error.response?.status === 409 && + error.response.data?.code === 'device_mismatch' + ) { + throw new HiveScaleSdDeviceMismatchError( + hiveHubErrorMessage(error, 'SD import failed'), + error.response.data.file_device_ids ?? [], + ); } - throw error; + throw new Error(hiveHubErrorMessage(error, 'SD import failed')); } }, onSuccess: () => { @@ -898,25 +1134,33 @@ export const useUploadHiveScaleFirmware = (deviceId: string | undefined) => { Error, HiveScaleFirmwareUploadInput >({ - mutationFn: async ({ file, version, target, active = true }) => { + mutationFn: async ({ file, version, target, board, active = true }) => { const formData = new FormData(); // Field order/names must match the FastAPI File()/Form() parameters. formData.append('file', file); formData.append('version', version); formData.append('target', target); + if (board) formData.append('board', board); formData.append('active', String(active)); // The apiClient request interceptor strips Content-Type for FormData so // the browser sets the multipart boundary itself. - const response = await apiClient.post( - `/api/hivescale/devices/${deviceId}/firmware`, - formData, - ); - return response.data; + try { + const response = await apiClient.post( + `/api/hivescale/devices/${deviceId}/firmware`, + formData, + ); + return response.data; + } catch (error) { + throw new Error(hiveHubErrorMessage(error, 'Firmware upload failed')); + } }, onSuccess: () => { // last_firmware_version is surfaced in the devices list, so refresh it. queryClient.invalidateQueries({ queryKey: HIVESCALE_KEYS.devices() }); + queryClient.invalidateQueries({ + queryKey: HIVESCALE_KEYS.firmwareStatus(deviceId), + }); }, }); }; @@ -932,6 +1176,24 @@ export interface HiveScaleFirmwareStatus { update_available: boolean; /** Update available but not yet approved — the device will not auto-flash. */ pending_approval: boolean; + /** Board the hub reports (esp32 / esp32-c6). */ + device_board?: string | null; + /** Newer releases uploaded for a different board than this hub's. */ + other_board_releases?: Array<{ version: string; board: string }>; + hiveinside_latest_version?: string | null; + /** Last HiveInside relay attempt per hive slot ("1".."18"). */ + hiveinside_relays?: Record; + beecounter_latest_version?: string | null; + /** Last HiveTraffic relay attempt per hive slot ("1".."18"). */ + beecounter_relays?: Record; +} + +export interface HiveScaleRelayStatus { + status: string; // pending | running | completed | failed | expired + message: string | null; + version: string | null; + created_at: string | null; + completed_at: string | null; } export interface HiveScaleFirmwareApproveResult { @@ -1000,40 +1262,428 @@ export interface HiveScaleRelayUpdateResult { id: number; command_type: string; payload: { slot: number }; + version?: string | null; + current_version?: string | null; } +export interface HiveScaleRelayUpdateInput { + slot: number; + /** Relay even when the node already runs this version or newer. */ + force?: boolean; +} + +const useQueueRelayUpdate = ( + deviceId: string | undefined, + command: 'update-hiveinside' | 'update-beecounter', + fallback: string, +) => { + const queryClient = useQueryClient(); + return useMutation< + HiveScaleRelayUpdateResult, + Error, + HiveScaleRelayUpdateInput + >({ + mutationFn: async ({ slot, force }) => { + try { + const response = await apiClient.post( + `/api/hivescale/devices/${deviceId}/commands/${command}`, + null, + { params: { slot, ...(force ? { force: true } : {}) } }, + ); + return response.data; + } catch (error) { + throw new Error(hiveHubErrorMessage(error, fallback)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: HIVESCALE_KEYS.firmwareStatus(deviceId), + }); + }, + }); +}; + /** - * Queue a HiveInside OTA relay for the paired sensor in the given slot. + * Queue a HiveInside OTA relay for the node paired with the given hive. * - * Uploading a HiveInside binary only *registers* the release; this triggers the - * HiveScale to actually download it and relay it to the sensor over BLE. The two - * steps are intentionally separate (upload once, then queue per slot). + * Uploading a HiveInside binary only *registers* the release; this makes the + * hub download it and relay it to the node over BLE. HiveHub refuses (409) a + * relay that is not newer than what the node runs unless `force` is set. + */ +export const useQueueHiveInsideUpdate = (deviceId: string | undefined) => + useQueueRelayUpdate( + deviceId, + 'update-hiveinside', + 'Failed to queue HiveInside OTA', + ); + +/** + * Queue a HiveTraffic counter OTA relay for the given hive. The counter stops + * counting for the duration of the transfer. */ -export const useQueueHiveInsideUpdate = (deviceId: string | undefined) => { - return useMutation({ - mutationFn: async ({ slot }) => { +export const useQueueBeeCounterUpdate = (deviceId: string | undefined) => + useQueueRelayUpdate( + deviceId, + 'update-beecounter', + 'Failed to queue HiveTraffic OTA', + ); + +// ── Remote setup access point ────────────────────────────────────────────── + +/** + * Ask the hub to open its setup access point. Queued: the AP appears after the + * hub's next check-in, up to one send interval later. + */ +export const useStartHiveScaleProvisioning = (deviceId: string | undefined) => + useMutation<{ status: string; id: number }, Error, void>({ + mutationFn: async () => { try { - const response = await apiClient.post( - `/api/hivescale/devices/${deviceId}/commands/update-hiveinside`, - null, - { params: { slot } }, + const response = await apiClient.post<{ status: string; id: number }>( + `/api/hivescale/devices/${deviceId}/provisioning/start`, ); return response.data; } catch (error) { - // Surface the backend message (e.g. "No active hiveinside firmware - // release") instead of Axios' generic status-code text. - if (isAxiosError<{ message?: string }>(error)) { - const data = error.response?.data; - const message = - (typeof data === 'object' && data !== null - ? data.message - : typeof data === 'string' - ? data - : undefined) ?? error.message; - throw new Error(message || 'Failed to queue HiveInside OTA'); + throw new Error( + hiveHubErrorMessage(error, 'Could not queue AP mode for the hub'), + ); + } + }, + }); + +// ── Inspection mode ──────────────────────────────────────────────────────── + +export interface HiveScaleInspection { + id: number; + device_id: string; + /** Hive indexes the inspection covers; null/empty means the whole hub. */ + hives: number[] | null; + started_at: string; + ended_at: string | null; + active: boolean; + source: 'device' | 'api' | 'dashboard' | string; + end_reason: string | null; + requested_at: string | null; + acknowledged_at: string | null; + note: string | null; + created_by: string | null; +} + +export interface HiveScaleInspectionStatus { + device_id: string; + active: boolean; + /** Requested from the app but not yet picked up by the hub. */ + pending: boolean; + inspection: HiveScaleInspection | null; + timeout_minutes: number; +} + +export interface HiveScaleInspectionsQuery { + start_at?: string; + end_at?: string; + limit?: number; +} + +export interface HiveScaleInspectionStartInput { + hives?: number[]; + note?: string; +} + +export const useHiveScaleInspectionStatus = ( + deviceId: string | undefined, + options: { refetchInterval?: number | false; enabled?: boolean } = {}, +) => + useQuery({ + queryKey: HIVESCALE_KEYS.inspectionStatus(deviceId), + queryFn: async () => { + const response = await apiClient.get( + `/api/hivescale/devices/${deviceId}/inspections/status`, + ); + return response.data; + }, + enabled: !!deviceId && (options.enabled ?? true), + refetchInterval: options.refetchInterval ?? 30000, + }); + +export const useHiveScaleInspections = ( + deviceId: string | undefined, + query: HiveScaleInspectionsQuery = {}, + options: { enabled?: boolean } = {}, +) => + useQuery({ + queryKey: HIVESCALE_KEYS.inspections(deviceId, query), + queryFn: async () => { + const response = await apiClient.get( + `/api/hivescale/devices/${deviceId}/inspections`, + { params: query }, + ); + return response.data; + }, + enabled: !!deviceId && (options.enabled ?? true), + staleTime: 30000, + }); + +const useInspectionMutation = ( + deviceId: string | undefined, + request: (input: TInput) => Promise, + fallback: string, +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async input => { + try { + return await request(input); + } catch (error) { + throw new Error(hiveHubErrorMessage(error, fallback)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: HIVESCALE_KEYS.inspectionStatus(deviceId), + }); + queryClient.invalidateQueries({ + queryKey: [...HIVESCALE_KEYS.all, 'inspections', deviceId], + }); + }, + }); +}; + +/** Start inspection mode: the hub masks readings until it is stopped. */ +export const useStartHiveScaleInspection = (deviceId: string | undefined) => + useInspectionMutation( + deviceId, + async input => + ( + await apiClient.post( + `/api/hivescale/devices/${deviceId}/inspections/start`, + input, + ) + ).data, + 'Could not start inspection mode', + ); + +export const useStopHiveScaleInspection = (deviceId: string | undefined) => + useInspectionMutation<{ note?: string }>( + deviceId, + async input => + ( + await apiClient.post( + `/api/hivescale/devices/${deviceId}/inspections/stop`, + input, + ) + ).data, + 'Could not stop inspection mode', + ); + +export const useUpdateHiveScaleInspection = (deviceId: string | undefined) => + useInspectionMutation<{ id: number; note: string | null }>( + deviceId, + async ({ id, note }) => + ( + await apiClient.patch( + `/api/hivescale/devices/${deviceId}/inspections/${id}`, + { note }, + ) + ).data, + 'Could not save the inspection note', + ); + +// ── Hive audio recordings ────────────────────────────────────────────────── + +export type HiveScaleRecordingStatus = + | 'requested' + | 'streaming' + | 'ready' + | 'failed'; + +export interface HiveScaleRecording { + id: number; + device_id: string; + hive_index: number; + status: HiveScaleRecordingStatus; + requested_at: string | null; + started_at: string | null; + completed_at: string | null; + requested_duration_s: number | null; + gain_db: number | null; + sample_rate: number | null; + bytes: number | null; + seconds: number | null; + /** Audio the node itself lost. */ + dropped_bytes: number | null; + /** Sequence gaps on the air. */ + gaps: number | null; + /** Notifications the hub's staging buffer could not take. */ + ring_overruns: number | null; + ring_dropped_bytes: number | null; + clipped_pct: number | null; + complete: boolean | null; + crc_ok: boolean | null; + confirmation: 'verified' | 'reported' | 'confirmed' | 'unknown' | string; + hub_message: string | null; + error: string | null; + requested_by: string | null; +} + +export interface HiveScaleRecordingRequestInput { + hive: number; + /** Seconds, 1–60. */ + duration: number; + gain_db?: number; +} + +/** Same-origin URL of a recording's WAV; the session cookie authorizes it. */ +export const hiveScaleRecordingWavUrl = (recordingId: number) => + `/api/hivescale/recordings/${recordingId}/audio.wav`; + +export const useHiveScaleRecordings = ( + deviceId: string | undefined, + hive?: number, + options: { refetchInterval?: number | false; enabled?: boolean } = {}, +) => + useQuery({ + queryKey: HIVESCALE_KEYS.recordings(deviceId, hive), + queryFn: async () => { + const response = await apiClient.get<{ + recordings: HiveScaleRecording[]; + }>(`/api/hivescale/devices/${deviceId}/recordings`, { + params: hive ? { hive, limit: 50 } : { limit: 50 }, + }); + return response.data.recordings ?? []; + }, + enabled: !!deviceId && (options.enabled ?? true), + refetchInterval: options.refetchInterval, + }); + +export const useRequestHiveScaleRecording = (deviceId: string | undefined) => { + const queryClient = useQueryClient(); + return useMutation( + { + mutationFn: async input => { + try { + const response = await apiClient.post( + `/api/hivescale/devices/${deviceId}/recordings`, + null, + { params: input }, + ); + return response.data; + } catch (error) { + throw new Error( + hiveHubErrorMessage(error, 'Could not request a recording'), + ); } - throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [...HIVESCALE_KEYS.all, 'recordings', deviceId], + }); + }, + }, + ); +}; + +export const useDeleteHiveScaleRecording = (deviceId: string | undefined) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async recordingId => { + try { + return ( + await apiClient.delete(`/api/hivescale/recordings/${recordingId}`) + ).data; + } catch (error) { + throw new Error( + hiveHubErrorMessage(error, 'Could not delete the recording'), + ); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [...HIVESCALE_KEYS.all, 'recordings', deviceId], + }); + }, + }); +}; + +// ── Data export / range delete ───────────────────────────────────────────── + +export interface HiveScaleExportQuery { + start_at?: string; + end_at?: string; +} + +export interface HiveScaleExportSummary { + devices: Array<{ + device_id: string; + measurements: number; + first_measured_at: string | null; + last_measured_at: string | null; + }>; + total_measurements: number; + filename: string; +} + +export const useHiveScaleExportSummary = ( + deviceId: string | undefined, + query: HiveScaleExportQuery = {}, + options: { enabled?: boolean } = {}, +) => + useQuery({ + queryKey: HIVESCALE_KEYS.exportSummary(deviceId, query), + queryFn: async () => { + const response = await apiClient.get( + `/api/hivescale/devices/${deviceId}/export/measurements/summary`, + { params: query }, + ); + return response.data; + }, + enabled: !!deviceId && (options.enabled ?? true), + }); + +/** + * Same-origin download URL for the NDJSON backup. A plain navigation, so the + * browser streams it to disk instead of buffering it in memory. + */ +export const hiveScaleExportUrl = ( + deviceId: string, + query: HiveScaleExportQuery & { hive?: number[] } = {}, +) => { + const params = new URLSearchParams(); + if (query.start_at) params.set('start_at', query.start_at); + if (query.end_at) params.set('end_at', query.end_at); + for (const hive of query.hive ?? []) params.append('hive', String(hive)); + const qs = params.toString(); + return `/api/hivescale/devices/${encodeURIComponent(deviceId)}/export/measurements${qs ? `?${qs}` : ''}`; +}; + +export interface HiveScaleMeasurementDeleteInput { + start_at: string; + end_at: string; + claim_code: string; +} + +export const useDeleteHiveScaleMeasurements = ( + deviceId: string | undefined, +) => { + const queryClient = useQueryClient(); + return useMutation< + { status: string; deleted: number }, + Error, + HiveScaleMeasurementDeleteInput + >({ + mutationFn: async input => { + try { + const response = await apiClient.post<{ + status: string; + deleted: number; + }>(`/api/hivescale/devices/${deviceId}/measurements/delete`, input); + return response.data; + } catch (error) { + throw new Error( + hiveHubErrorMessage(error, 'Could not delete the readings'), + ); } }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: HIVESCALE_KEYS.all }); + }, }); }; diff --git a/apps/frontend/src/pages/hive/hive-detail-page/hivehub-hive-card.tsx b/apps/frontend/src/pages/hive/hive-detail-page/hivehub-hive-card.tsx new file mode 100644 index 000000000..a47511cce --- /dev/null +++ b/apps/frontend/src/pages/hive/hive-detail-page/hivehub-hive-card.tsx @@ -0,0 +1,237 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ExternalLink } from 'lucide-react'; +import { + flatHiveField, + useHiveScaleInsights, + useHiveScaleMeasurements, + type HiveScaleMeasurement, +} from '@/api/hooks/useHiveScale'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { + useHiveHubSlotsForHive, + type HiveHubSlotRef, +} from '@/pages/hivescale/hivehub-links'; +import { + HiveHubInspectionStatusBadge, + formatInspectionTime, + inspectionCoversSlot, + useHiveHubInspectionStatus, +} from '@/pages/hivescale/hivehub-inspection-card'; +import { + HiveHubInspectionControl, + hiveHubDeviceName, +} from '@/pages/hivescale/hivehub-inspection-prompt'; +import { + HiveScaleSeverityPill, + sortAlerts, +} from '@/pages/hivescale/hivescale-insights-card'; + +const isNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +const slotWeight = (m: HiveScaleMeasurement, slot: number) => { + const compensated = flatHiveField(m, 'scale', slot, 'weight_kg_compensated'); + if (isNumber(compensated)) return compensated; + const weight = m.hives?.find(h => h.index === slot)?.weight_kg; + return isNumber(weight) ? weight : null; +}; + +// 84.5–85.5 °C is the DS18B20 power-on value, i.e. a disconnected probe. +const slotTemp = (m: HiveScaleMeasurement, slot: number) => { + const temp = m.hives?.find(h => h.index === slot)?.temp_c; + return isNumber(temp) && !(temp >= 84.5 && temp <= 85.5) ? temp : null; +}; + +const slotHumidity = (m: HiveScaleMeasurement, slot: number) => { + const hive = m.hives?.find(h => h.index === slot); + const value = hive?.humidity_percent ?? hive?.ble?.humidity_percent; + return isNumber(value) ? value : null; +}; + +const fmt = (value: number | null, digits: number, unit: string) => + value === null ? '—' : `${value.toFixed(digits)} ${unit}`; + +function Stat({ + label, + value, + className, +}: Readonly<{ label: string; value: string; className?: string }>) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function SlotSection({ slotRef }: Readonly<{ slotRef: HiveHubSlotRef }>) { + const { t } = useTranslation('hivescale'); + const { device, slot } = slotRef; + const deviceId = device.device_id; + // Fixed per mount so the query key (and cache) stays stable across renders. + const [startAt] = useState(() => + new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + ); + const measurements = useHiveScaleMeasurements(deviceId, { + start_at: startAt, + limit: 400, + }); + const insights = useHiveScaleInsights(deviceId, { lookbackDays: 14 }); + const status = useHiveHubInspectionStatus(deviceId); + + const summary = useMemo(() => { + const rows = [...(measurements.data ?? [])].sort( + (a, b) => Date.parse(a.measured_at) - Date.parse(b.measured_at), + ); + const pick = ( + list: HiveScaleMeasurement[], + read: (m: HiveScaleMeasurement) => T | null, + ) => { + for (const m of list) { + const value = read(m); + if (value !== null) return value; + } + return null; + }; + const newestFirst = [...rows].reverse(); + const weight = pick(newestFirst, m => slotWeight(m, slot)); + const firstWeight = pick(rows, m => slotWeight(m, slot)); + return { + weight, + weightChange: + weight !== null && firstWeight !== null ? weight - firstWeight : null, + temp: pick(newestFirst, m => slotTemp(m, slot)), + humidity: pick(newestFirst, m => slotHumidity(m, slot)), + lastReadingAt: newestFirst[0]?.measured_at ?? null, + }; + }, [measurements.data, slot]); + + const alerts = useMemo( + () => + sortAlerts(insights.data?.alerts).filter(alert => alert.channel === slot), + [insights.data?.alerts, slot], + ); + + const inspectionHere = + !!status.data && + (status.data.active || status.data.pending) && + inspectionCoversSlot(status.data.inspection, slot); + + const change = summary.weightChange; + const changeText = + change === null ? '—' : `${change > 0 ? '+' : ''}${change.toFixed(2)} kg`; + + return ( +
+
+
+ {slotRef.name} + + {' · '} + {hiveHubDeviceName(device)} + +
+ {status.data && ( + + )} +
+ +
+ + 0 && + 'text-emerald-700 dark:text-emerald-400', + change !== null && change < 0 && 'text-red-700 dark:text-red-400', + )} + /> + + +
+ +

+ {measurements.isLoading + ? t('common.loading') + : t('hiveLink.card.lastReading', { + time: formatInspectionTime(summary.lastReadingAt), + })} +

+ + {alerts.length > 0 && ( +
    + {alerts.map(alert => ( +
  • + + {alert.title} +
  • + ))} +
+ )} + + +
+ ); +} + +/** Compact HiveHub readings for a HivePal hive linked to one or more slots. */ +export function HiveHubHiveCard({ hiveId }: Readonly<{ hiveId: string }>) { + const { t } = useTranslation('hivescale'); + const { slots, isLoading } = useHiveHubSlotsForHive(hiveId); + if (isLoading || slots.length === 0) return null; + + return ( + + + {t('hiveLink.card.title')} + {t('hiveLink.card.description')} + + + {slots.map((ref, index) => ( +
0 && 'border-t pt-4')} + > + +
+ ))} + +
+
+ ); +} diff --git a/apps/frontend/src/pages/hive/hive-detail-page/page.tsx b/apps/frontend/src/pages/hive/hive-detail-page/page.tsx index 268372066..01e51e351 100644 --- a/apps/frontend/src/pages/hive/hive-detail-page/page.tsx +++ b/apps/frontend/src/pages/hive/hive-detail-page/page.tsx @@ -31,6 +31,7 @@ import { } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { HiveMinimap } from '@/components/hive-minimap'; +import { HiveHubHiveCard } from './hivehub-hive-card'; export const HiveDetailPage = () => { const { id: hiveId } = useParams<{ id: string }>(); @@ -320,8 +321,9 @@ export const HiveDetailPage = () => { {/* Action Sidebar - Hidden on mobile, visible on larger screens */} -
+
+ {hiveId && }
diff --git a/apps/frontend/src/pages/hivescale/hivehub-alert-settings-card.tsx b/apps/frontend/src/pages/hivescale/hivehub-alert-settings-card.tsx new file mode 100644 index 000000000..df822e55f --- /dev/null +++ b/apps/frontend/src/pages/hivescale/hivehub-alert-settings-card.tsx @@ -0,0 +1,128 @@ +import { useTranslation } from 'react-i18next'; +import { Bell } from 'lucide-react'; +import { toast } from 'sonner'; +import type { HiveHubAlertPreferences, UserPreferences } from 'shared-schemas'; +import { hiveHubErrorMessage } from '@/api/hooks/useHiveScale'; +import { usePreferences } from '@/api/hooks/useUserPreferences'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; + +const SEVERITIES = ['watch', 'warning', 'critical'] as const; +type MinSeverity = (typeof SEVERITIES)[number]; + +// Mirrors the backend's resolveAlertPreferences(): users who had the retired +// HivePal swarm check on keep getting alerts until they choose otherwise. +const resolveAlertPreferences = ( + preferences: UserPreferences | null | undefined, +): HiveHubAlertPreferences => { + const legacy = (preferences as { swarmAlert?: { enabled?: boolean } } | null) + ?.swarmAlert; + const minSeverity = preferences?.hiveHubAlerts?.minSeverity; + return { + enabled: preferences?.hiveHubAlerts?.enabled ?? legacy?.enabled ?? false, + minSeverity: SEVERITIES.includes(minSeverity as MinSeverity) + ? (minSeverity as MinSeverity) + : 'warning', + }; +}; + +export function HiveHubAlertSettingsCard() { + const { t } = useTranslation('hivescale'); + const { preferences, updatePreferences } = usePreferences(); + const current = resolveAlertPreferences(preferences.data); + const busy = preferences.isLoading || updatePreferences.isPending; + + const save = (next: HiveHubAlertPreferences) => { + // The endpoint replaces the whole preferences object, so everything else + // the user has set must be sent back unchanged. + updatePreferences.mutate( + { ...(preferences.data ?? {}), hiveHubAlerts: next }, + { + onSuccess: () => toast.success(t('alertSettings.saved')), + onError: error => + toast.error( + hiveHubErrorMessage(error, t('alertSettings.saveFailed')), + ), + }, + ); + }; + + return ( + + + + + {t('alertSettings.title')} + + {t('alertSettings.description')} + + + {preferences.isError && ( +

+ {t('alertSettings.loadFailed')} +

+ )} + +
+ save({ ...current, enabled })} + disabled={busy || preferences.isError} + /> + +
+ +
+ + +

+ {t('alertSettings.minSeverityHelp')} +

+
+ +
    +
  • {t('alertSettings.help.schedule')}
  • +
  • {t('alertSettings.help.once')}
  • +
  • {t('alertSettings.help.scope')}
  • +
  • {t('alertSettings.help.email')}
  • +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/hivescale/hivehub-audio-panel.tsx b/apps/frontend/src/pages/hivescale/hivehub-audio-panel.tsx new file mode 100644 index 000000000..fdf0ed5f9 --- /dev/null +++ b/apps/frontend/src/pages/hivescale/hivehub-audio-panel.tsx @@ -0,0 +1,637 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { + AlertTriangle, + CheckCircle2, + Download, + Loader2, + Mic, + Trash2, + XCircle, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { + hiveScaleRecordingWavUrl, + useDeleteHiveScaleRecording, + useHiveScaleRecordings, + useRequestHiveScaleRecording, + type HiveScaleDevice, + type HiveScaleMeasurement, + type HiveScaleRecording, +} from '@/api/hooks/useHiveScale'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Switch } from '@/components/ui/switch'; +import { hiveSlotName, type HiveMappingBySlot } from './hivehub-links'; + +// Same lengths as HiveHub's own panel. The node stops itself at 60 s, and a +// shorter session costs less battery and less time with the hive off the air +// (a connected node neither advertises nor samples). +const AUDIO_DURATIONS = [10, 30, 60] as const; +const DEFAULT_DURATION = 30; + +// HiveHub clamps gain to −20..+20 dB server-side. +const AUDIO_GAINS = [-20, -12, -6, 0, 6, 12, 20] as const; + +// Fast while a request is in flight so its state changes show up promptly; +// slow otherwise, because nothing moves until somebody asks for a recording. +const AUDIO_POLL_ACTIVE_MS = 3000; +const AUDIO_POLL_IDLE_MS = 60000; + +// Mirrors HiveHub's clipping warning threshold. +const CLIPPING_WARN_PCT = 5; + +const DASH = '—'; + +const dateTimeFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); + +const formatDateTime = (value: string | null) => { + if (!value) return DASH; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? DASH : dateTimeFormat.format(date); +}; + +const integerFormat = new Intl.NumberFormat(); + +const isInFlight = (rec: HiveScaleRecording) => + rec.status === 'requested' || rec.status === 'streaming'; + +interface HiveInsideNode { + slot: number; + deviceName: string | null; +} + +// Only a HiveInside has a microphone. Offering other hives would let somebody +// queue a request the hub can only reject a wake cycle later. +const hiveInsideNodes = (latest?: HiveScaleMeasurement): HiveInsideNode[] => + (latest?.hives ?? []) + .filter(hive => { + const ble = hive.ble; + if (!ble) return false; + return ( + /hiveinside/i.test(ble.sensor_type ?? '') || + /nrf54/i.test(ble.board ?? '') + ); + }) + .map(hive => ({ + slot: hive.index, + deviceName: hive.ble?.device_name ?? null, + })) + .sort((a, b) => a.slot - b.slot); + +const formatGain = (gain: number) => (gain > 0 ? `+${gain}` : `${gain}`); + +type Tone = 'good' | 'warn' | 'muted'; + +const toneClass: Record = { + good: 'text-emerald-700 dark:text-emerald-400', + warn: 'text-amber-700 dark:text-amber-400', + muted: 'text-muted-foreground', +}; + +const confirmationTone = (confirmation: string): Tone => { + if (confirmation === 'verified' || confirmation === 'confirmed') + return 'good'; + if (confirmation === 'reported') return 'warn'; + return 'muted'; +}; + +const confirmationLabel = (t: TFunction, confirmation: string) => { + switch (confirmation) { + case 'verified': + return t('audio.confirmation.verified'); + case 'reported': + return t('audio.confirmation.reported'); + case 'confirmed': + return t('audio.confirmation.confirmed'); + default: + return t('audio.confirmation.unknown'); + } +}; + +function StatusBadge({ rec }: { rec: HiveScaleRecording }) { + const { t } = useTranslation('hivescale'); + switch (rec.status) { + case 'requested': + return ( + + + {t('audio.status.requested')} + + ); + case 'streaming': + return ( + + + {t('audio.status.streaming')} + + ); + case 'ready': + return rec.complete ? ( + + + {t('audio.status.ready')} + + ) : ( + + + {t('audio.status.readyIncomplete')} + + ); + case 'failed': + return ( + + + {t('audio.status.failed')} + + ); + default: + return {rec.status}; + } +} + +function QualityFigure({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: Tone | null; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function RecordingQuality({ rec }: { rec: HiveScaleRecording }) { + const { t } = useTranslation('hivescale'); + const dropped = rec.dropped_bytes ?? 0; + const gaps = rec.gaps ?? 0; + const overruns = rec.ring_overruns ?? 0; + const clipped = rec.clipped_pct ?? 0; + const confirmTone = confirmationTone(rec.confirmation); + + // Three counters, three different remedies (move the node, fix the radio + // path, fix the hub's upload), so name each side rather than just "gaps". + const causes: string[] = []; + if (dropped) causes.push(t('audio.quality.cause.dropped')); + if (gaps) causes.push(t('audio.quality.cause.gaps')); + if (overruns) causes.push(t('audio.quality.cause.hubBuffer')); + + return ( +
+
+ + + + + = CLIPPING_WARN_PCT ? 'warn' : null} + /> + +
+ {causes.length > 0 && ( +

+ {t('audio.quality.gapNote', { causes: causes.join('; ') })} +

+ )} + {clipped >= CLIPPING_WARN_PCT && ( +

+ {t('audio.quality.clippingNote')} +

+ )} + {rec.confirmation === 'unknown' && ( +

+ {t('audio.quality.unknownNote')} +

+ )} +
+ ); +} + +function RecordingRow({ + rec, + hiveNames, + canWrite, + deleting, + onDelete, +}: { + rec: HiveScaleRecording; + hiveNames: HiveMappingBySlot; + canWrite: boolean; + deleting: boolean; + onDelete: (rec: HiveScaleRecording) => void; +}) { + const { t } = useTranslation('hivescale'); + const hasAudio = (rec.bytes ?? 0) > 0; + const wavUrl = hiveScaleRecordingWavUrl(rec.id); + const failure = rec.error || rec.hub_message; + + return ( +
  • +
    +
    +
    + {hiveSlotName( + hiveNames, + rec.hive_index, + t('audio.hiveFallback', { slot: rec.hive_index }), + )} +
    +
    + {[ + formatDateTime(rec.requested_at), + rec.requested_duration_s + ? t('audio.requestedLength', { + count: rec.requested_duration_s, + }) + : null, + rec.gain_db != null + ? t('audio.gainValue', { gain: formatGain(rec.gain_db) }) + : null, + rec.requested_by + ? t('audio.requestedBy', { name: rec.requested_by }) + : null, + ] + .filter(Boolean) + .join(' · ')} +
    +
    + +
    + + {rec.status === 'requested' && ( +

    + {t('audio.waitingNote')} +

    + )} + {rec.status === 'streaming' && ( +

    + {hasAudio ? t('audio.streamingPartial') : t('audio.streamingNote')} +

    + )} + {rec.status === 'failed' && ( +

    + {failure || t('audio.failedFallback')} +

    + )} + {/* A ready row can still carry the server's note, e.g. that the hub + never sent its report and the audio is unverified. */} + {rec.status === 'ready' && rec.error && ( +

    {rec.error}

    + )} + + {/* Offer the player whenever audio exists, as HiveHub does: a session + still uploading or one the hub never reported on has a real file. */} + {hasAudio && ( + <> + + + + )} + + {(hasAudio || canWrite) && ( +
    + {hasAudio && ( + + )} + {canWrite && ( + + )} +
    + )} +
  • + ); +} + +export function HiveHubAudioPanel({ + device, + latest, + hiveNames, +}: { + device: HiveScaleDevice; + latest?: HiveScaleMeasurement; + hiveNames: HiveMappingBySlot; +}) { + const { t } = useTranslation('hivescale'); + const deviceId = device.device_id; + const canWrite = device.role === 'owner' || device.role === 'admin'; + + const nodes = useMemo(() => hiveInsideNodes(latest), [latest]); + const [hiveChoice, setHiveChoice] = useState(''); + const [duration, setDuration] = useState(String(DEFAULT_DURATION)); + const [gain, setGain] = useState('0'); + const [onlySelectedHive, setOnlySelectedHive] = useState(false); + const [fastPoll, setFastPoll] = useState(false); + + // Fall back to the first node when the stored choice is gone (a node was + // unpaired, or the device changed). + const selectedSlot = + nodes.find(node => String(node.slot) === hiveChoice)?.slot ?? + nodes[0]?.slot; + + const filterSlot = onlySelectedHive ? selectedSlot : undefined; + const recordingsQuery = useHiveScaleRecordings(deviceId, filterSlot, { + refetchInterval: fastPoll ? AUDIO_POLL_ACTIVE_MS : AUDIO_POLL_IDLE_MS, + }); + const requestRecording = useRequestHiveScaleRecording(deviceId); + const deleteRecording = useDeleteHiveScaleRecording(deviceId); + + const recordings = useMemo( + () => + [...(recordingsQuery.data ?? [])].sort((a, b) => { + const at = a.requested_at ? Date.parse(a.requested_at) : 0; + const bt = b.requested_at ? Date.parse(b.requested_at) : 0; + return bt - at || b.id - a.id; + }), + [recordingsQuery.data], + ); + const anyInFlight = recordings.some(isInFlight); + + useEffect(() => { + setFastPoll(anyInFlight); + }, [anyInFlight]); + + const nodeLabel = (node: HiveInsideNode) => { + const name = hiveSlotName( + hiveNames, + node.slot, + t('audio.hiveFallback', { slot: node.slot }), + ); + return node.deviceName ? `${name} (${node.deviceName})` : name; + }; + + const record = () => { + if (selectedSlot == null) return; + requestRecording.mutate( + { + hive: selectedSlot, + duration: Number(duration), + gain_db: Number(gain), + }, + { + onSuccess: () => { + setFastPoll(true); + toast.success(t('audio.requestSuccess')); + }, + onError: error => + toast.error( + t('audio.requestError', { + message: error.message || t('common.unknownError'), + }), + ), + }, + ); + }; + + const remove = (rec: HiveScaleRecording) => { + if (!globalThis.confirm(t('audio.deleteConfirm'))) return; + deleteRecording.mutate(rec.id, { + onSuccess: () => toast.success(t('audio.deleteSuccess')), + onError: error => + toast.error( + t('audio.deleteError', { + message: error.message || t('common.unknownError'), + }), + ), + }); + }; + + const selectedNode = nodes.find(node => node.slot === selectedSlot); + const deletingId = deleteRecording.isPending + ? deleteRecording.variables + : undefined; + + return ( + + + + + {t('audio.title')} + + {t('audio.description')} + + + {nodes.length === 0 ? ( +

    + {t('audio.noNodes')} +

    + ) : ( +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +

    + {canWrite ? t('audio.nextWake') : t('audio.viewerNote')} +

    +
    +

    + {t('audio.lengthHint')} +

    +
    + )} + +
    +
    +

    {t('audio.listTitle')}

    + {selectedNode && ( +
    + + +
    + )} +
    + + {recordingsQuery.isLoading ? ( +
    + + +
    + ) : recordingsQuery.isError ? ( +

    + {t('audio.loadError', { + message: + recordingsQuery.error?.message || t('common.unknownError'), + })} +

    + ) : recordings.length === 0 ? ( +

    {t('audio.empty')}

    + ) : ( +
      + {recordings.map(rec => ( + + ))} +
    + )} +
    +
    +
    + ); +} diff --git a/apps/frontend/src/pages/hivescale/hivehub-calibration-card.tsx b/apps/frontend/src/pages/hivescale/hivehub-calibration-card.tsx new file mode 100644 index 000000000..c8ee02694 --- /dev/null +++ b/apps/frontend/src/pages/hivescale/hivehub-calibration-card.tsx @@ -0,0 +1,1510 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { + CheckCircle2, + ChevronDown, + Info, + Play, + Square, + Thermometer, + Weight, + Zap, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { + flatHiveField, + useFitHiveScaleTempCompensation, + useHiveScaleDeviceConfig, + useStartHiveScaleCalibrationMode, + useStopHiveScaleCalibrationMode, + useUpdateHiveScaleConfig, + type HiveScaleConfigPatch, + type HiveScaleDevice, + type HiveScaleDeviceConfig, + type HiveScaleMeasurement, + type HiveScaleTempCompensationFitResult, + type HiveScaleTempcoSource, +} from '@/api/hooks/useHiveScale'; +import { MAX_HIVE_SLOTS, type HiveMappingBySlot } from './hivehub-links'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Switch } from '@/components/ui/switch'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +// Firmware defaults for a hive 3..18 without a stored hive_scales entry: the +// values it is actually running with, so an untouched hive shows the truth. +const HIVE_SCALE_DEFAULTS = { offset: 0, factor: -7050, tempco: 0 }; +const FAST_MODE = { interval_seconds: 5, timeout_seconds: 600 }; +const DEFAULT_FIT_LOOKBACK_DAYS = 3; +const LOW_R_SQUARED = 0.5; + +type CapturedRawReading = { + raw: number; + measuredAt: string; +}; + +type HiveOption = { + index: number; + /** Short name for messages: the hive name, or "Hive N". */ + name: string; + /** Selector label: "Hive N · name" when named. */ + label: string; +}; + +type ScaleDraft = { offset: string; factor: string; knownWeightKg: string }; + +const numberOrDash = (value: number | null | undefined, digits = 1) => + typeof value === 'number' && Number.isFinite(value) + ? value.toFixed(digits) + : '—'; + +const formatDateTime = (value: string | null | undefined, t: TFunction) => { + if (!value) return t('common.never'); + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +}; + +const hasValidRaw = (raw: number | null | undefined): raw is number => + typeof raw === 'number' && Number.isFinite(raw); + +const parsePositiveNumber = (value: string) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +}; + +const formatRawCapture = (capture: CapturedRawReading | null, t: TFunction) => { + if (!capture) return t('calibration.notCapturedYet'); + return t('calibration.rawCapture', { + raw: capture.raw.toFixed(0), + time: formatDateTime(capture.measuredAt, t), + }); +}; + +/** Latest raw count for a hive: the canonical hives[] row, else the flat alias. */ +const hiveRaw = ( + latest: HiveScaleMeasurement | undefined, + index: number, +): number | null => { + const nested = latest?.hives?.find(h => h.index === index)?.raw_weight; + if (hasValidRaw(nested)) return nested; + const flat = flatHiveField(latest, 'scale', index, 'raw'); + return hasValidRaw(flat) ? flat : null; +}; + +const storedHiveScale = ( + config: HiveScaleDeviceConfig | undefined, + index: number, +) => config?.hive_scales?.find(h => Number(h.index) === index); + +/** Offset/factor as stored for a hive (hives 1–2 have dedicated columns). */ +const storedCalibration = ( + config: HiveScaleDeviceConfig | undefined, + index: number, +) => { + if (index === 1) + return { offset: config?.scale1_offset, factor: config?.scale1_factor }; + if (index === 2) + return { offset: config?.scale2_offset, factor: config?.scale2_factor }; + const entry = storedHiveScale(config, index); + return { + offset: entry?.offset ?? HIVE_SCALE_DEFAULTS.offset, + factor: entry?.factor ?? HIVE_SCALE_DEFAULTS.factor, + }; +}; + +const storedTempco = ( + config: HiveScaleDeviceConfig | undefined, + index: number, +) => { + if (index === 1) return config?.scale1_tempco_kg_per_c ?? 0; + if (index === 2) return config?.scale2_tempco_kg_per_c ?? 0; + return ( + storedHiveScale(config, index)?.tempco_kg_per_c ?? + HIVE_SCALE_DEFAULTS.tempco + ); +}; + +const initialScaleDraft = ( + config: HiveScaleDeviceConfig | undefined, + index: number, +): ScaleDraft => { + const stored = storedCalibration(config, index); + return { + offset: stored.offset === undefined ? '' : String(stored.offset), + factor: stored.factor === undefined ? '' : String(stored.factor), + knownWeightKg: '', + }; +}; + +/** + * Hives 1 and 2 always, plus every hive the device reports or has a stored + * calibration for — so a calibrated hive stays editable while it is offline. + */ +const useHiveOptions = ( + latest: HiveScaleMeasurement | undefined, + config: HiveScaleDeviceConfig | undefined, + hiveNames: HiveMappingBySlot, + t: TFunction, +): HiveOption[] => + useMemo(() => { + const indices = new Set([1, 2]); + for (const hive of latest?.hives ?? []) indices.add(hive.index); + for (const entry of config?.hive_scales ?? []) + indices.add(Number(entry.index)); + return [...indices] + .filter(n => Number.isInteger(n) && n >= 1 && n <= MAX_HIVE_SLOTS) + .sort((a, b) => a - b) + .map(index => { + const named = hiveNames[index]?.trim(); + return { + index, + name: named || t('calibration.perHive.hive', { index }), + label: named + ? t('calibration.perHive.hiveNamed', { index, name: named }) + : t('calibration.perHive.hive', { index }), + }; + }); + }, [latest?.hives, config?.hive_scales, hiveNames, t]); + +const computeFactorFromKnownWeight = ({ + raw, + offset, + knownWeightKg, + scaleName, + t, +}: { + raw: number | null | undefined; + offset: string; + knownWeightKg: string; + scaleName: string; + t: TFunction; +}): { error: string } | { error?: undefined; factor: string } => { + if (!hasValidRaw(raw)) { + return { error: t('calibration.errors.noLatestRaw', { scaleName }) }; + } + + const parsedOffset = Number(offset); + if (offset.trim() === '' || !Number.isFinite(parsedOffset)) { + return { error: t('calibration.errors.validOffset', { scaleName }) }; + } + + const parsedKnownWeightKg = Number(knownWeightKg); + if (!Number.isFinite(parsedKnownWeightKg) || parsedKnownWeightKg <= 0) { + return { error: t('calibration.errors.knownWeight', { scaleName }) }; + } + + const factor = (raw - parsedOffset) / parsedKnownWeightKg; + if (!Number.isFinite(factor) || factor === 0) { + return { + error: t('calibration.errors.factorNotCalculated', { scaleName }), + }; + } + + return { factor: Number(factor.toPrecision(12)).toString() }; +}; + +/** The config patch that writes offset/factor for one hive. */ +const calibrationPatch = ( + index: number, + offset: number, + factor: number, +): HiveScaleConfigPatch => { + if (index === 1) return { scale1_offset: offset, scale1_factor: factor }; + if (index === 2) return { scale2_offset: offset, scale2_factor: factor }; + return { hive_scales: [{ index, offset, factor }] }; +}; + +const HiveSelect = ({ + id, + options, + value, + onChange, +}: { + id: string; + options: HiveOption[]; + value: number; + onChange: (index: number) => void; +}) => ( + +); + +export function HiveHubCalibrationCard({ + device, + latest, + hiveNames, + onCalibrationPollingChange, +}: Readonly<{ + device: HiveScaleDevice; + latest: HiveScaleMeasurement | undefined; + hiveNames: HiveMappingBySlot; + onCalibrationPollingChange: (on: boolean) => void; +}>) { + const { t } = useTranslation('hivescale'); + const queryClient = useQueryClient(); + const deviceId = device.device_id; + const { data: config, isLoading } = useHiveScaleDeviceConfig(deviceId); + const updateConfig = useUpdateHiveScaleConfig(deviceId); + const startCalibrationMode = useStartHiveScaleCalibrationMode(deviceId); + const stopCalibrationMode = useStopHiveScaleCalibrationMode(deviceId); + const hiveOptions = useHiveOptions(latest, config, hiveNames, t); + + const [sendInterval, setSendInterval] = useState(''); + // Only edited hives are kept here; the rest read straight from the config. + const [drafts, setDrafts] = useState>({}); + const [advancedHive, setAdvancedHive] = useState(1); + const [isWizardOpen, setIsWizardOpen] = useState(false); + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + const [calibrationQueued, setCalibrationQueued] = useState(false); + const [activeHive, setActiveHive] = useState(1); + const [emptyCapture, setEmptyCapture] = useState( + null, + ); + const [loadedCapture, setLoadedCapture] = useState( + null, + ); + const [knownWeightKg, setKnownWeightKg] = useState(''); + + useEffect(() => { + if (!config) return; + setSendInterval(String(config.send_interval_seconds)); + setDrafts({}); + }, [config]); + + useEffect(() => { + setEmptyCapture(null); + setLoadedCapture(null); + setKnownWeightKg(''); + }, [activeHive, deviceId]); + + useEffect(() => { + if (latest?.calibration_mode === true) { + setCalibrationQueued(false); + onCalibrationPollingChange(true); + } + }, [latest?.calibration_mode, onCalibrationPollingChange]); + + const canConfigure = device.role === 'owner' || device.role === 'admin'; + const optionFor = (index: number): HiveOption => + hiveOptions.find(o => o.index === index) ?? { + index, + name: t('calibration.perHive.hive', { index }), + label: t('calibration.perHive.hive', { index }), + }; + const activeHiveName = optionFor(activeHive).name; + const latestRaw = hiveRaw(latest, activeHive); + const hasLatestRaw = hasValidRaw(latestRaw); + const hasLatestMeasurement = Boolean(latest?.measured_at); + const isCalibrationModeActive = latest?.calibration_mode === true; + const knownWeight = parsePositiveNumber(knownWeightKg); + + const calculatedFactor = useMemo(() => { + if (!emptyCapture || !loadedCapture || knownWeight === null) return null; + const factor = (loadedCapture.raw - emptyCapture.raw) / knownWeight; + if (!Number.isFinite(factor) || factor === 0) return null; + return Number(factor.toPrecision(12)); + }, [emptyCapture, knownWeight, loadedCapture]); + + const draftFor = (index: number) => + drafts[index] ?? initialScaleDraft(config, index); + const updateDraft = (index: number, change: Partial) => + setDrafts(prev => ({ + ...prev, + [index]: { + ...(prev[index] ?? initialScaleDraft(config, index)), + ...change, + }, + })); + + const invalidateHiveScaleData = () => { + queryClient.invalidateQueries({ queryKey: ['hivescale'] }); + }; + + const startFastMode = () => { + if (!canConfigure) return; + startCalibrationMode.mutate(FAST_MODE, { + onSuccess: () => { + setCalibrationQueued(true); + onCalibrationPollingChange(true); + invalidateHiveScaleData(); + toast.success(t('calibration.toasts.modeQueued')); + }, + onError: error => toast.error(error.message), + }); + }; + + const stopFastMode = (showToast = true) => { + if (!canConfigure) return; + stopCalibrationMode.mutate(undefined, { + onSuccess: () => { + setCalibrationQueued(false); + invalidateHiveScaleData(); + if (showToast) { + toast.success(t('calibration.toasts.stopQueued')); + } + }, + onError: error => toast.error(error.message), + }); + }; + + const captureLatestRaw = ( + type: 'empty' | 'loaded', + ): CapturedRawReading | null => { + if (!isCalibrationModeActive) { + toast.error(t('calibration.toasts.startModeFirst')); + return null; + } + + if (!latest?.measured_at || !hasValidRaw(latestRaw)) { + toast.error( + t('calibration.errors.noLatestRaw', { scaleName: activeHiveName }), + ); + return null; + } + + if (type === 'loaded') { + if (!emptyCapture) { + toast.error(t('calibration.toasts.captureEmptyFirst')); + return null; + } + if (knownWeight === null) { + toast.error(t('calibration.toasts.enterKnownWeightFirst')); + return null; + } + if ( + new Date(latest.measured_at).getTime() <= + new Date(emptyCapture.measuredAt).getTime() + ) { + toast.error(t('calibration.toasts.waitForNewReading')); + return null; + } + } + + return { raw: latestRaw, measuredAt: latest.measured_at }; + }; + + const captureEmptyRaw = () => { + const capture = captureLatestRaw('empty'); + if (!capture) return; + setEmptyCapture(capture); + setLoadedCapture(null); + toast.success( + t('calibration.toasts.emptyCaptured', { scaleName: activeHiveName }), + ); + }; + + const captureLoadedRaw = () => { + const capture = captureLatestRaw('loaded'); + if (!capture) return; + setLoadedCapture(capture); + toast.success( + t('calibration.toasts.weightedCaptured', { scaleName: activeHiveName }), + ); + }; + + const saveWizardCalibration = () => { + if (!config || !emptyCapture || calculatedFactor === null) return; + const hive = activeHive; + const offset = Math.round(emptyCapture.raw); + + updateConfig.mutate(calibrationPatch(hive, offset, calculatedFactor), { + onSuccess: () => { + updateDraft(hive, { + offset: String(offset), + factor: String(calculatedFactor), + }); + toast.success( + t('calibration.toasts.calibrationSaved', { + scaleName: activeHiveName, + }), + ); + stopFastMode(false); + setIsWizardOpen(false); + }, + onError: error => toast.error(error.message), + }); + }; + + const saveConfig = () => { + const parsedSendInterval = Number(sendInterval); + if ( + !Number.isFinite(parsedSendInterval) || + !Number.isInteger(parsedSendInterval) || + parsedSendInterval < 60 + ) { + toast.error(t('calibration.errors.sendInterval')); + return; + } + + const patch: HiveScaleConfigPatch = { + send_interval_seconds: parsedSendInterval, + }; + const hiveScales: NonNullable = []; + // Hives 1–2 are always sent (as before); hives 3+ only when edited, so + // opening the page never writes default entries for untouched hives. + const hivesToSend = hiveOptions + .map(o => o.index) + .filter(index => { + if (index <= 2) return true; + const draft = drafts[index]; + if (!draft) return false; + const initial = initialScaleDraft(config, index); + return ( + draft.offset !== initial.offset || draft.factor !== initial.factor + ); + }); + + for (const index of hivesToSend) { + const draft = draftFor(index); + const offset = Number(draft.offset); + const factor = Number(draft.factor); + if ( + draft.offset.trim() === '' || + !Number.isFinite(offset) || + !Number.isInteger(offset) + ) { + toast.error(t('calibration.errors.offsetsWhole')); + return; + } + if (draft.factor.trim() === '' || !Number.isFinite(factor) || !factor) { + toast.error(t('calibration.errors.factorsNonZero')); + return; + } + if (index === 1) { + patch.scale1_offset = offset; + patch.scale1_factor = factor; + } else if (index === 2) { + patch.scale2_offset = offset; + patch.scale2_factor = factor; + } else { + hiveScales.push({ index, offset, factor }); + } + } + if (hiveScales.length) patch.hive_scales = hiveScales; + + updateConfig.mutate(patch, { + onSuccess: () => toast.success(t('calibration.toasts.configUpdated')), + onError: error => toast.error(error.message), + }); + }; + + const advancedOption = optionFor(advancedHive); + const advancedDraft = draftFor(advancedHive); + const advancedRaw = hiveRaw(latest, advancedHive); + + const setLatestRawAsOffset = () => { + if (!hasValidRaw(advancedRaw)) { + toast.error( + t('calibration.errors.noLatestRaw', { + scaleName: advancedOption.name, + }), + ); + return; + } + updateDraft(advancedHive, { offset: String(advancedRaw) }); + toast.success( + t('calibration.toasts.offsetSet', { scaleName: advancedOption.name }), + ); + }; + + const calculateFactorFromKnownWeight = () => { + const result = computeFactorFromKnownWeight({ + raw: advancedRaw, + offset: advancedDraft.offset, + knownWeightKg: advancedDraft.knownWeightKg, + scaleName: advancedOption.name, + t, + }); + if (result.error !== undefined) { + toast.error(result.error); + return; + } + updateDraft(advancedHive, { factor: result.factor }); + toast.success( + t('calibration.toasts.factorCalculated', { + scaleName: advancedOption.name, + factor: result.factor, + }), + ); + }; + + const pendingAction = + updateConfig.isPending || + startCalibrationMode.isPending || + stopCalibrationMode.isPending; + + let calibrationModeBadgeLabel: string; + let calibrationModeDescription: string; + if (isCalibrationModeActive) { + calibrationModeBadgeLabel = t('calibration.mode.activeBadge'); + calibrationModeDescription = t('calibration.mode.activeDescription'); + } else if (calibrationQueued) { + calibrationModeBadgeLabel = t('calibration.mode.queuedBadge'); + calibrationModeDescription = t('calibration.mode.queuedDescription'); + } else { + calibrationModeBadgeLabel = t('calibration.mode.offBadge'); + calibrationModeDescription = t('calibration.mode.offDescription'); + } + + let wizardAlertTitle: string; + let wizardAlertDescription: string; + if (isCalibrationModeActive) { + wizardAlertTitle = t('calibration.wizard.alertActiveTitle'); + wizardAlertDescription = t('calibration.wizard.alertActiveDescription', { + time: formatDateTime(latest?.measured_at, t), + }); + } else if (calibrationQueued) { + wizardAlertTitle = t('calibration.wizard.alertQueuedTitle'); + wizardAlertDescription = t('calibration.wizard.alertQueuedDescription'); + } else { + wizardAlertTitle = t('calibration.wizard.alertStartTitle'); + wizardAlertDescription = t('calibration.wizard.alertStartDescription'); + } + + return ( + + +
    +
    + {t('calibration.title')} + {t('calibration.subtitle')} +
    + + + + + +

    {t('calibration.workflow.title')}

    +
      +
    1. {t('calibration.workflow.step1')}
    2. +
    3. {t('calibration.workflow.step2')}
    4. +
    5. {t('calibration.workflow.step3')}
    6. +
    7. {t('calibration.workflow.step4')}
    8. +
    9. {t('calibration.workflow.step5')}
    10. +
    +
    +
    +
    +
    + + {isLoading ? ( + + ) : config ? ( + <> +
    +
    +
    +

    {t('calibration.mode.title')}

    +

    + {calibrationModeDescription} +

    +
    + + {calibrationModeBadgeLabel} + +
    +
    + {hiveOptions.map(option => ( +
    + + {option.label} + + {t('calibration.raw', { + value: numberOrDash(hiveRaw(latest, option.index), 0), + })} +
    + ))} +
    +
    + + + + + + + + {t('calibration.wizard.title')} + + {t('calibration.wizard.description')} + + + +
    + + + {wizardAlertTitle} + + {wizardAlertDescription} + + + +
    + + +
    + +
    + +
    + {hiveOptions.map(option => { + const isSelected = activeHive === option.index; + return ( + + ); + })} +
    +
    + +
    +
    +
    + + 1 + +
    +

    + {t('calibration.wizard.emptyScale')} +

    +

    + {t('calibration.wizard.emptyScaleHint', { + scaleName: activeHiveName, + })} +

    +
    +
    +

    + {t('calibration.wizard.captured', { + value: formatRawCapture(emptyCapture, t), + })} +

    + +
    + +
    +
    + + 2 + +
    +

    + {t('calibration.wizard.knownWeight')} +

    +

    + {t('calibration.wizard.knownWeightHint')} +

    +
    +
    +
    + + { + setKnownWeightKg(event.target.value); + setLoadedCapture(null); + }} + placeholder={t( + 'calibration.perHive.knownWeightPlaceholder', + )} + /> +
    +

    + {t('calibration.wizard.captured', { + value: formatRawCapture(loadedCapture, t), + })} +

    + +
    + +
    +
    + + 3 + +
    +

    + {t('calibration.wizard.saveResult')} +

    +

    + {t('calibration.wizard.saveResultHint')} +

    +
    +
    +
    +

    + {t('calibration.wizard.offset')} +

    +

    + {emptyCapture ? Math.round(emptyCapture.raw) : '—'} +

    +

    + {t('calibration.wizard.factorRawPerKg')} +

    +

    + {calculatedFactor !== null ? calculatedFactor : '—'} +

    +
    + +
    +
    + + {emptyCapture && + loadedCapture && + calculatedFactor === null && ( + + + + {t('calibration.wizard.noChangeTitle')} + + + {t('calibration.wizard.noChangeDescription')} + + + )} +
    +
    +
    + + {!canConfigure && ( +

    + {t('calibration.viewerNotice')} +

    + )} + + + + + + +
    + + setSendInterval(event.target.value)} + disabled={!canConfigure} + /> +
    + +
    + + +
    + +
    +
    +

    + {t('calibration.advanced.manualValues', { + scaleName: advancedOption.name, + })} +

    +

    + {t('calibration.advanced.latestRawHint', { + value: numberOrDash(advancedRaw, 0), + })} +

    +
    +
    + +
    + + updateDraft(advancedHive, { + offset: event.target.value, + }) + } + disabled={!canConfigure} + /> + +
    +
    +
    + +
    + + updateDraft(advancedHive, { + knownWeightKg: event.target.value, + }) + } + placeholder={t( + 'calibration.perHive.knownWeightPlaceholder', + )} + disabled={!canConfigure} + /> + +
    +
    +
    + + + updateDraft(advancedHive, { + factor: event.target.value, + }) + } + disabled={!canConfigure} + /> +
    +

    + {t('calibration.perHive.manualSaveHint')} +

    +
    + + +
    + {t('calibration.advanced.configVersion', { + version: config.config_version, + })} +
    +
    +
    + + ) : ( +

    + {t('calibration.noConfig')} +

    + )} +
    +
    + ); +} + +const formatFitNumber = (value: number | null | undefined, digits: number) => + typeof value === 'number' && Number.isFinite(value) + ? value.toFixed(digits) + : '—'; + +export function HiveHubTempCompensationCard({ + device, + latest, + hiveNames, +}: Readonly<{ + device: HiveScaleDevice; + latest: HiveScaleMeasurement | undefined; + hiveNames: HiveMappingBySlot; +}>) { + const { t } = useTranslation('hivescale'); + const deviceId = device.device_id; + const { data: config, isLoading } = useHiveScaleDeviceConfig(deviceId); + const updateConfig = useUpdateHiveScaleConfig(deviceId); + const fitTempco = useFitHiveScaleTempCompensation(deviceId); + const hiveOptions = useHiveOptions(latest, config, hiveNames, t); + + // Load-cell temperature compensation (applied in the HiveHub backend). + const [tempcoEnabled, setTempcoEnabled] = useState(false); + const [tempcoSource, setTempcoSource] = + useState('ambient'); + const [tempcoRefTemp, setTempcoRefTemp] = useState(''); + // Only edited coefficients are kept here; the rest read from the config. + const [coeffDrafts, setCoeffDrafts] = useState>({}); + const [selectedHive, setSelectedHive] = useState(1); + const [lookbackDays, setLookbackDays] = useState( + String(DEFAULT_FIT_LOOKBACK_DAYS), + ); + const [setRefTemp, setSetRefTemp] = useState(false); + const [lastFit, setLastFit] = useState<{ + result: HiveScaleTempCompensationFitResult; + name: string; + } | null>(null); + + useEffect(() => { + if (!config) return; + setTempcoEnabled(Boolean(config.tempco_enabled)); + setTempcoSource(config.tempco_source ?? 'ambient'); + setTempcoRefTemp(String(config.tempco_ref_temp_c ?? 20)); + setCoeffDrafts({}); + }, [config]); + + useEffect(() => { + setLastFit(null); + }, [selectedHive, deviceId]); + + const canConfigure = device.role === 'owner' || device.role === 'admin'; + const selectedOption = hiveOptions.find(o => o.index === selectedHive) ?? { + index: selectedHive, + name: t('calibration.perHive.hive', { index: selectedHive }), + label: t('calibration.perHive.hive', { index: selectedHive }), + }; + const coeffFor = (index: number) => + coeffDrafts[index] ?? String(storedTempco(config, index)); + + const saveTempco = () => { + const parsedRef = Number(tempcoRefTemp); + if (tempcoRefTemp.trim() === '' || !Number.isFinite(parsedRef)) { + toast.error(t('calibration.tempco.errors.refTemp')); + return; + } + + const parseCoeff = (index: number) => { + const raw = coeffFor(index); + const value = Number(raw); + return raw.trim() === '' || !Number.isFinite(value) ? null : value; + }; + + const scale1 = parseCoeff(1); + const scale2 = parseCoeff(2); + // Hives 3+ are sent only when edited, so saving never creates entries. + const edited = Object.keys(coeffDrafts) + .map(Number) + .filter( + index => + index > 2 && + coeffDrafts[index] !== String(storedTempco(config, index)), + ); + const hiveCoeffs = edited.map(index => ({ + index, + tempco_kg_per_c: parseCoeff(index), + })); + if ( + scale1 === null || + scale2 === null || + hiveCoeffs.some(entry => entry.tempco_kg_per_c === null) + ) { + toast.error(t('calibration.tempco.errors.coeff')); + return; + } + + const patch: HiveScaleConfigPatch = { + tempco_enabled: tempcoEnabled, + tempco_source: tempcoSource, + tempco_ref_temp_c: parsedRef, + scale1_tempco_kg_per_c: scale1, + scale2_tempco_kg_per_c: scale2, + }; + if (hiveCoeffs.length) { + patch.hive_scales = hiveCoeffs.map(entry => ({ + index: entry.index, + tempco_kg_per_c: entry.tempco_kg_per_c as number, + })); + } + + updateConfig.mutate(patch, { + onSuccess: () => toast.success(t('calibration.tempco.toasts.saved')), + onError: error => toast.error(error.message), + }); + }; + + const runFit = (apply: boolean) => { + const days = Number(lookbackDays); + if (!Number.isInteger(days) || days < 1 || days > 90) { + toast.error(t('calibration.perHive.tempco.errors.lookback')); + return; + } + const { index: scale, name: scaleName } = selectedOption; + + fitTempco.mutate( + { + scale, + lookback_days: days, + temp_source: tempcoSource, + calibration_mode_only: false, + apply, + set_ref_temp: setRefTemp, + }, + { + onSuccess: result => { + if (!result.ok) { + setLastFit(null); + toast.error( + t('calibration.tempco.toasts.fitFailed', { + scaleName, + reason: + result.reason ?? t('calibration.tempco.toasts.noSignal'), + }), + ); + return; + } + setLastFit({ result, name: scaleName }); + const r2 = + result.r_squared === null ? 'n/a' : result.r_squared.toFixed(2); + if (apply) { + toast.success( + t('calibration.tempco.toasts.fitApplied', { + scaleName, + coeff: result.coeff_kg_per_c.toFixed(4), + refTemp: result.ref_temp_c.toFixed(1), + r2, + n: result.n, + }), + ); + return; + } + // Preview: fill the form for review; nothing is stored until Save. + setCoeffDrafts(prev => ({ + ...prev, + [scale]: String(result.coeff_kg_per_c), + })); + setTempcoEnabled(true); + if (result.temp_source) setTempcoSource(result.temp_source); + if (setRefTemp) setTempcoRefTemp(String(result.ref_temp_c)); + toast.success( + t('calibration.perHive.tempco.toasts.fitFilled', { + scaleName, + coeff: result.coeff_kg_per_c.toFixed(4), + r2, + n: result.n, + }), + ); + }, + onError: error => toast.error(error.message), + }, + ); + }; + + const fitSummary = lastFit + ? t('calibration.perHive.tempco.fitSummary', { + scaleName: lastFit.name, + coeff: formatFitNumber(lastFit.result.coeff_kg_per_c, 5), + r2: formatFitNumber(lastFit.result.r_squared, 3), + n: lastFit.result.n, + min: formatFitNumber(lastFit.result.temp_min_c, 1), + max: formatFitNumber(lastFit.result.temp_max_c, 1), + mean: formatFitNumber(lastFit.result.ref_temp_c, 2), + }) + : null; + const lowRSquared = + lastFit !== null && + lastFit.result.r_squared !== null && + lastFit.result.r_squared < LOW_R_SQUARED; + + return ( + + + + + {t('calibration.tempco.title')} + + {t('calibration.tempco.description')} + + + {isLoading ? ( + + ) : config ? ( + <> +
    + + +
    + +
    + + +

    + {t('calibration.tempco.sourceHint')} +

    +
    + +
    + + setTempcoRefTemp(event.target.value)} + disabled={!canConfigure} + /> +

    + {t('calibration.perHive.tempco.refHint')} +

    +
    + +
    +
    + + +
    + +
    + + + setCoeffDrafts(prev => ({ + ...prev, + [selectedHive]: event.target.value, + })) + } + disabled={!canConfigure} + /> +
    + +
    + + setLookbackDays(event.target.value)} + disabled={!canConfigure} + /> +
    + +
    + setSetRefTemp(checked === true)} + disabled={!canConfigure} + className="mt-0.5" + /> +
    + +

    + {t('calibration.perHive.tempco.setRefTempHint')} +

    +
    +
    + +
    + + +
    + + {fitSummary && ( +

    {fitSummary}

    + )} + {lowRSquared && ( +

    + {t('calibration.perHive.tempco.lowRSquared')} +

    + )} +
    + +

    + {t('calibration.perHive.tempco.autoFitHint')} +

    + + {!canConfigure && ( +

    + {t('calibration.viewerNotice')} +

    + )} + + + + ) : ( +

    + {t('calibration.noConfig')} +

    + )} +
    +
    + ); +} diff --git a/apps/frontend/src/pages/hivescale/hivehub-data-card.tsx b/apps/frontend/src/pages/hivescale/hivehub-data-card.tsx new file mode 100644 index 000000000..d0b38ee09 --- /dev/null +++ b/apps/frontend/src/pages/hivescale/hivehub-data-card.tsx @@ -0,0 +1,428 @@ +import { FormEvent, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Download, HardDrive, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + hiveScaleExportUrl, + useDeleteHiveScaleMeasurements, + useHiveScaleExportSummary, + type HiveScaleDevice, + type HiveScaleExportQuery, +} from '@/api/hooks/useHiveScale'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { hiveSlotName, type HiveMappingBySlot } from './hivehub-links'; + +const dateFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); +const formatDate = (value: string | null | undefined) => + value ? dateFormat.format(new Date(value)) : '—'; + +// yields YYYY-MM-DD; the range covers whole local days. +const dayStartIso = (day: string) => + day ? new Date(`${day}T00:00:00`).toISOString() : undefined; +const dayEndIso = (day: string) => + day ? new Date(`${day}T23:59:59.999`).toISOString() : undefined; +const localDateTimeIso = (value: string) => { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +}; + +export function HiveHubDataCard({ + device, + hiveNames, + reportedHives, +}: Readonly<{ + device: HiveScaleDevice; + hiveNames: HiveMappingBySlot; + reportedHives: number[]; +}>) { + const { t } = useTranslation('hivescale'); + const deviceId = device.device_id; + const canExport = device.role === 'owner' || device.role === 'admin'; + const canDelete = device.role === 'owner'; + + // ── Export ──────────────────────────────────────────────────────────────── + const [fromDay, setFromDay] = useState(''); + const [toDay, setToDay] = useState(''); + const [selectedHives, setSelectedHives] = useState([]); + const exportQuery = useMemo( + () => ({ start_at: dayStartIso(fromDay), end_at: dayEndIso(toDay) }), + [fromDay, toDay], + ); + const exportRangeInvalid = !!fromDay && !!toDay && fromDay > toDay; + const exportSummary = useHiveScaleExportSummary(deviceId, exportQuery, { + enabled: canExport && !exportRangeInvalid, + }); + const summaryDevice = + exportSummary.data?.devices.find(d => d.device_id === deviceId) ?? + exportSummary.data?.devices[0]; + const exportCount = exportSummary.data?.total_measurements; + const downloadUrl = hiveScaleExportUrl(deviceId, { + ...exportQuery, + hive: selectedHives, + }); + + const toggleHive = (slot: number, checked: boolean) => + setSelectedHives(current => + checked + ? [...current, slot].sort((a, b) => a - b) + : current.filter(s => s !== slot), + ); + + // ── Range delete ────────────────────────────────────────────────────────── + const [deleteStart, setDeleteStart] = useState(''); + const [deleteEnd, setDeleteEnd] = useState(''); + const [claimCode, setClaimCode] = useState(''); + const [confirmOpen, setConfirmOpen] = useState(false); + const deleteStartIso = localDateTimeIso(deleteStart); + const deleteEndIso = localDateTimeIso(deleteEnd); + const deleteQuery = useMemo( + () => ({ + start_at: deleteStartIso ?? undefined, + end_at: deleteEndIso ?? undefined, + }), + [deleteStartIso, deleteEndIso], + ); + // Only counted once the user asks to review, so typing a range does not + // fire a summary request per keystroke. + const deleteSummary = useHiveScaleExportSummary(deviceId, deleteQuery, { + enabled: canDelete && confirmOpen && !!deleteStartIso && !!deleteEndIso, + }); + const deleteCount = deleteSummary.data?.total_measurements; + const deleteMeasurements = useDeleteHiveScaleMeasurements(deviceId); + + const onReviewDelete = (event: FormEvent) => { + event.preventDefault(); + if (!canDelete) return; + if (!deleteStartIso || !deleteEndIso) { + toast.error(t('dataTools.delete.errors.missingRange')); + return; + } + if (new Date(deleteEndIso) < new Date(deleteStartIso)) { + toast.error(t('dataTools.delete.errors.invalidRange')); + return; + } + if (claimCode.trim().length < 4) { + toast.error(t('dataTools.delete.errors.missingClaimCode')); + return; + } + setConfirmOpen(true); + }; + + const onConfirmDelete = () => { + if (!deleteStartIso || !deleteEndIso) return; + deleteMeasurements.mutate( + { + start_at: deleteStartIso, + end_at: deleteEndIso, + claim_code: claimCode.trim(), + }, + { + onSuccess: result => { + setConfirmOpen(false); + setClaimCode(''); + toast.success( + t('dataTools.delete.success', { count: result.deleted }), + ); + }, + onError: error => toast.error(error.message), + }, + ); + }; + + return ( + + + + + {t('dataTools.title')} + + {t('dataTools.description')} + + +
    +
    +

    + + {t('dataTools.export.title')} +

    +

    + {t('dataTools.export.description')} +

    +
    + + {canExport ? ( + <> +
    +
    + + setFromDay(event.target.value)} + /> +
    +
    + + setToDay(event.target.value)} + /> +
    +
    +

    + {t('dataTools.export.rangeHelp')} +

    + + {reportedHives.length > 0 && ( +
    + +
    + {reportedHives.map(slot => ( +
    + + toggleHive(slot, checked === true) + } + /> + +
    + ))} +
    +

    + {selectedHives.length === 0 + ? t('dataTools.export.allHives') + : t('dataTools.export.someHives', { + count: selectedHives.length, + })} +

    +
    + )} + +
    + {exportRangeInvalid ? ( +

    + {t('dataTools.export.invalidRange')} +

    + ) : exportSummary.isLoading ? ( +

    {t('common.loading')}

    + ) : exportSummary.isError ? ( +

    + {t('dataTools.export.summaryFailed')} +

    + ) : ( + <> +

    + {t('dataTools.export.count', { + count: exportCount ?? 0, + })} +

    +

    + {t('dataTools.export.span', { + first: formatDate(summaryDevice?.first_measured_at), + last: formatDate(summaryDevice?.last_measured_at), + })} +

    + + )} +
    + + {exportRangeInvalid || exportCount === 0 ? ( + + ) : ( + + )} +

    + {t('dataTools.export.formatHelp')} +

    + + ) : ( +

    + {t('dataTools.export.noPermission', { role: device.role })} +

    + )} +
    + + + +
    +
    +

    + + {t('dataTools.delete.title')} +

    +

    + {t('dataTools.delete.description')} +

    +
    + + {canDelete ? ( +
    + + + {t('dataTools.delete.warningTitle')} + + {t('dataTools.delete.warning')} + + +
    +
    + + setDeleteStart(event.target.value)} + /> +
    +
    + + setDeleteEnd(event.target.value)} + /> +
    +
    +
    + + setClaimCode(event.target.value)} + /> +

    + {t('dataTools.delete.claimCodeHelp')} +

    +
    + +
    + ) : ( +

    + {t('dataTools.delete.noPermission', { role: device.role })} +

    + )} +
    +
    + + + + + {t('dataTools.delete.confirmTitle')} + + {t('dataTools.delete.confirmRange', { + start: formatDate(deleteStartIso), + end: formatDate(deleteEndIso), + })} + + +
    + {deleteSummary.isLoading ? ( +

    {t('common.loading')}

    + ) : deleteSummary.isError ? ( +

    + {t('dataTools.delete.countFailed')} +

    + ) : ( +

    + {t('dataTools.delete.confirmCount', { + count: deleteCount ?? 0, + })} +

    + )} +

    + {t('dataTools.delete.confirmIrreversible')} +

    +
    + + + + +
    +
    +
    + ); +} diff --git a/apps/frontend/src/pages/hivescale/hivehub-firmware-card.tsx b/apps/frontend/src/pages/hivescale/hivehub-firmware-card.tsx new file mode 100644 index 000000000..d63c13113 --- /dev/null +++ b/apps/frontend/src/pages/hivescale/hivehub-firmware-card.tsx @@ -0,0 +1,991 @@ +import { FormEvent, useEffect, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { + AlertTriangle, + CheckCircle2, + Cpu, + RefreshCw, + Upload, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { + useApproveHiveScaleFirmware, + useHiveScaleFirmwareStatus, + useQueueBeeCounterUpdate, + useQueueHiveInsideUpdate, + useUploadHiveScaleFirmware, + type HiveScaleDevice, + type HiveScaleFirmwareBoard, + type HiveScaleFirmwareStatus, + type HiveScaleFirmwareTarget, + type HiveScaleMeasurement, + type HiveScaleRelayStatus, +} from '@/api/hooks/useHiveScale'; +import { hiveSlotName, type HiveMappingBySlot } from './hivehub-links'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; + +const DASH = '—'; +const RELAY_POLL_MS = 10_000; + +const dateFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); +const formatDate = (value: string | null | undefined) => { + if (!value) return DASH; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? DASH : dateFormat.format(date); +}; + +const badgeTone = { + warn: 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300', + info: 'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300', + good: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + danger: 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300', + muted: 'text-muted-foreground', +} as const; + +/** + * Numeric, component-wise version compare ("0.10.0" > "0.9.9"), mirroring + * HiveHub's parse_version so an "update available" flag never promises a + * relay the server would refuse as not newer. + */ +const versionIsNewer = ( + a: string | null | undefined, + b: string | null | undefined, +) => { + const parse = (v: string | null | undefined) => + String(v ?? '') + .split('.') + .map(part => Number.parseInt(part.replace(/\D/g, ''), 10) || 0); + const x = parse(a); + const y = parse(b); + for (let i = 0; i < Math.max(x.length, y.length); i++) { + const d = (x[i] ?? 0) - (y[i] ?? 0); + if (d) return d > 0; + } + return false; +}; + +// ── Filename detection (same rules as HiveHub's dashboard and firmware.py) ── + +/** + * The v-stamped token is tried first because the trailing-token rule would + * drag "-lowpower.signed" into the version of a HiveInside Zephyr artifact. + */ +const versionFromFilename = (name: string) => { + const base = name.replace(/\.[^.]*$/, ''); + const stamped = /[-_]v(\d+\.\d+(?:\.\d+)*)(?=[-_.]|$)/i.exec(base); + if (stamped) return stamped[1]; + const m = /(\d+\.\d+(?:\.\d+)*(?:[-_][0-9A-Za-z.]+)?)$/.exec(base); + return m ? m[1] : ''; +}; + +type UploadTarget = 'hivehub' | 'hiveinside' | 'beecounter'; + +const targetFromFilename = (name: string): UploadTarget | '' => { + const n = name.toLowerCase(); + if (/hiveinside/.test(n)) return 'hiveinside'; + if (/hivetraffic|beecounter/.test(n)) return 'beecounter'; + if (/hivehub|hivescale/.test(n)) return 'hivehub'; + return ''; +}; + +// 'esp32-c6' contains 'esp32', so the C6 token has to be matched first. +const boardFromFilename = (name: string): HiveScaleFirmwareBoard | null => { + const n = name.toLowerCase(); + if (n.includes('nrf54')) return 'nrf54lm20a'; + if (n.includes('esp32-c6') || n.includes('esp32c6') || n.includes('xiao')) + return 'esp32-c6'; + if (n.includes('esp32')) return 'esp32'; + return null; +}; + +// HiveHub refuses a board that is not valid for the target, or that disagrees +// with the board token in the filename, so only valid pairs are offered. +const BOARDS_BY_TARGET: Record = { + hivehub: ['esp32', 'esp32-c6'], + hiveinside: ['nrf54lm20a'], + beecounter: ['esp32-c6'], +}; + +type BoardChoice = HiveScaleFirmwareBoard | 'auto'; + +// ── Nodes carried by the hub ──────────────────────────────────────────────── + +type RelayKind = 'hiveinside' | 'beecounter'; + +interface RelayNode { + slot: number; + name: string; + deviceName: string | null; + mac: string | null; + version: string | null; + board: string | null; +} + +const relayNodes = ( + latest: HiveScaleMeasurement | undefined, + kind: RelayKind, + hiveNames: HiveMappingBySlot, + t: TFunction, +): RelayNode[] => { + const nodes: RelayNode[] = []; + for (const hive of latest?.hives ?? []) { + const slot = Number(hive.index); + if (!slot) continue; + const name = hiveSlotName( + hiveNames, + slot, + t('firmware.relays.hiveFallback', { slot }), + ); + if (kind === 'hiveinside') { + const ble = hive.ble; + if ( + !ble || + !( + /hiveinside/i.test(ble.sensor_type ?? '') || + /nrf54/i.test(ble.board ?? '') + ) + ) + continue; + nodes.push({ + slot, + name, + deviceName: ble.device_name ?? null, + mac: ble.mac ?? null, + version: ble.firmware_version ?? null, + board: ble.board ?? null, + }); + } else { + // A paired but unreachable counter still reports ok:false, and stays + // listed so a failed relay to it remains visible. + const counter = hive.bee_counter; + if (!counter) continue; + nodes.push({ + slot, + name, + deviceName: counter.device_name ?? null, + mac: counter.mac ?? null, + version: counter.version ?? null, + board: null, + }); + } + } + return nodes.sort((a, b) => a.slot - b.slot); +}; + +const relayInFlight = (relay: HiveScaleRelayStatus | undefined) => + !!relay && + (relay.status === 'pending' || + relay.status === 'claimed' || + relay.status === 'running'); + +const relayFailed = (relay: HiveScaleRelayStatus | undefined) => + !!relay && (relay.status === 'failed' || relay.status === 'expired'); + +// HiveHub answers 409 "… is not newer … pass force=true to relay it anyway"; +// the proxy keeps only the message, so that is what identifies the refusal. +const isNotNewerRefusal = (message: string) => + /not newer|force=true/i.test(message); + +// ── Hub status ────────────────────────────────────────────────────────────── + +function HubStatusSection({ + status, + isLoading, + canManage, + deviceId, +}: Readonly<{ + status: HiveScaleFirmwareStatus | undefined; + isLoading: boolean; + canManage: boolean; + deviceId: string; +}>) { + const { t } = useTranslation('hivescale'); + const approveFirmware = useApproveHiveScaleFirmware(deviceId); + + const onApprove = () => { + if (!status) return; + if ( + !globalThis.confirm( + t('firmware.board.approveConfirm', { + version: status.latest_version ?? DASH, + }), + ) + ) + return; + approveFirmware.mutate(undefined, { + onSuccess: result => + toast.success( + t('firmware.update.applied', { version: result.version }), + ), + onError: error => toast.error(error.message), + }); + }; + + let body: ReactNode; + if (isLoading) { + body = ( +

    {t('common.loading')}

    + ); + } else if (!status) { + body = ( +

    + {t('firmware.update.unavailable')} +

    + ); + } else if (status.update_available && status.pending_approval) { + body = ( +
    +
    + +
    +

    + {t('firmware.update.availableTitle', { + version: status.latest_version, + })} +

    +

    + {t('firmware.update.availableBody', { + current: status.current_version ?? DASH, + version: status.latest_version, + })} + {status.latest_is_official + ? ` ${t('firmware.update.officialSuffix')}` + : ''} +

    +
    +
    + {canManage ? ( + + ) : ( +

    + {t('firmware.update.viewerNotice')} +

    + )} +
    + ); + } else if (status.update_available) { + body = ( +
    + +

    + {t('firmware.update.queued', { version: status.latest_version })} +

    +
    + ); + } else { + body = ( +
    + +

    + {t('firmware.update.upToDate', { + version: status.current_version ?? DASH, + })} +

    +
    + ); + } + + let badge: ReactNode = null; + if (status) { + const tone = status.update_available + ? status.pending_approval + ? 'warn' + : 'info' + : 'good'; + const label = status.update_available + ? status.pending_approval + ? t('firmware.board.badgePending') + : t('firmware.board.badgeApproved') + : t('firmware.board.badgeUpToDate'); + badge = ( + + {label} + + ); + } + + const otherBoards = status?.other_board_releases ?? []; + + return ( +
    +
    +
    + +
    +

    {t('firmware.update.title')}

    +

    + {t('firmware.update.description')} +

    +
    +
    + {badge} +
    + + {status && ( +
    + {( + [ + ['current', status.current_version], + ['latest', status.latest_version], + ['approved', status.approved_version], + ['board', status.device_board], + ] as const + ).map(([key, value]) => ( +
    +
    + {t(`firmware.board.${key}`)} +
    +
    {value || DASH}
    +
    + ))} +
    + )} + + {/* Without this an upload for the wrong board looks like it vanished. */} + {otherBoards.length > 0 && ( +
    + +

    + {t( + status?.device_board + ? 'firmware.board.otherBoards' + : 'firmware.board.otherBoardsUnknown', + { + board: status?.device_board, + releases: otherBoards + .map(release => `${release.board} ${release.version}`) + .join(', '), + }, + )} +

    +
    + )} + + {body} +
    + ); +} + +// ── Sub-device relays ─────────────────────────────────────────────────────── + +function RelayBadge({ relay }: Readonly<{ relay?: HiveScaleRelayStatus }>) { + const { t } = useTranslation('hivescale'); + if (!relay) return null; + let tone: keyof typeof badgeTone; + let label: string; + switch (relay.status) { + case 'pending': + tone = 'info'; + label = t('firmware.relays.status.pending'); + break; + case 'claimed': + case 'running': + tone = 'info'; + label = t('firmware.relays.status.running'); + break; + case 'completed': + tone = 'good'; + label = t('firmware.relays.status.completed'); + break; + case 'failed': + tone = 'danger'; + label = t('firmware.relays.status.failed'); + break; + case 'expired': + tone = 'danger'; + label = t('firmware.relays.status.expired'); + break; + default: + return null; + } + return ( + + {label} + + ); +} + +function RelaySection({ + kind, + nodes, + status, + canManage, + deviceId, +}: Readonly<{ + kind: RelayKind; + nodes: RelayNode[]; + status: HiveScaleFirmwareStatus | undefined; + canManage: boolean; + deviceId: string; +}>) { + const { t } = useTranslation('hivescale'); + const queueHiveInside = useQueueHiveInsideUpdate(deviceId); + const queueBeeCounter = useQueueBeeCounterUpdate(deviceId); + const queue = kind === 'hiveinside' ? queueHiveInside : queueBeeCounter; + // Slots whose last relay was refused as "not newer", with HiveHub's message, + // so the row can offer a forced relay. + const [refused, setRefused] = useState>({}); + const [busySlot, setBusySlot] = useState(null); + + if (nodes.length === 0) return null; + + const prefix = `firmware.relays.${kind}`; + const latest = + (kind === 'hiveinside' + ? status?.hiveinside_latest_version + : status?.beecounter_latest_version) ?? null; + const relays = + (kind === 'hiveinside' + ? status?.hiveinside_relays + : status?.beecounter_relays) ?? {}; + + const startRelay = (node: RelayNode, force: boolean) => { + const confirmText = [ + t('firmware.relays.confirm', { + name: t(`${prefix}.name`), + version: latest ?? DASH, + hive: node.name, + }), + kind === 'beecounter' ? t('firmware.relays.beecounter.confirmExtra') : '', + ] + .filter(Boolean) + .join('\n\n'); + if (!globalThis.confirm(confirmText)) return; + setBusySlot(node.slot); + queue.mutate( + { slot: node.slot, force }, + { + onSuccess: result => { + setRefused(prev => { + const next = { ...prev }; + delete next[node.slot]; + return next; + }); + const version = result.version ?? latest ?? DASH; + toast.success( + result.current_version + ? t('firmware.relays.queuedFrom', { + hive: node.name, + current: result.current_version, + version, + }) + : t('firmware.relays.queued', { hive: node.name, version }), + ); + }, + onError: error => { + if (!force && isNotNewerRefusal(error.message)) { + setRefused(prev => ({ ...prev, [node.slot]: error.message })); + } + toast.error(error.message); + }, + onSettled: () => setBusySlot(null), + }, + ); + }; + + return ( +
    +
    + +
    +

    {t(`${prefix}.title`)}

    +

    + {t(`${prefix}.description`)} +

    +
    +
    + + {kind === 'beecounter' && ( +
    + +

    + {t('firmware.relays.beecounter.countingWarning')} +

    +
    + )} + +
    + {nodes.map(node => { + const relay = relays[String(node.slot)]; + const inFlight = relayInFlight(relay); + const updateAvailable = + !!latest && (!node.version || versionIsNewer(latest, node.version)); + const refusal = refused[node.slot]; + const busy = busySlot === node.slot && queue.isPending; + const identity = [node.deviceName, node.mac] + .filter(Boolean) + .join(' · '); + return ( +
    +
    +
    +

    {node.name}

    +

    + {identity || t(`${prefix}.unnamed`)} +

    +
    +
    + + {[node.version ? `v${node.version}` : DASH, node.board] + .filter(Boolean) + .join(' · ')} + + {latest && ( + + {updateAvailable + ? t('firmware.relays.updateAvailable', { + version: latest, + }) + : t('firmware.relays.latest', { version: latest })} + + )} + +
    +
    + + {relayFailed(relay) && ( +

    + {t('firmware.relays.failedDetail', { + message: relay?.message || t('firmware.relays.noReason'), + date: formatDate(relay?.completed_at ?? relay?.created_at), + })} +

    + )} + + {refusal && ( +

    {refusal}

    + )} + + {canManage && latest && ( +
    + + {refusal && ( + + )} +
    + )} +
    + ); + })} +
    + + {!latest && ( +

    + {t(`${prefix}.noRelease`)} +

    + )} +
    + ); +} + +// ── Upload ────────────────────────────────────────────────────────────────── + +function FirmwareUploadForm({ + deviceId, + canManage, + hiveNames, +}: Readonly<{ + deviceId: string; + canManage: boolean; + hiveNames: HiveMappingBySlot; +}>) { + const { t } = useTranslation('hivescale'); + const [file, setFile] = useState(null); + const [version, setVersion] = useState(''); + const [target, setTarget] = useState('hivehub'); + const [board, setBoard] = useState('auto'); + const [detected, setDetected] = useState(null); + // Reset key lets us clear the native file input after a successful upload. + const [fileInputKey, setFileInputKey] = useState(0); + + const uploadFirmware = useUploadHiveScaleFirmware(deviceId); + const disabled = !canManage || uploadFirmware.isPending; + const boards = BOARDS_BY_TARGET[target]; + + const changeTarget = (next: UploadTarget) => { + setTarget(next); + if (board !== 'auto' && !BOARDS_BY_TARGET[next].includes(board)) { + setBoard('auto'); + } + }; + + // A silently switched Target select is easy to miss, so say what was + // recognized in the filename — and when nothing was. + const onFileChange = (picked: File | null) => { + setFile(picked); + if (!picked) { + setDetected(null); + return; + } + const name = picked.name; + const nextVersion = versionFromFilename(name); + const nextTarget = targetFromFilename(name) || target; + const nextBoard = boardFromFilename(name); + if (nextVersion) setVersion(nextVersion); + setTarget(nextTarget); + setBoard( + nextBoard && BOARDS_BY_TARGET[nextTarget].includes(nextBoard) + ? nextBoard + : 'auto', + ); + const filled = [ + targetFromFilename(name) + ? t('firmware.uploadForm.detectedTarget', { + target: t(`firmware.uploadForm.targets.${nextTarget}`), + }) + : '', + nextBoard + ? t('firmware.uploadForm.detectedBoard', { board: nextBoard }) + : '', + nextVersion + ? t('firmware.uploadForm.detectedVersion', { version: nextVersion }) + : '', + ].filter(Boolean); + setDetected( + filled.length + ? t('firmware.uploadForm.detected', { + name, + fields: filled.join(', '), + }) + : t('firmware.uploadForm.nothingDetected', { name }), + ); + }; + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canManage) return; + if (!file) { + toast.error(t('firmware.errors.missingFile')); + return; + } + const normalizedVersion = version.trim(); + if (!normalizedVersion) { + toast.error(t('firmware.errors.missingVersion')); + return; + } + + const hiveList = (slots: number[]) => + slots + .map(slot => + hiveSlotName( + hiveNames, + slot, + t('firmware.relays.hiveFallback', { slot }), + ), + ) + .join(', '); + + uploadFirmware.mutate( + { + file, + version: normalizedVersion, + target: target satisfies HiveScaleFirmwareTarget, + ...(board === 'auto' ? {} : { board }), + active: true, + }, + { + onSuccess: result => { + setFile(null); + setVersion(''); + setDetected(null); + setFileInputKey(key => key + 1); + toast.success( + t('firmware.success', { + version: result.version, + target: result.target, + }), + ); + // Sub-device uploads may auto-queue relays to every hive reporting + // such a node; say which were queued and which failed. + const autoQueued = result.auto_queued_updates ?? []; + const queued = autoQueued + .filter(update => update.status === 'queued') + .map(update => update.slot); + const failed = autoQueued.filter( + update => update.status === 'failed', + ); + if (queued.length > 0) { + toast.success( + t('firmware.uploadForm.autoQueued', { hives: hiveList(queued) }), + ); + } + if (failed.length > 0) { + toast.error( + t('firmware.uploadForm.autoQueueFailed', { + hives: hiveList(failed.map(update => update.slot)), + }), + { + description: + failed + .map(update => update.error) + .filter(Boolean) + .join('; ') || undefined, + }, + ); + } + }, + onError: error => toast.error(error.message), + }, + ); + }; + + return ( +
    +
    +

    + + {t('firmware.uploadForm.title')} +

    +

    + {t('firmware.uploadForm.description')} +

    +
    + +
    + + onFileChange(event.target.files?.[0] ?? null)} + disabled={disabled} + /> + {file && ( +

    + {file.name} ({(file.size / 1024).toFixed(0)} KB) +

    + )} + {detected && ( +

    {detected}

    + )} +
    + +
    +
    + + +
    + +
    + + +
    +
    +

    + {t(`firmware.uploadForm.boardHint.${target}`)} +

    + +
    + + setVersion(event.target.value)} + placeholder="0.30.5" + disabled={disabled} + /> +
    + + +
    + ); +} + +// ── Card ──────────────────────────────────────────────────────────────────── + +export function HiveHubFirmwareCard({ + device, + latest, + hiveNames, +}: Readonly<{ + device: HiveScaleDevice; + latest?: HiveScaleMeasurement; + hiveNames: HiveMappingBySlot; +}>) { + const { t } = useTranslation('hivescale'); + const deviceId = device.device_id; + const canManage = device.role === 'owner' || device.role === 'admin'; + + const statusQuery = useHiveScaleFirmwareStatus(deviceId); + const status = statusQuery.data; + const { refetch } = statusQuery; + + // A relay takes minutes and only reports back through the firmware status, + // so poll while one is queued or running and stop once all have settled. + const relayActive = [ + ...Object.values(status?.hiveinside_relays ?? {}), + ...Object.values(status?.beecounter_relays ?? {}), + ].some(relayInFlight); + useEffect(() => { + if (!relayActive) return; + const timer = setInterval(() => void refetch(), RELAY_POLL_MS); + return () => clearInterval(timer); + }, [relayActive, refetch]); + + const insideNodes = relayNodes(latest, 'hiveinside', hiveNames, t); + const counterNodes = relayNodes(latest, 'beecounter', hiveNames, t); + + return ( + + + + + {t('firmware.board.cardTitle')} + + {t('firmware.board.cardDescription')} + + + {!canManage && ( +

    + {t('firmware.notice.noPermission', { role: device.role })} +

    + )} + + + + + + +
    + +
    +
    +
    + ); +} diff --git a/apps/frontend/src/pages/hivescale/hivehub-health-card.tsx b/apps/frontend/src/pages/hivescale/hivehub-health-card.tsx new file mode 100644 index 000000000..e14854d06 --- /dev/null +++ b/apps/frontend/src/pages/hivescale/hivehub-health-card.tsx @@ -0,0 +1,501 @@ +import { useMemo, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { formatDistanceToNowStrict } from 'date-fns'; +import { + ArrowDownUp, + Bluetooth, + Cpu, + HeartPulse, + Scale, + type LucideIcon, +} from 'lucide-react'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { + HiveScaleDevice, + HiveScaleHiveReading, + HiveScaleMeasurement, +} from '@/api/hooks/useHiveScale'; +import { hiveSlotName, type HiveMappingBySlot } from './hivehub-links'; + +// Night-mode idle bit of the HiveTraffic status bitfield — the one bit with a +// published meaning (firmware/include/bee_counter_wire.h in HiveHub). +const BEE_COUNTER_STATUS_NIGHT_IDLE = 0x80; + +// Hub subsystems that report a pass/fail flag. The hub sends no code with a +// failure, so each fault carries the reason text from `health.hubReason.*`. +const HUB_CHECKS = [ + 'sht_ok', + 'rtc_ok', + 'sd_ok', + 'mic_ok', + 'mic_left_ok', + 'mic_right_ok', + 'battery_monitor_ok', + 'solar_monitor_ok', +] as const; + +type HubCheck = (typeof HUB_CHECKS)[number]; +type CheckState = 'ok' | 'fault' | 'absent'; + +const STATE_BADGE: Record = { + ok: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300', + fault: + 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300', + absent: 'text-muted-foreground', +}; + +const isNum = (v: unknown): v is number => + typeof v === 'number' && Number.isFinite(v); + +const stateOf = (ok: boolean | null | undefined): CheckState => + ok == null ? 'absent' : ok ? 'ok' : 'fault'; + +const hexByte = (v: number) => + `0x${(Number(v) & 0xff).toString(16).toUpperCase().padStart(2, '0')}`; + +const timeOf = (iso: string | null | undefined) => { + const t = iso ? new Date(iso).getTime() : NaN; + return Number.isFinite(t) ? t : -Infinity; +}; + +const hiveOf = (m: HiveScaleMeasurement | undefined, n: number) => + m?.hives?.find(h => Number(h.index) === n); + +// Only 0x80 is specified, so every other bit is shown raw for matching against +// the counter's own firmware notes rather than translated into a guess. +function beeCounterStatusText(t: TFunction, flags: number): string { + const byte = Number(flags) & 0xff; + const parts: string[] = []; + if (byte & BEE_COUNTER_STATUS_NIGHT_IDLE) + parts.push(t('health.counter.nightIdle')); + const rest = byte & ~BEE_COUNTER_STATUS_NIGHT_IDLE; + if (rest) parts.push(t('health.counter.otherBits', { hex: hexByte(rest) })); + return parts.length ? parts.join(', ') : t('health.counter.noFlags'); +} + +interface CheckRow { + key: string; + icon: LucideIcon; + label: string; + state: CheckState; + stateLabel?: string; + meta?: ReactNode; + code?: string | null; + detail?: string | null; +} + +function CheckRowView({ row }: Readonly<{ row: CheckRow }>) { + const { t } = useTranslation('hivescale'); + const Icon = row.icon; + return ( +
  • + +
    +
    + {row.label} + + {row.stateLabel ?? + (row.state === 'ok' + ? t('health.state.ok') + : row.state === 'fault' + ? t('health.state.fault') + : t('health.state.notFitted'))} + +
    + {row.meta && ( +
    + {row.meta} +
    + )} + {row.detail && ( +

    + {row.code && ( + + {row.code} + + )} + {row.detail} +

    + )} +
    +
  • + ); +} + +const metaBits = (bits: (string | null | undefined | false)[]) => { + const kept = bits.filter((b): b is string => !!b); + return kept.length + ? kept.map((bit, i) => {bit}) + : undefined; +}; + +/** + * Pass/fail health of every subsystem HiveHub reports: the hub's own sensors + * plus, per hive, its scale, in-hive BLE node, HiveTraffic counter and + * HiveHeart. A null flag means "not fitted / not configured" and is never + * counted as a fault. + * + * `measurements` is optional history; with it, a silent node's fault line says + * when it was last heard, as HiveHub's own dashboard does. + */ +export function HiveHubSensorHealthCard({ + device, + latest, + hiveNames, + measurements, +}: Readonly<{ + device: HiveScaleDevice; + latest?: HiveScaleMeasurement; + hiveNames: HiveMappingBySlot; + measurements?: HiveScaleMeasurement[]; +}>) { + const { t } = useTranslation('hivescale'); + + // Newest first, so the first hit of a scan is the most recent reading. + const history = useMemo(() => { + const rows = [...(measurements ?? [])]; + if (latest && !rows.some(r => r.id === latest.id)) rows.push(latest); + return rows.sort((a, b) => timeOf(b.measured_at) - timeOf(a.measured_at)); + }, [latest, measurements]); + const hasHistory = (measurements?.length ?? 0) > 0; + + const { hubRows, hiveSections, faults } = useMemo(() => { + const newestWhere = (probe: (m: HiveScaleMeasurement) => boolean) => + history.find(probe) ?? null; + // Identity fields persist across a missed scan: a node that went unheard + // still has a name worth showing. + const lastKnown = ( + n: number, + group: 'ble' | 'bee_counter', + key: K, + ): string | null => { + for (const m of history) { + const block = hiveOf(m, n)?.[group] as + | Record + | null + | undefined; + const value = block?.[key]; + if (value != null && value !== '') return String(value); + } + return null; + }; + const ago = (iso: string) => + formatDistanceToNowStrict(new Date(iso), { addSuffix: true }); + + const hub: CheckRow[] = HUB_CHECKS.map((field: HubCheck) => { + const state = stateOf(latest?.[field]); + return { + key: field, + icon: Cpu, + label: t(`health.hub.${field}`), + state, + detail: state === 'fault' ? t(`health.hubReason.${field}`) : null, + }; + }); + + const hives = [...(latest?.hives ?? [])].sort((a, b) => a.index - b.index); + const sections = hives.map((hive: HiveScaleHiveReading) => { + const n = hive.index; + const rows: CheckRow[] = []; + + const scaleOk = hive.scale_ok ?? (isNum(hive.weight_kg) ? true : null); + if (scaleOk != null) { + rows.push({ + key: 'scale', + icon: Scale, + label: t('health.sensor.scale'), + state: stateOf(scaleOk), + meta: metaBits([ + isNum(hive.weight_kg) ? `${hive.weight_kg.toFixed(2)} kg` : null, + hive.scale_source, + ]), + detail: scaleOk ? null : t('health.reason.scale'), + }); + } + + const ble = hive.ble; + const heard = hive.accel?.ok; + const bleSeen = + ble != null && + [ble.sensor_type, ble.device_name, ble.mac, ble.board].some( + v => v != null && v !== '', + ); + if (bleSeen || heard != null || ble?.present != null) { + const silent = heard === false || ble?.present === false; + const type = ble?.sensor_type || lastKnown(n, 'ble', 'sensor_type'); + const board = ble?.board || lastKnown(n, 'ble', 'board'); + const nodeName = + ble?.device_name || + lastKnown(n, 'ble', 'device_name') || + ble?.mac || + lastKnown(n, 'ble', 'mac'); + let detail: string | null = null; + if (silent) { + const kind = type + ? board + ? `${type} (${board})` + : type + : t('health.reason.inHiveKindFallback'); + const parts = [t('health.reason.inHive', { kind })]; + if (nodeName) parts.push(t('health.reason.node', { node: nodeName })); + if (hasHistory) { + const last = newestWhere(m => hiveOf(m, n)?.accel?.ok === true); + parts.push( + last + ? t('health.reason.lastHeard', { ago: ago(last.measured_at) }) + : t('health.reason.notHeardInRange'), + ); + } + detail = parts.join(' '); + } + rows.push({ + key: 'inHive', + icon: Bluetooth, + label: type + ? board + ? `${type} (${board})` + : type + : t('health.sensor.inHive'), + state: silent ? 'fault' : 'ok', + stateLabel: silent ? t('health.state.notHeard') : undefined, + meta: metaBits([ + ble?.device_name, + ble?.mac, + ble?.firmware_version && + t('health.meta.firmware', { version: ble.firmware_version }), + isNum(ble?.battery_percent) + ? t('health.meta.batteryPercent', { + value: Math.round(ble.battery_percent), + }) + : isNum(ble?.battery_mv) + ? t('health.meta.batteryMv', { + value: Math.round(ble.battery_mv), + }) + : null, + isNum(ble?.rssi_dbm) + ? t('health.meta.rssi', { value: Math.round(ble.rssi_dbm) }) + : null, + ]), + detail, + }); + } + + const bc = hive.bee_counter; + if (bc && bc.ok != null) { + const flags = bc.status_flags; + const nightIdle = + isNum(flags) && (flags & BEE_COUNTER_STATUS_NIGHT_IDLE) !== 0; + let detail: string | null = null; + let code: string | null = null; + if (!bc.ok) { + // A counter that never answered cannot annotate its own failure, so + // the code shown is the status from the last read that succeeded. + const parts = [t('health.reason.counter')]; + const node = + lastKnown(n, 'bee_counter', 'device_name') || + lastKnown(n, 'bee_counter', 'mac'); + if (node) parts.push(t('health.reason.counterNode', { node })); + const last = newestWhere(m => hiveOf(m, n)?.bee_counter?.ok === true); + if (hasHistory) { + parts.push( + last + ? t('health.reason.lastAnswered', { + ago: ago(last.measured_at), + }) + : t('health.reason.notAnsweredInRange'), + ); + } + const lastBc = last ? hiveOf(last, n)?.bee_counter : null; + if (isNum(lastBc?.status_flags)) { + parts.push( + t('health.reason.statusThen', { + status: beeCounterStatusText(t, lastBc.status_flags), + }), + ); + code = t('health.counter.statusCode', { + hex: hexByte(lastBc.status_flags), + }); + } + if (isNum(lastBc?.mcps_healthy)) { + parts.push( + t('health.reason.expandersThen', { count: lastBc.mcps_healthy }), + ); + } + detail = parts.join(' '); + } + const banks = isNum(bc.banks) + ? [1, 2, 3].filter(bank => (bc.banks! >> (bank - 1)) & 1) + : null; + rows.push({ + key: 'counter', + icon: ArrowDownUp, + label: t('health.sensor.counter'), + state: stateOf(bc.ok), + code, + meta: metaBits([ + bc.version && t('health.meta.firmware', { version: bc.version }), + nightIdle && t('health.counter.nightIdleBadge'), + isNum(bc.mcps_healthy) && + t('health.counter.expanders', { count: bc.mcps_healthy }), + isNum(bc.glitch_count) && + t('health.counter.glitches', { count: bc.glitch_count }), + banks && + (banks.length + ? t('health.counter.banks', { list: banks.join(', ') }) + : t('health.counter.noBanks')), + ]), + detail, + }); + } + + const hh = hive.hiveheart; + if ( + hh && + (hh.present != null || + [hh.battery_v, hh.rssi_dbm, hh.frequency_hz, hh.energy].some(isNum)) + ) { + const silent = hh.present === false; + rows.push({ + key: 'hiveheart', + icon: HeartPulse, + label: t('health.sensor.hiveheart'), + state: silent ? 'fault' : 'ok', + stateLabel: silent ? t('health.state.notHeard') : undefined, + meta: metaBits([ + isNum(hh.battery_v) + ? t('health.meta.batteryV', { value: hh.battery_v.toFixed(2) }) + : null, + isNum(hh.rssi_dbm) + ? t('health.meta.rssi', { value: Math.round(hh.rssi_dbm) }) + : null, + ]), + detail: silent ? t('health.reason.hiveheart') : null, + }); + } + + return { n, rows }; + }); + + const count = + hub.filter(r => r.state === 'fault').length + + sections.reduce( + (sum, s) => sum + s.rows.filter(r => r.state === 'fault').length, + 0, + ); + return { hubRows: hub, hiveSections: sections, faults: count }; + }, [history, hasHistory, latest, t]); + + const anyReported = + hubRows.some(r => r.state !== 'absent') || + hiveSections.some(s => s.rows.length > 0); + + return ( + + +
    +
    + {t('health.title')} + + {latest?.measured_at + ? t('health.descriptionAt', { + ago: formatDistanceToNowStrict( + new Date(latest.measured_at), + { addSuffix: true }, + ), + }) + : t('health.description', { + name: device.display_name || device.device_id, + })} + +
    + + {!anyReported + ? t('health.summary.noData') + : faults + ? t('health.summary.faults', { count: faults }) + : t('health.summary.allOk')} + +
    +
    + + {!latest ? ( +

    {t('health.noData')}

    + ) : ( + <> +
    +

    + {t('health.hubHeading')} +

    +
      + {hubRows.map(row => ( + + ))} +
    +
    + + {hiveSections.map(({ n, rows }) => ( +
    +

    + + {hiveSlotName(hiveNames, n, t('health.hiveIndex', { n }))} + + {hiveNames[n]?.trim() && ( + {t('health.hiveIndex', { n })} + )} +

    + {rows.length ? ( +
      + {rows.map(row => ( + + ))} +
    + ) : ( +

    + {t('health.noHiveSensors')} +

    + )} +
    + ))} + + {faults > 0 && ( +

    + {t('health.faultNote')} +

    + )} + + )} +
    +
    + ); +} diff --git a/apps/frontend/src/pages/hivescale/hivehub-hive-cards.tsx b/apps/frontend/src/pages/hivescale/hivehub-hive-cards.tsx new file mode 100644 index 000000000..ccc7a2468 --- /dev/null +++ b/apps/frontend/src/pages/hivescale/hivehub-hive-cards.tsx @@ -0,0 +1,574 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { formatDistanceToNowStrict } from 'date-fns'; +import { + ArrowDownUp, + Bluetooth, + ChevronDown, + HeartPulse, + Loader2, + Scale, + type LucideIcon, +} from 'lucide-react'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { + flatHiveField, + useHiveScaleDeviceConfig, + type HiveScaleDevice, + type HiveScaleHiveReading, + type HiveScaleInsightAlert, + type HiveScaleMeasurement, +} from '@/api/hooks/useHiveScale'; +import { + HiveScaleAlertList, + HiveScaleSeverityPill, + sortAlerts, +} from './hivescale-insights-card'; +import { HiveScaleInsightsHistoryDialog } from './hivescale-insights-history-dialog'; +import { + allHiveSlots, + hiveSlotName, + type HiveMappingBySlot, +} from './hivehub-links'; + +// Carry-forward window, mirroring HiveHub's dashboard: a value missing from the +// newest upload may be shown from an older one for this many cycles. The floor +// keeps a fast test loop from expiring values at once; the ceiling keeps a +// down-sampled history from stretching the apparent cycle. +const STALE_CYCLES = 3; +const MIN_STALE_MS = 15 * 60000; +const MAX_STALE_MS = 24 * 3600000; +const DEFAULT_CYCLE_MS = 60 * 60000; +const DAY_MS = 24 * 3600000; +// How far the reference reading may sit from "24 h ago" and still be called a +// 24 h change — otherwise a short history would pass off a 2 h delta as 24 h. +const DELTA_TOLERANCE_MS = 6 * 3600000; + +const isNum = (v: unknown): v is number => + typeof v === 'number' && Number.isFinite(v); + +const timeOf = (m: HiveScaleMeasurement) => { + const t = new Date(m.measured_at).getTime(); + return Number.isFinite(t) ? t : null; +}; + +const hiveOf = (m: HiveScaleMeasurement | undefined, n: number) => + m?.hives?.find(h => Number(h.index) === n); + +// 85 °C is the DS18B20 power-on value, which is what a disconnected probe reads. +const cleanTemp = (v: number | null | undefined) => + isNum(v) && !(v >= 84.5 && v <= 85.5) ? v : null; + +// Median gap between the newest rows: one long sleep or a backfilled SD import +// cannot stretch it the way a mean would. +function observedCycleMs(rows: HiveScaleMeasurement[]): number { + const times = rows + .slice(0, 6) + .map(timeOf) + .filter((t): t is number => t != null); + const gaps: number[] = []; + for (let i = 1; i < times.length; i++) { + const gap = times[i - 1] - times[i]; + if (gap > 0) gaps.push(gap); + } + if (!gaps.length) return DEFAULT_CYCLE_MS; + gaps.sort((a, b) => a - b); + return gaps[Math.floor(gaps.length / 2)]; +} + +// Rows whose hive fields were blanked by an inspection, not merely unreported. +function inspectionMasked(m: HiveScaleMeasurement, n: number) { + if (!m.inspection) return false; + const hives = m.inspection_hives; + if (!Array.isArray(hives) || !hives.length) return true; + return hives.some(h => Number(h) === n); +} + +interface Reading { + value: T; + at: string; + stale: boolean; + row: HiveScaleMeasurement; +} + +function readingBy( + rows: HiveScaleMeasurement[], + n: number, + limitMs: number, + pick: (m: HiveScaleMeasurement) => T | null, +): Reading | null { + if (!rows.length) return null; + const newest = timeOf(rows[0]); + for (const m of rows) { + const value = pick(m); + if (value != null) { + const at = timeOf(m); + if (at != null && newest != null && newest - at > limitMs) return null; + return { + value, + at: m.measured_at, + stale: at != null && newest != null && at < newest, + row: m, + }; + } + // A blanked row is a deliberate silence, not a missed cycle: stop rather + // than reach around the inspection for an older value. + if (inspectionMasked(m, n)) return null; + } + return null; +} + +const compensatedWeight = (m: HiveScaleMeasurement, n: number) => { + const v = flatHiveField(m, 'scale', n, 'weight_kg_compensated'); + return isNum(v) ? v : null; +}; +const rawWeight = (m: HiveScaleMeasurement, n: number) => { + const v = hiveOf(m, n)?.weight_kg; + return isNum(v) ? v : null; +}; + +type SensorState = 'ok' | 'fault'; +interface SensorIcon { + key: string; + icon: LucideIcon; + labelKey: string; + state: SensorState; +} + +function sensorIcons(hive: HiveScaleHiveReading | undefined): SensorIcon[] { + if (!hive) return []; + const out: SensorIcon[] = []; + const scaleOk = hive.scale_ok ?? (isNum(hive.weight_kg) ? true : null); + if (scaleOk != null) + out.push({ + key: 'scale', + icon: Scale, + labelKey: 'hiveCards.sensor.scale', + state: scaleOk ? 'ok' : 'fault', + }); + const ble = hive.ble; + const bleSeen = + ble != null && + [ble.sensor_type, ble.device_name, ble.mac, ble.board].some( + v => v != null && v !== '', + ); + if (bleSeen || hive.accel?.ok != null || ble?.present != null) + out.push({ + key: 'inHive', + icon: Bluetooth, + labelKey: 'hiveCards.sensor.inHive', + state: + hive.accel?.ok === false || ble?.present === false ? 'fault' : 'ok', + }); + if (hive.bee_counter?.ok != null) + out.push({ + key: 'counter', + icon: ArrowDownUp, + labelKey: 'hiveCards.sensor.counter', + state: hive.bee_counter.ok ? 'ok' : 'fault', + }); + const hh = hive.hiveheart; + if ( + hh && + (hh.present != null || + [hh.battery_v, hh.rssi_dbm, hh.frequency_hz, hh.energy].some(isNum)) + ) + out.push({ + key: 'hiveheart', + icon: HeartPulse, + labelKey: 'hiveCards.sensor.hiveheart', + state: hh.present === false ? 'fault' : 'ok', + }); + return out; +} + +function MetricValue({ + label, + reading, + digits, + unit, + sub, + large, +}: Readonly<{ + label: string; + reading: Reading | null; + digits: number; + unit: string; + sub?: string | null; + large?: boolean; +}>) { + const { t } = useTranslation('hivescale'); + const age = reading?.stale + ? formatDistanceToNowStrict(new Date(reading.at), { addSuffix: true }) + : null; + return ( +
    +

    {label}

    +

    + {reading ? reading.value.toFixed(digits) : '—'} + {reading && ( + + {unit} + + )} +

    + {(sub || age) && ( +

    + {[sub, age].filter(Boolean).join(' · ')} +

    + )} +
    + ); +} + +function HiveCard({ + deviceId, + n, + name, + named, + rows, + limitMs, + alerts, + scale1Name, + scale2Name, + insightsLoading, + insightsError, +}: Readonly<{ + deviceId: string; + n: number; + name: string; + named: boolean; + rows: HiveScaleMeasurement[]; + limitMs: number; + alerts: HiveScaleInsightAlert[]; + scale1Name: string; + scale2Name: string; + insightsLoading?: boolean; + insightsError?: boolean; +}>) { + const { t } = useTranslation('hivescale'); + const [alertsOpen, setAlertsOpen] = useState(false); + const latest = rows[0]; + const hive = hiveOf(latest, n); + + const { weight, delta, temp, humidity } = useMemo(() => { + // The column is decided per row — compensated only exists where + // compensation ran — and the delta reuses it so the two cannot disagree. + const weightReading = readingBy(rows, n, limitMs, m => { + const comp = compensatedWeight(m, n); + if (comp != null) return { kg: comp, comp: true }; + const raw = rawWeight(m, n); + return raw != null ? { kg: raw, comp: false } : null; + }); + let weightDelta: number | null = null; + const at = weightReading ? timeOf(weightReading.row) : null; + if (weightReading && at != null) { + const target = at - DAY_MS; + const pick = weightReading.value.comp ? compensatedWeight : rawWeight; + let best: { kg: number; off: number } | null = null; + for (const m of rows) { + const tm = timeOf(m); + if (tm == null || tm >= at) continue; + const kg = pick(m, n); + if (kg == null) continue; + const off = Math.abs(tm - target); + if (off <= DELTA_TOLERANCE_MS && (!best || off < best.off)) + best = { kg, off }; + } + if (best) weightDelta = weightReading.value.kg - best.kg; + } + const toNumber = ( + r: Reading<{ kg: number; comp: boolean }> | null, + ): Reading | null => (r ? { ...r, value: r.value.kg } : null); + return { + weight: toNumber(weightReading), + delta: weightDelta, + temp: readingBy(rows, n, limitMs, m => cleanTemp(hiveOf(m, n)?.temp_c)), + humidity: readingBy(rows, n, limitMs, m => { + const h = hiveOf(m, n); + const v = h?.humidity_percent ?? h?.ble?.humidity_percent; + return isNum(v) ? v : null; + }), + }; + }, [rows, n, limitMs]); + + const icons = sensorIcons(hive); + const counter = hive?.bee_counter; + const inspected = + !!latest?.inspection && + (!latest.inspection_hives?.length || + latest.inspection_hives.some(h => Number(h) === n)); + const highest = alerts[0]?.severity; + + return ( + + +
    +
    + {name} + {named && ( +

    + {t('hiveCards.hiveIndex', { n })} +

    + )} +
    +
    + {inspected && ( + + {t('hiveCards.inspection')} + + )} + {insightsLoading ? ( + + ) : insightsError ? ( + + {t('hiveCards.insightsUnavailable')} + + ) : ( + + )} + +
    +
    +
    + +
    + = 0 ? '+' : ''}${delta.toFixed(2)}`, + }) + : null + } + /> + + +
    + +
    +
    + {icons.length === 0 ? ( + + {t('hiveCards.noSensors')} + + ) : ( + icons.map(({ key, icon: Icon, labelKey, state }) => ( + + + + + + + + {t(labelKey)} ·{' '} + {state === 'ok' + ? t('hiveCards.state.ok') + : t('hiveCards.state.fault')} + + + )) + )} +
    + {counter?.ok != null && ( + + {t('hiveCards.traffic', { + in: isNum(counter.interval_in) ? counter.interval_in : '—', + out: isNum(counter.interval_out) ? counter.interval_out : '—', + })} + + )} +
    + + {!insightsLoading && !insightsError && alerts.length > 0 && ( + + + + + + + + + )} +
    +
    + ); +} + +/** + * One card per hive: current weight, climate, sensors and alerts at a glance. + * A value missing from the newest upload is carried forward from a recent one + * (muted, with its age) so a single missed BLE scan does not blank the card. + */ +export function HiveHubHiveCards({ + device, + measurements, + hiveNames, + alerts, + insightsLoading, + insightsError, +}: Readonly<{ + device: HiveScaleDevice; + measurements?: HiveScaleMeasurement[]; + hiveNames: HiveMappingBySlot; + alerts: HiveScaleInsightAlert[]; + insightsLoading?: boolean; + insightsError?: boolean; +}>) { + const { t } = useTranslation('hivescale'); + const config = useHiveScaleDeviceConfig(device.device_id); + + const rows = useMemo( + () => + [...(measurements ?? [])] + .filter(m => timeOf(m) != null) + .sort((a, b) => timeOf(b)! - timeOf(a)!), + [measurements], + ); + + const intervalS = config.data?.send_interval_seconds; + const limitMs = useMemo(() => { + const cycle = + isNum(intervalS) && intervalS > 0 + ? intervalS * 1000 + : observedCycleMs(rows); + return Math.min(Math.max(STALE_CYCLES * cycle, MIN_STALE_MS), MAX_STALE_MS); + }, [intervalS, rows]); + + const slots = useMemo(() => { + const set = new Set(); + for (const h of rows[0]?.hives ?? []) set.add(Number(h.index)); + for (const slot of allHiveSlots()) + if (hiveNames[slot]?.trim()) set.add(slot); + return [...set].filter(n => n >= 1).sort((a, b) => a - b); + }, [rows, hiveNames]); + + const alertsBySlot = useMemo(() => { + const map = new Map(); + for (const alert of sortAlerts(alerts)) { + const list = map.get(alert.channel) ?? []; + list.push(alert); + map.set(alert.channel, list); + } + return map; + }, [alerts]); + + if (!slots.length) { + return ( +

    + {t('hiveCards.empty')} +

    + ); + } + + const scale1Name = hiveSlotName( + hiveNames, + 1, + t('hiveCards.hiveIndex', { n: 1 }), + ); + const scale2Name = hiveSlotName( + hiveNames, + 2, + t('hiveCards.hiveIndex', { n: 2 }), + ); + + return ( +
    + {slots.map(n => ( + + ))} +
    + ); +} diff --git a/apps/frontend/src/pages/hivescale/hivehub-hub-access-card.tsx b/apps/frontend/src/pages/hivescale/hivehub-hub-access-card.tsx new file mode 100644 index 000000000..191631765 --- /dev/null +++ b/apps/frontend/src/pages/hivescale/hivehub-hub-access-card.tsx @@ -0,0 +1,215 @@ +import { FormEvent, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Clock, Save, Wifi } from 'lucide-react'; +import { toast } from 'sonner'; +import { + hiveHubErrorMessage, + useHiveScaleDeviceConfig, + useStartHiveScaleProvisioning, + useUpdateHiveScaleConfig, + type HiveScaleDevice, + type HiveScaleMeasurement, +} from '@/api/hooks/useHiveScale'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; + +const DEFAULT_INSPECTION_TIMEOUT = 60; +const dateFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); + +export function HiveHubHubAccessCard({ + device, + latest, +}: Readonly<{ + device: HiveScaleDevice; + latest: HiveScaleMeasurement | null | undefined; +}>) { + const { t } = useTranslation('hivescale'); + const configQuery = useHiveScaleDeviceConfig(device.device_id); + const updateConfig = useUpdateHiveScaleConfig(device.device_id); + const startProvisioning = useStartHiveScaleProvisioning(device.device_id); + const config = configQuery.data; + + const canEdit = device.role === 'owner' || device.role === 'admin'; + const [confirmOpen, setConfirmOpen] = useState(false); + const [timeoutEdit, setTimeoutEdit] = useState(null); + const storedTimeout = + config?.inspection_timeout_minutes ?? DEFAULT_INSPECTION_TIMEOUT; + const timeoutValue = timeoutEdit ?? String(storedTimeout); + + const intervalMinutes = config + ? Math.max(1, Math.round(config.send_interval_seconds / 60)) + : null; + const lastCheckIn = latest?.received_at ?? device.last_seen_at; + + const onOpenAp = () => { + startProvisioning.mutate(undefined, { + onSuccess: () => { + setConfirmOpen(false); + toast.success(t('hubAccess.ap.queued')); + }, + onError: error => + toast.error(hiveHubErrorMessage(error, t('hubAccess.ap.failed'))), + }); + }; + + const onSaveTimeout = (event: FormEvent) => { + event.preventDefault(); + if (!canEdit || !config) return; + const minutes = Number(timeoutValue); + if (!Number.isInteger(minutes) || minutes < 1 || minutes > 1440) { + toast.error(t('hubAccess.inspection.invalid')); + return; + } + if (minutes === storedTimeout) { + toast.info(t('hubAccess.noChanges')); + return; + } + updateConfig.mutate( + { inspection_timeout_minutes: minutes }, + { + onSuccess: () => { + setTimeoutEdit(null); + toast.success(t('hubAccess.inspection.saved')); + }, + onError: error => + toast.error( + hiveHubErrorMessage(error, t('hubAccess.inspection.saveFailed')), + ), + }, + ); + }; + + return ( + + + + + {t('hubAccess.title')} + + {t('hubAccess.description')} + + + {!canEdit && ( +

    + {t('hubAccess.readOnly', { role: device.role })} +

    + )} + +
    +

    {t('hubAccess.ap.title')}

    +

    + {t('hubAccess.ap.description')} +

    +

    + {t('hubAccess.ap.lastCheckIn', { + time: lastCheckIn + ? dateFormat.format(new Date(lastCheckIn)) + : t('common.never'), + })} +

    + {canEdit && ( + + )} +
    + + + +
    +

    + + {t('hubAccess.inspection.title')} +

    +

    + {t('hubAccess.inspection.description')} +

    +
    + + setTimeoutEdit(event.target.value)} + disabled={!canEdit || !config || updateConfig.isPending} + /> +
    + {canEdit && ( + + )} +
    +
    + + + + + {t('hubAccess.ap.confirmTitle')} + + {intervalMinutes != null + ? t('hubAccess.ap.confirmTiming', { count: intervalMinutes }) + : t('hubAccess.ap.confirmTimingUnknown')} + + +
    +

    {t('hubAccess.ap.confirmOffline')}

    +

    {t('hubAccess.ap.confirmTimeout')}

    +
    + + + + +
    +
    +
    + ); +} diff --git a/apps/frontend/src/pages/hivescale/hivehub-hub-status-card.tsx b/apps/frontend/src/pages/hivescale/hivehub-hub-status-card.tsx new file mode 100644 index 000000000..987dd88ec --- /dev/null +++ b/apps/frontend/src/pages/hivescale/hivehub-hub-status-card.tsx @@ -0,0 +1,304 @@ +import { useMemo, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { formatDistanceToNowStrict } from 'date-fns'; +import { AlertTriangle, Battery, Radio, Sun } from 'lucide-react'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { + useHiveScaleDeviceConfig, + type HiveScaleDevice, + type HiveScaleMeasurement, +} from '@/api/hooks/useHiveScale'; + +// A reading older than this many send intervals means the hub has missed +// check-ins, not just that the next one is due. +const STALE_CYCLES = 3; +const FALLBACK_STALE_MS = 30 * 60000; + +const BADGE_CLASS = { + good: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300', + fault: + 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300', + warn: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300', +} as const; + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); + +const isNum = (v: unknown): v is number => + typeof v === 'number' && Number.isFinite(v); + +const fmt = (v: number | null | undefined, digits: number, unit = '') => + isNum(v) ? `${v.toFixed(digits)}${unit ? ` ${unit}` : ''}` : '—'; + +const timeOf = (iso: string | null | undefined) => { + const t = iso ? new Date(iso).getTime() : NaN; + return Number.isFinite(t) ? t : null; +}; + +type SignalQuality = 'excellent' | 'good' | 'fair' | 'poor'; + +const signalQuality = (rssi: number): SignalQuality => + rssi >= -60 + ? 'excellent' + : rssi >= -70 + ? 'good' + : rssi >= -80 + ? 'fair' + : 'poor'; + +const SIGNAL_BADGE: Record = { + excellent: BADGE_CLASS.good, + good: BADGE_CLASS.good, + fair: BADGE_CLASS.warn, + poor: BADGE_CLASS.fault, +}; + +function StatusRow({ + label, + children, +}: Readonly<{ label: string; children: ReactNode }>) { + return ( +
    + {label} + + {children} + +
    + ); +} + +function SectionHeading({ + icon: Icon, + children, +}: Readonly<{ icon: typeof Radio; children: ReactNode }>) { + return ( +

    + + {children} +

    + ); +} + +function StampValue({ iso }: Readonly<{ iso: string | null | undefined }>) { + const t = timeOf(iso); + if (t == null) return —; + return ( + + {formatDistanceToNowStrict(t, { addSuffix: true })} + + ); +} + +/** + * The hub itself: uplink, clock, power and whether it is still checking in. + * Everything here is device-level; per-hive sensors live in the health card. + */ +export function HiveHubHubStatusCard({ + device, + latest, + measurements, +}: Readonly<{ + device: HiveScaleDevice; + latest?: HiveScaleMeasurement; + measurements?: HiveScaleMeasurement[]; +}>) { + const { t } = useTranslation('hivescale'); + const config = useHiveScaleDeviceConfig(device.device_id); + + const m = useMemo(() => { + if (latest) return latest; + let newest: HiveScaleMeasurement | undefined; + for (const row of measurements ?? []) { + if ((timeOf(row.measured_at) ?? -1) > (timeOf(newest?.measured_at) ?? -1)) + newest = row; + } + return newest; + }, [latest, measurements]); + + const intervalS = config.data?.send_interval_seconds; + const staleLimitMs = + isNum(intervalS) && intervalS > 0 + ? STALE_CYCLES * intervalS * 1000 + : FALLBACK_STALE_MS; + const readingAt = timeOf(m?.measured_at); + const stale = readingAt != null && Date.now() - readingAt > staleLimitMs; + + const rssi = m?.rssi_dbm; + const quality = isNum(rssi) ? signalQuality(rssi) : null; + const transport = m?.network_transport?.trim().toLowerCase(); + const transportLabel = !transport + ? '—' + : transport === 'wifi' || transport === 'cellular' + ? t(`hubStatus.transport.${transport}`) + : m?.network_transport; + + const batteryV = m?.battery_voltage_v ?? m?.battery_voltage; + const hasSolar = + m?.solar_monitor_ok === true || + [ + m?.solar_power_mw, + m?.solar_current_ma, + m?.solar_bus_voltage_v, + m?.solar_load_voltage_v, + ].some(isNum); + + const firmware = m?.firmware_version || device.last_firmware_version; + + return ( + + +
    +
    + {t('hubStatus.title')} + {t('hubStatus.description')} +
    +
    + {m?.inspection && ( + + {t('hubStatus.badge.inspection')} + + )} + {m?.calibration_mode && ( + + {t('hubStatus.badge.calibration')} + + )} + {m && !m.inspection && !m.calibration_mode && ( + + {stale ? t('hubStatus.badge.stale') : t('hubStatus.badge.live')} + + )} +
    +
    +
    + + {!m ? ( +

    + {t('hubStatus.noData')} +

    + ) : ( + <> + {stale && readingAt != null && ( +
    + + + {t('hubStatus.staleWarning', { + ago: formatDistanceToNowStrict(readingAt), + })} + +
    + )} + + + {t('hubStatus.section.connectivity')} + +
    + + {fmt(rssi, 0, 'dBm')} + {quality && ( + + {t(`hubStatus.signalQuality.${quality}`)} + + )} + + + {transportLabel} + + + {m.time_source || '—'} + {m.rtc_ok != null && ( + + {m.rtc_ok + ? t('hubStatus.rtc.ok') + : t('hubStatus.rtc.fault')} + + )} + + + + + + + + + {firmware || '—'} + + + {isNum(m.boot_count) ? m.boot_count.toLocaleString() : '—'} + +
    + + + {t('hubStatus.section.battery')} + +
    + + {fmt(m.battery_soc_percent, 0, '%')} + + + {fmt(batteryV, 2, 'V')} + + {m.battery_alert && ( + + + {t('hubStatus.batteryAlertActive')} + + + )} +
    + + {hasSolar && ( + <> + + {t('hubStatus.section.solar')} + +
    + + {fmt(m.solar_power_mw, 0, 'mW')} + + + {fmt(m.solar_current_ma, 0, 'mA')} + + + {fmt(m.solar_bus_voltage_v, 2, 'V')} + + + {fmt(m.solar_load_voltage_v, 2, 'V')} + +
    + + )} + + )} +
    +
    + ); +} diff --git a/apps/frontend/src/pages/hivescale/hivehub-inspection-card.tsx b/apps/frontend/src/pages/hivescale/hivehub-inspection-card.tsx new file mode 100644 index 000000000..f5d2fec78 --- /dev/null +++ b/apps/frontend/src/pages/hivescale/hivehub-inspection-card.tsx @@ -0,0 +1,641 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { Check, Pencil, Play, Square, X } from 'lucide-react'; +import { toast } from 'sonner'; +import { + useHiveScaleInspections, + useHiveScaleInspectionStatus, + useStartHiveScaleInspection, + useStopHiveScaleInspection, + useUpdateHiveScaleInspection, + type HiveScaleDevice, + type HiveScaleInspection, + type HiveScaleInspectionStatus, +} from '@/api/hooks/useHiveScale'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import { hiveSlotName, type HiveMappingBySlot } from './hivehub-links'; + +const dateTimeFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); +const timeFormat = new Intl.DateTimeFormat(undefined, { timeStyle: 'short' }); + +export const formatInspectionTime = (iso: string | null | undefined) => + iso ? dateTimeFormat.format(new Date(iso)) : '—'; + +export const canControlInspection = (device: HiveScaleDevice | undefined) => + device?.role === 'owner' || device?.role === 'admin'; + +/** Empty or missing hive list means the whole hub. */ +export const inspectionCoversSlot = ( + inspection: HiveScaleInspection | null | undefined, + slot: number, +) => !inspection?.hives?.length || inspection.hives.includes(slot); + +/** + * Inspection status that polls faster while an inspection is open or waiting + * for the hub: that is when the beekeeper is standing at the hive watching it. + */ +export const useHiveHubInspectionStatus = ( + deviceId: string | undefined, + options: { enabled?: boolean } = {}, +) => { + const [fast, setFast] = useState(false); + const status = useHiveScaleInspectionStatus(deviceId, { + enabled: options.enabled, + refetchInterval: fast ? 15000 : undefined, + }); + const shouldBeFast = !!(status.data?.active || status.data?.pending); + if (shouldBeFast !== fast) setFast(shouldBeFast); + return status; +}; + +const sourceLabel = (t: TFunction, source: string) => + ['device', 'api', 'dashboard'].includes(source) + ? t(`inspectionMode.source.${source}`) + : source; + +const endReasonLabel = (t: TFunction, reason: string) => + ['timeout', 'device', 'api', 'dashboard'].includes(reason) + ? t(`inspectionMode.endReason.${reason}`) + : reason; + +const formatDuration = (t: TFunction, startIso: string, endIso: string) => { + const minutes = Math.max( + 0, + Math.round((Date.parse(endIso) - Date.parse(startIso)) / 60000), + ); + if (minutes < 60) + return t('inspectionMode.duration.minutes', { count: minutes }); + return t('inspectionMode.duration.hoursMinutes', { + hours: Math.floor(minutes / 60), + minutes: minutes % 60, + }); +}; + +const scopeLabel = ( + t: TFunction, + hives: number[] | null | undefined, + hiveNames: HiveMappingBySlot, +) => + hives?.length + ? hives.map(slot => hiveSlotName(hiveNames, slot)).join(', ') + : t('inspectionMode.scope.wholeHub'); + +/** Active / pending / off badge for an inspection status. */ +export function HiveHubInspectionStatusBadge({ + status, + className, +}: Readonly<{ + status: HiveScaleInspectionStatus | undefined; + className?: string; +}>) { + const { t } = useTranslation('hivescale'); + if (status?.pending) { + return ( + + {t('inspectionMode.status.pending')} + + ); + } + if (status?.active) { + return ( + + {t('inspectionMode.status.active', { + time: formatInspectionTime(status.inspection?.started_at), + })} + + ); + } + return ( + + {t('inspectionMode.status.off')} + + ); +} + +function StartInspectionDialog({ + device, + hiveNames, + reportedHives, + open, + onOpenChange, +}: Readonly<{ + device: HiveScaleDevice; + hiveNames: HiveMappingBySlot; + reportedHives: number[]; + open: boolean; + onOpenChange: (open: boolean) => void; +}>) { + const { t } = useTranslation('hivescale'); + const start = useStartHiveScaleInspection(device.device_id); + const [wholeHub, setWholeHub] = useState(true); + const [selected, setSelected] = useState([]); + const [note, setNote] = useState(''); + + const reset = () => { + setWholeHub(true); + setSelected([]); + setNote(''); + }; + + const toggleSlot = (slot: number, checked: boolean) => { + setSelected(prev => + checked + ? [...prev, slot].sort((a, b) => a - b) + : prev.filter(s => s !== slot), + ); + }; + + const canSubmit = wholeHub || selected.length > 0; + + const submit = () => { + const trimmed = note.trim(); + start.mutate( + { + ...(wholeHub ? {} : { hives: selected }), + ...(trimmed ? { note: trimmed } : {}), + }, + { + onSuccess: () => { + toast.success(t('inspectionMode.toast.started')); + reset(); + onOpenChange(false); + }, + onError: error => toast.error(error.message), + }, + ); + }; + + return ( + { + if (!next) reset(); + onOpenChange(next); + }} + > + + + {t('inspectionMode.startDialog.title')} + + {t('inspectionMode.startDialog.description')} + + +
    +
    + + + {reportedHives.length > 0 && ( +
    + {reportedHives.map(slot => ( + + ))} +
    + )} + {!canSubmit && ( +

    + {t('inspectionMode.startDialog.selectHive')} +

    + )} +
    +
    + +