smcgateway-sv 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { recording } from './recording.svelte.js';
4
+ import InputDatetime from './InputDatetime.svelte';
5
+ import { colors } from './colours.js';
6
+
7
+ type Segment = {
8
+ url: string;
9
+ start: Date;
10
+ end: Date;
11
+ from: string;
12
+ to: string;
13
+ startPercent: number;
14
+ durPercent: number;
15
+ color: string;
16
+ style: string;
17
+ };
18
+
19
+ // Auto assign colours to devices
20
+ let nextColorIndex = 0;
21
+ let deviceColorMap: { [k: string]: string } = {};
22
+
23
+ const WINDOW_MS = 10 * 60 * 1000;
24
+ const STEP_MS = 10_000;
25
+ const TICK_MS = 200;
26
+
27
+ let windowStart = $state(new Date(Date.now() - WINDOW_MS));
28
+ let windowEnd = $derived(new Date(windowStart.getTime() + WINDOW_MS));
29
+ let segments: Segment[] = $state([]);
30
+ let audioEl: HTMLAudioElement;
31
+ let activeSegment: Segment | undefined = $state(undefined);
32
+ let playing = $state(false);
33
+ // svelte-ignore state_referenced_locally
34
+ let cursor = $state(windowStart.getTime());
35
+ let cursorStyle = $derived(
36
+ `left: ${((cursor - windowStart.getTime()) / (windowEnd.getTime() - windowStart.getTime())) * 100}%`
37
+ );
38
+ let playbackStartRealTime = 0;
39
+ let tickTimer = 0;
40
+ let isLoading = $state(false);
41
+ let error = $state('');
42
+
43
+ const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
44
+
45
+ function findSegmentIndex(tMs: number) {
46
+ return segments.findIndex((s) => tMs >= s.start.getTime() && tMs < s.end.getTime());
47
+ }
48
+
49
+ async function loadSegments() {
50
+ isLoading = true;
51
+ error = '';
52
+ try {
53
+ const ret = await recording.getRecordings(windowStart, windowEnd);
54
+ // console.log('getRecordings()', ret);
55
+ segments = ret.map((r) => {
56
+ let color = deviceColorMap[r.from];
57
+ if (!color) {
58
+ color = colors[nextColorIndex];
59
+ deviceColorMap[r.from] = color;
60
+ nextColorIndex++;
61
+ }
62
+ const startPercent = ((r.start.getTime() - windowStart.getTime()) / WINDOW_MS) * 100;
63
+ const durPercent = ((r.dur * 1000) / WINDOW_MS) * 100;
64
+ return {
65
+ url: `/api/recordings/${r.file}`,
66
+ start: r.start,
67
+ end: new Date(r.start.getTime() + r.dur * 1000),
68
+ from: r.from,
69
+ to: r.to,
70
+ startPercent,
71
+ durPercent,
72
+ color,
73
+ style: `left:${startPercent}%; width:${durPercent}%; background-color: ${color}`
74
+ };
75
+ });
76
+ } catch (e) {
77
+ error = 'Failed to fetch audio segments';
78
+ } finally {
79
+ isLoading = false;
80
+ }
81
+ }
82
+
83
+ async function ensureAudioForCursor() {
84
+ const segIdx = findSegmentIndex(cursor);
85
+ if (segIdx === -1) {
86
+ // console.log('ensureAudioForCursor: in a gap', segIdx);
87
+ activeSegment = undefined;
88
+ // in a gap → ensure audio is paused
89
+ if (!audioEl.paused) audioEl.pause();
90
+ return;
91
+ }
92
+
93
+ const seg = segments[segIdx];
94
+ const offsetSec = (cursor - seg.start.getTime()) / 1000;
95
+
96
+ if (!audioEl.src.endsWith(seg.url)) {
97
+ // console.log('LOAD', seg.url, audioEl.src);
98
+ activeSegment = seg;
99
+ audioEl.src = seg.url;
100
+ await audioEl.play().catch(console.error);
101
+ }
102
+
103
+ if (Math.abs(audioEl.currentTime - offsetSec) > 0.5) {
104
+ audioEl.currentTime = offsetSec;
105
+ }
106
+
107
+ if (audioEl.paused && playing) {
108
+ await audioEl.play().catch(console.error);
109
+ }
110
+ }
111
+
112
+ function play() {
113
+ playbackStartRealTime = Date.now() - (cursor - windowStart.getTime());
114
+ playing = true;
115
+ startTick();
116
+ }
117
+
118
+ function pause() {
119
+ playing = false;
120
+ stopTick();
121
+ audioEl?.pause();
122
+ }
123
+
124
+ function togglePlay() {
125
+ playing ? pause() : play();
126
+ }
127
+
128
+ function seekTo(ms: number) {
129
+ cursor = clamp(ms, windowStart.getTime(), windowEnd.getTime());
130
+ playbackStartRealTime = Date.now() - (cursor - windowStart.getTime());
131
+ ensureAudioForCursor();
132
+ }
133
+
134
+ function rewind(ms = STEP_MS) {
135
+ seekTo(cursor - ms);
136
+ }
137
+ function ffwd(ms = STEP_MS) {
138
+ seekTo(cursor + ms);
139
+ }
140
+
141
+ function startTick() {
142
+ stopTick();
143
+ tickTimer = setInterval(async () => {
144
+ if (!playing) return;
145
+ cursor = windowStart.getTime() + (Date.now() - playbackStartRealTime);
146
+ if (cursor >= windowEnd.getTime()) {
147
+ pause();
148
+ return;
149
+ }
150
+ await ensureAudioForCursor();
151
+ }, TICK_MS);
152
+ }
153
+ function stopTick() {
154
+ if (tickTimer) clearInterval(tickTimer);
155
+ tickTimer = 0;
156
+ }
157
+
158
+ function onScrub(e: MouseEvent) {
159
+ if (e && e.currentTarget) {
160
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
161
+ const pct = clamp((e.clientX - rect.left) / rect.width, 0, 1);
162
+ seekTo(windowStart.getTime() + pct * (windowEnd.getTime() - windowStart.getTime()));
163
+ }
164
+ }
165
+
166
+ async function onDateChange() {
167
+ // windowStart = fromLocalInputValue(dateTimeLocal);
168
+ windowEnd = new Date(windowStart.getTime() + WINDOW_MS);
169
+ cursor = windowStart.getTime();
170
+ await loadSegments();
171
+ ensureAudioForCursor();
172
+ }
173
+
174
+ function shiftWindow(delta: number) {
175
+ windowStart = new Date(windowStart.getTime() + delta);
176
+ windowEnd = new Date(windowStart.getTime() + WINDOW_MS);
177
+ // dateTimeLocal = toLocalInputValue(windowStart);
178
+ cursor = windowStart.getTime();
179
+ loadSegments();
180
+ }
181
+
182
+ onMount(() => {
183
+ loadSegments();
184
+ });
185
+ onDestroy(() => stopTick());
186
+ </script>
187
+
188
+ <div class="player">
189
+ {@render selector(shiftWindow)}
190
+ {@render timeline()}
191
+ {@render callInfo(activeSegment)}
192
+ {@render controls(rewind, ffwd)}
193
+
194
+ {#snippet selector(shiftWindow: (delta: number) => void)}
195
+ <div class="d-flex justify-content-start">
196
+ <div>
197
+ <div class="input-group">
198
+ <InputDatetime
199
+ class="form-control text-center"
200
+ bind:value={windowStart}
201
+ onchange={onDateChange}
202
+ ></InputDatetime>
203
+ <button class="btn btn-outline-secondary" onclick={() => shiftWindow(-10 * 60000)}
204
+ >&lt;&lt; 10m</button
205
+ >
206
+ <button class="btn btn-outline-secondary" onclick={() => shiftWindow(-5 * 60000)}
207
+ >&lt;&lt; 5m</button
208
+ >
209
+ <button class="btn btn-outline-secondary" onclick={() => shiftWindow(5 * 60000)}
210
+ >5m &gt;&gt;</button
211
+ >
212
+ <button class="btn btn-outline-secondary" onclick={() => shiftWindow(10 * 60000)}
213
+ >10m &gt;&gt;</button
214
+ >
215
+ </div>
216
+ </div>
217
+ </div>
218
+ {/snippet}
219
+
220
+ {#snippet controls(rewind: (delta: number) => void, ffwd: (delta: number) => void)}
221
+ <div class="d-flex justify-content-center mt-1">
222
+ <div>
223
+ <div class="input-group">
224
+ <button class="btn btn-outline-secondary" onclick={() => rewind(60000)}
225
+ >&lt;&lt; 60s</button
226
+ >
227
+ <button class="btn btn-outline-secondary" onclick={() => rewind(10000)}
228
+ >&lt;&lt; 10s</button
229
+ >
230
+ <button class="btn btn-outline-secondary" onclick={() => rewind(5000)}>&lt;&lt; 5s</button
231
+ >
232
+ <button class="btn btn-outline-secondary" onclick={togglePlay}
233
+ >{playing ? '⏸ Pause' : '▶️ Play'}</button
234
+ >
235
+ <button class="btn btn-outline-secondary" onclick={() => ffwd(5000)}>&gt;&gt; 5s</button>
236
+ <button class="btn btn-outline-secondary" onclick={() => ffwd(10000)}>&gt;&gt; 10s</button
237
+ >
238
+ <button class="btn btn-outline-secondary" onclick={() => ffwd(60000)}>&gt;&gt; 60s</button
239
+ >
240
+ </div>
241
+ </div>
242
+ </div>
243
+ {/snippet}
244
+
245
+ {#snippet timeline()}
246
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
247
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
248
+ <div class="timeline mt-1">
249
+ <div class="bar" onclick={onScrub}>
250
+ {@render calls(segments)}
251
+ <div class="cursor" style={cursorStyle}></div>
252
+ </div>
253
+ <div class="d-flex justify-content-between">
254
+ <div class="windowStartLabel">
255
+ {windowStart.toLocaleTimeString()}
256
+ </div>
257
+ <div>{new Date(cursor).toLocaleString()}</div>
258
+ <div class="windowEndLabel">
259
+ {windowEnd.toLocaleTimeString()}
260
+ </div>
261
+ </div>
262
+ </div>
263
+ {/snippet}
264
+
265
+ {#snippet callInfo(call: Segment | undefined)}
266
+ <div class="callinfo text-center mt-1">
267
+ {#if call}
268
+ Radio {call.from} [{call.to}]
269
+ {:else}
270
+ Idle
271
+ {/if}
272
+ </div>
273
+ {/snippet}
274
+
275
+ {#snippet calls(calls: Segment[])}
276
+ {#each calls as call}
277
+ <div class="call" title={call.from} style={call.style}></div>
278
+ {/each}
279
+ {/snippet}
280
+
281
+ <audio bind:this={audioEl} crossorigin="anonymous" preload="auto"></audio>
282
+
283
+ {#if isLoading}<p>Loading audio…</p>{/if}
284
+ {#if error}<p style="color:red;">{error}</p>{/if}
285
+ </div>
286
+
287
+ <!-- windowStart: {windowStart.toISOString()}<br />
288
+ windowEnd: {windowEnd.toISOString()}<br />
289
+ cursor: {new Date(cursor).toISOString()}<br /> -->
290
+
291
+ <!-- <pre>Segments:
292
+ {JSON.stringify(segments, null, 2)}
293
+ </pre> -->
294
+
295
+ <style>
296
+ .player {
297
+ padding: 1rem;
298
+ border: 1px solid black;
299
+ border-radius: 8px;
300
+ }
301
+ .bar {
302
+ height: 2em;
303
+ background: var(--bs-bosy-color-rgb);
304
+ border-top: 1px solid black;
305
+ border-bottom: 1px solid black;
306
+ cursor: pointer;
307
+ position: relative;
308
+ overflow: hidden;
309
+ }
310
+ .timeline {
311
+ border-left: 1px solid black;
312
+ border-right: 1px solid black;
313
+ }
314
+ .cursor {
315
+ position: absolute;
316
+ top: 0;
317
+ bottom: 0;
318
+ border-radius: 0;
319
+ border-right: 3px red solid;
320
+ }
321
+ .call {
322
+ position: absolute;
323
+ top: 0;
324
+ bottom: 0;
325
+ left: 0;
326
+ min-width: 2px;
327
+ border-radius: 0;
328
+ }
329
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const AudioTimeLinePlayer: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type AudioTimeLinePlayer = ReturnType<typeof AudioTimeLinePlayer>;
3
+ export default AudioTimeLinePlayer;
@@ -5,14 +5,14 @@
5
5
  value = $bindable(),
6
6
  class: className = '',
7
7
  disabled = false,
8
- nodate = new Date(1970,1,1),
8
+ nodate = "",
9
9
  min = new Date(2000, 1, 1),
10
10
  max = new Date()
11
11
  }: {
12
- value: Date;
12
+ value: Date | "";
13
13
  class?: string;
14
14
  disabled?: boolean;
15
- nodate?: Date;
15
+ nodate?: Date | "";
16
16
  min?: Date;
17
17
  max?: Date;
18
18
  } = $props();
@@ -25,7 +25,9 @@
25
25
 
26
26
  onMount(() => {
27
27
  value = fromInputFormat(toInputFormat(value));
28
- nodate.setHours(0, -nodate.getTimezoneOffset(), 0, 0);
28
+ if (nodate instanceof Date) {
29
+ nodate.setHours(0, -nodate.getTimezoneOffset(), 0, 0);
30
+ }
29
31
  });
30
32
 
31
33
  // Sync internal when external date changes
@@ -36,7 +38,7 @@
36
38
  }
37
39
  });
38
40
 
39
- function toInputFormat(date: Date): string {
41
+ function toInputFormat(date: Date | ""): string {
40
42
  if (date instanceof Date && !isNaN(date.getTime())) {
41
43
  let d = new Date(date);
42
44
  // Remove timezone
@@ -58,6 +60,7 @@
58
60
  value = fromInputFormat(target.value);
59
61
  } else {
60
62
  value = nodate
63
+ internal = ""
61
64
  }
62
65
  }
63
66
  </script>
@@ -1,8 +1,8 @@
1
1
  type $$ComponentProps = {
2
- value: Date;
2
+ value: Date | "";
3
3
  class?: string;
4
4
  disabled?: boolean;
5
- nodate?: Date;
5
+ nodate?: Date | "";
6
6
  min?: Date;
7
7
  max?: Date;
8
8
  };
@@ -5,32 +5,44 @@
5
5
  value = $bindable(),
6
6
  class: className = '',
7
7
  disabled = false,
8
- nodate = new Date(1970,1,1),
8
+ nodate = '',
9
9
  min = new Date(2000, 1, 1),
10
- max = new Date()
10
+ max = new Date(),
11
+ onchange = (e:Event) => {}
11
12
  }: {
12
- value: Date;
13
+ value: Date | '';
13
14
  class?: string;
14
15
  disabled?: boolean;
15
- nodate?: Date;
16
+ nodate?: Date | '';
16
17
  min?: Date;
17
18
  max?: Date;
19
+ onchange?: (e:Event) => void;
18
20
  } = $props();
19
21
 
20
22
  let minDate = $derived(toInputFormat(min));
21
23
  let maxDate = $derived(toInputFormat(max));
22
24
 
23
25
  // internal is a string so as to work with the "input type=datetime-local"
24
- // let internal = $state('');
26
+ let internal: string | undefined = $state('');
25
27
 
26
28
  onMount(() => {
27
29
  value = fromInputFormat(toInputFormat(value));
28
- nodate.setHours(0, -nodate.getTimezoneOffset(), 0, 0);
30
+ if (nodate instanceof Date) {
31
+ nodate.setHours(0, -nodate.getTimezoneOffset(), 0, 0);
32
+ }
29
33
  });
30
34
 
31
- let internal = $derived(toInputFormat(value))
35
+ // let internal = $derived(toInputFormat(value));
36
+
37
+ // Sync internal when external date changes
38
+ $effect(() => {
39
+ let v = toInputFormat(value);
40
+ if (v) {
41
+ internal = v;
42
+ }
43
+ });
32
44
 
33
- function toInputFormat(date: Date): string {
45
+ function toInputFormat(date: Date | ''): string {
34
46
  if (date instanceof Date && !isNaN(date.getTime())) {
35
47
  // Remove timezone to make time local
36
48
  let d = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
@@ -46,11 +58,15 @@
46
58
 
47
59
  function handleInput(event: Event) {
48
60
  const target = event.target as HTMLInputElement;
61
+ console.log('handleInput', `[${target.value}]`);
49
62
  if (target.value) {
50
63
  value = fromInputFormat(target.value);
51
64
  } else {
52
- value = nodate
65
+ console.log('handleInput', `nodate`);
66
+ value = nodate;
67
+ internal = undefined;
53
68
  }
69
+ onchange(event);
54
70
  }
55
71
  </script>
56
72
 
@@ -1,10 +1,11 @@
1
1
  type $$ComponentProps = {
2
- value: Date;
2
+ value: Date | '';
3
3
  class?: string;
4
4
  disabled?: boolean;
5
- nodate?: Date;
5
+ nodate?: Date | '';
6
6
  min?: Date;
7
7
  max?: Date;
8
+ onchange?: (e: Event) => void;
8
9
  };
9
10
  declare const InputDatetime: import("svelte").Component<$$ComponentProps, {}, "value">;
10
11
  type InputDatetime = ReturnType<typeof InputDatetime>;
@@ -4,6 +4,9 @@ export type User = {
4
4
  name: string;
5
5
  groups: string[];
6
6
  roles: string[];
7
+ ident_uri: string;
8
+ created: Date | undefined;
9
+ modified: Date | undefined;
7
10
  };
8
11
  declare const auth: {
9
12
  user: User | undefined;
@@ -15,6 +18,6 @@ declare const auth: {
15
18
  fetchUser(): void;
16
19
  login(login: string, password: string): Promise<unknown>;
17
20
  logout(): void;
18
- fetch: (url: string, method?: string, data?: any) => Promise<Response>;
21
+ fetch: (url: string, method?: string, data?: any, timeout?: number) => Promise<Response>;
19
22
  };
20
23
  export { auth };
@@ -10,6 +10,10 @@ const auth = $state({
10
10
  user.name = user.login;
11
11
  }
12
12
  this.user = user;
13
+ if (this.user) {
14
+ this.user.created = this.user?.created ? new Date(this.user.created) : undefined;
15
+ this.user.modified = this.user?.modified ? new Date(this.user.modified) : undefined;
16
+ }
13
17
  this.isAdmin = !!user && user.login === "admin";
14
18
  this.isUser = !!user && user.login != "";
15
19
  },
@@ -20,9 +24,7 @@ const auth = $state({
20
24
  this.fetchUser();
21
25
  },
22
26
  fetchUser() {
23
- const controller = new AbortController();
24
- setTimeout(() => controller.abort(), 2000);
25
- fetch("/api/me", { signal: controller.signal })
27
+ fetch("/api/me", { signal: AbortSignal.timeout(5000) })
26
28
  .then((resp) => {
27
29
  switch (resp.status) {
28
30
  case 200:
@@ -42,11 +44,9 @@ const auth = $state({
42
44
  },
43
45
  login(login, password) {
44
46
  return new Promise((resolve, reject) => {
45
- const controller = new AbortController();
46
- setTimeout(() => controller.abort("Timeout waiting for response"), 3000);
47
47
  fetch("/api/login", {
48
48
  method: "post",
49
- signal: controller.signal,
49
+ signal: AbortSignal.timeout(5000),
50
50
  body: JSON.stringify({
51
51
  login,
52
52
  password
@@ -75,11 +75,11 @@ const auth = $state({
75
75
  },
76
76
  logout() {
77
77
  auth.setUser(undefined);
78
- fetch("/api/logout").then((resp) => {
78
+ fetch("/api/logout", { signal: AbortSignal.timeout(5000) }).then((resp) => {
79
79
  auth.fetchUser();
80
80
  });
81
81
  },
82
- fetch: (url, method = 'get', data = undefined) => {
82
+ fetch: (url, method = 'get', data = undefined, timeout = 5000) => {
83
83
  return new Promise((resolve, reject) => {
84
84
  let body;
85
85
  let headers = {
@@ -94,6 +94,7 @@ const auth = $state({
94
94
  body,
95
95
  mode: "cors",
96
96
  headers,
97
+ signal: AbortSignal.timeout(timeout)
97
98
  }).then(response => {
98
99
  // response only can be ok in range of 2XX
99
100
  if (response.ok) {
@@ -0,0 +1,2 @@
1
+ export declare function getMidrangeWebSafeColors(): string[];
2
+ export declare const colors: string[];
@@ -0,0 +1,61 @@
1
+ export function getMidrangeWebSafeColors() {
2
+ // const steps = [0x00, 0x33, 0x66, 0x99, 0xCC, 0xFF];
3
+ const steps = [0x00, 0x33, 0x66, 0x99, 0xCC, 0xFF];
4
+ const colors = [];
5
+ // Generate all 216 web-safe colors
6
+ for (const r of steps) {
7
+ for (const g of steps) {
8
+ for (const b of steps) {
9
+ // Compute perceived luminance (0–255)
10
+ const lum = 0.299 * r + 0.587 * g + 0.114 * b;
11
+ // Keep midrange only (avoid very dark/light)
12
+ if (lum > 60 && lum < 200) {
13
+ colors.push({
14
+ hex: `#${r.toString(16).padStart(2, '0')}${g
15
+ .toString(16)
16
+ .padStart(2, '0')}${b.toString(16).padStart(2, '0')}`,
17
+ lum,
18
+ });
19
+ }
20
+ }
21
+ }
22
+ }
23
+ // Sort by luminance
24
+ colors.sort((a, b) => a.lum - b.lum);
25
+ // Reorder to maximize contrast — alternate from dark to light ends
26
+ const contrasted = [];
27
+ let left = 0;
28
+ let right = colors.length - 1;
29
+ while (left <= right) {
30
+ contrasted.push(colors[left++].hex);
31
+ if (left <= right)
32
+ contrasted.push(colors[right--].hex);
33
+ }
34
+ return contrasted;
35
+ }
36
+ // A map of different colours to use per radio ident
37
+ export const colors = [
38
+ '#3300ff',
39
+ '#006600',
40
+ '#ff9900',
41
+ '#660066',
42
+ '#3399cc',
43
+ '#ffcccc',
44
+ '#ccff00',
45
+ '#996600',
46
+ '#ff6666',
47
+ '#003399',
48
+ '#00ffff',
49
+ '#66cc66',
50
+ '#00ff33',
51
+ '#9933cc',
52
+ '#009966',
53
+ '#66ffcc',
54
+ '#9999ff',
55
+ '#ff00ff',
56
+ '#00ff99',
57
+ '#666666',
58
+ '#66cc00',
59
+ '#990000',
60
+ '#cc0066'
61
+ ];
@@ -1,13 +1,12 @@
1
1
  export type GeoFence = {
2
- id: number;
2
+ id?: number;
3
3
  name: string;
4
+ points?: string;
4
5
  size?: number;
5
6
  };
6
- /**
7
- * GeoFence store
8
- */
9
- declare const geofence: {
10
- all: GeoFence[];
11
- load: () => Promise<void>;
7
+ export declare const geofence: {
8
+ readonly all: GeoFence[];
9
+ get: (id: number) => Promise<GeoFence>;
10
+ save: (g: GeoFence) => Promise<number>;
11
+ load: () => Promise<unknown>;
12
12
  };
13
- export { geofence, };
@@ -1,17 +1,16 @@
1
- /**
2
- * GeoFence store
3
- */
4
- const geofence = $state({
5
- all: [],
6
- load: (() => {
1
+ function createStore() {
2
+ let data = $state([]);
3
+ let loaded = false;
4
+ async function load() {
5
+ loaded = true;
7
6
  return new Promise((resolve, reject) => {
8
- fetch('/api/module/entitygeofence?select=id,name,size&order=size desc')
7
+ fetch('/api/module/entitygeofence?select=id,name,size&order=size desc', { signal: AbortSignal.timeout(5000) })
9
8
  .then(resp => {
10
9
  switch (resp.status) {
11
10
  case 200:
12
- resp.json().then(data => {
13
- geofence.all = data;
14
- resolve();
11
+ resp.json().then(dat => {
12
+ data = dat;
13
+ resolve(data);
15
14
  }).catch(err => {
16
15
  reject(err);
17
16
  });
@@ -24,7 +23,65 @@ const geofence = $state({
24
23
  reject(err);
25
24
  });
26
25
  });
27
- })
28
- });
29
- geofence.load();
30
- export { geofence, };
26
+ }
27
+ async function get(id) {
28
+ return new Promise((resolve, reject) => {
29
+ fetch(`/api/module/entitygeofence/${id}`, { signal: AbortSignal.timeout(5000) })
30
+ .then(resp => {
31
+ switch (resp.status) {
32
+ case 200:
33
+ return resp.json();
34
+ default:
35
+ return resp.text().then(t => {
36
+ throw new Error(t);
37
+ });
38
+ }
39
+ }).then(dat => {
40
+ resolve(dat);
41
+ })
42
+ .catch(err => {
43
+ console.log("err", err);
44
+ reject(err);
45
+ });
46
+ });
47
+ }
48
+ async function save(g) {
49
+ return new Promise((resolve, reject) => {
50
+ let url = `/api/module/entitygeofence`;
51
+ let method = "post";
52
+ if (g.id) {
53
+ url += `/${g.id}`;
54
+ method = "put";
55
+ }
56
+ fetch(url, { method, body: JSON.stringify(g), signal: AbortSignal.timeout(5000) })
57
+ .then(resp => {
58
+ switch (resp.status) {
59
+ case 200:
60
+ load();
61
+ // api is not returning the new id
62
+ return resp.text();
63
+ default:
64
+ return resp.text().then(t => {
65
+ throw new Error(t);
66
+ });
67
+ }
68
+ }).then(t => {
69
+ resolve(parseInt(t));
70
+ })
71
+ .catch(err => {
72
+ reject(err);
73
+ });
74
+ });
75
+ }
76
+ return {
77
+ get all() {
78
+ if (!loaded)
79
+ load();
80
+ return data;
81
+ },
82
+ get,
83
+ save,
84
+ load
85
+ };
86
+ }
87
+ export const geofence = createStore();
@@ -3,29 +3,21 @@ const ghistory = $state({
3
3
  state: [],
4
4
  loadState() {
5
5
  return new Promise((resolve, reject) => {
6
- fetch(`/api/module/entityhistory/state`)
6
+ fetch(`/api/module/entityhistory/state`, { signal: AbortSignal.timeout(10000) })
7
7
  .then(resp => {
8
- switch (resp.status) {
9
- case 200:
10
- resp.json().then(data => {
11
- ghistory.state = data.map(s => {
12
- s.date = new Date(s.timestamp);
13
- if (s.data) {
14
- try {
15
- s.data = JSON.parse(s.data);
16
- }
17
- catch (e) { }
18
- }
19
- return s;
20
- });
21
- resolve();
22
- }).catch(err => {
23
- reject(err);
8
+ if (resp.status === 200) {
9
+ resp.json().then(data => {
10
+ ghistory.state = data.map(s => {
11
+ s.date = new Date(s.timestamp);
12
+ if (s.data) {
13
+ s.data = JSON.parse(s.data);
14
+ }
15
+ return s;
24
16
  });
25
- break;
26
- default:
27
- reject(resp.statusText);
28
- break;
17
+ resolve();
18
+ }).catch(err => {
19
+ reject(err);
20
+ });
29
21
  }
30
22
  }).catch(err => {
31
23
  reject(err);
@@ -50,28 +42,24 @@ const ghistory = $state({
50
42
  }
51
43
  fetch(`/api/module/entityhistory/log?${q.toString()}`)
52
44
  .then(resp => {
53
- switch (resp.status) {
54
- case 200:
55
- resp.json().then(data => {
56
- ghistory.log = data.map((l) => {
57
- l.date = new Date(l.timestamp);
58
- if (l.data) {
59
- try {
60
- l.data = JSON.parse(l.data);
61
- }
62
- catch (e) { }
45
+ if (resp.status === 200) {
46
+ resp.json().then(data => {
47
+ ghistory.log = data.map((l) => {
48
+ l.date = new Date(l.timestamp);
49
+ if (l.data) {
50
+ try {
51
+ l.data = JSON.parse(l.data);
63
52
  }
64
- return l;
65
- });
66
- resolve();
67
- }).catch(err => {
68
- reject(err);
53
+ catch (e) { }
54
+ }
55
+ return l;
69
56
  });
70
- break;
71
- default:
72
- reject(resp.statusText);
73
- break;
57
+ resolve();
58
+ }).catch(err => {
59
+ reject(err);
60
+ });
74
61
  }
62
+ reject(resp.statusText);
75
63
  }).catch(err => {
76
64
  reject(err);
77
65
  });
@@ -6,4 +6,5 @@ declare let gtime: {
6
6
  diff: number;
7
7
  ok: boolean;
8
8
  };
9
- export { gtime, };
9
+ declare const syncTimeToBrowser: () => Promise<string>;
10
+ export { gtime, syncTimeToBrowser };
@@ -8,11 +8,10 @@ let gtime = $state({
8
8
  ok: true,
9
9
  });
10
10
  let ticks = 0; // Number of ticks since last fetch
11
+ let timer = 0;
11
12
  const fetchTime = () => {
12
13
  const start = new Date().getTime();
13
- const controller = new AbortController();
14
- setTimeout(() => controller.abort(), 2000);
15
- fetch("/api/date", { signal: controller.signal })
14
+ fetch("/api/date", { signal: AbortSignal.timeout(2000) })
16
15
  .then((resp) => {
17
16
  gtime.ok = false;
18
17
  if (resp.ok) {
@@ -35,7 +34,17 @@ setInterval(() => {
35
34
  ticks++;
36
35
  if (ticks > fetchIntervalSecs) {
37
36
  ticks = 0;
38
- setTimeout(fetchTime, 0);
37
+ timer = setTimeout(fetchTime, 0);
39
38
  }
40
39
  }, 1000);
41
- export { gtime, };
40
+ const syncTimeToBrowser = async () => {
41
+ const body = JSON.stringify({ "timestamp": Math.round(new Date().getTime() / 1000) });
42
+ const resp = await fetch("/api/date", { method: "POST", body });
43
+ if (resp.status === 200) {
44
+ clearTimeout(timer);
45
+ timer = setTimeout(fetchTime, 0);
46
+ return "ok";
47
+ }
48
+ throw new Error(resp.statusText);
49
+ };
50
+ export { gtime, syncTimeToBrowser };
@@ -8,7 +8,9 @@ export type Recording = {
8
8
  };
9
9
  declare const recording: {
10
10
  all: Recording[];
11
+ loadedHours: string[];
11
12
  clear: () => void;
13
+ getRecordings: (start: Date, end: Date) => Promise<Recording[]>;
12
14
  loadHour: (date?: Date) => Promise<void>;
13
15
  };
14
16
  export { recording, };
@@ -1,17 +1,36 @@
1
1
  const recording = $state({
2
2
  all: [],
3
+ loadedHours: [], // Cache of hours loaded ["2025-10-19T19"]
3
4
  clear: () => {
4
5
  recording.all = [];
5
6
  },
7
+ getRecordings: async (start, end) => {
8
+ // Check we have the required hours loaded, befire
9
+ // before return the requested hours
10
+ // Calc the end date/time
11
+ // Set Start the begining of the hour
12
+ let d = new Date(start);
13
+ d.setMilliseconds(0);
14
+ d.setSeconds(0);
15
+ d.setMinutes(0);
16
+ for (; d < end; d = new Date(d.getTime() + 1000 * 60 * 60)) {
17
+ const hr = d.toISOString().slice(0, 13);
18
+ if (!recording.loadedHours.find(h => h == hr)) {
19
+ await recording.loadHour(d);
20
+ }
21
+ }
22
+ return recording.all.filter(r => r.start < end && (r.start.getTime() + r.dur * 1000) > start.getTime());
23
+ },
6
24
  loadHour: ((date) => {
7
25
  return new Promise((resolve, reject) => {
8
26
  if (!date)
9
27
  date = new Date();
10
- let [ymd, time] = date.toISOString().split("T");
11
- let [year, month, day] = ymd.split("-");
12
- let [hour, min, sec] = time.split(":");
28
+ const [ymd, time] = date.toISOString().split("T");
29
+ const [year, month, day] = ymd.split("-");
30
+ const [hour, min, sec] = time.split(":");
13
31
  fetch(`/api/recordings/${year}/${month}/${day}/${hour}`)
14
32
  .then(resp => {
33
+ const hr = date.toISOString().slice(0, 13);
15
34
  switch (resp.status) {
16
35
  case 200:
17
36
  resp.json().then(data => {
@@ -24,11 +43,17 @@ const recording = $state({
24
43
  .reduce((m, o) => m.set(o.file, o), new Map)
25
44
  .values());
26
45
  recording.all.sort((a, b) => a.start.getTime() - b.start.getTime());
46
+ if (!recording.loadedHours.find(h => h == hr)) {
47
+ recording.loadedHours.push(hr);
48
+ }
27
49
  resolve();
28
50
  }).catch(err => {
29
51
  reject(err);
30
52
  });
31
53
  break;
54
+ case 404:
55
+ resolve();
56
+ break;
32
57
  default:
33
58
  reject(resp.statusText);
34
59
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smcgateway-sv",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
4
4
  "author": "Kevin Golding <kevin.golding@smc-gateway.com> (https://smc-gateway.com)",
5
5
  "license": "MIT",
6
6
  "scripts": {