@openfn/language-varo 1.1.4 → 2.1.0

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.
package/README.md CHANGED
@@ -239,9 +239,9 @@ openfn workflow.json -m --only convertToEms
239
239
 
240
240
  Some workflows required authorization to access the resources.
241
241
 
242
- ## Gmail token
242
+ ## Gmail access token
243
243
 
244
- Use Postman to retrieve an access token. This is a short-lived token will last 60 minutes and will have to be manually retrieved. See the documentation in the [Gmail adaptor readme](https://docs.openfn.org/adaptors/packages/gmail-readme#use-the-postman-application-to-query-the-oauth-enpoint-and-retrieve-an-access-token) for a guide on how to configure Postman to retrieve the access token.
244
+ An access token is required to access the Gmail API. This short-lived token will last 60 minutes and will have to be manually refreshed. See the documentation in the [Gmail adaptor readme](https://docs.openfn.org/adaptors/packages/gmail-readme#retrieve-an-access-token) for a guide on how to use Google Developers OAuth 2.0 Playground to retrieve and refresh the access token.
245
245
 
246
246
  ## OpenFn collections token
247
247
 
@@ -322,10 +322,94 @@ pnpm build
322
322
  pnpm run setup
323
323
  ```
324
324
 
325
- ### Switch to the working branch of the varo adaptor
325
+ ### Switch to a working branch of the varo adaptor
326
326
 
327
327
  ```
328
328
  cd openfn/adaptors/adaptors
329
- git checkout nhgh-varo
329
+ git checkout -b varo-enhancements
330
330
  ```
331
331
 
332
+ # FridgeTag `records` vs. `zReports`
333
+
334
+ ## Purpose
335
+
336
+ The FridgeTag parser (`parseFridgeTagToReport`) reads structured device output and emits two parallel data structures: `records` and `zReports`. This dual design allows strict compliance with EMS standards, while also preserving valuable out-of-spec information from the original FridgeTag device logs.
337
+
338
+ ## `records`: EMS-compliant daily extremes
339
+
340
+ FridgeTag source data provides daily minimum and maximum temperatures, including the exact timestamp each was observed. These data points are directly compatible with EMS requirements, which demand time-stamped records representing sensor readings.
341
+
342
+ For each day in the log, the parser generates:
343
+
344
+ - One EMS record for the minimum temperature, and
345
+ - One EMS record for the maximum temperature.
346
+
347
+ Each record includes:
348
+ - `ABST`: Absolute ISO-8601 timestamp (e.g., `"2024-10-08T08:15:00.000Z"`).
349
+ - `TVC`: Temperature value in °C (e.g., `18.5`).
350
+ - `ALRM`: Optional alarm flag (e.g., `"HEAT"` or `"FRZE"`), derived from alarm conditions.
351
+ - `zdescription`: A label for context (e.g., `"2024-10-08 Min T"`).
352
+
353
+ Example:
354
+ ```json
355
+ {
356
+ "ABST": "2024-10-08T08:15:00.000Z",
357
+ "TVC": 18.5,
358
+ "ALRM": null,
359
+ "zdescription": "2024-10-08 Min T"
360
+ }
361
+ ```
362
+
363
+ These entries fully conform to EMS specifications and can be directly integrated into compliant pipelines or summaries.
364
+
365
+ ## `zReports`: Out-of-spec aggregates & alarm summaries
366
+
367
+ In addition to min/max values, FridgeTag records include:
368
+
369
+ - A daily average temperature,
370
+ - Detailed alarm metadata: including duration of condition (`t Acc`), first alarm timestamp (`TS A`), and alarm count (`C A`).
371
+
372
+ This information is not compatible with EMS formats, which don't align with aggregated statistics and rich alarm metadata. However, this data remains operationally meaningful, especially for:
373
+
374
+ - 60-day summary reports,
375
+ - Country-level immunization program dashboards,
376
+ - Quick on-site reviews by technicians.
377
+
378
+ To retain this data without violating EMS constraints, the parser generates a `zReports` array. Each entry summarizes one day:
379
+
380
+ ```json
381
+ {
382
+ "date": "2024-10-08T00:00:00.000Z",
383
+ "duration": "1D",
384
+ "alarms": [
385
+ {
386
+ "condition": "HEAT",
387
+ "conditionMinutes": 840,
388
+ "alarmTime": "2024-10-08T00:00:00.000Z"
389
+ }
390
+ ],
391
+ "aggregates": [
392
+ {
393
+ "id": "TVC",
394
+ "min": 18.5,
395
+ "max": 21.2,
396
+ "average": 19.2
397
+ }
398
+ ]
399
+ }
400
+ ```
401
+
402
+ This structure is deliberately out-of-spec (hence the `z` prefix) and is ignored by EMS consumers. It is retained only for dashboards, exports, and enriched user-facing analytics.
403
+
404
+ ## Design philosophy
405
+
406
+ This split structure reflects a disciplined compromise between compliance and pragmatism:
407
+
408
+ - `records`: strictly EMS-compliant atomic data points.
409
+ - `zReports`: high-value extras that don't align with EMS, but still provide value.
410
+
411
+ This ensures that:
412
+
413
+ - Nothing is lost from the original FridgeTag data.
414
+ - Downstream EMS systems remain unaffected.
415
+ - Local insights and user-friendly summaries remain rich and actionable.
package/ast.json CHANGED
@@ -166,6 +166,124 @@
166
166
  ]
167
167
  },
168
168
  "valid": true
169
+ },
170
+ {
171
+ "name": "parseUtcForDataRange",
172
+ "params": [
173
+ "timeZone",
174
+ "startIso",
175
+ "endIso"
176
+ ],
177
+ "docs": {
178
+ "description": "Computes the UTC datetime range that corresponds to a given IANA timezone.",
179
+ "tags": [
180
+ {
181
+ "title": "public",
182
+ "description": null,
183
+ "type": null
184
+ },
185
+ {
186
+ "title": "function",
187
+ "description": null,
188
+ "name": null
189
+ },
190
+ {
191
+ "title": "param",
192
+ "description": "An IANA time zone identifier (e.g. \"America/Los_Angeles\").",
193
+ "type": {
194
+ "type": "NameExpression",
195
+ "name": "string"
196
+ },
197
+ "name": "timeZone"
198
+ },
199
+ {
200
+ "title": "param",
201
+ "description": "Starting date in ISO format.",
202
+ "type": {
203
+ "type": "NameExpression",
204
+ "name": "string"
205
+ },
206
+ "name": "startIso"
207
+ },
208
+ {
209
+ "title": "param",
210
+ "description": "Ending date range in ISO format.",
211
+ "type": {
212
+ "type": "NameExpression",
213
+ "name": "string"
214
+ },
215
+ "name": "endIso"
216
+ },
217
+ {
218
+ "title": "returns",
219
+ "description": null,
220
+ "type": {
221
+ "type": "NameExpression",
222
+ "name": "UtcRange"
223
+ }
224
+ }
225
+ ]
226
+ },
227
+ "valid": true
228
+ },
229
+ {
230
+ "name": "isKeyInRange",
231
+ "params": [
232
+ "key",
233
+ "start",
234
+ "end"
235
+ ],
236
+ "docs": {
237
+ "description": "Checks whether the timestamp embedded in a key falls within a UTC datetime range.",
238
+ "tags": [
239
+ {
240
+ "title": "public",
241
+ "description": null,
242
+ "type": null
243
+ },
244
+ {
245
+ "title": "function",
246
+ "description": null,
247
+ "name": null
248
+ },
249
+ {
250
+ "title": "param",
251
+ "description": "A string key containing a UTC timestamp in the format `YYYYMMDDTHHMMSS`, following a colon (e.g. \"prefix:20250624T101530\").",
252
+ "type": {
253
+ "type": "NameExpression",
254
+ "name": "string"
255
+ },
256
+ "name": "key"
257
+ },
258
+ {
259
+ "title": "param",
260
+ "description": "The inclusive lower bound of the UTC datetime range.",
261
+ "type": {
262
+ "type": "NameExpression",
263
+ "name": "Date"
264
+ },
265
+ "name": "start"
266
+ },
267
+ {
268
+ "title": "param",
269
+ "description": "The exclusive upper bound of the UTC datetime range.",
270
+ "type": {
271
+ "type": "NameExpression",
272
+ "name": "Date"
273
+ },
274
+ "name": "end"
275
+ },
276
+ {
277
+ "title": "returns",
278
+ "description": "True if the parsed UTC timestamp is within the range, false otherwise.",
279
+ "type": {
280
+ "type": "NameExpression",
281
+ "name": "boolean"
282
+ }
283
+ }
284
+ ]
285
+ },
286
+ "valid": true
169
287
  }
170
288
  ],
171
289
  "exports": [],
package/dist/index.cjs CHANGED
@@ -33,9 +33,10 @@ __export(src_exports, {
33
33
  fields: () => import_language_common2.fields,
34
34
  fn: () => import_language_common2.fn,
35
35
  fnIf: () => import_language_common2.fnIf,
36
- http: () => import_language_common2.http,
36
+ isKeyInRange: () => isKeyInRange,
37
37
  lastReferenceValue: () => import_language_common2.lastReferenceValue,
38
38
  merge: () => import_language_common2.merge,
39
+ parseUtcForDataRange: () => parseUtcForDataRange,
39
40
  sourceValue: () => import_language_common2.sourceValue
40
41
  });
41
42
  module.exports = __toCommonJS(src_exports);
@@ -56,13 +57,15 @@ __export(Adaptor_exports, {
56
57
  fields: () => import_language_common2.fields,
57
58
  fn: () => import_language_common2.fn,
58
59
  fnIf: () => import_language_common2.fnIf,
59
- http: () => import_language_common2.http,
60
+ isKeyInRange: () => isKeyInRange,
60
61
  lastReferenceValue: () => import_language_common2.lastReferenceValue,
61
62
  merge: () => import_language_common2.merge,
63
+ parseUtcForDataRange: () => parseUtcForDataRange,
62
64
  sourceValue: () => import_language_common2.sourceValue
63
65
  });
64
66
  var import_language_common = require("@openfn/language-common");
65
67
  var import_util = require("@openfn/language-common/util");
68
+ var import_luxon = require("luxon");
66
69
 
67
70
  // src/Utils.js
68
71
  function parseMetadata(message) {
@@ -81,7 +84,7 @@ function parseMetadata(message) {
81
84
  }
82
85
  function removeNullProps(obj) {
83
86
  for (const key in obj) {
84
- if (obj[key] == null) {
87
+ if (obj[key] === null) {
85
88
  delete obj[key];
86
89
  }
87
90
  }
@@ -128,7 +131,18 @@ function formatDeviceInfo(data) {
128
131
  output += formatType("Appliance", data.AMFR, data.AMOD, data.ASER);
129
132
  output += formatType("Logger", data.LMFR, data.LMOD, data.LSER);
130
133
  output += formatType("EMD", data.EMFR, data.EMOD, data.ESER);
131
- return output || "No valid data found";
134
+ return output || "Cannot determine device info; no valid data found.";
135
+ }
136
+ function abbreviatedIsoToDate(iso) {
137
+ const [year, month, day, hour, minute, second] = [
138
+ iso.slice(0, 4),
139
+ iso.slice(4, 6),
140
+ iso.slice(6, 8),
141
+ iso.slice(9, 11),
142
+ iso.slice(11, 13),
143
+ iso.slice(13, 15)
144
+ ];
145
+ return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`);
132
146
  }
133
147
 
134
148
  // src/StreamingUtils.js
@@ -204,7 +218,6 @@ function mergeRecords(records, groupKey) {
204
218
  }
205
219
  if (tambAlrm != null) {
206
220
  mergedRecord["zTambAlrm"] = tambAlrm;
207
- mergedRecord["ALRM"] = tambAlrm;
208
221
  }
209
222
  if (tvcAlrm != null) {
210
223
  mergedRecord["zTvcAlrm"] = tvcAlrm;
@@ -317,7 +330,7 @@ function promoteDeviceProperties(source, destination) {
317
330
  }
318
331
 
319
332
  // src/VaroEmsUtils.js
320
- function parseVaroEmsToReport(metadata, data, dataPath2) {
333
+ function parseVaroEmsToReport(metadata, data, rtcwMaps) {
321
334
  const report = {
322
335
  CID: null,
323
336
  LAT: metadata.location.used.latitude,
@@ -341,10 +354,14 @@ function parseVaroEmsToReport(metadata, data, dataPath2) {
341
354
  LSV: data.LSV,
342
355
  records: []
343
356
  };
344
- const deviceDate = parseRelativeDateFromUsbPluggedInfo(dataPath2);
357
+ removeNullProps(report);
345
358
  for (const item of data.records) {
346
- const durations = [item.RELT];
347
- const absoluteDate = parseAdjustedDate(deviceDate, durations);
359
+ const deviceDate = rtcwMaps.get(item.RTCW);
360
+ if (!deviceDate) {
361
+ report.zHasUnreconciledRtcw = true;
362
+ continue;
363
+ }
364
+ const absoluteDate = applyDurationToDate(deviceDate, item.RELT);
348
365
  const abst = parseIsoToAbbreviatedIso(absoluteDate);
349
366
  const record = {
350
367
  ABST: abst,
@@ -361,67 +378,82 @@ function parseVaroEmsToReport(metadata, data, dataPath2) {
361
378
  TAMB: item.TAMB,
362
379
  TVC: item.TVC
363
380
  };
381
+ removeNullProps(record);
364
382
  report.records.push(record);
365
383
  }
366
384
  return report;
367
385
  }
368
- function parseRelativeDateFromUsbPluggedInfo(path) {
369
- const regex = /_CURRENT_DATA_(?<duration>\w+?)_(?<date>\w+?)\.json$/;
370
- const match = path.match(regex);
371
- if (!match) {
372
- throw new Error(`Path format is incorrect: ${path}`);
386
+ function applyDurationToDate(incomingDate, duration, subtract = false) {
387
+ const date = normalizeIncomingDate(incomingDate);
388
+ const parsedDuration = parseDuration(duration, subtract);
389
+ if (!parsedDuration)
390
+ return date;
391
+ date.setUTCDate(date.getUTCDate() + parsedDuration.days);
392
+ date.setUTCHours(date.getUTCHours() + parsedDuration.hours);
393
+ date.setUTCMinutes(date.getUTCMinutes() + parsedDuration.minutes);
394
+ date.setUTCSeconds(date.getUTCSeconds() + parsedDuration.seconds);
395
+ return date;
396
+ }
397
+ function parseDuration(duration, subtract) {
398
+ const regex = /^P(?:(?<days>\d+)D)?(?:T(?:(?<hours>\d+)H)?(?:(?<minutes>\d+)M)?(?:(?<seconds>\d+)S)?)?$/;
399
+ const match = duration.match(regex);
400
+ if (!match)
401
+ throw new Error(`Invalid duration format: ${duration}`);
402
+ const m = subtract ? -1 : 1;
403
+ const days = +(match.groups.days || 0) * m;
404
+ const hours = +(match.groups.hours || 0) * m;
405
+ const minutes = +(match.groups.minutes || 0) * m;
406
+ const seconds = +(match.groups.seconds || 0) * m;
407
+ return days || hours || minutes || seconds ? { days, hours, minutes, seconds } : null;
408
+ }
409
+ function normalizeIncomingDate(incomingDate) {
410
+ let date = incomingDate;
411
+ if (typeof date === "string" && /^\d{8}T\d{6}Z$/.test(date)) {
412
+ date = parseAbbreviatedIsoToIso(date);
373
413
  }
374
- const usbPluggedInfo = {
375
- date: match.groups.date,
376
- duration: match.groups.duration
377
- };
378
- const isoDate = parseAbbreviatedIsoToIso(usbPluggedInfo.date);
379
- return parseAdjustedDate(isoDate, [usbPluggedInfo.duration], true);
380
- }
381
- function parseAdjustedDate(incomingDate, durations, subtract = false) {
382
- function parseDuration(duration) {
383
- const regex = /^P((?<days>\d+)D)?(T((?<hours>\d+)H)?((?<minutes>\d+)M)?((?<seconds>\d+)S)?)?$/;
384
- const match = duration.match(regex);
385
- if (!match) {
386
- throw new Error(`Invalid duration format: ${duration}`);
414
+ return new Date(date);
415
+ }
416
+ function parseAbbreviatedIsoToIso(abbrIso) {
417
+ const m = abbrIso.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
418
+ if (!m)
419
+ throw new Error(`Invalid abbreviated ISO date format: ${abbrIso}`);
420
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
421
+ }
422
+ function parseIsoToAbbreviatedIso(iso) {
423
+ return iso.toISOString().replace(/[\-\:]/g, "").replace(".000Z", "Z");
424
+ }
425
+ function buildDeviceRtcwDateMaps(contents) {
426
+ const dataContents = contents.filter((c) => c.data);
427
+ const deviceRtcwDateMaps = /* @__PURE__ */ new Map();
428
+ for (const content of dataContents) {
429
+ const { deviceId, deviceDate, finalRtcw } = extractDeviceData(content);
430
+ let rtcwDateMap = deviceRtcwDateMaps.get(deviceId);
431
+ if (!rtcwDateMap) {
432
+ rtcwDateMap = /* @__PURE__ */ new Map();
433
+ deviceRtcwDateMaps.set(deviceId, rtcwDateMap);
387
434
  }
388
- const multiplier = subtract ? -1 : 1;
389
- const days = parseInt(match.groups.days || 0, 10) * multiplier;
390
- const hours = parseInt(match.groups.hours || 0, 10) * multiplier;
391
- const minutes = parseInt(match.groups.minutes || 0, 10) * multiplier;
392
- const seconds = parseInt(match.groups.seconds || 0, 10) * multiplier;
393
- if (days === 0 && hours === 0 && minutes === 0 && seconds === 0) {
394
- return null;
435
+ const existingDate = rtcwDateMap.get(finalRtcw);
436
+ if (!existingDate || deviceDate < existingDate) {
437
+ rtcwDateMap.set(finalRtcw, deviceDate);
395
438
  }
396
- return { days, hours, minutes, seconds };
397
439
  }
398
- function applyDuration(date, duration) {
399
- date.setDate(date.getDate() + duration.days);
400
- date.setHours(date.getHours() + duration.hours);
401
- date.setMinutes(date.getMinutes() + duration.minutes);
402
- date.setSeconds(date.getSeconds() + duration.seconds);
403
- }
404
- const adjustedDate = new Date(incomingDate);
405
- for (const duration of durations) {
406
- if (!duration)
407
- continue;
408
- const parsedDuration = parseDuration(duration);
409
- if (!parsedDuration)
410
- continue;
411
- applyDuration(adjustedDate, parsedDuration);
412
- }
413
- return adjustedDate;
440
+ return deviceRtcwDateMaps;
414
441
  }
415
- function parseAbbreviatedIsoToIso(abbreviatedIso) {
416
- const regex = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/;
417
- const match = abbreviatedIso.match(regex);
418
- if (!match) {
419
- throw new Error(`Invalid abbreviated ISO date format: ${abbreviatedIso}`);
442
+ function extractDeviceData(content) {
443
+ const regex = /^([a-zA-Z0-9]+)_(?:.+)_([A-Z0-9]{4,15})_([0-9]{8}T[0-9]{6}Z)\.json$/;
444
+ const [, deviceId, deviceRelt, abbrUsbDate] = content.data.filename.match(regex);
445
+ const deviceDate = applyDurationToDate(abbrUsbDate, deviceRelt, true);
446
+ if (typeof content.data.content === "string") {
447
+ try {
448
+ content.data.content = JSON.parse(content.data.content);
449
+ } catch (e) {
450
+ console.error("Invalid JSON string in content.data.content:", e);
451
+ }
420
452
  }
421
- return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`;
422
- }
423
- function parseIsoToAbbreviatedIso(iso) {
424
- return iso.toISOString().replace(/[\-\:]/g, "").replace(".000Z", "Z");
453
+ content.zDeviceId = deviceId;
454
+ const data = content.data.content;
455
+ const finalRtcw = data.records[data.records.length - 1].RTCW;
456
+ return { deviceId, deviceDate, finalRtcw };
425
457
  }
426
458
 
427
459
  // src/FridgeTagUtils.js
@@ -481,7 +513,7 @@ function parseFridgeTagToReport(metadata, nodes) {
481
513
  ABST: dateTime,
482
514
  TVC: temp,
483
515
  ALRM: alarm,
484
- zdescription: date + " " + tempField
516
+ zDescription: date + " " + tempField
485
517
  });
486
518
  function parseAlarm(alarmNode, description) {
487
519
  if (alarmNode["t Acc"] === "0")
@@ -579,38 +611,46 @@ function parseFridgeTag(text) {
579
611
  var import_language_common2 = require("@openfn/language-common");
580
612
  function convertToEms(messageContents) {
581
613
  return async (state) => {
582
- var _a, _b;
583
614
  const [resolvedMessageContents] = (0, import_util.expandReferences)(state, messageContents);
584
615
  const reports = [];
585
- console.log("Incoming message contents", resolvedMessageContents.length);
586
- for (const content of resolvedMessageContents) {
587
- if ((_a = content.fridgeTag) == null ? void 0 : _a.content) {
588
- const metadata = parseMetadata(content);
589
- if (!metadata)
590
- continue;
591
- const fridgeTagNodes = parseFridgeTag(content.fridgeTag.content);
592
- const result = parseFridgeTagToReport(metadata, fridgeTagNodes);
593
- reports.push(result);
594
- continue;
595
- }
596
- if ((_b = content.data) == null ? void 0 : _b.content) {
597
- const metadata = parseMetadata(content);
598
- if (!metadata)
599
- continue;
600
- const data = JSON.parse(content.data.content);
601
- const dataPath2 = content.data.filename;
602
- const result = parseVaroEmsToReport(metadata, data, dataPath2);
603
- reports.push(result);
604
- continue;
605
- }
616
+ console.info("Incoming message contents", resolvedMessageContents.length);
617
+ processFridgeTagContents(resolvedMessageContents, reports);
618
+ processDataContents(resolvedMessageContents, reports);
619
+ for (const content of resolvedMessageContents.filter((c) => !c.zProcessed)) {
606
620
  console.error(
607
621
  `Insufficient content found for MessageID: ${content.messageId}`
608
622
  );
609
623
  }
610
- console.log("Converted message contents", reports.length);
624
+ console.info("Converted message contents", reports.length);
611
625
  return { ...(0, import_language_common.composeNextState)(state, reports) };
612
626
  };
613
627
  }
628
+ function processFridgeTagContents(contents, reports) {
629
+ const fridgeTagContents = contents.filter((c) => c.fridgeTag);
630
+ for (const content of fridgeTagContents) {
631
+ const metadata = parseMetadata(content);
632
+ if (!metadata)
633
+ continue;
634
+ const fridgeTagNodes = parseFridgeTag(content.fridgeTag.content);
635
+ const result = parseFridgeTagToReport(metadata, fridgeTagNodes);
636
+ reports.push(result);
637
+ content.zProcessed = true;
638
+ }
639
+ }
640
+ function processDataContents(contents, reports) {
641
+ const dataContents = contents.filter((c) => c.data);
642
+ const deviceRtcwDateMaps = buildDeviceRtcwDateMaps(dataContents);
643
+ for (const content of dataContents) {
644
+ const metadata = parseMetadata(content);
645
+ if (!metadata)
646
+ continue;
647
+ const data = content.data.content;
648
+ const rtcwMaps = deviceRtcwDateMaps.get(content.zDeviceId);
649
+ const result = parseVaroEmsToReport(metadata, data, rtcwMaps);
650
+ reports.push(result);
651
+ content.zProcessed = true;
652
+ }
653
+ }
614
654
  function convertItemsToReports(items, reportType = "unknown") {
615
655
  return async (state) => {
616
656
  const [resolvedRecords, resolvedReportType] = (0, import_util.expandReferences)(
@@ -638,7 +678,7 @@ function convertReportsToMessageContents(reports, reportType = "unknown") {
638
678
  reportType
639
679
  );
640
680
  const messageContents = [];
641
- for (const report of resolvedReports) {
681
+ for (const report of resolvedReports ?? []) {
642
682
  report["zReportType"] = resolvedReportType;
643
683
  report["zGeneratedTimestamp"] = new Date().toISOString();
644
684
  const serialNumber = report["ESER"] || report["LSER"] || report["ASER"];
@@ -655,6 +695,45 @@ function convertReportsToMessageContents(reports, reportType = "unknown") {
655
695
  return { ...(0, import_language_common.composeNextState)(state, messageContents) };
656
696
  };
657
697
  }
698
+ function parseUtcForDataRange(timeZone, startIso, endIso) {
699
+ const localNow = import_luxon.DateTime.now().setZone(timeZone);
700
+ const wallClock = new Date(
701
+ localNow.year,
702
+ localNow.month - 1,
703
+ localNow.day,
704
+ localNow.hour,
705
+ localNow.minute,
706
+ localNow.second,
707
+ localNow.millisecond
708
+ );
709
+ const startLocal = import_luxon.DateTime.fromISO(startIso, { zone: timeZone });
710
+ const endLocal = import_luxon.DateTime.fromISO(endIso, { zone: timeZone });
711
+ const startUtc = startLocal.toUTC().toJSDate();
712
+ const endUtc = endLocal.toUTC().toJSDate();
713
+ const daysDiff = Math.floor(endLocal.diff(startLocal, "days").days);
714
+ const collectionKeyPattern = "yyyyLLdd";
715
+ const collectionKeys = [];
716
+ for (let i = 0; i <= daysDiff; i++) {
717
+ const currentDay = startLocal.plus({ days: i });
718
+ collectionKeys.push(`*${currentDay.toFormat(collectionKeyPattern)}*`);
719
+ }
720
+ return {
721
+ wallClock,
722
+ startUtc,
723
+ endUtc,
724
+ collectionKeys
725
+ };
726
+ }
727
+ function isKeyInRange(key, start, end) {
728
+ console.error(
729
+ "i changed this implementation and need to verify the scenarios."
730
+ );
731
+ const iso = key == null ? void 0 : key.split(":")[1];
732
+ if (!iso || iso.length < 15)
733
+ return false;
734
+ const date = abbreviatedIsoToDate(iso);
735
+ return date >= start && date < end;
736
+ }
658
737
 
659
738
  // src/index.js
660
739
  var src_default = Adaptor_exports;
@@ -673,8 +752,9 @@ var src_default = Adaptor_exports;
673
752
  fields,
674
753
  fn,
675
754
  fnIf,
676
- http,
755
+ isKeyInRange,
677
756
  lastReferenceValue,
678
757
  merge,
758
+ parseUtcForDataRange,
679
759
  sourceValue
680
760
  });
package/dist/index.js CHANGED
@@ -20,13 +20,15 @@ __export(Adaptor_exports, {
20
20
  fields: () => fields,
21
21
  fn: () => fn,
22
22
  fnIf: () => fnIf,
23
- http: () => http,
23
+ isKeyInRange: () => isKeyInRange,
24
24
  lastReferenceValue: () => lastReferenceValue,
25
25
  merge: () => merge,
26
+ parseUtcForDataRange: () => parseUtcForDataRange,
26
27
  sourceValue: () => sourceValue
27
28
  });
28
29
  import { composeNextState } from "@openfn/language-common";
29
30
  import { expandReferences } from "@openfn/language-common/util";
31
+ import { DateTime } from "luxon";
30
32
 
31
33
  // src/Utils.js
32
34
  function parseMetadata(message) {
@@ -45,7 +47,7 @@ function parseMetadata(message) {
45
47
  }
46
48
  function removeNullProps(obj) {
47
49
  for (const key in obj) {
48
- if (obj[key] == null) {
50
+ if (obj[key] === null) {
49
51
  delete obj[key];
50
52
  }
51
53
  }
@@ -92,7 +94,18 @@ function formatDeviceInfo(data) {
92
94
  output += formatType("Appliance", data.AMFR, data.AMOD, data.ASER);
93
95
  output += formatType("Logger", data.LMFR, data.LMOD, data.LSER);
94
96
  output += formatType("EMD", data.EMFR, data.EMOD, data.ESER);
95
- return output || "No valid data found";
97
+ return output || "Cannot determine device info; no valid data found.";
98
+ }
99
+ function abbreviatedIsoToDate(iso) {
100
+ const [year, month, day, hour, minute, second] = [
101
+ iso.slice(0, 4),
102
+ iso.slice(4, 6),
103
+ iso.slice(6, 8),
104
+ iso.slice(9, 11),
105
+ iso.slice(11, 13),
106
+ iso.slice(13, 15)
107
+ ];
108
+ return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`);
96
109
  }
97
110
 
98
111
  // src/StreamingUtils.js
@@ -168,7 +181,6 @@ function mergeRecords(records, groupKey) {
168
181
  }
169
182
  if (tambAlrm != null) {
170
183
  mergedRecord["zTambAlrm"] = tambAlrm;
171
- mergedRecord["ALRM"] = tambAlrm;
172
184
  }
173
185
  if (tvcAlrm != null) {
174
186
  mergedRecord["zTvcAlrm"] = tvcAlrm;
@@ -281,7 +293,7 @@ function promoteDeviceProperties(source, destination) {
281
293
  }
282
294
 
283
295
  // src/VaroEmsUtils.js
284
- function parseVaroEmsToReport(metadata, data, dataPath2) {
296
+ function parseVaroEmsToReport(metadata, data, rtcwMaps) {
285
297
  const report = {
286
298
  CID: null,
287
299
  LAT: metadata.location.used.latitude,
@@ -305,10 +317,14 @@ function parseVaroEmsToReport(metadata, data, dataPath2) {
305
317
  LSV: data.LSV,
306
318
  records: []
307
319
  };
308
- const deviceDate = parseRelativeDateFromUsbPluggedInfo(dataPath2);
320
+ removeNullProps(report);
309
321
  for (const item of data.records) {
310
- const durations = [item.RELT];
311
- const absoluteDate = parseAdjustedDate(deviceDate, durations);
322
+ const deviceDate = rtcwMaps.get(item.RTCW);
323
+ if (!deviceDate) {
324
+ report.zHasUnreconciledRtcw = true;
325
+ continue;
326
+ }
327
+ const absoluteDate = applyDurationToDate(deviceDate, item.RELT);
312
328
  const abst = parseIsoToAbbreviatedIso(absoluteDate);
313
329
  const record = {
314
330
  ABST: abst,
@@ -325,67 +341,82 @@ function parseVaroEmsToReport(metadata, data, dataPath2) {
325
341
  TAMB: item.TAMB,
326
342
  TVC: item.TVC
327
343
  };
344
+ removeNullProps(record);
328
345
  report.records.push(record);
329
346
  }
330
347
  return report;
331
348
  }
332
- function parseRelativeDateFromUsbPluggedInfo(path) {
333
- const regex = /_CURRENT_DATA_(?<duration>\w+?)_(?<date>\w+?)\.json$/;
334
- const match = path.match(regex);
335
- if (!match) {
336
- throw new Error(`Path format is incorrect: ${path}`);
349
+ function applyDurationToDate(incomingDate, duration, subtract = false) {
350
+ const date = normalizeIncomingDate(incomingDate);
351
+ const parsedDuration = parseDuration(duration, subtract);
352
+ if (!parsedDuration)
353
+ return date;
354
+ date.setUTCDate(date.getUTCDate() + parsedDuration.days);
355
+ date.setUTCHours(date.getUTCHours() + parsedDuration.hours);
356
+ date.setUTCMinutes(date.getUTCMinutes() + parsedDuration.minutes);
357
+ date.setUTCSeconds(date.getUTCSeconds() + parsedDuration.seconds);
358
+ return date;
359
+ }
360
+ function parseDuration(duration, subtract) {
361
+ const regex = /^P(?:(?<days>\d+)D)?(?:T(?:(?<hours>\d+)H)?(?:(?<minutes>\d+)M)?(?:(?<seconds>\d+)S)?)?$/;
362
+ const match = duration.match(regex);
363
+ if (!match)
364
+ throw new Error(`Invalid duration format: ${duration}`);
365
+ const m = subtract ? -1 : 1;
366
+ const days = +(match.groups.days || 0) * m;
367
+ const hours = +(match.groups.hours || 0) * m;
368
+ const minutes = +(match.groups.minutes || 0) * m;
369
+ const seconds = +(match.groups.seconds || 0) * m;
370
+ return days || hours || minutes || seconds ? { days, hours, minutes, seconds } : null;
371
+ }
372
+ function normalizeIncomingDate(incomingDate) {
373
+ let date = incomingDate;
374
+ if (typeof date === "string" && /^\d{8}T\d{6}Z$/.test(date)) {
375
+ date = parseAbbreviatedIsoToIso(date);
337
376
  }
338
- const usbPluggedInfo = {
339
- date: match.groups.date,
340
- duration: match.groups.duration
341
- };
342
- const isoDate = parseAbbreviatedIsoToIso(usbPluggedInfo.date);
343
- return parseAdjustedDate(isoDate, [usbPluggedInfo.duration], true);
344
- }
345
- function parseAdjustedDate(incomingDate, durations, subtract = false) {
346
- function parseDuration(duration) {
347
- const regex = /^P((?<days>\d+)D)?(T((?<hours>\d+)H)?((?<minutes>\d+)M)?((?<seconds>\d+)S)?)?$/;
348
- const match = duration.match(regex);
349
- if (!match) {
350
- throw new Error(`Invalid duration format: ${duration}`);
377
+ return new Date(date);
378
+ }
379
+ function parseAbbreviatedIsoToIso(abbrIso) {
380
+ const m = abbrIso.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
381
+ if (!m)
382
+ throw new Error(`Invalid abbreviated ISO date format: ${abbrIso}`);
383
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
384
+ }
385
+ function parseIsoToAbbreviatedIso(iso) {
386
+ return iso.toISOString().replace(/[\-\:]/g, "").replace(".000Z", "Z");
387
+ }
388
+ function buildDeviceRtcwDateMaps(contents) {
389
+ const dataContents = contents.filter((c) => c.data);
390
+ const deviceRtcwDateMaps = /* @__PURE__ */ new Map();
391
+ for (const content of dataContents) {
392
+ const { deviceId, deviceDate, finalRtcw } = extractDeviceData(content);
393
+ let rtcwDateMap = deviceRtcwDateMaps.get(deviceId);
394
+ if (!rtcwDateMap) {
395
+ rtcwDateMap = /* @__PURE__ */ new Map();
396
+ deviceRtcwDateMaps.set(deviceId, rtcwDateMap);
351
397
  }
352
- const multiplier = subtract ? -1 : 1;
353
- const days = parseInt(match.groups.days || 0, 10) * multiplier;
354
- const hours = parseInt(match.groups.hours || 0, 10) * multiplier;
355
- const minutes = parseInt(match.groups.minutes || 0, 10) * multiplier;
356
- const seconds = parseInt(match.groups.seconds || 0, 10) * multiplier;
357
- if (days === 0 && hours === 0 && minutes === 0 && seconds === 0) {
358
- return null;
398
+ const existingDate = rtcwDateMap.get(finalRtcw);
399
+ if (!existingDate || deviceDate < existingDate) {
400
+ rtcwDateMap.set(finalRtcw, deviceDate);
359
401
  }
360
- return { days, hours, minutes, seconds };
361
402
  }
362
- function applyDuration(date, duration) {
363
- date.setDate(date.getDate() + duration.days);
364
- date.setHours(date.getHours() + duration.hours);
365
- date.setMinutes(date.getMinutes() + duration.minutes);
366
- date.setSeconds(date.getSeconds() + duration.seconds);
367
- }
368
- const adjustedDate = new Date(incomingDate);
369
- for (const duration of durations) {
370
- if (!duration)
371
- continue;
372
- const parsedDuration = parseDuration(duration);
373
- if (!parsedDuration)
374
- continue;
375
- applyDuration(adjustedDate, parsedDuration);
376
- }
377
- return adjustedDate;
403
+ return deviceRtcwDateMaps;
378
404
  }
379
- function parseAbbreviatedIsoToIso(abbreviatedIso) {
380
- const regex = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/;
381
- const match = abbreviatedIso.match(regex);
382
- if (!match) {
383
- throw new Error(`Invalid abbreviated ISO date format: ${abbreviatedIso}`);
405
+ function extractDeviceData(content) {
406
+ const regex = /^([a-zA-Z0-9]+)_(?:.+)_([A-Z0-9]{4,15})_([0-9]{8}T[0-9]{6}Z)\.json$/;
407
+ const [, deviceId, deviceRelt, abbrUsbDate] = content.data.filename.match(regex);
408
+ const deviceDate = applyDurationToDate(abbrUsbDate, deviceRelt, true);
409
+ if (typeof content.data.content === "string") {
410
+ try {
411
+ content.data.content = JSON.parse(content.data.content);
412
+ } catch (e) {
413
+ console.error("Invalid JSON string in content.data.content:", e);
414
+ }
384
415
  }
385
- return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`;
386
- }
387
- function parseIsoToAbbreviatedIso(iso) {
388
- return iso.toISOString().replace(/[\-\:]/g, "").replace(".000Z", "Z");
416
+ content.zDeviceId = deviceId;
417
+ const data = content.data.content;
418
+ const finalRtcw = data.records[data.records.length - 1].RTCW;
419
+ return { deviceId, deviceDate, finalRtcw };
389
420
  }
390
421
 
391
422
  // src/FridgeTagUtils.js
@@ -445,7 +476,7 @@ function parseFridgeTagToReport(metadata, nodes) {
445
476
  ABST: dateTime,
446
477
  TVC: temp,
447
478
  ALRM: alarm,
448
- zdescription: date + " " + tempField
479
+ zDescription: date + " " + tempField
449
480
  });
450
481
  function parseAlarm(alarmNode, description) {
451
482
  if (alarmNode["t Acc"] === "0")
@@ -551,45 +582,52 @@ import {
551
582
  fields,
552
583
  fn,
553
584
  fnIf,
554
- http,
555
585
  lastReferenceValue,
556
586
  merge,
557
587
  sourceValue
558
588
  } from "@openfn/language-common";
559
589
  function convertToEms(messageContents) {
560
590
  return async (state) => {
561
- var _a, _b;
562
591
  const [resolvedMessageContents] = expandReferences(state, messageContents);
563
592
  const reports = [];
564
- console.log("Incoming message contents", resolvedMessageContents.length);
565
- for (const content of resolvedMessageContents) {
566
- if ((_a = content.fridgeTag) == null ? void 0 : _a.content) {
567
- const metadata = parseMetadata(content);
568
- if (!metadata)
569
- continue;
570
- const fridgeTagNodes = parseFridgeTag(content.fridgeTag.content);
571
- const result = parseFridgeTagToReport(metadata, fridgeTagNodes);
572
- reports.push(result);
573
- continue;
574
- }
575
- if ((_b = content.data) == null ? void 0 : _b.content) {
576
- const metadata = parseMetadata(content);
577
- if (!metadata)
578
- continue;
579
- const data = JSON.parse(content.data.content);
580
- const dataPath2 = content.data.filename;
581
- const result = parseVaroEmsToReport(metadata, data, dataPath2);
582
- reports.push(result);
583
- continue;
584
- }
593
+ console.info("Incoming message contents", resolvedMessageContents.length);
594
+ processFridgeTagContents(resolvedMessageContents, reports);
595
+ processDataContents(resolvedMessageContents, reports);
596
+ for (const content of resolvedMessageContents.filter((c) => !c.zProcessed)) {
585
597
  console.error(
586
598
  `Insufficient content found for MessageID: ${content.messageId}`
587
599
  );
588
600
  }
589
- console.log("Converted message contents", reports.length);
601
+ console.info("Converted message contents", reports.length);
590
602
  return { ...composeNextState(state, reports) };
591
603
  };
592
604
  }
605
+ function processFridgeTagContents(contents, reports) {
606
+ const fridgeTagContents = contents.filter((c) => c.fridgeTag);
607
+ for (const content of fridgeTagContents) {
608
+ const metadata = parseMetadata(content);
609
+ if (!metadata)
610
+ continue;
611
+ const fridgeTagNodes = parseFridgeTag(content.fridgeTag.content);
612
+ const result = parseFridgeTagToReport(metadata, fridgeTagNodes);
613
+ reports.push(result);
614
+ content.zProcessed = true;
615
+ }
616
+ }
617
+ function processDataContents(contents, reports) {
618
+ const dataContents = contents.filter((c) => c.data);
619
+ const deviceRtcwDateMaps = buildDeviceRtcwDateMaps(dataContents);
620
+ for (const content of dataContents) {
621
+ const metadata = parseMetadata(content);
622
+ if (!metadata)
623
+ continue;
624
+ const data = content.data.content;
625
+ const rtcwMaps = deviceRtcwDateMaps.get(content.zDeviceId);
626
+ const result = parseVaroEmsToReport(metadata, data, rtcwMaps);
627
+ reports.push(result);
628
+ content.zProcessed = true;
629
+ }
630
+ }
593
631
  function convertItemsToReports(items, reportType = "unknown") {
594
632
  return async (state) => {
595
633
  const [resolvedRecords, resolvedReportType] = expandReferences(
@@ -617,7 +655,7 @@ function convertReportsToMessageContents(reports, reportType = "unknown") {
617
655
  reportType
618
656
  );
619
657
  const messageContents = [];
620
- for (const report of resolvedReports) {
658
+ for (const report of resolvedReports ?? []) {
621
659
  report["zReportType"] = resolvedReportType;
622
660
  report["zGeneratedTimestamp"] = new Date().toISOString();
623
661
  const serialNumber = report["ESER"] || report["LSER"] || report["ASER"];
@@ -634,6 +672,45 @@ function convertReportsToMessageContents(reports, reportType = "unknown") {
634
672
  return { ...composeNextState(state, messageContents) };
635
673
  };
636
674
  }
675
+ function parseUtcForDataRange(timeZone, startIso, endIso) {
676
+ const localNow = DateTime.now().setZone(timeZone);
677
+ const wallClock = new Date(
678
+ localNow.year,
679
+ localNow.month - 1,
680
+ localNow.day,
681
+ localNow.hour,
682
+ localNow.minute,
683
+ localNow.second,
684
+ localNow.millisecond
685
+ );
686
+ const startLocal = DateTime.fromISO(startIso, { zone: timeZone });
687
+ const endLocal = DateTime.fromISO(endIso, { zone: timeZone });
688
+ const startUtc = startLocal.toUTC().toJSDate();
689
+ const endUtc = endLocal.toUTC().toJSDate();
690
+ const daysDiff = Math.floor(endLocal.diff(startLocal, "days").days);
691
+ const collectionKeyPattern = "yyyyLLdd";
692
+ const collectionKeys = [];
693
+ for (let i = 0; i <= daysDiff; i++) {
694
+ const currentDay = startLocal.plus({ days: i });
695
+ collectionKeys.push(`*${currentDay.toFormat(collectionKeyPattern)}*`);
696
+ }
697
+ return {
698
+ wallClock,
699
+ startUtc,
700
+ endUtc,
701
+ collectionKeys
702
+ };
703
+ }
704
+ function isKeyInRange(key, start, end) {
705
+ console.error(
706
+ "i changed this implementation and need to verify the scenarios."
707
+ );
708
+ const iso = key == null ? void 0 : key.split(":")[1];
709
+ if (!iso || iso.length < 15)
710
+ return false;
711
+ const date = abbreviatedIsoToDate(iso);
712
+ return date >= start && date < end;
713
+ }
637
714
 
638
715
  // src/index.js
639
716
  var src_default = Adaptor_exports;
@@ -652,8 +729,9 @@ export {
652
729
  fields,
653
730
  fn,
654
731
  fnIf,
655
- http,
732
+ isKeyInRange,
656
733
  lastReferenceValue,
657
734
  merge,
735
+ parseUtcForDataRange,
658
736
  sourceValue
659
737
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openfn/language-varo",
3
3
  "label": "Varo",
4
- "version": "1.1.4",
4
+ "version": "2.1.0",
5
5
  "description": "OpenFn varo adaptor for cold chain information",
6
6
  "type": "module",
7
7
  "exports": {
@@ -21,6 +21,7 @@
21
21
  "configuration-schema.json"
22
22
  ],
23
23
  "dependencies": {
24
+ "luxon": "^3.6.1",
24
25
  "@openfn/language-common": "3.0.2"
25
26
  },
26
27
  "devDependencies": {
@@ -57,4 +57,50 @@ export function convertItemsToReports(items: any[], reportType?: string): Operat
57
57
  * convertReportsToMessageContents(emsReports, "ems");
58
58
  */
59
59
  export function convertReportsToMessageContents(reports: any, reportType?: string): Function;
60
- export { alterState, combine, cursor, dataPath, dataValue, each, field, fields, fn, fnIf, http, lastReferenceValue, merge, sourceValue } from "@openfn/language-common";
60
+ /**
61
+ * @typedef {Object} UtcRange
62
+ * @property {Date} wallClock - The current local datetime as it appears on the wall in the specified timezone.
63
+ * @property {Date} startUtc - UTC start date range (inclusive).
64
+ * @property {Date} endUtc - UTC end of date range (exclusive).
65
+ * @property {Array} collectionKeys - Array of wildcard patterns to match UTC dates which correspond with date range (e.g. "*20250624*").
66
+ */
67
+ /**
68
+ * Computes the UTC datetime range that corresponds to a given IANA timezone.
69
+ * @public
70
+ * @function
71
+ * @param {string} timeZone - An IANA time zone identifier (e.g. "America/Los_Angeles").
72
+ * @param {string} startIso - Starting date in ISO format.
73
+ * @param {string} endIso - Ending date range in ISO format.
74
+ * @returns {UtcRange}
75
+ */
76
+ export function parseUtcForDataRange(timeZone: string, startIso: string, endIso: string): UtcRange;
77
+ /**
78
+ * Checks whether the timestamp embedded in a key falls within a UTC datetime range.
79
+ *
80
+ * @public
81
+ * @function
82
+ * @param {string} key - A string key containing a UTC timestamp in the format `YYYYMMDDTHHMMSS`, following a colon (e.g. "prefix:20250624T101530").
83
+ * @param {Date} start - The inclusive lower bound of the UTC datetime range.
84
+ * @param {Date} end - The exclusive upper bound of the UTC datetime range.
85
+ * @returns {boolean} True if the parsed UTC timestamp is within the range, false otherwise.
86
+ */
87
+ export function isKeyInRange(key: string, start: Date, end: Date): boolean;
88
+ export type UtcRange = {
89
+ /**
90
+ * - The current local datetime as it appears on the wall in the specified timezone.
91
+ */
92
+ wallClock: Date;
93
+ /**
94
+ * - UTC start date range (inclusive).
95
+ */
96
+ startUtc: Date;
97
+ /**
98
+ * - UTC end of date range (exclusive).
99
+ */
100
+ endUtc: Date;
101
+ /**
102
+ * - Array of wildcard patterns to match UTC dates which correspond with date range (e.g. "*20250624*").
103
+ */
104
+ collectionKeys: any[];
105
+ };
106
+ export { alterState, combine, cursor, dataPath, dataValue, each, field, fields, fn, fnIf, lastReferenceValue, merge, sourceValue } from "@openfn/language-common";
package/types/Utils.d.ts CHANGED
@@ -2,3 +2,4 @@ export function parseMetadata(message: any): any;
2
2
  export function removeNullProps(obj: any): any;
3
3
  export function deepEqual(a: any, b: any): boolean;
4
4
  export function formatDeviceInfo(data: any): string;
5
+ export function abbreviatedIsoToDate(iso: any): Date;
@@ -1,4 +1,4 @@
1
- export function parseVaroEmsToReport(metadata: any, data: any, dataPath: any): {
1
+ export function parseVaroEmsToReport(metadata: any, data: any, rtcwMaps: any): {
2
2
  CID: any;
3
3
  LAT: any;
4
4
  LNG: any;
@@ -21,3 +21,10 @@ export function parseVaroEmsToReport(metadata: any, data: any, dataPath: any): {
21
21
  LSV: any;
22
22
  records: any[];
23
23
  };
24
+ export function applyDurationToDate(incomingDate: any, duration: any, subtract?: boolean): Date;
25
+ export function buildDeviceRtcwDateMaps(contents: any): Map<any, any>;
26
+ export function extractDeviceData(content: any): {
27
+ deviceId: any;
28
+ deviceDate: Date;
29
+ finalRtcw: any;
30
+ };