@hello.nrfcloud.com/proto-map 4.0.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.
Files changed (169) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +122 -0
  3. package/dist/generator/addDocBlock.js +5 -0
  4. package/dist/generator/generateLwM2MDefinitions.js +82 -0
  5. package/dist/generator/generateLwM2MDefinitions.spec.js +91 -0
  6. package/dist/generator/generateLwm2mTimestampResources.js +69 -0
  7. package/dist/generator/generateModels.js +142 -0
  8. package/dist/generator/generateType.js +90 -0
  9. package/dist/generator/generateValidator.js +132 -0
  10. package/dist/generator/generateValidators.js +63 -0
  11. package/dist/generator/isDir.js +163 -0
  12. package/dist/generator/isDir.spec.js +212 -0
  13. package/dist/generator/lwm2m.js +106 -0
  14. package/dist/generator/models.js +306 -0
  15. package/dist/generator/printNode.js +8 -0
  16. package/dist/generator/tokenizeName.js +5 -0
  17. package/dist/generator/tokenizeName.spec.js +98 -0
  18. package/dist/generator/types.js +140 -0
  19. package/dist/lwm2m/LWM2MObjectDefinition.js +88 -0
  20. package/dist/lwm2m/LWM2MObjectInfo.js +9 -0
  21. package/dist/lwm2m/LwM2MObject.d.js +1 -0
  22. package/dist/lwm2m/LwM2MObjectID.js +57 -0
  23. package/dist/lwm2m/ParsedLwM2MObjectDefinition.js +1 -0
  24. package/dist/lwm2m/check-lwm2m-rules.js +480 -0
  25. package/dist/lwm2m/definitions.js +596 -0
  26. package/dist/lwm2m/fromXML2JSON.js +194 -0
  27. package/dist/lwm2m/instanceTs.js +9 -0
  28. package/dist/lwm2m/instanceTs.spec.js +16 -0
  29. package/dist/lwm2m/isRegisteredLwM2MObject.js +3 -0
  30. package/dist/lwm2m/isRegisteredLwM2MObject.spec.js +45 -0
  31. package/dist/lwm2m/object/14201.d.js +5 -0
  32. package/dist/lwm2m/object/14202.d.js +5 -0
  33. package/dist/lwm2m/object/14203.d.js +5 -0
  34. package/dist/lwm2m/object/14204.d.js +5 -0
  35. package/dist/lwm2m/object/14205.d.js +5 -0
  36. package/dist/lwm2m/object/14210.d.js +5 -0
  37. package/dist/lwm2m/object/14220.d.js +5 -0
  38. package/dist/lwm2m/object/14230.d.js +5 -0
  39. package/dist/lwm2m/object/validate14201.js +18 -0
  40. package/dist/lwm2m/object/validate14202.js +17 -0
  41. package/dist/lwm2m/object/validate14203.js +19 -0
  42. package/dist/lwm2m/object/validate14204.js +17 -0
  43. package/dist/lwm2m/object/validate14205.js +15 -0
  44. package/dist/lwm2m/object/validate14210.js +13 -0
  45. package/dist/lwm2m/object/validate14220.js +12 -0
  46. package/dist/lwm2m/object/validate14230.js +13 -0
  47. package/dist/lwm2m/objects.js +8 -0
  48. package/dist/lwm2m/parseRangeEnumeration.js +20 -0
  49. package/dist/lwm2m/parseRangeEnumeration.spec.js +27 -0
  50. package/dist/lwm2m/resourceType.js +12 -0
  51. package/dist/lwm2m/timestampResources.js +12 -0
  52. package/dist/lwm2m/unwrapNestedArray.js +114 -0
  53. package/dist/lwm2m/unwrapNestedArray.spec.js +374 -0
  54. package/dist/lwm2m/validate.js +14 -0
  55. package/dist/lwm2m/validation.js +146 -0
  56. package/dist/lwm2m/validators.js +20 -0
  57. package/dist/markdown/getCodeBlock.js +74 -0
  58. package/dist/markdown/getFrontMatter.js +15 -0
  59. package/dist/markdown/parseREADME.js +19 -0
  60. package/dist/models/asset_tracker_v2+AWS/examples/examples.spec.js +489 -0
  61. package/dist/models/check-model-rules.js +137 -0
  62. package/dist/models/models.js +137 -0
  63. package/dist/models/types.js +13 -0
  64. package/dist/senml/SenMLSchema.js +79 -0
  65. package/dist/senml/SenMLSchema.spec.js +23 -0
  66. package/dist/senml/hasValue.js +8 -0
  67. package/dist/senml/hasValue.spec.js +103 -0
  68. package/dist/senml/lwm2mToSenML.js +137 -0
  69. package/dist/senml/lwm2mToSenML.spec.js +104 -0
  70. package/dist/senml/parseResourceId.js +58 -0
  71. package/dist/senml/parseResourceId.spec.js +13 -0
  72. package/dist/senml/senMLtoLwM2M.js +126 -0
  73. package/dist/senml/senMLtoLwM2M.spec.js +226 -0
  74. package/dist/senml/validateSenML.js +6 -0
  75. package/dist/senml/validateSenML.spec.js +31 -0
  76. package/export.js +13 -0
  77. package/index.d.ts +14 -0
  78. package/lwm2m/14201.xml +94 -0
  79. package/lwm2m/14202.xml +84 -0
  80. package/lwm2m/14203.xml +104 -0
  81. package/lwm2m/14204.xml +84 -0
  82. package/lwm2m/14205.xml +64 -0
  83. package/lwm2m/14210.xml +44 -0
  84. package/lwm2m/14220.xml +34 -0
  85. package/lwm2m/14230.xml +44 -0
  86. package/lwm2m/LWM2M-v1_1.xsd +168 -0
  87. package/lwm2m/LWM2MObjectDefinition.ts +84 -0
  88. package/lwm2m/LWM2MObjectInfo.ts +42 -0
  89. package/lwm2m/LwM2MObject.d.ts +19 -0
  90. package/lwm2m/LwM2MObjectID.ts +73 -0
  91. package/lwm2m/ParsedLwM2MObjectDefinition.ts +28 -0
  92. package/lwm2m/check-lwm2m-rules.ts +160 -0
  93. package/lwm2m/definitions.ts +278 -0
  94. package/lwm2m/format.sh +3 -0
  95. package/lwm2m/fromXML2JSON.ts +44 -0
  96. package/lwm2m/instanceTs.spec.ts +19 -0
  97. package/lwm2m/instanceTs.ts +10 -0
  98. package/lwm2m/isRegisteredLwM2MObject.spec.ts +48 -0
  99. package/lwm2m/isRegisteredLwM2MObject.ts +4 -0
  100. package/lwm2m/object/14201.d.ts +73 -0
  101. package/lwm2m/object/14202.d.ts +59 -0
  102. package/lwm2m/object/14203.d.ts +67 -0
  103. package/lwm2m/object/14204.d.ts +55 -0
  104. package/lwm2m/object/14205.d.ts +43 -0
  105. package/lwm2m/object/14210.d.ts +31 -0
  106. package/lwm2m/object/14220.d.ts +25 -0
  107. package/lwm2m/object/14230.d.ts +31 -0
  108. package/lwm2m/object/validate14201.ts +10 -0
  109. package/lwm2m/object/validate14202.ts +10 -0
  110. package/lwm2m/object/validate14203.ts +10 -0
  111. package/lwm2m/object/validate14204.ts +10 -0
  112. package/lwm2m/object/validate14205.ts +10 -0
  113. package/lwm2m/object/validate14210.ts +10 -0
  114. package/lwm2m/object/validate14220.ts +10 -0
  115. package/lwm2m/object/validate14230.ts +10 -0
  116. package/lwm2m/objects.ts +16 -0
  117. package/lwm2m/parseRangeEnumeration.spec.ts +34 -0
  118. package/lwm2m/parseRangeEnumeration.ts +29 -0
  119. package/lwm2m/resourceType.ts +11 -0
  120. package/lwm2m/timestampResources.ts +4 -0
  121. package/lwm2m/unwrapNestedArray.spec.ts +241 -0
  122. package/lwm2m/unwrapNestedArray.ts +27 -0
  123. package/lwm2m/validate.ts +30 -0
  124. package/lwm2m/validation.ts +120 -0
  125. package/lwm2m/validators.ts +21 -0
  126. package/models/PCA20035+solar/README.md +10 -0
  127. package/models/PCA20035+solar/transforms/airQuality.md +48 -0
  128. package/models/PCA20035+solar/transforms/battery.md +46 -0
  129. package/models/PCA20035+solar/transforms/button.md +45 -0
  130. package/models/PCA20035+solar/transforms/deviceInfo.md +72 -0
  131. package/models/PCA20035+solar/transforms/gain.md +45 -0
  132. package/models/PCA20035+solar/transforms/geolocationFromGroundfix.md +67 -0
  133. package/models/PCA20035+solar/transforms/geolocationFromMessage.md +80 -0
  134. package/models/PCA20035+solar/transforms/humidity.md +43 -0
  135. package/models/PCA20035+solar/transforms/networkInfo.md +84 -0
  136. package/models/PCA20035+solar/transforms/pressure.md +43 -0
  137. package/models/PCA20035+solar/transforms/temperature.md +43 -0
  138. package/models/README.md +10 -0
  139. package/models/asset_tracker_v2+AWS/README.md +6 -0
  140. package/models/asset_tracker_v2+AWS/examples/examples.spec.ts +229 -0
  141. package/models/asset_tracker_v2+AWS/examples/shadow/example-1.json +24 -0
  142. package/models/asset_tracker_v2+AWS/examples/shadow/example-2.json +30 -0
  143. package/models/asset_tracker_v2+AWS/examples/shadow/example-3.json +37 -0
  144. package/models/asset_tracker_v2+AWS/examples/shadow/example-4.json +48 -0
  145. package/models/asset_tracker_v2+AWS/examples/shadow/example-5.json +43 -0
  146. package/models/asset_tracker_v2+AWS/transforms/GNSS.md +66 -0
  147. package/models/asset_tracker_v2+AWS/transforms/battery-voltage.md +50 -0
  148. package/models/asset_tracker_v2+AWS/transforms/device-info.md +61 -0
  149. package/models/asset_tracker_v2+AWS/transforms/env.md +69 -0
  150. package/models/asset_tracker_v2+AWS/transforms/fuel-gauge.md +62 -0
  151. package/models/asset_tracker_v2+AWS/transforms/roam.md +100 -0
  152. package/models/asset_tracker_v2+AWS/transforms/solar.md +58 -0
  153. package/models/check-model-rules.ts +125 -0
  154. package/models/kartverket-vasstandsdata/README.md +13 -0
  155. package/models/models.ts +36 -0
  156. package/models/types.ts +17 -0
  157. package/package.json +111 -0
  158. package/senml/SenMLSchema.spec.ts +21 -0
  159. package/senml/SenMLSchema.ts +74 -0
  160. package/senml/hasValue.spec.ts +19 -0
  161. package/senml/hasValue.ts +12 -0
  162. package/senml/lwm2mToSenML.spec.ts +74 -0
  163. package/senml/lwm2mToSenML.ts +62 -0
  164. package/senml/parseResourceId.spec.ts +13 -0
  165. package/senml/parseResourceId.ts +23 -0
  166. package/senml/senMLtoLwM2M.spec.ts +181 -0
  167. package/senml/senMLtoLwM2M.ts +121 -0
  168. package/senml/validateSenML.spec.ts +16 -0
  169. package/senml/validateSenML.ts +8 -0
@@ -0,0 +1,125 @@
1
+ import chalk from 'chalk'
2
+ import jsonata from 'jsonata'
3
+ import assert from 'node:assert/strict'
4
+ import { readFile, readdir, stat } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+ import { FrontMatter, ModelIDRegExp } from './types.js'
7
+ import { senMLtoLwM2M } from '../senml/senMLtoLwM2M.js'
8
+ import { getCodeBlock } from '../markdown/getCodeBlock.js'
9
+ import { getFrontMatter } from '../markdown/getFrontMatter.js'
10
+ import { validateSenML } from '../senml/validateSenML.js'
11
+ import { isRegisteredLwM2MObject } from '../lwm2m/isRegisteredLwM2MObject.js'
12
+ import { hasValue } from '../senml/hasValue.js'
13
+ import { parseREADME } from 'markdown/parseREADME.js'
14
+
15
+ console.log(chalk.gray('Models rules check'))
16
+ console.log('')
17
+ const modelsDir = path.join(process.cwd(), 'models')
18
+ for (const model of await readdir(modelsDir)) {
19
+ const modelDir = path.join(modelsDir, model)
20
+ if (!(await stat(modelDir)).isDirectory()) continue
21
+ console.log(chalk.white('·'), chalk.white.bold(model))
22
+ assert.match(
23
+ model,
24
+ ModelIDRegExp,
25
+ 'Model identifiers must consist of numbers, letters, dash, plus, and underscore only',
26
+ )
27
+ console.log(chalk.green('✔'), chalk.gray('Model name is correct'))
28
+
29
+ // A README.md should exist
30
+ try {
31
+ await stat(path.join(modelDir, 'README.md'))
32
+ } catch {
33
+ throw new Error(`No README.md defined for model ${model}!`)
34
+ }
35
+ console.log(chalk.green('✔'), chalk.gray(`README.md exists`))
36
+ try {
37
+ parseREADME(await readFile(path.join(modelDir, 'README.md'), 'utf-8'))
38
+ } catch (err) {
39
+ console.error(err)
40
+ throw new Error(`README is not valid for ${model}!`)
41
+ }
42
+ console.log(chalk.green('✔'), chalk.gray(`README.md is valid`))
43
+
44
+ // Validate jsonata expressions
45
+ let hasTransforms = false
46
+ const transformsFolder = path.join(modelDir, 'transforms')
47
+ try {
48
+ await stat(transformsFolder)
49
+ hasTransforms = true
50
+ console.log(' ', chalk.gray('Transforms:'))
51
+ } catch {
52
+ console.log(' ', chalk.gray('No transforms found.'))
53
+ }
54
+ if (hasTransforms) {
55
+ for (const transform of (await readdir(transformsFolder)).filter((f) =>
56
+ f.endsWith('.md'),
57
+ )) {
58
+ console.log(' ', chalk.white('·'), chalk.white.bold(transform))
59
+ const markdown = await readFile(
60
+ path.join(modelDir, 'transforms', transform),
61
+ 'utf-8',
62
+ )
63
+
64
+ // Validate front-matter
65
+ const type = getFrontMatter(markdown, FrontMatter).type
66
+ console.log(' ', chalk.green('✔'), chalk.gray(`Type ${type} is valid`))
67
+ const findBlock = getCodeBlock(markdown)
68
+ const matchExpression = findBlock('jsonata', 'Match Expression')
69
+ const transformExpression = findBlock('jsonata', 'Transform Expression')
70
+ const inputExample = JSON.parse(findBlock('json', 'Input Example'))
71
+ const resultExample = JSON.parse(findBlock('json', 'Result Example'))
72
+
73
+ const selectResult = await jsonata(matchExpression).evaluate(inputExample)
74
+ if (selectResult !== true) {
75
+ throw new Error(
76
+ `The select expression did not evaluate to true with the given example.`,
77
+ )
78
+ }
79
+ console.log(
80
+ ' ',
81
+ chalk.green('✔'),
82
+ chalk.gray('Select expression evaluated to true for the example input'),
83
+ )
84
+
85
+ const transformResult = await jsonata(
86
+ // For testing purposes this function call result is hardcoded
87
+ transformExpression.replace('$millis()', '1699999999999'),
88
+ ).evaluate(inputExample)
89
+
90
+ const maybeValidSenML = validateSenML(transformResult.filter(hasValue))
91
+ if ('errors' in maybeValidSenML) {
92
+ console.error(maybeValidSenML.errors)
93
+ throw new Error('The JSONata expression must produce valid SenML')
94
+ }
95
+
96
+ assert.deepEqual(maybeValidSenML.value, resultExample)
97
+ console.log(
98
+ ' ',
99
+ chalk.green('✔'),
100
+ chalk.gray('Transformation result is valid SenML'),
101
+ )
102
+
103
+ assert.deepEqual(maybeValidSenML.value, resultExample)
104
+ console.log(
105
+ ' ',
106
+ chalk.green('✔'),
107
+ chalk.gray('The transformation result matches the example'),
108
+ )
109
+
110
+ // Validate
111
+ for (const object of senMLtoLwM2M(maybeValidSenML.value)) {
112
+ if (!isRegisteredLwM2MObject(object, console.error)) {
113
+ throw new Error(
114
+ 'The LwM2M object must follow LwM2M schema definition',
115
+ )
116
+ }
117
+ console.log(
118
+ ' ',
119
+ chalk.green('✔'),
120
+ chalk.gray('SenML object is valid LwM2M'),
121
+ )
122
+ }
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,13 @@
1
+ # Kartverket Vasstandsdata
2
+
3
+ A simulated device reporting the current sea level as provided by the
4
+ [Kartverket](https://www.kartverket.no/)'s (Norwegian Mapping Authority)
5
+ [API for vasstandsdata](https://api.sehavniva.no/tideapi_no.html) (API for water
6
+ level data).
7
+
8
+ Reports sea water level using the Object [`14230`](../../lwm2m/14230.xml).
9
+
10
+ The data is licensed by the
11
+ [Norwegian Mapping Authority](https://www.kartverket.no/)’s under the
12
+ [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/)
13
+ license.
@@ -0,0 +1,36 @@
1
+ import { type Transform, TransformType } from "./types.js";
2
+ /**
3
+ * The Model IDs defined in this repo.
4
+ */
5
+ export enum ModelID {
6
+ PCA20035_solar = "PCA20035+solar",
7
+ Asset_tracker_v2_AWS = "asset_tracker_v2+AWS",
8
+ Kartverket_vasstandsdata = "kartverket-vasstandsdata"
9
+ }
10
+ export type Model = {
11
+ /**
12
+ * The Model ID
13
+ */
14
+ "id": ModelID;
15
+ /**
16
+ * The transforms defined for this model.
17
+ */
18
+ "transforms": Array<Transform>;
19
+ /**
20
+ * Description of the Model from the README.md
21
+ */
22
+ "about": {
23
+ /**
24
+ * The text of the H1 headline
25
+ */
26
+ "title": string;
27
+ /**
28
+ * The text of the paragraphs following the H1 headline
29
+ */
30
+ "description": string;
31
+ };
32
+ };
33
+ /**
34
+ * The models defined for hello.nrfcloud.com
35
+ */
36
+ export const models: Readonly<Record<ModelID, Model>> = { [ModelID.PCA20035_solar]: { "id": ModelID.PCA20035_solar, "transforms": [{ "type": TransformType.Messages, "match": "appId = 'AIR_QUAL'", "transform": "[\n {\"bn\": \"/14205/0/\", \"n\": \"10\", \"v\": $number(data), \"bt\": ts }\n]" }, { "type": TransformType.Messages, "match": "appId = 'BATTERY'", "transform": "[\n {\"bn\": \"/14202/0/\", \"n\": \"0\", \"v\": $number(data), \"bt\": ts }\n]" }, { "type": TransformType.Messages, "match": "appId = 'BUTTON'", "transform": "[\n {\"bn\": \"/14220/0/\", \"n\": \"0\", \"v\": $number(data), \"bt\": ts }\n]" }, { "type": TransformType.Messages, "match": "appId = 'DEVICE' and $exists(data.deviceInfo)", "transform": "[\n {\"bn\": \"/14204/0/\", \"n\": \"0\", \"vs\": data.deviceInfo.imei, \"bt\": ts },\n {\"n\": \"1\", \"vs\": data.deviceInfo.iccid },\n {\"n\": \"2\", \"vs\": data.deviceInfo.modemFirmware },\n {\"n\": \"3\", \"vs\": data.deviceInfo.appVersion },\n {\"n\": \"4\", \"vs\": data.deviceInfo.board },\n {\"n\": \"5\", \"vs\": data.deviceInfo.bat }\n]" }, { "type": TransformType.Messages, "match": "appId = 'SOLAR'", "transform": "[\n {\"bn\": \"/14210/0/\", \"n\": \"0\", \"v\": $number(data), \"bt\": ts }\n]" }, { "type": TransformType.Messages, "match": "appId = 'GROUND_FIX' and $exists(data.lat) and $exists(data.lon) and $exists(data.uncertainty) and $exists(data.fulfilledWith)", "transform": "[\n {\"bn\": \"/14201/1/\", \"n\": \"0\", \"v\": data.lat, \"bt\": $millis() },\n {\"n\": \"1\", \"v\": data.lon },\n {\"n\": \"3\", \"v\": data.uncertainty },\n {\"n\": \"6\", \"vs\": data.fulfilledWith }\n]" }, { "type": TransformType.Messages, "match": "appId = 'GNSS'", "transform": "[\n {\"bn\": \"/14201/0/\", \"n\": \"0\", \"v\": data.lat, \"bt\": ts },\n {\"n\": \"1\", \"v\": data.lng },\n {\"n\": \"2\", \"v\": data.alt },\n {\"n\": \"3\", \"v\": data.acc },\n {\"n\": \"4\", \"v\": data.spd },\n {\"n\": \"5\", \"v\": data.hdg },\n {\"n\": \"6\", \"vs\": \"GNSS\" }\n]" }, { "type": TransformType.Messages, "match": "appId = 'HUMID'", "transform": "[\n {\"bn\": \"/14205/0/\", \"n\": \"1\", \"v\": $number(data), \"bt\": ts }\n]" }, { "type": TransformType.Messages, "match": "appId = 'DEVICE' and $exists(data.networkInfo)", "transform": "[\n {\"bn\": \"/14203/0/\", \"n\": \"0\", \"vs\": data.networkInfo.networkMode, \"bt\": ts },\n {\"n\": \"1\", \"v\": data.networkInfo.currentBand },\n {\"n\": \"2\", \"v\": data.networkInfo.rsrp },\n {\"n\": \"3\", \"v\": data.networkInfo.areaCode },\n {\"n\": \"4\", \"v\": data.networkInfo.cellID },\n {\"n\": \"5\", \"v\": data.networkInfo.mccmnc },\n {\"n\": \"6\", \"vs\": data.networkInfo.ipAddress },\n {\"n\": \"11\", \"v\": data.networkInfo.eest }\n]" }, { "type": TransformType.Messages, "match": "appId = 'AIR_PRESS'", "transform": "[\n {\"bn\": \"/14205/0/\", \"n\": \"2\", \"v\": $number(data)*10, \"bt\": ts }\n]" }, { "type": TransformType.Messages, "match": "appId = 'TEMP'", "transform": "[\n {\"bn\": \"/14205/0/\", \"n\": \"0\", \"v\": $number(data), \"bt\": ts }\n]" }], "about": { "title": "Thingy:91 with Solar Shield", "description": "The Nordic Thingy:91 Solar Shield is a plug-and-play prototyping platform. Powerfoyle solar cell is mounted onto the Thingy to quickly get started exploring the endless possibilities with solar powered IoT applications and to develop products with eternal life or even battery-free products.\u200B\nThe Thingy:91 runs the asset_tracker_v2 application and sends messages to nRF Cloud using MQTT." } }, [ModelID.Asset_tracker_v2_AWS]: { "id": ModelID.Asset_tracker_v2_AWS, "transforms": [{ "type": TransformType.Shadow, "match": "$exists(state.reported.gnss)", "transform": "[\n {\"bn\": \"/14201/0/\", \"n\": \"0\", \"v\": state.reported.gnss.v.lat, \"bt\": state.reported.gnss.ts },\n {\"n\": \"1\", \"v\": state.reported.gnss.v.lng },\n {\"n\": \"2\", \"v\": state.reported.gnss.v.alt },\n {\"n\": \"3\", \"v\": state.reported.gnss.v.acc },\n {\"n\": \"4\", \"v\": state.reported.gnss.v.spd },\n {\"n\": \"5\", \"v\": state.reported.gnss.v.hdg },\n {\"n\": \"6\", \"vs\": \"GNSS\" }\n]" }, { "type": TransformType.Shadow, "match": "$exists(state.reported.bat)", "transform": "[\n {\"bn\": \"/14202/0/\", \"n\": \"1\", \"v\": state.reported.bat.v/1000, \"bt\": state.reported.bat.ts }\n]" }, { "type": TransformType.Shadow, "match": "$exists(state.reported.dev)", "transform": "[\n {\"bn\": \"/14204/0/\", \"n\": \"0\", \"vs\": state.reported.dev.v.imei, \"bt\": state.reported.dev.ts },\n {\"n\": \"1\", \"vs\": state.reported.dev.v.iccid },\n {\"n\": \"2\", \"vs\": state.reported.dev.v.modV },\n {\"n\": \"3\", \"vs\": state.reported.dev.v.appV },\n {\"n\": \"4\", \"vs\": state.reported.dev.v.brdV }\n]" }, { "type": TransformType.Shadow, "match": "$exists(state.reported.env)", "transform": "[\n {\"bn\": \"/14205/0/\", \"n\": \"0\", \"v\": state.reported.env.v.temp, \"bt\": state.reported.env.ts },\n {\"n\": \"1\", \"v\": state.reported.env.v.hum },\n {\"n\": \"2\", \"v\": state.reported.env.v.atmp },\n {\"n\": \"10\", \"v\": state.reported.env.v.bsec_iaq }\n]" }, { "type": TransformType.Shadow, "match": "$exists(state.reported.fg)", "transform": "[\n {\"bn\": \"/14202/0/\", \"n\": \"0\", \"v\": state.reported.fg.v.SoC, \"bt\": state.reported.fg.ts },\n {\"n\": \"1\", \"v\": state.reported.fg.v.V/1000 },\n {\"n\": \"2\", \"v\": state.reported.fg.v.I },\n {\"n\": \"3\", \"v\": state.reported.fg.v.T = null ? null : state.reported.fg.v.T/10 },\n {\"n\": \"4\", \"v\": state.reported.fg.v.TTF },\n {\"n\": \"5\", \"v\": state.reported.fg.v.TTE }\n]" }, { "type": TransformType.Shadow, "match": "$exists(state.reported.roam)", "transform": "[\n {\"bn\": \"/14203/0/\", \"n\": \"0\", \"vs\": state.reported.roam.v.nw, \"bt\": state.reported.roam.ts },\n {\"n\": \"1\", \"v\": state.reported.roam.v.band },\n {\"bn\": \"/14203/0/\", \"n\": \"2\", \"v\": state.reported.roam.v.rsrp, \"bt\": state.reported.roam.ts },\n {\"n\": \"3\", \"v\": state.reported.roam.v.area },\n {\"n\": \"4\", \"v\": state.reported.roam.v.cell },\n {\"n\": \"5\", \"v\": state.reported.roam.v.mccmnc },\n {\"n\": \"6\", \"vs\": state.reported.roam.v.ip },\n {\"bn\": \"/14203/0/\", \"n\": \"11\", \"v\": state.reported.roam.v.eest, \"bt\": state.reported.roam.ts }\n]" }, { "type": TransformType.Shadow, "match": "$exists(state.reported.sol)", "transform": "[\n {\"bn\": \"/14210/0/\", \"n\": \"0\", \"v\": state.reported.sol.v.gain, \"bt\": state.reported.sol.ts },\n {\"n\": \"1\", \"v\": state.reported.sol.v.bat }\n]" }], "about": { "title": "asset_tracker_v2 on AWS", "description": "This implements the conversion for the asset_tracker_v2 message protocol when connected to AWS IoT." } }, [ModelID.Kartverket_vasstandsdata]: { "id": ModelID.Kartverket_vasstandsdata, "transforms": [], "about": { "title": "Kartverket Vasstandsdata", "description": "A simulated device reporting the current sea level as provided by the Kartverket's (Norwegian Mapping Authority) API for vasstandsdata (API for water level data).\nReports sea water level using the Object 14230.\nThe data is licensed by the Norwegian Mapping Authority\u2019s under the Creative Commons Attribution 4.0 International (CC BY 4.0) license." } } } as const;
@@ -0,0 +1,17 @@
1
+ import { Type } from '@sinclair/typebox'
2
+
3
+ export const ModelIDRegExp = /^[A-Za-z0-9+_-]+$/
4
+
5
+ export enum TransformType {
6
+ Shadow = 'shadow',
7
+ Messages = 'messages',
8
+ }
9
+ export type Transform = {
10
+ type: TransformType
11
+ match: string
12
+ transform: string
13
+ }
14
+
15
+ export const FrontMatter = Type.Object({
16
+ type: Type.Union([Type.Literal('shadow'), Type.Literal('messages')]),
17
+ })
package/package.json ADDED
@@ -0,0 +1,111 @@
1
+ {
2
+ "name": "@hello.nrfcloud.com/proto-map",
3
+ "version": "4.0.0",
4
+ "description": "Documents the communication protocol between devices, the hello.nrfcloud.com/map backend and web application",
5
+ "type": "module",
6
+ "types": "./index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./export.js",
10
+ "node": "./export.js"
11
+ }
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/hello-nrfcloud/proto-map.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/hello-nrfcloud/proto-map/issues"
19
+ },
20
+ "homepage": "https://hello.nrfcloud.com",
21
+ "keywords": [
22
+ "nordicsemiconductor",
23
+ "cellular-iot",
24
+ "hello-nrfcloud",
25
+ "hello-nrfcloud-map"
26
+ ],
27
+ "author": "Nordic Semiconductor ASA | nordicsemi.no",
28
+ "license": "BSD-3-Clause",
29
+ "engines": {
30
+ "node": ">=20.0.0",
31
+ "npm": ">=9.0.0"
32
+ },
33
+ "scripts": {
34
+ "prepare": "husky",
35
+ "test": "find ./ -type f -name \\*.spec.ts -not -path './node_modules/*' | xargs npx tsx --test --test-reporter spec",
36
+ "prepublishOnly": "./compile.sh"
37
+ },
38
+ "devDependencies": {
39
+ "@bifravst/eslint-config-typescript": "6.0.11",
40
+ "@bifravst/prettier-config": "1.0.0",
41
+ "@commitlint/config-conventional": "19.1.0",
42
+ "@swc/cli": "0.3.10",
43
+ "@swc/core": "1.4.8",
44
+ "@types/node": "20.11.27",
45
+ "@types/xml2js": "0.4.14",
46
+ "chalk": "5.3.0",
47
+ "husky": "9.0.11",
48
+ "lint-staged": "15.2.2",
49
+ "prettier-plugin-organize-imports": "3.2.4",
50
+ "remark": "15.0.1",
51
+ "remark-frontmatter": "5.0.0",
52
+ "tsmatchers": "5.0.2",
53
+ "tsx": "4.7.1",
54
+ "xml2js": "0.6.2",
55
+ "yaml": "2.4.1"
56
+ },
57
+ "lint-staged": {
58
+ "*.{ts,tsx}": [
59
+ "prettier --write",
60
+ "eslint"
61
+ ],
62
+ "*.{md,json,yaml,yml,html}": [
63
+ "prettier --write"
64
+ ],
65
+ "*.xml": [
66
+ "./lwm2m/format.sh"
67
+ ]
68
+ },
69
+ "prettier": "@bifravst/prettier-config",
70
+ "release": {
71
+ "branches": [
72
+ "saga",
73
+ {
74
+ "name": "!(saga|v[0-9].[0-9].x|*_*|*/*)",
75
+ "prerelease": true
76
+ }
77
+ ],
78
+ "remoteTags": true,
79
+ "plugins": [
80
+ "@semantic-release/commit-analyzer",
81
+ "@semantic-release/release-notes-generator",
82
+ "@semantic-release/npm",
83
+ [
84
+ "@semantic-release/github",
85
+ {
86
+ "successComment": false,
87
+ "failTitle": false
88
+ }
89
+ ]
90
+ ]
91
+ },
92
+ "publishConfig": {
93
+ "access": "public"
94
+ },
95
+ "files": [
96
+ "package-lock.json",
97
+ "LICENSE",
98
+ "README.md",
99
+ "dist",
100
+ "export.js",
101
+ "index.d.ts",
102
+ "senml",
103
+ "models",
104
+ "lwm2m"
105
+ ],
106
+ "dependencies": {
107
+ "@sinclair/typebox": "0.32.15",
108
+ "ajv": "8.12.0",
109
+ "jsonata": "2.0.4"
110
+ }
111
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, it } from 'node:test'
2
+ import { SenML, type SenMLType } from './SenMLSchema.js'
3
+ import assert from 'node:assert/strict'
4
+ import { validate } from '../validate.js'
5
+
6
+ void describe('SenMLType', () => {
7
+ void it('it should validate a SenML payload', () => {
8
+ const example: SenMLType = [
9
+ {
10
+ bn: '/14201/0/',
11
+ n: '0',
12
+ v: 33.98755678796222,
13
+ bt: 1698155694999,
14
+ },
15
+ { n: '1', v: -84.506132079174634 },
16
+ ]
17
+ const res = validate(SenML)(example)
18
+ assert.equal('errors' in res, false)
19
+ assert.deepEqual('value' in res && res.value, example)
20
+ })
21
+ })
@@ -0,0 +1,74 @@
1
+ import { Type, type Static } from '@sinclair/typebox'
2
+
3
+ const ResourceIDPart = Type.RegExp(/^[0-9/]+$/, {
4
+ title: 'ResourceIDPart',
5
+ description:
6
+ 'Combines `bn` and `n` to a fully qualified resource identifier in the form of `/<object ID>/<object instance ID>/<resource ID>/0`. (Multiple resource instances are not supported right now.).',
7
+ examples: ['/', '/14201/0/', '5'],
8
+ })
9
+ const BaseValue = Type.Number({ title: 'Base Value' })
10
+ const Value = Type.Number({ title: 'Value' })
11
+ const Time = Type.Integer({ minimum: 0, title: 'Time' })
12
+
13
+ /**
14
+ * Defines a SenML type with some unsupported elements removed: Sum, Base Sum, Update Time
15
+ *
16
+ * @see https://datatracker.ietf.org/doc/html/rfc8428
17
+ */
18
+ export const Measurement = Type.Intersect(
19
+ [
20
+ Type.Union([
21
+ Type.Object({
22
+ n: ResourceIDPart,
23
+ }),
24
+ Type.Object({
25
+ bn: ResourceIDPart,
26
+ n: ResourceIDPart,
27
+ blv: Type.String({
28
+ minLength: 1,
29
+ description: 'The LwM2M object version used',
30
+ default: '1.0',
31
+ }),
32
+ bt: Type.Optional(Time),
33
+ }),
34
+ ]),
35
+ // Value combinations
36
+ Type.Union([
37
+ Type.Union([
38
+ Type.Object({
39
+ bv: BaseValue,
40
+ }),
41
+ Type.Object({
42
+ bv: BaseValue,
43
+ v: Value,
44
+ }),
45
+ Type.Object({
46
+ // Value can be undefined, if it is not mandatory
47
+ v: Type.Optional(Value),
48
+ }),
49
+ ]),
50
+ Type.Object({
51
+ vs: Type.String({ minLength: 1, title: 'String Value' }),
52
+ }),
53
+ Type.Object({
54
+ vb: Type.Boolean({ title: 'Boolean Value' }),
55
+ }),
56
+ Type.Object({
57
+ vd: Type.String({
58
+ title: 'Data Value.',
59
+ description:
60
+ 'Octets in the Data Value are base64 encoded with the URL-safe alphabet as defined in Section 5 of [RFC4648], with padding omitted.',
61
+ }),
62
+ }),
63
+ ]),
64
+ ],
65
+ {
66
+ description:
67
+ 'SenML schema for conversion results. This is limited to properties useful for the hello.nrfcloud.com/map application.',
68
+ },
69
+ )
70
+
71
+ export const SenML = Type.Array(Measurement, { minItems: 1 })
72
+
73
+ export type SenMLType = Static<typeof SenML>
74
+ export type MeasurementType = Static<typeof Measurement>
@@ -0,0 +1,19 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { hasValue } from './hasValue.js'
4
+
5
+ void describe('hasValue() should determine whether an object has a value', () => {
6
+ for (const [record, expected] of [
7
+ [{ bn: 14202, n: 0, v: 99, bt: 1699049685992 }, true],
8
+ [{ n: 1, v: 4.179 }, true],
9
+ [{ n: 2, v: 0 }, true],
10
+ [{ n: 3, v: 25.7 }, true],
11
+ [{ n: 4, v: null }, false],
12
+ [{ n: 5, v: null }, false],
13
+ ]) {
14
+ void it(`should determine that ${JSON.stringify(
15
+ record,
16
+ )} has a value: ${JSON.stringify(expected)}`, () =>
17
+ assert.equal(hasValue(record), expected))
18
+ }
19
+ })
@@ -0,0 +1,12 @@
1
+ export const hasValue = (m: unknown): boolean => {
2
+ if (m === null) return false
3
+ if (typeof m !== 'object') return false
4
+ const v =
5
+ ('bv' in m ? m.bv : undefined) ??
6
+ ('v' in m ? m.v : undefined) ??
7
+ ('vs' in m ? m.vs : undefined) ??
8
+ ('vb' in m ? m.vb : undefined) ??
9
+ ('vd' in m ? m.vd : undefined)
10
+ if (v === null || v === undefined) return false
11
+ return true
12
+ }
@@ -0,0 +1,74 @@
1
+ import { it, describe } from 'node:test'
2
+ import type { SenMLType } from './SenMLSchema.js'
3
+ import type {
4
+ Geolocation_14201,
5
+ SeaWaterLevel_14230,
6
+ } from '../lwm2m/objects.js'
7
+ import { LwM2MObjectID } from '../lwm2m/LwM2MObjectID.js'
8
+ import assert from 'node:assert/strict'
9
+ import type { LwM2MObjectInstance } from './senMLtoLwM2M.js'
10
+ import { lwm2mToSenML } from './lwm2mToSenML.js'
11
+
12
+ void describe('lwm2mToSenML()', () => {
13
+ void it('should convert LwM2M to SenML', () => {
14
+ const location: LwM2MObjectInstance<Geolocation_14201> = {
15
+ ObjectID: LwM2MObjectID.Geolocation_14201,
16
+ ObjectVersion: '1.0',
17
+ Resources: {
18
+ '0': 62.469414,
19
+ '1': 6.151946,
20
+ '6': 'Fixed',
21
+ '3': 1,
22
+ '99': new Date(1710147413003),
23
+ },
24
+ }
25
+ const level1: LwM2MObjectInstance<SeaWaterLevel_14230> = {
26
+ ObjectID: LwM2MObjectID.SeaWaterLevel_14230,
27
+ ObjectVersion: '1.0',
28
+ Resources: {
29
+ '0': 84.3,
30
+ '1': 'AES',
31
+ '99': new Date(1710140400000),
32
+ },
33
+ }
34
+ const level2: LwM2MObjectInstance<SeaWaterLevel_14230> = {
35
+ ObjectID: LwM2MObjectID.SeaWaterLevel_14230,
36
+ ObjectVersion: '1.0',
37
+ Resources: {
38
+ '0': 140.4,
39
+ '1': 'AES',
40
+ '99': new Date(1710144000000),
41
+ },
42
+ }
43
+ const level3: LwM2MObjectInstance<SeaWaterLevel_14230> = {
44
+ ObjectID: LwM2MObjectID.SeaWaterLevel_14230,
45
+ ObjectVersion: '1.0',
46
+ ObjectInstanceID: 1,
47
+ Resources: {
48
+ '0': 140.7,
49
+ '1': 'AES',
50
+ '99': new Date(1710144001000),
51
+ },
52
+ }
53
+ const lwm2m: Array<LwM2MObjectInstance<any>> = [
54
+ location,
55
+ level1,
56
+ level2,
57
+ level3,
58
+ ]
59
+ const expected: SenMLType = [
60
+ { bn: '/14201/0/', n: '0', v: 62.469414, bt: 1710147413003 },
61
+ { n: '1', v: 6.151946 },
62
+ { n: '3', v: 1 },
63
+ { n: '6', vs: 'Fixed' },
64
+ { bn: '/14230/0/', n: '0', v: 84.3, bt: 1710140400000 },
65
+ { n: '1', vs: 'AES' },
66
+ { bn: '/14230/0/', n: '0', v: 140.4, bt: 1710144000000 },
67
+ { n: '1', vs: 'AES' },
68
+ { bn: '/14230/1/', n: '0', v: 140.7, bt: 1710144001000 },
69
+ { n: '1', vs: 'AES' },
70
+ ]
71
+
72
+ assert.deepEqual(lwm2mToSenML(lwm2m), expected)
73
+ })
74
+ })
@@ -0,0 +1,62 @@
1
+ import type { SenMLType } from './SenMLSchema'
2
+ import { instanceTs } from '../lwm2m/instanceTs.js'
3
+ import type { LwM2MObjectInstance, LwM2MResourceValue } from './senMLtoLwM2M'
4
+ import { timestampResources } from '../lwm2m/timestampResources.js'
5
+ import { definitions } from '../lwm2m/definitions.js'
6
+ import { ResourceType, type LWM2MObjectInfo } from '../lwm2m/LWM2MObjectInfo.js'
7
+
8
+ /**
9
+ * Convert LwM2M Object Instances to senML
10
+ */
11
+ export const lwm2mToSenML = (
12
+ lwm2m: Array<LwM2MObjectInstance<any>>,
13
+ ): SenMLType =>
14
+ lwm2m
15
+ .map(asSenML)
16
+ .flat()
17
+ .filter((v) => v !== null) as SenMLType
18
+
19
+ const asSenML = (lwm2m: LwM2MObjectInstance<any>): SenMLType | null => {
20
+ const def = definitions[lwm2m.ObjectID]
21
+ const i = instanceTs(lwm2m)
22
+ const tsResourceId = timestampResources[lwm2m.ObjectID] as number // All registered objects must have a timestamp resource
23
+ const [first, ...rest] = Object.entries({
24
+ ...lwm2m.Resources,
25
+ [tsResourceId]: undefined,
26
+ })
27
+ // Filter out undefined values (and timestamp resource)
28
+ .filter((r): r is [string, LwM2MResourceValue] => r[1] !== undefined)
29
+
30
+ if (first === undefined) return null
31
+ return [
32
+ {
33
+ bn: `/${lwm2m.ObjectID}/${lwm2m.ObjectInstanceID ?? 0}/`,
34
+ n: first[0],
35
+ [toKey(def, parseInt(first[0], 10))]: first[1],
36
+ bt: i.getTime(),
37
+ },
38
+ ...rest.map((r) => ({
39
+ n: r[0],
40
+ [toKey(def, parseInt(r[0], 10))]: r[1],
41
+ })),
42
+ ]
43
+ }
44
+
45
+ const toKey = (def: LWM2MObjectInfo, resourceId: number) => {
46
+ switch (def.Resources[resourceId]?.Type) {
47
+ case ResourceType.String:
48
+ return 'vs'
49
+ case ResourceType.Boolean:
50
+ return 'vb'
51
+ case ResourceType.Float:
52
+ case ResourceType.Integer:
53
+ case ResourceType.Time:
54
+ return 'v'
55
+ case ResourceType.Opaque:
56
+ return 'vd'
57
+ default:
58
+ throw new Error(
59
+ `Unknown ResourceID ${resourceId} for LwM2M Object ${def.ObjectID}!`,
60
+ )
61
+ }
62
+ }
@@ -0,0 +1,13 @@
1
+ import assert from 'node:assert'
2
+ import { describe, it } from 'node:test'
3
+ import { parseResourceId } from './parseResourceId.js'
4
+
5
+ void describe('parseResourceId()', () => {
6
+ void it('should parse an LwM2M resource ID', () =>
7
+ assert.deepEqual(parseResourceId('/14201/1/2/0'), {
8
+ ObjectID: 14201,
9
+ ObjectInstanceID: 1,
10
+ ResourceID: 2,
11
+ ResourceInstanceId: 0,
12
+ }))
13
+ })
@@ -0,0 +1,23 @@
1
+ export type ResourceID = {
2
+ ObjectID: number
3
+ ObjectInstanceID: number
4
+ ResourceID: number
5
+ ResourceInstanceId: number
6
+ }
7
+
8
+ export const parseResourceId = (resourceId: string): ResourceID | null => {
9
+ if (!/^\/\d+\/\d+\/\d+\/\d+$/.test(resourceId)) return null
10
+
11
+ const [ObjectID, ObjectInstanceID, ResourceID, ResourceInstanceId] =
12
+ resourceId
13
+ .slice(1)
14
+ .split('/')
15
+ .map((s) => parseInt(s, 10)) as [number, number, number, number]
16
+
17
+ return {
18
+ ObjectID,
19
+ ObjectInstanceID,
20
+ ResourceID,
21
+ ResourceInstanceId,
22
+ }
23
+ }