Skip to content

Commit 84c0beb

Browse files
committed
feat: resolve single cell geo location
Closes #138 Closes #146
1 parent ffe16cd commit 84c0beb

17 files changed

+544
-63
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ need to prepare nRF Cloud API key.
3636
./cli.sh create-health-check-device
3737
```
3838

39+
#### nRF Cloud Location Services Service Key
40+
41+
The single-cell geo-location features uses the nRF Cloud
42+
[Ground Fix API](https://api.nrfcloud.com/v1#tag/Ground-Fix) which requires the
43+
service to be enabled in the account's plan. Manage the account at
44+
<https://nrfcloud.com/#/manage-plan>.
45+
3946
### Deploy
4047

4148
```bash
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# 004: Resolve all cell geo locations
2+
3+
All (single) cell geo locations are resolved as soon as a device sends new
4+
network information instead of resolving it only on user request, or if a
5+
websocket connection is active for the device (meaning a user is observing the
6+
device on the web application).
7+
8+
This is in line with the ground fix implementation: all ground fix messages by
9+
devices are resolved.
10+
11+
Resolving all device locations based on the device's network information allows
12+
to:
13+
14+
1. show device location on the map immediately (if it is already resolved)
15+
2. show an approximate location right after the device has connected (because
16+
one of the first messages right after boot is the device information)
17+
3. show a location trail of the device based purely on LTE network information
18+
4. show single cell (SCELL) vs. multi cell (MCELL) performance using nRF Cloud
19+
Location services (these services can be purchased individually and have
20+
different pricing: https://nrfcloud.com/#/pricing)

cdk/BackendLambdas.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ type BackendLambdas = {
1414
historicalDataRequest: PackedLambda
1515
kpis: PackedLambda
1616
configureDevice: PackedLambda
17+
resolveSingleCellGeoLocation: PackedLambda
1718
}

cdk/packBackendLambdas.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,8 @@ export const packBackendLambdas = async (): Promise<BackendLambdas> => ({
3939
'configureDevice',
4040
'lambda/configureDevice.ts',
4141
),
42+
resolveSingleCellGeoLocation: await packLambdaFromPath(
43+
'resolveSingleCellGeoLocation',
44+
'lambda/resolveSingleCellGeoLocation.ts',
45+
),
4246
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
Duration,
3+
aws_iam as IAM,
4+
aws_iot as IoT,
5+
aws_lambda as Lambda,
6+
aws_logs as Logs,
7+
Stack,
8+
} from 'aws-cdk-lib'
9+
import { Construct } from 'constructs'
10+
import type { PackedLambda } from '../helpers/lambdas/packLambda.js'
11+
import { LambdaSource } from './LambdaSource.js'
12+
import type { WebsocketAPI } from './WebsocketAPI.js'
13+
import { IoTActionRole } from './IoTActionRole.js'
14+
import type { DeviceStorage } from './DeviceStorage.js'
15+
16+
/**
17+
* Resolve device geo location based on network information
18+
*/
19+
export class SingleCellGeoLocation extends Construct {
20+
public constructor(
21+
parent: Construct,
22+
{
23+
lambdaSources,
24+
layers,
25+
websocketAPI,
26+
deviceStorage,
27+
}: {
28+
websocketAPI: WebsocketAPI
29+
deviceStorage: DeviceStorage
30+
lambdaSources: {
31+
resolveSingleCellGeoLocation: PackedLambda
32+
}
33+
layers: Lambda.ILayerVersion[]
34+
},
35+
) {
36+
super(parent, 'SingleCellGeoLocation')
37+
38+
const fn = new Lambda.Function(this, 'fn', {
39+
handler: lambdaSources.resolveSingleCellGeoLocation.handler,
40+
architecture: Lambda.Architecture.ARM_64,
41+
runtime: Lambda.Runtime.NODEJS_18_X,
42+
timeout: Duration.seconds(60),
43+
memorySize: 1792,
44+
code: new LambdaSource(this, lambdaSources.resolveSingleCellGeoLocation)
45+
.code,
46+
description: 'Resolve device geo location based on network information',
47+
environment: {
48+
VERSION: this.node.tryGetContext('version'),
49+
LOG_LEVEL: this.node.tryGetContext('logLevel'),
50+
EVENTBUS_NAME: websocketAPI.eventBus.eventBusName,
51+
NODE_NO_WARNINGS: '1',
52+
DISABLE_METRICS: this.node.tryGetContext('isTest') === true ? '1' : '0',
53+
STACK_NAME: Stack.of(this).stackName,
54+
DEVICES_TABLE_NAME: deviceStorage.devicesTable.tableName,
55+
},
56+
layers,
57+
logRetention: Logs.RetentionDays.ONE_WEEK,
58+
initialPolicy: [
59+
new IAM.PolicyStatement({
60+
actions: ['ssm:GetParameter'],
61+
resources: [
62+
`arn:aws:ssm:${Stack.of(this).region}:${
63+
Stack.of(this).account
64+
}:parameter/${Stack.of(this).stackName}/thirdParty/nrfcloud/*`,
65+
],
66+
}),
67+
new IAM.PolicyStatement({
68+
actions: ['ssm:GetParametersByPath'],
69+
resources: [
70+
`arn:aws:ssm:${Stack.of(this).region}:${
71+
Stack.of(this).account
72+
}:parameter/${Stack.of(this).stackName}/thirdParty/nrfcloud`,
73+
],
74+
}),
75+
],
76+
})
77+
websocketAPI.eventBus.grantPutEventsTo(fn)
78+
deviceStorage.devicesTable.grantReadData(fn)
79+
80+
const rule = new IoT.CfnTopicRule(this, 'topicRule', {
81+
topicRulePayload: {
82+
description: `Resolve device geo location based on network information`,
83+
ruleDisabled: false,
84+
awsIotSqlVersion: '2016-03-23',
85+
sql: `
86+
select
87+
* as message,
88+
topic(4) as deviceId,
89+
timestamp() as timestamp
90+
from 'data/+/+/+/+'
91+
where messageType = 'DATA'
92+
and appId = 'DEVICE'
93+
`,
94+
actions: [
95+
{
96+
lambda: {
97+
functionArn: fn.functionArn,
98+
},
99+
},
100+
],
101+
errorAction: {
102+
republish: {
103+
roleArn: new IoTActionRole(this).roleArn,
104+
topic: 'errors',
105+
},
106+
},
107+
},
108+
})
109+
110+
fn.addPermission('topicRule', {
111+
principal: new IAM.ServicePrincipal('iot.amazonaws.com'),
112+
sourceArn: rule.attrArn,
113+
})
114+
}
115+
}

cdk/stacks/BackendStack.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { WebsocketAPI } from '../resources/WebsocketAPI.js'
2626
import { KPIs } from '../resources/kpis/KPIs.js'
2727
import { STACK_NAME } from './stackConfig.js'
2828
import { ConfigureDevice } from '../resources/ConfigureDevice.js'
29+
import { SingleCellGeoLocation } from '../resources/SingleCellGeoLocation.js'
2930

3031
export class BackendStack extends Stack {
3132
public constructor(
@@ -165,6 +166,13 @@ export class BackendStack extends Stack {
165166
websocketAPI,
166167
})
167168

169+
new SingleCellGeoLocation(this, {
170+
lambdaSources,
171+
layers: lambdaLayers,
172+
websocketAPI,
173+
deviceStorage,
174+
})
175+
168176
// Outputs
169177
new CfnOutput(this, 'webSocketURI', {
170178
exportName: `${this.stackName}:webSocketURI`,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Single-cell geo location
2+
3+
> The network information sent by the device as part of the `DEVICE` message
4+
> should be used to determine the approximate device location.
5+
6+
## Background
7+
8+
Given I have the fingerprint for a `PCA20035+solar` device in `fingerprint`
9+
10+
And I connect to the websocket using fingerprint `${fingerprint}`
11+
12+
And I store `$now()` into `now`
13+
14+
And this nRF Cloud API is queued for a `GET /v1/account/service-token` request
15+
16+
```
17+
HTTP/1.1 200 OK
18+
Content-Type: application/json
19+
20+
{
21+
"createdAt": "${now}",
22+
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzU0NDQ2NDAsImlhdCI6MTYzMjg1MjY1NCwic3ViIjoibnJmY2xvdWQtZXZhbHVhdGlvbi1kZXZpY2UtM2JmNTBlY2YtMmY3Zi00NjlmLTg4YTQtMmFhODhiZGMwODNiIn0.ldxPFg7xofD8gxjRkdu8WXl-cD01wVqn-VhvhyeuEXMeAmFpDHbSxEo5rs1DofEougUQnZy31T-e_5EQ8rlNMg"
23+
}
24+
```
25+
26+
And this nRF Cloud API is queued for a `POST /v1/location/ground-fix` request
27+
28+
```
29+
HTTP/1.1 200 OK
30+
Content-Type: application/json
31+
32+
{
33+
"lat": 63.41999531,
34+
"lon": 10.42999506,
35+
"uncertainty": 2420,
36+
"fulfilledWith": "SCELL"
37+
}
38+
```
39+
40+
<!-- @retry:delayExecution=5000 -->
41+
42+
## Device publishes network information, which is then resolved
43+
44+
Given I store `$millis()` into `ts`
45+
46+
When the device `${fingerprint_deviceId}` publishes this message to the topic
47+
`m/d/${fingerprint_deviceId}/d2c`
48+
49+
```json
50+
{
51+
"appId": "DEVICE",
52+
"messageType": "DATA",
53+
"ts": "$number{ts}",
54+
"data": {
55+
"networkInfo": {
56+
"currentBand": 20,
57+
"networkMode": "LTE-M",
58+
"rsrp": -102,
59+
"areaCode": 2305,
60+
"mccmnc": 24202,
61+
"cellID": 34237196,
62+
"ipAddress": "100.74.127.55",
63+
"eest": 7
64+
}
65+
}
66+
}
67+
```
68+
69+
<!-- @retryScenario -->
70+
71+
Soon I should receive a message on the websocket that matches
72+
73+
```json
74+
{
75+
"@context": "https://github.com/hello-nrfcloud/proto/transformed/PCA20035%2Bsolar/single-cell-geo-location",
76+
"id": "${fingerprint_deviceId}",
77+
"lat": 63.41999531,
78+
"lng": 10.42999506,
79+
"accuracy": 2420,
80+
"ts": "$number{ts}"
81+
}
82+
```
83+
84+
<!-- @retryScenario -->
85+
86+
Soon the nRF Cloud API should have been called with
87+
88+
```
89+
POST /v1/location/ground-fix HTTP/1.1
90+
Content-Type: application/json
91+
Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzU0NDQ2NDAsImlhdCI6MTYzMjg1MjY1NCwic3ViIjoibnJmY2xvdWQtZXZhbHVhdGlvbi1kZXZpY2UtM2JmNTBlY2YtMmY3Zi00NjlmLTg4YTQtMmFhODhiZGMwODNiIn0.ldxPFg7xofD8gxjRkdu8WXl-cD01wVqn-VhvhyeuEXMeAmFpDHbSxEo5rs1DofEougUQnZy31T-e_5EQ8rlNMg
92+
93+
{
94+
"lte": [
95+
{
96+
"mcc": 242,
97+
"mnc": "02",
98+
"eci": 34237196,
99+
"tac": 2305,
100+
"rsrp": -102
101+
}
102+
]
103+
}
104+
```

historicalData/historicalDataRepository.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import {
44
QueryCommand,
55
type TimestreamQueryClient,
66
} from '@aws-sdk/client-timestream-query'
7+
import { Context } from '@hello.nrfcloud.com/proto/hello'
78
import {
8-
Context,
99
HistoricalDataRequest,
1010
HistoricalDataResponse,
11-
} from '@hello.nrfcloud.com/proto/hello'
11+
} from '@hello.nrfcloud.com/proto/hello/chart'
1212
import { parseResult } from '@nordicsemiconductor/timestream-helpers'
1313
import { type Static } from '@sinclair/typebox'
1414
import { groupBy } from 'lodash-es'

lambda/configureDevice.ts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@ import { metricsForComponent } from './metrics/metrics.js'
1313
import type { WebsocketPayload } from './publishToWebsocketClients.js'
1414
import { logger } from './util/logger.js'
1515
import type { Static } from '@sinclair/typebox'
16-
import { getNRFCloudSSMParameters } from './util/getSSMParameter.js'
17-
import { once } from 'lodash-es'
1816
import { slashless } from '../util/slashless.js'
17+
import { getNrfCloudAPIConfig } from './getNrfCloudAPIConfig.js'
1918

2019
type Request = Omit<WebsocketPayload, 'message'> & {
2120
message: {
@@ -34,23 +33,6 @@ const eventBus = new EventBridge({})
3433

3534
const { track, metrics } = metricsForComponent('configureDevice')
3635

37-
const getNrfCloudAPIConfig: () => Promise<{
38-
apiKey: string
39-
apiEndpoint: URL
40-
}> = once(async () => {
41-
const [apiKey, apiEndpoint] = await getNRFCloudSSMParameters(stackName, [
42-
'apiKey',
43-
'apiEndpoint',
44-
])
45-
if (apiKey === undefined)
46-
throw new Error(`nRF Cloud API key for ${stackName} is not configured.`)
47-
48-
return {
49-
apiKey,
50-
apiEndpoint: new URL(apiEndpoint ?? 'https://api.nrfcloud.com/'),
51-
}
52-
})
53-
5436
/**
5537
* Handle configure device request
5638
*/
@@ -61,7 +43,7 @@ const h = async (
6143
>,
6244
): Promise<void> => {
6345
log.info('event', { event })
64-
const { apiEndpoint, apiKey } = await getNrfCloudAPIConfig()
46+
const { apiEndpoint, apiKey } = await getNrfCloudAPIConfig(stackName)
6547

6648
const {
6749
deviceId,

lambda/fetchDeviceShadow.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
PutEventsCommand,
66
} from '@aws-sdk/client-eventbridge'
77
import { SSMClient } from '@aws-sdk/client-ssm'
8-
import { proto } from '@hello.nrfcloud.com/proto/hello'
8+
import { proto } from '@hello.nrfcloud.com/proto/hello/model/PCA20035+solar'
99
import { getShadowUpdateTime } from '@hello.nrfcloud.com/proto/nrfCloud'
1010
import middy from '@middy/core'
1111
import { fromEnv } from '@nordicsemiconductor/from-env'

0 commit comments

Comments
 (0)