Skip to content

Commit

Permalink
feat: store device updates as LwM2M
Browse files Browse the repository at this point in the history
  • Loading branch information
coderbyheart committed Nov 5, 2023
1 parent fe04cfc commit ee6f01b
Show file tree
Hide file tree
Showing 16 changed files with 536 additions and 12 deletions.
1 change: 1 addition & 0 deletions cdk/BackendLambdas.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ type BackendLambdas = {
onNetworkSurveyLocated: PackedLambda
parseSinkMessages: PackedLambda
nrplusGatewayScan: PackedLambda
updatesToLwM2M: PackedLambda
}
4 changes: 4 additions & 0 deletions cdk/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const packagesInLayer: string[] = [
'@sinclair/typebox',
'ajv',
'@nordicsemiconductor/timestream-helpers',
'@hello.nrfcloud.com/proto-lwm2m',
'jsonata',
'ulid',
]
const pack = async (
id: string,
Expand Down Expand Up @@ -47,6 +50,7 @@ new BackendApp({
onNetworkSurveyLocated: await pack('onNetworkSurveyLocated'),
parseSinkMessages: await pack('parseSinkMessages'),
nrplusGatewayScan: await pack('nrplusGatewayScan'),
updatesToLwM2M: await pack('updatesToLwM2M'),
},
layer: await packLayer({
id: 'baseLayer',
Expand Down
121 changes: 121 additions & 0 deletions cdk/resources/LwM2M.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Construct } from 'constructs'
import {
aws_dynamodb as DynamoDB,
RemovalPolicy,
Duration,
aws_iam as IAM,
aws_iot as IoT,
aws_lambda as Lambda,
Stack,
aws_logs as Logs,
} from 'aws-cdk-lib'
import type { PackedLambda } from '../backend'

/**
* Contains resources that provide LwM2M based data for devices
*/
export class LwM2M extends Construct {
public readonly table: DynamoDB.ITable
constructor(
parent: Construct,
{
lambdaSources,
baseLayer,
}: {
lambdaSources: {
updatesToLwM2M: PackedLambda
}
baseLayer: Lambda.ILayerVersion
},
) {
super(parent, 'LwM2M')

this.table = new DynamoDB.Table(this, 'table', {
billingMode: DynamoDB.BillingMode.PAY_PER_REQUEST,
partitionKey: {
name: 'id',
type: DynamoDB.AttributeType.STRING,
},
timeToLiveAttribute: 'ttl',
removalPolicy: RemovalPolicy.DESTROY,
})

const fn = new Lambda.Function(this, 'updatesToLwM2M', {
handler: lambdaSources.updatesToLwM2M.handler,
architecture: Lambda.Architecture.ARM_64,
runtime: Lambda.Runtime.NODEJS_18_X,
timeout: Duration.seconds(60),
memorySize: 1792,
code: Lambda.Code.fromAsset(lambdaSources.updatesToLwM2M.lambdaZipFile),
description:
'Invoked when devices report their cell locationStore shadow updates asset_tracker_v2 shadow format as LwM2M objects in a named shadow. Also store the updates in a table for historical data.',
layers: [baseLayer],
environment: {
VERSION: this.node.tryGetContext('version'),
TABLE_NAME: this.table.tableName,
},
initialPolicy: [
new IAM.PolicyStatement({
actions: ['iot:UpdateThingShadow'],
resources: ['*'],
}),
],
logRetention: Logs.RetentionDays.ONE_WEEK,
})

this.table.grantWriteData(fn)

const ruleRole = new IAM.Role(this, 'ruleRole', {
assumedBy: new IAM.ServicePrincipal(
'iot.amazonaws.com',
) as IAM.IPrincipal,
inlinePolicies: {
rootPermissions: new IAM.PolicyDocument({
statements: [
new IAM.PolicyStatement({
actions: ['iot:Publish'],
resources: [
`arn:aws:iot:${Stack.of(parent).region}:${
Stack.of(parent).account
}:topic/errors`,
],
}),
],
}),
},
})

const rule = new IoT.CfnTopicRule(this, 'rule', {
topicRulePayload: {
description: `Convert shadow updates to LwM2M`,
ruleDisabled: false,
awsIotSqlVersion: '2016-03-23',
sql: [
`SELECT * as update,`,
`topic(3) as deviceId`,
`FROM '$aws/things/+/shadow/update'`,
].join(' '),
actions: [
{
lambda: {
functionArn: fn.functionArn,
},
},
],
errorAction: {
republish: {
roleArn: ruleRole.roleArn,
topic: 'errors',
},
},
},
})

fn.addPermission('invokeByRule', {
principal: new IAM.ServicePrincipal(
'iot.amazonaws.com',
) as IAM.IPrincipal,
sourceArn: rule.attrArn,
})
}
}
4 changes: 2 additions & 2 deletions cdk/resources/Map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class Map extends Construct {
public readonly map: CfnMap

constructor(
parent: Stack,
parent: Construct,
id: string,
{
userAuthentication,
Expand All @@ -18,7 +18,7 @@ export class Map extends Construct {
super(parent, id)

this.map = new Location.CfnMap(this, 'mapDark', {
mapName: `${parent.stackName}-map`,
mapName: `${Stack.of(parent).stackName}-map`,
description:
'Map used to display on the dashboard (Esri Dark Gray Canvas)',
configuration: {
Expand Down
3 changes: 1 addition & 2 deletions cdk/resources/PublishSummaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
aws_iam as IAM,
aws_lambda as Lambda,
Duration,
Stack,
aws_logs as Logs,
} from 'aws-cdk-lib'
import type { IPrincipal } from 'aws-cdk-lib/aws-iam/index.js'
Expand All @@ -17,7 +16,7 @@ import type { WebsocketAPI } from './WebsocketAPI.js'
*/
export class PublishSummaries extends Construct {
public constructor(
parent: Stack,
parent: Construct,
{
lambdaSources,
baseLayer,
Expand Down
6 changes: 4 additions & 2 deletions cdk/resources/ResolveCellLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { WebsocketAPI } from './WebsocketAPI.js'

export class ResolveCellLocation extends Construct {
public constructor(
parent: Stack,
parent: Construct,
{
lambdaSources,
baseLayer,
Expand Down Expand Up @@ -85,7 +85,9 @@ export class ResolveCellLocation extends Construct {
new IAM.PolicyStatement({
actions: ['iot:Publish'],
resources: [
`arn:aws:iot:${parent.region}:${parent.account}:topic/errors`,
`arn:aws:iot:${Stack.of(parent).region}:${
Stack.of(parent).account
}:topic/errors`,
],
}),
],
Expand Down
3 changes: 1 addition & 2 deletions cdk/resources/ResolveNetworkSurveyGeoLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
aws_iam as IAM,
aws_lambda as Lambda,
aws_lambda_event_sources as LambdaEvents,
Stack,
aws_logs as Logs,
} from 'aws-cdk-lib'
import { Construct } from 'constructs'
Expand All @@ -18,7 +17,7 @@ import type { WebsocketAPI } from './WebsocketAPI'
*/
export class ResolveNetworkSurveyGeoLocation extends Construct {
constructor(
parent: Stack,
parent: Construct,
{
lambdaSources,
baseLayer,
Expand Down
3 changes: 1 addition & 2 deletions cdk/resources/UserAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
aws_cognito as Cognito,
aws_iam as IAM,
RemovalPolicy,
Stack,
} from 'aws-cdk-lib'
import { Construct } from 'constructs'

Expand All @@ -11,7 +10,7 @@ export class UserAuthentication extends Construct {
public readonly unauthenticatedUserRole: IAM.IRole
public readonly identityPool: Cognito.CfnIdentityPool
public readonly userPool: Cognito.UserPool
constructor(parent: Stack, id: string) {
constructor(parent: Construct, id: string) {
super(parent, id)

this.userPool = new Cognito.UserPool(this, 'userPool', {
Expand Down
11 changes: 11 additions & 0 deletions cdk/stacks/BackendStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { UserAuthentication } from '../resources/UserAuthentication.js'
import { WebsocketAPI } from '../resources/WebsocketAPI.js'
import { STACK_NAME } from './stackName.js'
import { NRPlusGateway } from '../resources/NRPlusGateway.js'
import { LwM2M } from '../resources/LwM2M.js'

export class BackendStack extends Stack {
public constructor(
Expand Down Expand Up @@ -96,6 +97,11 @@ export class BackendStack extends Stack {
lambdaSources,
})

const lwm2m = new LwM2M(this, {
lambdaSources,
baseLayer,
})

// Outputs
new CfnOutput(this, 'WebSocketURI', {
exportName: `${this.stackName}:WebSocketURI`,
Expand All @@ -122,6 +128,11 @@ export class BackendStack extends Stack {
value: userAuthentication.identityPool.ref,
exportName: `${this.stackName}:identityPoolId`,
})

new CfnOutput(this, 'lwm2mTableName', {
value: lwm2m.table.tableName,
exportName: `${this.stackName}:lwm2mTableName`,
})
}
}

Expand Down
113 changes: 113 additions & 0 deletions lambda/updatesToLwM2M.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {
AttributeValue,
BatchWriteItemCommand,
DynamoDBClient,
} from '@aws-sdk/client-dynamodb'
import {
IoTDataPlaneClient,
UpdateThingShadowCommand,
} from '@aws-sdk/client-iot-data-plane'
import { fromEnv } from '@nordicsemiconductor/from-env'
import { transformShadowUpdateToLwM2M } from '../lwm2m/transformShadowUpdateToLwM2M.js'
import { models, type LwM2MObject } from '@hello.nrfcloud.com/proto-lwm2m'
import { ulid } from 'ulid'
import { updatesToShadow } from '../lwm2m/objectsToShadow.js'

const { tableName } = fromEnv({
tableName: 'TABLE_NAME',
})(process.env)

const db = new DynamoDBClient({})
const iotData = new IoTDataPlaneClient({})
const transformUpdate = transformShadowUpdateToLwM2M(
models['asset_tracker_v2+AWS'].transforms,
)

const persist = async (
deviceId: string,
objects: LwM2MObject[],
): Promise<void> => {
await db.send(
new BatchWriteItemCommand({
RequestItems: {
[tableName]: objects.map((object) => ({
PutRequest: {
Item: {
id: { S: ulid() },
deviceId: { S: deviceId },
ObjectId: { N: object.ObjectID.toString() },
ObjectVersion:
object.ObjectVersion !== undefined
? { S: object.ObjectVersion }
: { S: '1.0' },
Resources: {
M: Object.entries(object.Resources).reduce(
(map, [k, v]) => ({
...map,
[k]: convertValue(v),
}),
{},
),
},
ttl: {
N: Math.round(Date.now() / 1000 + 30 * 24 * 60 * 60).toString(),
},
},
},
})),
},
}),
)
}

const convertValue = (v: string | number | boolean | Date): AttributeValue => {
if (typeof v === 'number') return { N: v.toString() }
if (typeof v === 'boolean') return { BOOL: v }
if (typeof v === 'object' && v instanceof Date) return { S: v.toISOString() }
return { S: v }
}

const updateShadow = async (
deviceId: string,
objects: LwM2MObject[],
): Promise<void> => {
await iotData.send(
new UpdateThingShadowCommand({
thingName: deviceId,
shadowName: 'lwm2m',
payload: JSON.stringify({
state: {
reported: updatesToShadow(objects),
},
}),
}),
)
}

/**
* Store shadow updates in asset_tracker_v2 shadow format as LwM2M objects in a named shadow.
*
* Also store the updates in a table for historical data.
*/
export const handler = async (event: {
deviceId: string
update: {
state: {
reported?: Record<string, unknown>
desired?: Record<string, unknown>
}
}
}): Promise<void> => {
console.debug(JSON.stringify({ event }))
const { deviceId, update } = event
const objects = await transformUpdate(update)
console.log(
JSON.stringify({
deviceId,
objects,
}),
)

void persist(deviceId, objects)
void updateShadow(deviceId, objects)
}
Loading

0 comments on commit ee6f01b

Please sign in to comment.