From caf76ee322a847dbeba85ac8468375d4393ece1e Mon Sep 17 00:00:00 2001 From: Mark Nolan Date: Sun, 19 Jul 2026 08:44:56 +0100 Subject: [PATCH 1/2] DEV-900 Write Verisense RWC as base-station local civil time The documented time-sync contract is that the device RWC holds the base station's LOCAL time (unix + local tz offset), which the downstream file parser depends on for midnight/midday CSV splits (pinned GMT+0 calendar) and 'Local =' header times. Adds utcToLocalCivilMillis()/localCivilUnixSecondsNow() helpers and VerisenseClient.writeTimeLocalNow(), documents the domain on writeTimeUnixSeconds, and switches formatVerisenseUnixAndHuman to the Date UTC accessors so a civil-domain value renders verbatim instead of having the browser offset applied a second time. New tests in tests/verisense/civil-time.test.ts; full suite 241 green. Version 0.1.7 -> 0.1.8. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 4 +- package.json | 2 +- src/devices/verisense/VerisenseClient.ts | 22 +++++++++ src/devices/verisense/protocolUtils.ts | 48 ++++++++++++++++--- src/index.ts | 2 + tests/verisense/civil-time.test.ts | 61 ++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 tests/verisense/civil-time.test.ts diff --git a/package-lock.json b/package-lock.json index 418e332..7bbc4bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.7", + "version": "0.1.8", "license": "BSD-3-Clause", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index a5f451a..e5250f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shimmerresearch/shimmer-web-sdk", - "version": "0.1.7", + "version": "0.1.8", "description": "Web Bluetooth and Web Serial API for Shimmer sensor devices (Shimmer3R, Verisense IMU/Pulse+)", "type": "module", "main": "dist/shimmer-web-sdk.cjs", diff --git a/src/devices/verisense/VerisenseClient.ts b/src/devices/verisense/VerisenseClient.ts index 0db0197..a8ce61f 100644 --- a/src/devices/verisense/VerisenseClient.ts +++ b/src/devices/verisense/VerisenseClient.ts @@ -37,6 +37,7 @@ import { u16le_at, u24le, nowMillis, + localCivilUnixSecondsNow, computeCrcLikeCSharp, getOriginalCrcLE, parseStatusPayload, @@ -1135,10 +1136,31 @@ export class VerisenseBleDevice extends BaseShimmerClient { await this.writeProperty(ASM_PROPERTY.TIME, payload); } + /** + * Write a raw timestamp to the device RWC. NOTE: the Verisense time-sync + * contract is that the RWC holds the base station's LOCAL civil time (unix + * seconds with the local timezone offset baked in), not UTC - callers + * syncing "now" should use {@link writeTimeLocalNow} rather than passing + * `Date.now()/1000` here. + */ async writeTimeUnixSeconds(unixSeconds: number): Promise { await this.writeTime(unixSecondsToAsmRtcBytes(unixSeconds)); } + /** + * Synchronise the device RWC to the host's current LOCAL civil time - the + * documented Verisense time-sync semantics ("the Base Station's local + * time"). The downstream file parser relies on this domain for its + * midnight/midday CSV splits and "Local =" header times. + * + * @returns the unix-seconds value written (local-civil domain). + */ + async writeTimeLocalNow(): Promise { + const civilUnixSeconds = localCivilUnixSecondsNow(); + await this.writeTimeUnixSeconds(civilUnixSeconds); + return civilUnixSeconds; + } + /** * Request the Verisense firmware to expose the Nordic Secure DFU service. * diff --git a/src/devices/verisense/protocolUtils.ts b/src/devices/verisense/protocolUtils.ts index 74e7cfa..88c154f 100644 --- a/src/devices/verisense/protocolUtils.ts +++ b/src/devices/verisense/protocolUtils.ts @@ -132,6 +132,31 @@ export function nowMillis(): number { return Date.now(); } +/** + * Convert a UTC unix-ms instant to the "local civil" timestamp domain used by + * the Verisense real-world clock: unix ms with the host's local timezone + * offset baked in, so that hour-of-day of the raw value equals the wall-clock + * hour where the base station is. + * + * This is the documented time-sync contract ("synchronises the sensor's + * real-world clock with the Base Station's local time" - Verisense + * communication protocol) and what the downstream file parser assumes: it + * evaluates midnight/midday CSV-split boundaries on the raw RWC value in a + * pinned GMT+0 calendar, and labels CSV timestamp columns + * "Unix_ms_plus_local_time_zone_offset". + * + * Note `getTimezoneOffset()` is evaluated at `utcMillis` itself, so the DST + * rule in effect at that instant is applied. + */ +export function utcToLocalCivilMillis(utcMillis: number = Date.now()): number { + return utcMillis - new Date(utcMillis).getTimezoneOffset() * 60_000; +} + +/** Current time in the Verisense local-civil RWC domain, in whole unix seconds. */ +export function localCivilUnixSecondsNow(): number { + return Math.floor(utcToLocalCivilMillis() / 1000); +} + /** * Compute CRC-16/CCITT-FALSE over `bytes`. * @@ -514,7 +539,16 @@ export interface VerisenseEventLogEntry { * seconds). Values beyond this are uninitialised/garbage bytes, not dates. */ export const VERISENSE_MAX_PLAUSIBLE_UNIX_SECONDS = 4102444800; -/** Format unix seconds as raw + human-readable local datetime for logging. */ +/** + * Format a device-RWC timestamp (unix seconds) as raw + human-readable datetime. + * + * The device RWC lives in the "local civil" domain (unix seconds with the + * base station's timezone offset already baked in - see + * {@link utcToLocalCivilMillis}), so the value is rendered VERBATIM via the + * Date UTC accessors: the wall-clock time shown is exactly what the device's + * clock reads. Rendering with the local-time accessors would apply the + * browser's timezone offset a second time. + */ export function formatVerisenseUnixAndHuman(unixSeconds: number): VerisenseUnixAndHumanTimestamp { const unix = Number(unixSeconds); if (!Number.isFinite(unix)) { @@ -527,12 +561,12 @@ export function formatVerisenseUnixAndHuman(unixSeconds: number): VerisenseUnixA return { unix, human: 'not-valid' }; } const d = new Date(unix * 1000); - const yyyy = d.getFullYear(); - const mm = String(d.getMonth() + 1).padStart(2, '0'); - const dd = String(d.getDate()).padStart(2, '0'); - const HH = String(d.getHours()).padStart(2, '0'); - const MM = String(d.getMinutes()).padStart(2, '0'); - const SS = String(d.getSeconds()).padStart(2, '0'); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const HH = String(d.getUTCHours()).padStart(2, '0'); + const MM = String(d.getUTCMinutes()).padStart(2, '0'); + const SS = String(d.getUTCSeconds()).padStart(2, '0'); return { unix, human: `${yyyy}-${mm}-${dd} ${HH}:${MM}:${SS}`, diff --git a/src/index.ts b/src/index.ts index d462a58..e7b639d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -166,6 +166,8 @@ export { parseHexByteString, formatPendingEventProperties, formatVerisenseUnixAndHuman, + utcToLocalCivilMillis, + localCivilUnixSecondsNow, inferVerisenseChargerChipFamily, describeVerisenseChargerStatus, formatVerisenseChargerStatus, diff --git a/tests/verisense/civil-time.test.ts b/tests/verisense/civil-time.test.ts new file mode 100644 index 0000000..5124756 --- /dev/null +++ b/tests/verisense/civil-time.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { + utcToLocalCivilMillis, + localCivilUnixSecondsNow, + formatVerisenseUnixAndHuman, +} from '../../src/devices/verisense/protocolUtils.js'; + +// The Verisense RWC time-sync contract: the device clock holds the base +// station's LOCAL civil time (unix + local tz offset), not UTC. See DEV-900. + +describe('utcToLocalCivilMillis', () => { + it('applies the timezone offset in effect at the given instant', () => { + const utc = Date.UTC(2026, 6, 18, 23, 54, 23); // 2026-07-18 23:54:23 UTC + const expectedOffsetMin = -new Date(utc).getTimezoneOffset(); + expect(utcToLocalCivilMillis(utc) - utc).toBe(expectedOffsetMin * 60_000); + }); + + it('round-trips hour-of-day to the host wall clock', () => { + // Whatever the host timezone, the civil value rendered via the UTC + // accessors must equal the wall-clock reading of the same instant. + const utc = Date.UTC(2026, 0, 15, 12, 0, 0); + const civil = new Date(utcToLocalCivilMillis(utc)); + const wall = new Date(utc); + expect(civil.getUTCHours()).toBe(wall.getHours()); + expect(civil.getUTCMinutes()).toBe(wall.getMinutes()); + expect(civil.getUTCDate()).toBe(wall.getDate()); + }); + + it('is a no-op only when the host offset is zero', () => { + const utc = Date.UTC(2026, 6, 1); + const offsetMin = new Date(utc).getTimezoneOffset(); + if (offsetMin === 0) { + expect(utcToLocalCivilMillis(utc)).toBe(utc); + } else { + expect(utcToLocalCivilMillis(utc)).not.toBe(utc); + } + }); +}); + +describe('localCivilUnixSecondsNow', () => { + it('matches utcToLocalCivilMillis(now) to within a couple of seconds', () => { + const secs = localCivilUnixSecondsNow(); + const expected = Math.floor(utcToLocalCivilMillis(Date.now()) / 1000); + expect(Math.abs(secs - expected)).toBeLessThanOrEqual(2); + }); +}); + +describe('formatVerisenseUnixAndHuman (civil-domain rendering)', () => { + it('renders the raw value verbatim (UTC accessors), not host-local shifted', () => { + // 2026-07-18 23:54:23 in the civil domain must display as 23:54:23 + // regardless of the machine timezone running this test. + const civilUnix = Date.UTC(2026, 6, 18, 23, 54, 23) / 1000; + expect(formatVerisenseUnixAndHuman(civilUnix).human).toBe('2026-07-18 23:54:23'); + }); + + it('keeps the invalid/epoch/implausible guards', () => { + expect(formatVerisenseUnixAndHuman(NaN).human).toBe('invalid'); + expect(formatVerisenseUnixAndHuman(0).human).toBe('1970-01-01 00:00:00 (epoch)'); + expect(formatVerisenseUnixAndHuman(5e9).human).toBe('not-valid'); + }); +}); From 76273d0d93e9f5c96660b86b81955d43ee2d9a4f Mon Sep 17 00:00:00 2001 From: Mark Nolan Date: Sun, 19 Jul 2026 08:59:19 +0100 Subject: [PATCH 2/2] DEV-900 Fix civil-time test on zero-offset runners (negative-zero toBe) The offset-application assertion compared the millisecond delta against -getTimezoneOffset()*60000, which computes to -0 on a UTC-timezone runner; vitest's toBe uses Object.is, where +0 !== -0, so CI (UTC) failed while local (BST, offset 60) passed. Compare absolute timestamps instead. Verified under TZ=UTC, TZ=Asia/Kuala_Lumpur and host BST; full suite 241 green. Co-Authored-By: Claude Opus 4.8 --- tests/verisense/civil-time.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/verisense/civil-time.test.ts b/tests/verisense/civil-time.test.ts index 5124756..5be9adb 100644 --- a/tests/verisense/civil-time.test.ts +++ b/tests/verisense/civil-time.test.ts @@ -11,8 +11,10 @@ import { describe('utcToLocalCivilMillis', () => { it('applies the timezone offset in effect at the given instant', () => { const utc = Date.UTC(2026, 6, 18, 23, 54, 23); // 2026-07-18 23:54:23 UTC - const expectedOffsetMin = -new Date(utc).getTimezoneOffset(); - expect(utcToLocalCivilMillis(utc) - utc).toBe(expectedOffsetMin * 60_000); + // Compare absolute values, not the delta: on a UTC-timezone runner the + // expected delta computes as -0, and Object.is(+0, -0) is false. + const offsetMin = new Date(utc).getTimezoneOffset(); + expect(utcToLocalCivilMillis(utc)).toBe(utc - offsetMin * 60_000); }); it('round-trips hour-of-day to the host wall clock', () => {