Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support payload normalization #508

Merged
merged 9 commits into from
Aug 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: '~1.17'
go-version: '~1.18'
- name: Set up Node
uses: actions/setup-node@v2
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: '~1.17'
go-version: '~1.18'
- uses: actions/setup-node@v2
with:
node-version: '16'
Expand Down
110 changes: 8 additions & 102 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Development dependencies:

- Node.js version 16.x
- npm version 8.x
- Go version 1.17.x
- Go version 1.18.x

To check your Node.js, npm and Go versions:

Expand Down Expand Up @@ -288,6 +288,12 @@ uplinkDecoder:
data:
direction: 'N'
speed: 32
# Normalized output, uses the normalizeUplink function (optional)
normalizedOutput:
data:
- wind:
speed: 16.4608
direction: 0
# Downlink encoder encodes JSON object into a binary data downlink (optional)
downlinkEncoder:
fileName: codec.js
Expand Down Expand Up @@ -316,108 +322,8 @@ downlinkDecoder:

The actual **Payload codec implementation** is in the referenced filename: `vendor/<vendor-id>/<codec-filename>`.

An example codec for a wind direction and speed sensor with controllable LED looks like this:

```js
var directions = ["N", "E", "S", "W"];
var colors = ["red", "green"];
// input = { fPort: 1, bytes: [1, 62] }
function decodeUplink(input) {
switch (input.fPort) {
case 1:
return {
// Decoded data
data: {
direction: directions[input.bytes[0]],
speed: input.bytes[1]
}
}
default:
return {
errors: ["unknown FPort"]
}
}
}
// input = { data: { led: "green" } }
function encodeDownlink(input) {
var i = colors.indexOf(input.data.led);
if (i === -1) {
return {
errors: ["invalid LED color"]
}
}
return {
// LoRaWAN FPort used for the downlink message
fPort: 2,
// Encoded bytes
bytes: [i]
}
}
// input = { fPort: 2, bytes: [1] }
function decodeDownlink(input) {
switch (input.fPort) {
case 2:
return {
// Decoded downlink (must be symmetric with encodeDownlink)
data: {
led: colors[input.bytes[0]]
}
}
default:
return {
errors: ["invalid FPort"]
}
}
}
```
See [The Things Stack documentation](https://www.thethingsindustries.com/docs/integrations/payload-formatters/javascript) for how to write JavaScript functions for decoding, normalizing and encoding data.

#### Errors and Warnings

Scripts can return warnings and errors to inform the application layer of potential issues with the data or indicate that the payload is malformatted.

The warnings and errors are string arrays. If there are any errors, the message fails. Any warnings are added to the message.

Example warning:

```js
// input = { fPort: 1, bytes: [1, 2, 3] }
function decodeUplink(input) {
var warnings = [];
var battery = input.bytes[0] << 8 | input.bytes[1];
if (battery < 2000) {
warnings.push("unreliable battery level");
}
return {
// Decoded data
data: {
battery: battery
},
// Warnings
warnings: warnings
}
}
```

Example error:

```js
function encodeDownlink(input) {
if (typeof input.data.gate !== 'boolean') {
return {
errors: [
"missing required field: gate"
]
}
}
return {
fPort: 1,
bytes: [input.data.gate ? 1 : 0]
}
}
```
## Legal

The API is distributed under [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). See `LICENSE` for more information.
Expand Down
36 changes: 30 additions & 6 deletions bin/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const isEqual = require('lodash.isequal');
const readChunk = require('read-chunk');
const imageType = require('image-type');

const ajv = new Ajv({ schemas: [require('../schema.json')] });
const ajv = new Ajv({ schemas: [require('../lib/payload.json'), require('../schema.json')] });

const options = yargs.usage('Usage: --vendor <file>').option('v', {
alias: 'vendor',
Expand Down Expand Up @@ -78,15 +78,36 @@ function validatePayloadCodecs(vendorId, payloadEncoding) {
{ def: payloadEncoding.downlinkDecoder, routine: 'decodeDownlink' },
].forEach((d) => {
if (d.def) {
let fileName = `${vendorId}/${d.def.fileName}`;
const { routine } = d;
const fileName = `${vendorId}/${d.def.fileName}`;
promises.push(requireFile(fileName));
if (d.def.examples) {
d.def.examples.forEach((e) => {
const { input, output, description, normalizedOutput } = e;
runs.push({
fileName: fileName,
routine: d.routine,
...e,
fileName,
routine,
description,
input,
output,
});
if (normalizedOutput && d.routine === 'decodeUplink') {
runs.push({
fileName,
routine: 'normalizeUplink',
description: `${description} (normalized)`,
input: output,
output: normalizedOutput,
transformOutput: (output) => {
// The normalizer can return an object or an array of objects.
// If it's an object, convert it to an array with a single item.
if (output.data && !Array.isArray(output.data)) {
output.data = [output.data];
}
return output;
},
});
}
});
}
}
Expand All @@ -104,7 +125,10 @@ function validatePayloadCodecs(vendorId, payloadEncoding) {
reject(stderr);
} else {
const expected = r.output;
const actual = JSON.parse(stdout);
let actual = JSON.parse(stdout);
if (r.transformOutput) {
actual = r.transformOutput(actual);
}
if (isEqual(expected, actual)) {
console.debug(`${r.fileName}:${r.routine}: ${r.description} has correct output`);
resolve();
Expand Down
86 changes: 86 additions & 0 deletions lib/payload.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://schema.thethings.network/devicerepository/1/payload/schema",
"title": "LoRaWAN Device Repository Payload",
"description": "Payload definitions for the LoRaWAN Device Repository",
"definitions": {
johanstokking marked this conversation as resolved.
Show resolved Hide resolved
"temperature": {
"type": "number",
"description": "Temperature (°C)",
"minimum": -273.15
},
"direction": {
"type": "number",
"description": "Direction (°)",
"minimum": 0,
"exclusiveMaximum": 360
},
"speed": {
"type": "number",
"description": "Speed (m/s)",
"minimum": 0
},
"percentage": {
"type": "number",
"description": "Percentage (%)",
"minimum": 0,
"maximum": 100
},
"measurement": {
"type": "object",
"properties": {
"time": {
"type": "string",
"format": "date-time",
"description": "Date and time of the measurement (RFC3339)"
},
"air": {
"type": "object",
"properties": {
"temperature": {
"description": "Air temperature (°C)",
"$ref": "#/definitions/temperature"
},
"relativeHumidity": {
"description": "Relative humidity (%)",
"$ref": "#/definitions/percentage"
},
"pressure": {
"type": "number",
"description": "Atmospheric pressure (hPa)",
"minimum": 900,
"maximum": 1100
}
},
"additionalProperties": false
},
"wind": {
"type": "object",
"properties": {
"speed": {
"description": "Wind speed (m/s)",
"$ref": "#/definitions/speed"
},
"direction": {
"description": "Wind direction (°)",
"$ref": "#/definitions/direction"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"uplinkPayload": {
"type": "array",
"items": {
"$ref": "#/definitions/measurement"
}
}
},
"oneOf": [
{
"$ref": "#/definitions/uplinkPayload"
}
]
}
45 changes: 43 additions & 2 deletions schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,43 @@
}
}
]
},
"normalizeUplinkInput": {
"type": "object",
"properties": {
"data": {
"type": "object"
}
},
"required": ["data"]
},
"normalizeUplinkOutput": {
"allOf": [
{
"$ref": "#/definitions/endDevicePayloadCodec/definitions/scriptOutput"
},
{
"type": "object",
"properties": {
"data": { "$ref": "https://schema.thethings.network/devicerepository/1/payload/schema#/definitions/uplinkPayload" }
}
},
{
"if": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"maxItems": 0
}
}
},
"then": {
"type": "object",
"required": ["data"]
}
}
]
}
},
"properties": {
Expand All @@ -442,6 +479,9 @@
},
"output": {
"$ref": "#/definitions/endDevicePayloadCodec/definitions/decodeOutput"
},
"normalizedOutput": {
"$ref": "#/definitions/endDevicePayloadCodec/definitions/normalizeUplinkOutput"
}
}
}
Expand Down Expand Up @@ -498,7 +538,8 @@
}
]
}
}
},
"additionalProperties": false
},
"endDeviceProfile": {
"title": "End Device Profile",
Expand Down Expand Up @@ -1176,5 +1217,5 @@
"required": ["name", "firmwareVersions"]
}
},
"anyOf": [{ "$ref": "#/definitions/endDevice" }, { "$ref": "#/definitions/endDeviceProfile" }, { "$ref": "#/definitions/vendorsIndex" }, { "$ref": "#/definitions/vendorIndex" }]
"oneOf": [{ "$ref": "#/definitions/endDevice" }, { "$ref": "#/definitions/endDeviceProfile" }, { "$ref": "#/definitions/vendorsIndex" }, { "$ref": "#/definitions/vendorIndex" }]
}
Loading