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..5be9adb --- /dev/null +++ b/tests/verisense/civil-time.test.ts @@ -0,0 +1,63 @@ +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 + // 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', () => { + // 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'); + }); +});