/**
* AgriHub360 vendor bridge template
*
* Pull readings from a system that already holds them (a LoRaWAN network
* server, a weather-station vendor's API, a farm's own datalogger) and forward
* them to AgriHub360.
*
* Copy it, replace `fetchVendorReadings`, and you have an integration.
*
* Run: npx tsx bridge-template.ts
* Node 18+ (uses the built-in fetch).
*/
// ---- configuration ---------------------------------------------------------
const INGEST_URL = process.env.AGRIHUB_INGEST_URL
?? 'https://devices.agrihub360.example/api/metric-values'
/**
* One AgriHub360 device + key per PHYSICAL sensor.
*
* It is tempting to run a whole fleet through one key. Don't: you lose
* per-device revocation and per-device rate limiting, and one leaked key
* exposes every sensor you own. Keys are free.
*/
interface DeviceBinding {
vendorSensorId: string
deviceId: string
apiKey: string
}
const DEVICES: DeviceBinding[] = [
{
vendorSensorId: 'vendor-sensor-001',
deviceId: process.env.AGRIHUB_DEVICE_ID ?? '00000000-0000-0000-0000-000000000000',
apiKey: process.env.AGRIHUB_DEVICE_KEY ?? '',
},
]
/**
* Vendor field name → AgriHub360 metric_id. See quickstart.md for the
* catalogue.
*
* Map only what you are sure about. An unmapped field is a gap you can see;
* a wrongly mapped one is silent corruption that reaches agronomic models.
*/
const METRIC_MAP: Record<string, number> = {
soil_temperature: 70,
humidity: 110,
ph: 120,
battery: 200,
signal: 201,
soil_moisture: 220,
nitrogen: 221,
phosphorus: 222,
potassium: 223,
conductivity: 224,
}
// ---- vendor side (REPLACE THIS) --------------------------------------------
interface VendorReading {
sensorId: string
/** Vendor's field name; looked up in METRIC_MAP. */
field: string
value: number
}
/**
* Replace with a real call to your vendor's API.
*
* Two things worth getting right here:
*
* 1. UNITS. Vendors differ (°F vs °C, VWC fraction vs percent, kPa vs bar).
* Convert here, once, rather than letting a wrong scale reach the platform
* where it will look plausible and be wrong by a constant factor.
*
* 2. FRESHNESS. Only forward readings you have not already sent. Re-posting
* the same reading each poll produces a flat series that looks like a
* working sensor and hides an outage.
*/
async function fetchVendorReadings(): Promise<VendorReading[]> {
// Example shape only.
return [
{ sensorId: 'vendor-sensor-001', field: 'soil_moisture', value: 42.5 },
{ sensorId: 'vendor-sensor-001', field: 'soil_temperature', value: 11.2 },
{ sensorId: 'vendor-sensor-001', field: 'battery', value: 87 },
]
}
// ---- AgriHub360 side -------------------------------------------------------
interface PostResult { ok: boolean; status: number; retryAfterSeconds?: number }
async function postMetric(
binding: DeviceBinding, metricId: number, value: number,
): Promise<PostResult> {
const res = await fetch(INGEST_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${binding.apiKey}`,
},
// No timestamp: the server assigns it. Anything sent is ignored.
body: JSON.stringify({
device_id: binding.deviceId,
metric_id: metricId,
value,
}),
})
if (res.status === 201) return { ok: true, status: 201 }
if (res.status === 429) {
const body = await res.json().catch(() => ({}) as Record<string, unknown>)
const retry = Number((body as { retry_after_seconds?: number }).retry_after_seconds ?? 60)
return { ok: false, status: 429, retryAfterSeconds: retry }
}
if (res.status === 401) {
// Retrying will never help: the key was revoked or rotated. Surface it
// loudly rather than burying it in a retry loop that looks like a
// transient network problem.
console.error(`[${binding.deviceId}] 401: device key rejected. Re-issue it.`)
return { ok: false, status: 401 }
}
console.error(`[${binding.deviceId}] HTTP ${res.status}: ${await res.text()}`)
return { ok: false, status: res.status }
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function runOnce(): Promise<void> {
const readings = await fetchVendorReadings()
const byId = new Map(DEVICES.map((d) => [d.vendorSensorId, d]))
let sent = 0
const unmapped = new Set<string>()
for (const r of readings) {
const binding = byId.get(r.sensorId)
if (!binding) continue
const metricId = METRIC_MAP[r.field]
if (metricId === undefined) {
unmapped.add(r.field)
continue
}
if (!Number.isFinite(r.value)) {
// A gap is visible; a NaN posted as a reading is not.
console.warn(`[${r.sensorId}] skipping non-numeric ${r.field}`)
continue
}
const result = await postMetric(binding, metricId, r.value)
if (result.ok) {
sent += 1
} else if (result.status === 429) {
// Honour the server's own backoff rather than guessing one.
console.warn(`rate limited, waiting ${result.retryAfterSeconds}s`)
await sleep((result.retryAfterSeconds ?? 60) * 1000)
}
}
// Report what was dropped. A bridge that silently ignores fields it does not
// understand looks like it is working while quietly losing data.
console.log(`forwarded ${sent}/${readings.length} readings`)
if (unmapped.size > 0) {
console.warn(`unmapped vendor fields (add to METRIC_MAP): ${[...unmapped].join(', ')}`)
}
}
// Poll interval. The ingest limit is 120 requests per 60s PER DEVICE, so the
// binding constraint is usually how many metrics each sensor reports, not how
// many sensors you have.
const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS ?? 15 * 60 * 1000)
async function main(): Promise<void> {
if (!DEVICES.every((d) => d.apiKey)) {
console.error('Set AGRIHUB_DEVICE_KEY (see docs/devices/quickstart.md).')
process.exit(1)
}
for (;;) {
try {
await runOnce()
} catch (err) {
// Never let one bad poll kill the bridge; the next one may succeed.
console.error('poll failed:', err)
}
await sleep(POLL_INTERVAL_MS)
}
}
void main()