@bradtech/sensor-lorawan 1.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/HOWTO.md +99 -0
- package/LICENSE +620 -0
- package/README.md +27 -0
- package/dist/BradOSCodec.d.ts +68 -0
- package/dist/BradOSCodec.d.ts.map +1 -0
- package/dist/BradOSCodec.js +123 -0
- package/dist/BradOSCodec.js.map +1 -0
- package/dist/LoRaWanPipeline.d.ts +142 -0
- package/dist/LoRaWanPipeline.d.ts.map +1 -0
- package/dist/LoRaWanPipeline.js +280 -0
- package/dist/LoRaWanPipeline.js.map +1 -0
- package/dist/defaultAdapters.d.ts +49 -0
- package/dist/defaultAdapters.d.ts.map +1 -0
- package/dist/defaultAdapters.js +70 -0
- package/dist/defaultAdapters.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
- package/src/BradOSCodec.ts +165 -0
- package/src/LoRaWanPipeline.ts +443 -0
- package/src/defaultAdapters.ts +82 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { defaultSensorAdapters } from './defaultAdapters'
|
|
2
|
+
import { BradOSCodec } from './BradOSCodec'
|
|
3
|
+
import type { ConversionOutput } from '@bradtech/sensor'
|
|
4
|
+
import type {
|
|
5
|
+
CompanyUri,
|
|
6
|
+
DeviceUri,
|
|
7
|
+
OkfMetricUri,
|
|
8
|
+
PlotUri,
|
|
9
|
+
} from '@bradtech/types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pure LoRaWAN Uplink message contract from ChirpStack or external Network Server.
|
|
13
|
+
* Contains purely radio, frame counter, port, and raw binary payload data.
|
|
14
|
+
* Note: Hardware probe payloads NEVER contain agronomic or soil calibration parameters.
|
|
15
|
+
*/
|
|
16
|
+
export interface PipelineUplinkInput {
|
|
17
|
+
/** Device metadata from Network Server */
|
|
18
|
+
deviceInfo?: {
|
|
19
|
+
/** 64-bit IEEE Extended Unique Identifier in hex (e.g. "0018b20000001234") */
|
|
20
|
+
devEui?: string
|
|
21
|
+
/** Human device name (e.g. "b25s004" or "b26w001") */
|
|
22
|
+
deviceName?: string
|
|
23
|
+
/** ChirpStack Application UUID */
|
|
24
|
+
applicationId?: string
|
|
25
|
+
/** ChirpStack Application Name */
|
|
26
|
+
applicationName?: string
|
|
27
|
+
/** Optional generic Network Server metadata tags */
|
|
28
|
+
tags?: Record<string, string>
|
|
29
|
+
}
|
|
30
|
+
/** Alternative flat devEUI property */
|
|
31
|
+
devEUI?: string
|
|
32
|
+
/** LoRaWAN Application Port (FPort) indicating the physical channel */
|
|
33
|
+
fPort?: number
|
|
34
|
+
/** Frame uplink counter */
|
|
35
|
+
fCnt?: number
|
|
36
|
+
/** Base64-encoded binary payload string */
|
|
37
|
+
data?: string
|
|
38
|
+
/** Array of receiving gateway metadata records (RSSI, SNR, GPS) */
|
|
39
|
+
rxInfo?: Array<{
|
|
40
|
+
gatewayId?: string
|
|
41
|
+
rssi?: number
|
|
42
|
+
snr?: number
|
|
43
|
+
location?: { latitude?: number; longitude?: number; altitude?: number }
|
|
44
|
+
}>
|
|
45
|
+
/** Transmission RF metadata (frequency in Hz, DataRate) */
|
|
46
|
+
txInfo?: {
|
|
47
|
+
frequency?: number
|
|
48
|
+
dataRate?: number
|
|
49
|
+
}
|
|
50
|
+
/** ISO 8601 message publication timestamp */
|
|
51
|
+
publishedAt?: string
|
|
52
|
+
/** Alternative ISO 8601 message timestamp */
|
|
53
|
+
time?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Agronomic & Plot context resolved from the Backoffice / Master Data Management database.
|
|
58
|
+
* The Backoffice links a physical probe (devEUI) to an agricultural parcel (Plot),
|
|
59
|
+
* with its specific soil profile, texture classification, and optional lab calibration model.
|
|
60
|
+
*/
|
|
61
|
+
export interface AgronomicPlotContext {
|
|
62
|
+
/** Canonical Plot identifier or URI (e.g. "plots/parcelle-nord-01" or "parcelle-nord-01") */
|
|
63
|
+
plot?: PlotUri | (string & {})
|
|
64
|
+
/** Canonical Company / Tenant identifier or URI (e.g. "companies/domaine-alpha" or "domaine-alpha") */
|
|
65
|
+
company?: CompanyUri | (string & {})
|
|
66
|
+
/** Soil texture classification assigned to the plot in Backoffice (e.g. "clay", "sand", "loam", "silt", "peat") */
|
|
67
|
+
soilTexture?: 'sand' | 'loam' | 'clay' | 'silt' | 'peat' | 'default' | (string & {})
|
|
68
|
+
/** Custom plot-specific laboratory calibration model (linear regression: y = slope * raw + intercept) */
|
|
69
|
+
soilLinearRegression?: {
|
|
70
|
+
slope: number
|
|
71
|
+
intercept: number
|
|
72
|
+
modelLabel?: string
|
|
73
|
+
}
|
|
74
|
+
/** Optional hardware vendor tag */
|
|
75
|
+
vendor?: string
|
|
76
|
+
/** Optional hardware model tag */
|
|
77
|
+
vendorModel?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Normalized output contract representing an immutable DataPoint ready for PostgreSQL storage.
|
|
82
|
+
*/
|
|
83
|
+
export interface PipelineDataPointOutput {
|
|
84
|
+
/** Canonical device URI (e.g. 'probes/b25s004' or 'weather-stations/b26w001') */
|
|
85
|
+
device: DeviceUri | (string & {})
|
|
86
|
+
/** Canonical plot URI (e.g. 'plots/e5eadee3-...') */
|
|
87
|
+
plot?: PlotUri | (string & {})
|
|
88
|
+
/** Canonical company/tenant URI (e.g. 'companies/32c18f05-...') */
|
|
89
|
+
company?: CompanyUri | (string & {})
|
|
90
|
+
/** Canonical OKF metric identifier */
|
|
91
|
+
metric: OkfMetricUri | (string & {})
|
|
92
|
+
/** Calibrated numerical value */
|
|
93
|
+
value: number
|
|
94
|
+
/** Human-readable physical unit symbol */
|
|
95
|
+
unit: string
|
|
96
|
+
|
|
97
|
+
/** Kind indicator: 'measured' for direct sensor readings, 'computed' for agronomic algorithms */
|
|
98
|
+
kind: 'measured' | 'computed'
|
|
99
|
+
/** Quality and confidence score between 0.0 and 1.0 */
|
|
100
|
+
confidence: number
|
|
101
|
+
/** Observation timestamp */
|
|
102
|
+
timestamp: string
|
|
103
|
+
/** Comprehensive metadata contract */
|
|
104
|
+
metadata: Record<string, any>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* End-to-End LoRaWAN Telemetry Ingestion & Transformation Pipeline.
|
|
109
|
+
*
|
|
110
|
+
* Coordinates:
|
|
111
|
+
* - IEEE 754 Float32 binary decoding via `BradOSCodec`.
|
|
112
|
+
* - Automatic hardware sensor attribution (SHT40, Brad soil sensor, Davis, SI1145, BMP280, MP34DT01).
|
|
113
|
+
* - Routing to decoupled domain converter packages (`@bradtech/sensor-*`).
|
|
114
|
+
* - Agronomic context enrichment from Backoffice (Plot association, Soil profile, Custom calibration curves).
|
|
115
|
+
*/
|
|
116
|
+
export class LoRaWanPipeline {
|
|
117
|
+
private static _airConverter = defaultSensorAdapters.canopyAir
|
|
118
|
+
private static _soilMoistureConverter = defaultSensorAdapters.soilMoisture
|
|
119
|
+
private static _soilWpConverter = defaultSensorAdapters.soilWaterPotential
|
|
120
|
+
private static _soilTempConverter = defaultSensorAdapters.soilTemperature
|
|
121
|
+
private static _soilEcConverter = defaultSensorAdapters.soilEc
|
|
122
|
+
private static _solarConverter = defaultSensorAdapters.solarRadiation
|
|
123
|
+
private static _rainConverter = defaultSensorAdapters.rainGauge
|
|
124
|
+
private static _windConverter = defaultSensorAdapters.wind
|
|
125
|
+
private static _baroConverter = defaultSensorAdapters.barometricPressure
|
|
126
|
+
private static _batteryConverter = defaultSensorAdapters.battery
|
|
127
|
+
private static _acousticSplConverter = defaultSensorAdapters.acousticSpl
|
|
128
|
+
private static _acousticWeatherConverter = defaultSensorAdapters.acousticWeather
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Ingests a raw LoRaWAN uplink message, decodes binary payload, evaluates domain converters
|
|
132
|
+
* with optional agronomic plot context provided by the Backoffice, and returns validated DataPoints.
|
|
133
|
+
*
|
|
134
|
+
* @param uplink - Incoming ChirpStack LoRaWAN message object (pure radio frame).
|
|
135
|
+
* @param agronomicContext - Optional agronomic context resolved from Backoffice (Plot ID, Soil Texture, Calibration).
|
|
136
|
+
* @returns Array of transformed DataPoints.
|
|
137
|
+
*/
|
|
138
|
+
static process(
|
|
139
|
+
uplink: PipelineUplinkInput,
|
|
140
|
+
agronomicContext?: AgronomicPlotContext,
|
|
141
|
+
): PipelineDataPointOutput[] {
|
|
142
|
+
const dataPoints: PipelineDataPointOutput[] = []
|
|
143
|
+
|
|
144
|
+
// 1. Resolve Canonical Device & Tenancy Identifiers
|
|
145
|
+
const rawDeviceName = uplink.deviceInfo?.deviceName || uplink.deviceInfo?.devEui || uplink.devEUI || 'unknown'
|
|
146
|
+
const deviceUri = this._buildDeviceUri(rawDeviceName)
|
|
147
|
+
|
|
148
|
+
// Plot & Company resolved from Backoffice agronomic context (with fallback to metadata tags)
|
|
149
|
+
const rawPlot = agronomicContext?.plot || uplink.deviceInfo?.tags?.plot
|
|
150
|
+
const plotUri = rawPlot ? (rawPlot.startsWith('plots/') ? rawPlot : `plots/${rawPlot}`) : undefined
|
|
151
|
+
|
|
152
|
+
const rawCompany = agronomicContext?.company || uplink.deviceInfo?.tags?.company
|
|
153
|
+
const companyUri = rawCompany ? (rawCompany.startsWith('companies/') ? rawCompany : `companies/${rawCompany}`) : undefined
|
|
154
|
+
|
|
155
|
+
const timestamp = uplink.publishedAt || uplink.time || new Date().toISOString()
|
|
156
|
+
|
|
157
|
+
// Soil texture and calibration model come from the Backoffice plot definition
|
|
158
|
+
const soilTexture = agronomicContext?.soilTexture || (uplink.deviceInfo?.tags?.soilTexture as any) || 'default'
|
|
159
|
+
const soilLinearRegression = agronomicContext?.soilLinearRegression || this._extractLinearRegression(uplink.deviceInfo?.tags)
|
|
160
|
+
|
|
161
|
+
// 2. Build LoRaWAN Radio Metadata Contract
|
|
162
|
+
const bestGateway = uplink.rxInfo?.[0]
|
|
163
|
+
const vendor = agronomicContext?.vendor || (uplink.deviceInfo?.tags?.vendor as any) || 'brad'
|
|
164
|
+
const radioMetadata = {
|
|
165
|
+
interface: '@bradtech/types:LoRaWanMetadataInterface',
|
|
166
|
+
vendor,
|
|
167
|
+
vendorModel: agronomicContext?.vendorModel || uplink.deviceInfo?.tags?.vendorModel || (rawDeviceName.startsWith('b26w') ? 'Brad Weather Station v1' : 'Brad Soil Probe v2.5'),
|
|
168
|
+
vendorDeviceId: rawDeviceName,
|
|
169
|
+
integrationType: 'lorawan' as const,
|
|
170
|
+
devEui: uplink.deviceInfo?.devEui || uplink.devEUI,
|
|
171
|
+
fPort: uplink.fPort,
|
|
172
|
+
fCnt: uplink.fCnt,
|
|
173
|
+
frequency: uplink.txInfo?.frequency,
|
|
174
|
+
dataRate: uplink.txInfo?.dataRate,
|
|
175
|
+
rssi: bestGateway?.rssi,
|
|
176
|
+
snr: bestGateway?.snr,
|
|
177
|
+
gatewayId: bestGateway?.gatewayId,
|
|
178
|
+
gatewayCount: uplink.rxInfo?.length || 0,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 3. Emit Network Quality DataPoints
|
|
182
|
+
if (bestGateway?.rssi !== undefined) {
|
|
183
|
+
dataPoints.push({
|
|
184
|
+
device: deviceUri,
|
|
185
|
+
plot: plotUri,
|
|
186
|
+
company: companyUri,
|
|
187
|
+
metric: 'okf:radio/lorawan/rssi',
|
|
188
|
+
value: bestGateway.rssi,
|
|
189
|
+
unit: 'dBm',
|
|
190
|
+
kind: 'measured',
|
|
191
|
+
confidence: 1.0,
|
|
192
|
+
timestamp,
|
|
193
|
+
metadata: radioMetadata,
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (bestGateway?.snr !== undefined) {
|
|
198
|
+
dataPoints.push({
|
|
199
|
+
device: deviceUri,
|
|
200
|
+
plot: plotUri,
|
|
201
|
+
company: companyUri,
|
|
202
|
+
metric: 'okf:radio/lorawan/snr',
|
|
203
|
+
value: Number(bestGateway.snr.toFixed(1)),
|
|
204
|
+
unit: 'dB',
|
|
205
|
+
kind: 'measured',
|
|
206
|
+
confidence: 1.0,
|
|
207
|
+
timestamp,
|
|
208
|
+
metadata: radioMetadata,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 4. Decode Payload via BradOSCodec
|
|
213
|
+
if (!uplink.fPort || !uplink.data) {
|
|
214
|
+
return dataPoints
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Special Case: FPort 1 (Boot Frame)
|
|
218
|
+
if (uplink.fPort === 1) {
|
|
219
|
+
const boot = BradOSCodec.decodeBootPayload(uplink.data)
|
|
220
|
+
if (boot) {
|
|
221
|
+
const battOutputs = LoRaWanPipeline._batteryConverter.convert({ voltageMv: boot.batteryMv })
|
|
222
|
+
for (const b of battOutputs) {
|
|
223
|
+
dataPoints.push({
|
|
224
|
+
device: deviceUri,
|
|
225
|
+
plot: plotUri,
|
|
226
|
+
company: companyUri,
|
|
227
|
+
metric: b.metric,
|
|
228
|
+
value: b.value,
|
|
229
|
+
unit: b.unit,
|
|
230
|
+
kind: 'measured',
|
|
231
|
+
confidence: b.confidence,
|
|
232
|
+
timestamp,
|
|
233
|
+
metadata: {
|
|
234
|
+
...radioMetadata,
|
|
235
|
+
bootVersion: boot.version,
|
|
236
|
+
buildDoy: boot.buildDoy,
|
|
237
|
+
resetReason: boot.resetReason,
|
|
238
|
+
...b.metadata,
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return dataPoints
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Standard Telemetry: FPort 2 to 52
|
|
247
|
+
const channel = BradOSCodec.decodeFPortChannel(uplink.fPort, uplink.data)
|
|
248
|
+
if (!channel) {
|
|
249
|
+
return dataPoints
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const conversionOutputs: ConversionOutput[] = []
|
|
253
|
+
|
|
254
|
+
switch (channel.channelType) {
|
|
255
|
+
case 'canopy_temp': {
|
|
256
|
+
const outputs = LoRaWanPipeline._runConverter(LoRaWanPipeline._airConverter, {
|
|
257
|
+
temperature: channel.rawValue,
|
|
258
|
+
humidity: 60.0,
|
|
259
|
+
})
|
|
260
|
+
const tempOut = outputs.find((o) => o.metric.includes('temperature'))
|
|
261
|
+
if (tempOut) conversionOutputs.push(tempOut)
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case 'canopy_hum': {
|
|
266
|
+
const outputs = LoRaWanPipeline._runConverter(LoRaWanPipeline._airConverter, {
|
|
267
|
+
temperature: 20.0,
|
|
268
|
+
humidity: channel.rawValue,
|
|
269
|
+
})
|
|
270
|
+
const humOut = outputs.find((o) => o.metric.includes('humidity'))
|
|
271
|
+
if (humOut) conversionOutputs.push(humOut)
|
|
272
|
+
break
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case 'soil_moisture': {
|
|
276
|
+
// Apply plot-specific soil texture & calibration model from agronomic context
|
|
277
|
+
const vwcOut = LoRaWanPipeline._runConverter(
|
|
278
|
+
LoRaWanPipeline._soilMoistureConverter,
|
|
279
|
+
{ depthCm: channel.depthCm || 10, rawValue: channel.rawValue },
|
|
280
|
+
{ soilTexture, soilLinearRegression },
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
conversionOutputs.push(...vwcOut)
|
|
284
|
+
|
|
285
|
+
// Automatically compute derived pF matric potential based on plot soil texture
|
|
286
|
+
if (vwcOut.length > 0) {
|
|
287
|
+
const wpOut = LoRaWanPipeline._runConverter(
|
|
288
|
+
LoRaWanPipeline._soilWpConverter,
|
|
289
|
+
{ depthCm: channel.depthCm || 10, vwcPercent: vwcOut[0].value },
|
|
290
|
+
{ soilTexture },
|
|
291
|
+
)
|
|
292
|
+
conversionOutputs.push(...wpOut)
|
|
293
|
+
}
|
|
294
|
+
break
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
case 'soil_temp': {
|
|
298
|
+
const tempOut = LoRaWanPipeline._runConverter(LoRaWanPipeline._soilTempConverter, {
|
|
299
|
+
depthCm: channel.depthCm || 10,
|
|
300
|
+
temperature: channel.rawValue,
|
|
301
|
+
})
|
|
302
|
+
conversionOutputs.push(...tempOut)
|
|
303
|
+
break
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
case 'soil_ec': {
|
|
307
|
+
const ecOut = LoRaWanPipeline._runConverter(LoRaWanPipeline._soilEcConverter, {
|
|
308
|
+
depthCm: channel.depthCm || 10,
|
|
309
|
+
bulkEcMsCm: channel.rawValue,
|
|
310
|
+
})
|
|
311
|
+
conversionOutputs.push(...ecOut)
|
|
312
|
+
break
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case 'solar': {
|
|
316
|
+
const solarOut = LoRaWanPipeline._runConverter(LoRaWanPipeline._solarConverter, { irradianceWm2: channel.rawValue })
|
|
317
|
+
conversionOutputs.push(...solarOut)
|
|
318
|
+
break
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
case 'rain': {
|
|
322
|
+
const rainOut = LoRaWanPipeline._runConverter(LoRaWanPipeline._rainConverter, { tipCount: channel.rawValue })
|
|
323
|
+
conversionOutputs.push(...rainOut)
|
|
324
|
+
break
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case 'wind_speed': {
|
|
328
|
+
const windOut = LoRaWanPipeline._runConverter(LoRaWanPipeline._windConverter, { speedMs: channel.rawValue })
|
|
329
|
+
conversionOutputs.push(...windOut)
|
|
330
|
+
break
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
case 'wind_dir': {
|
|
334
|
+
const windDirOut = LoRaWanPipeline._runConverter(LoRaWanPipeline._windConverter, { speedMs: 0, directionDegrees: channel.rawValue })
|
|
335
|
+
conversionOutputs.push(...windDirOut)
|
|
336
|
+
break
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
case 'pressure': {
|
|
340
|
+
const baroOut = LoRaWanPipeline._runConverter(LoRaWanPipeline._baroConverter, { pressureHpa: channel.rawValue })
|
|
341
|
+
conversionOutputs.push(...baroOut)
|
|
342
|
+
break
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
case 'battery': {
|
|
346
|
+
const battOut = LoRaWanPipeline._runConverter(LoRaWanPipeline._batteryConverter, { voltageV: channel.rawValue })
|
|
347
|
+
conversionOutputs.push(...battOut)
|
|
348
|
+
break
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case 'acoustic_spl': {
|
|
352
|
+
const splOut = LoRaWanPipeline._runConverter(LoRaWanPipeline._acousticSplConverter, { rmsDbfs: channel.rawValue })
|
|
353
|
+
conversionOutputs.push(...splOut)
|
|
354
|
+
break
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
case 'acoustic_rain':
|
|
358
|
+
case 'acoustic_wind': {
|
|
359
|
+
const acWeatherOut = LoRaWanPipeline._runConverter(LoRaWanPipeline._acousticWeatherConverter, {
|
|
360
|
+
highFrequencyEnergyRms: channel.channelType === 'acoustic_rain' ? channel.rawValue : -80,
|
|
361
|
+
lowFrequencyEnergyRms: channel.channelType === 'acoustic_wind' ? channel.rawValue : -80,
|
|
362
|
+
})
|
|
363
|
+
conversionOutputs.push(...acWeatherOut)
|
|
364
|
+
break
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Map conversion outputs to final DataPoints
|
|
369
|
+
for (const out of conversionOutputs) {
|
|
370
|
+
const isDerived = out.metric.includes('potential') || out.metric.includes('par_ppfd') || out.metric.includes('percentage')
|
|
371
|
+
|
|
372
|
+
dataPoints.push({
|
|
373
|
+
device: deviceUri,
|
|
374
|
+
plot: plotUri,
|
|
375
|
+
company: companyUri,
|
|
376
|
+
metric: out.metric,
|
|
377
|
+
value: out.value,
|
|
378
|
+
unit: out.unit,
|
|
379
|
+
kind: isDerived ? 'computed' : 'measured',
|
|
380
|
+
confidence: out.confidence,
|
|
381
|
+
timestamp,
|
|
382
|
+
metadata: {
|
|
383
|
+
...radioMetadata,
|
|
384
|
+
sensorSource: channel.sensorSource,
|
|
385
|
+
sensorModel: channel.sensorModel,
|
|
386
|
+
...out.metadata,
|
|
387
|
+
interface: isDerived ? '@bradtech/types:ComputedMetadataInterface' : '@bradtech/types:LoRaWanMetadataInterface',
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return dataPoints
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Executes a converter instance and injects class name, model code, and semver version into metadata.
|
|
397
|
+
*/
|
|
398
|
+
private static _runConverter(
|
|
399
|
+
converter: any,
|
|
400
|
+
raw: any,
|
|
401
|
+
context?: any,
|
|
402
|
+
): ConversionOutput[] {
|
|
403
|
+
const outputs: ConversionOutput[] = converter.convert(raw, context)
|
|
404
|
+
return outputs.map((out) => ({
|
|
405
|
+
...out,
|
|
406
|
+
metadata: {
|
|
407
|
+
converterClass: converter.constructor.name,
|
|
408
|
+
modelCode: converter.modelCode,
|
|
409
|
+
modelVersion: converter.modelVersion,
|
|
410
|
+
...out.metadata,
|
|
411
|
+
},
|
|
412
|
+
}))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Normalizes raw hardware device names into canonical OKF device URIs.
|
|
417
|
+
*/
|
|
418
|
+
private static _buildDeviceUri(deviceName: string): string {
|
|
419
|
+
const clean = deviceName.trim().toLowerCase()
|
|
420
|
+
if (clean.startsWith('probes/') || clean.startsWith('weather-stations/')) {
|
|
421
|
+
return clean
|
|
422
|
+
}
|
|
423
|
+
if (clean.startsWith('b26w') || clean.startsWith('station')) {
|
|
424
|
+
return `weather-stations/${clean}`
|
|
425
|
+
}
|
|
426
|
+
return `probes/${clean}`
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Helper extracting linear regression calibration parameters if provided in tags.
|
|
431
|
+
*/
|
|
432
|
+
private static _extractLinearRegression(tags?: Record<string, string>) {
|
|
433
|
+
if (!tags?.soilSlope || !tags?.soilIntercept) return undefined
|
|
434
|
+
const slope = parseFloat(tags.soilSlope)
|
|
435
|
+
const intercept = parseFloat(tags.soilIntercept)
|
|
436
|
+
if (isNaN(slope) || isNaN(intercept)) return undefined
|
|
437
|
+
return {
|
|
438
|
+
slope,
|
|
439
|
+
intercept,
|
|
440
|
+
modelLabel: tags.soilModelLabel,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Sensor } from '@bradtech/sensor'
|
|
2
|
+
import {
|
|
3
|
+
CanopyMicroclimateConverter,
|
|
4
|
+
EvapotranspirationConverter,
|
|
5
|
+
GroundFrostRiskConverter,
|
|
6
|
+
} from '@bradtech/sensor-air'
|
|
7
|
+
import {
|
|
8
|
+
SoilMoistureConverter,
|
|
9
|
+
SoilWaterPotentialConverter,
|
|
10
|
+
SoilTemperatureConverter,
|
|
11
|
+
SoilElectricalConductivityConverter,
|
|
12
|
+
} from '@bradtech/sensor-soil'
|
|
13
|
+
import {
|
|
14
|
+
SolarRadiationConverter,
|
|
15
|
+
RainGaugeConverter,
|
|
16
|
+
WindConverter,
|
|
17
|
+
BarometricPressureConverter,
|
|
18
|
+
} from '@bradtech/sensor-weather'
|
|
19
|
+
import { BatterySoCConverter, SolarHarvestingConverter } from '@bradtech/sensor-power'
|
|
20
|
+
import { AcousticSplConverter, AcousticWeatherConverter } from '@bradtech/sensor-acoustic'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pre-instantiated out-of-the-box domain sensor adapters dictionary.
|
|
24
|
+
* Avoids redundant allocations across high-frequency message processing pipelines.
|
|
25
|
+
*/
|
|
26
|
+
export const defaultSensorAdapters = {
|
|
27
|
+
/** In-canopy temperature, humidity, dew point and foliar VPD adapter */
|
|
28
|
+
canopyAir: new CanopyMicroclimateConverter(),
|
|
29
|
+
/** Hargreaves-Samani FAO-56 daily reference evapotranspiration (ET0) adapter */
|
|
30
|
+
evapotranspiration: new EvapotranspirationConverter(),
|
|
31
|
+
/** Roland Stull wet-bulb temperature and ground frost risk evaluator */
|
|
32
|
+
frostRisk: new GroundFrostRiskConverter(),
|
|
33
|
+
/** Multi-depth soil volumetric water content (VWC %) calibrated adapter */
|
|
34
|
+
soilMoisture: new SoilMoistureConverter(),
|
|
35
|
+
/** Van Genuchten soil matric water potential (kPa) and pF scale adapter */
|
|
36
|
+
soilWaterPotential: new SoilWaterPotentialConverter(),
|
|
37
|
+
/** Multi-depth soil profile thermistor adapter */
|
|
38
|
+
soilTemperature: new SoilTemperatureConverter(),
|
|
39
|
+
/** 25°C temperature-normalized soil electrical conductivity (mS/cm) adapter */
|
|
40
|
+
soilEc: new SoilElectricalConductivityConverter(),
|
|
41
|
+
/** Broadband solar pyranometer (W/m²) and PAR PPFD photon flux adapter */
|
|
42
|
+
solarRadiation: new SolarRadiationConverter(),
|
|
43
|
+
/** Tipping bucket rainfall accumulation (mm) and rain rate (mm/h) adapter */
|
|
44
|
+
rainGauge: new RainGaugeConverter(),
|
|
45
|
+
/** Anemometer wind speed (km/h) and 16-cardinal vane direction adapter */
|
|
46
|
+
wind: new WindConverter(),
|
|
47
|
+
/** Absolute barometric pressure and sea-level QNH reduction adapter */
|
|
48
|
+
barometricPressure: new BarometricPressureConverter(),
|
|
49
|
+
/** Chemistry-aware non-linear battery SoC (%) and brownout adapter */
|
|
50
|
+
battery: new BatterySoCConverter(),
|
|
51
|
+
/** Solar panel photovoltaic harvesting voltage adapter */
|
|
52
|
+
solarHarvesting: new SolarHarvestingConverter(),
|
|
53
|
+
/** MEMS microphone Sound Pressure Level (dBA SPL) adapter */
|
|
54
|
+
acousticSpl: new AcousticSplConverter(),
|
|
55
|
+
/** Acoustic rain droplet impact and aerodynamic wind turbulence adapter */
|
|
56
|
+
acousticWeather: new AcousticWeatherConverter(),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Registers all built-in Brad domain sensor adapters into the global Quatrain Sensor facade.
|
|
61
|
+
*
|
|
62
|
+
* @returns The initialized Sensor singleton class for fluent chaining.
|
|
63
|
+
*/
|
|
64
|
+
export function registerDefaultSensorAdapters(): typeof Sensor {
|
|
65
|
+
Sensor.addAdapter(defaultSensorAdapters.canopyAir, 'canopyAir')
|
|
66
|
+
Sensor.addAdapter(defaultSensorAdapters.evapotranspiration, 'evapotranspiration')
|
|
67
|
+
Sensor.addAdapter(defaultSensorAdapters.frostRisk, 'frostRisk')
|
|
68
|
+
Sensor.addAdapter(defaultSensorAdapters.soilMoisture, 'soilMoisture')
|
|
69
|
+
Sensor.addAdapter(defaultSensorAdapters.soilWaterPotential, 'soilWaterPotential')
|
|
70
|
+
Sensor.addAdapter(defaultSensorAdapters.soilTemperature, 'soilTemperature')
|
|
71
|
+
Sensor.addAdapter(defaultSensorAdapters.soilEc, 'soilEc')
|
|
72
|
+
Sensor.addAdapter(defaultSensorAdapters.solarRadiation, 'solarRadiation')
|
|
73
|
+
Sensor.addAdapter(defaultSensorAdapters.rainGauge, 'rainGauge')
|
|
74
|
+
Sensor.addAdapter(defaultSensorAdapters.wind, 'wind')
|
|
75
|
+
Sensor.addAdapter(defaultSensorAdapters.barometricPressure, 'barometricPressure')
|
|
76
|
+
Sensor.addAdapter(defaultSensorAdapters.battery, 'battery')
|
|
77
|
+
Sensor.addAdapter(defaultSensorAdapters.solarHarvesting, 'solarHarvesting')
|
|
78
|
+
Sensor.addAdapter(defaultSensorAdapters.acousticSpl, 'acousticSpl')
|
|
79
|
+
Sensor.addAdapter(defaultSensorAdapters.acousticWeather, 'acousticWeather')
|
|
80
|
+
|
|
81
|
+
return Sensor
|
|
82
|
+
}
|
package/src/index.ts
ADDED