Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 22 additions & 0 deletions src/devices/verisense/VerisenseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
u16le_at,
u24le,
nowMillis,
localCivilUnixSecondsNow,
computeCrcLikeCSharp,
getOriginalCrcLE,
parseStatusPayload,
Expand Down Expand Up @@ -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<void> {
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<number> {
const civilUnixSeconds = localCivilUnixSecondsNow();
await this.writeTimeUnixSeconds(civilUnixSeconds);
return civilUnixSeconds;
}

/**
* Request the Verisense firmware to expose the Nordic Secure DFU service.
*
Expand Down
48 changes: 41 additions & 7 deletions src/devices/verisense/protocolUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*
Expand Down Expand Up @@ -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)) {
Expand All @@ -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}`,
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export {
parseHexByteString,
formatPendingEventProperties,
formatVerisenseUnixAndHuman,
utcToLocalCivilMillis,
localCivilUnixSecondsNow,
inferVerisenseChargerChipFamily,
describeVerisenseChargerStatus,
formatVerisenseChargerStatus,
Expand Down
63 changes: 63 additions & 0 deletions tests/verisense/civil-time.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading