feat(farm-aggregator): standalone farm data aggregator service

Node.js service for the farm server that replaces the Chrome extension concept.
Pulls from AG-Refine REST API and Dropbox (DairyComp/FeedLync logs), synthesises
with Claude Haiku on a cron schedule, pushes summaries back to Dropbox and WhatsApp.

- Hourly pulse, daily digest (Haiku), weekly summary (Sonnet)
- TomTom geofence webhook receiver — entry/exit events → WhatsApp + Dropbox log
- WhatsApp Business API send (gracefully disabled if no credentials)
- Express HTTP server with /health, /status, manual /run/* trigger endpoints
- Windows start.bat for farm server deployment
- .env.example documents all credentials (none committed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBD2dN2KEjzz3UQFa9hEpu
This commit is contained in:
Claude 2026-06-28 03:51:49 +00:00
parent 8e375ba687
commit db1ba75274
No known key found for this signature in database
14 changed files with 732 additions and 0 deletions

83
farm-aggregator/README.md Normal file
View file

@ -0,0 +1,83 @@
# Farm Aggregator
A lightweight Node.js service that runs on the farm server. It pulls data from AG-Refine and Dropbox, synthesises it with Claude Haiku, and pushes summaries back to Dropbox and WhatsApp on a schedule.
## Data flow
```
AG-Refine (Render) ─┐
DairyComp logs ──┤ Dropbox ──→ farm-aggregator ──→ Haiku ──→ Dropbox /Summaries
FeedLync files ──┘ │ WhatsApp
TomTom geofence webhooks ────────────────→─┘
```
## Setup
### 1. Install Node.js 20+
Download from https://nodejs.org/
### 2. Clone the repo and install dependencies
```bat
cd farm-aggregator
npm install
```
### 3. Create your .env file
```bat
copy .env.example .env
notepad .env
```
Fill in:
- `ANTHROPIC_API_KEY` — your Anthropic key
- `DROPBOX_*` — see Dropbox section below
- `WHATSAPP_*` — optional, leave blank to skip
### 4. Dropbox OAuth setup
1. Go to https://www.dropbox.com/developers/apps and create an app
2. Set permissions: `files.content.read`, `files.content.write`
3. Generate an access token OR set up the refresh token flow
4. Copy App Key, App Secret, and Refresh Token into `.env`
### 5. Start the service
```bat
start.bat
```
Or to run as a Windows service (stays running after logout):
```bat
npm install -g node-windows
node install-service.js
```
## Schedules
| Job | Default | What it does |
|-----|---------|--------------|
| Pulse | Hourly | Quick snapshot — tickets, lab flags, ops |
| Daily | 06:00 | Full daily digest pushed to Dropbox + WhatsApp |
| Weekly | Mon 06:00 | Weekly summary (Sonnet, deeper analysis) |
Change schedules in `.env` using cron syntax.
## TomTom geofencing
1. In TomTom Geofencing API, create fences for each field boundary
2. Set webhook URL to `http://YOUR_SERVER_IP:3001/webhook/tomtom`
3. Entry/exit events are logged to Dropbox and sent to WhatsApp
## Manual triggers (testing)
```bash
curl -X POST http://localhost:3001/run/pulse
curl -X POST http://localhost:3001/run/daily
curl -X POST http://localhost:3001/run/weekly
```
## Planned integrations
- **iyohtah / nTELL AI** — herd health data once API access is confirmed
- **Daitrix** — AI camera feed events (scale automation, pen detection)
- **Oracle Cloud** — heavy data processing offload

41
farm-aggregator/config.js Normal file
View file

@ -0,0 +1,41 @@
import 'dotenv/config';
export const config = {
anthropic: {
apiKey: process.env.ANTHROPIC_API_KEY,
model: 'claude-haiku-4-5-20251001',
modelDeep: 'claude-sonnet-4-6',
},
agrefine: {
url: process.env.AGREFINE_API_URL ?? 'https://ag-refine.onrender.com/api',
apiKey: process.env.AGREFINE_API_KEY ?? '',
},
dropbox: {
appKey: process.env.DROPBOX_APP_KEY,
appSecret: process.env.DROPBOX_APP_SECRET,
refreshToken: process.env.DROPBOX_REFRESH_TOKEN,
paths: {
inbox: process.env.DROPBOX_INBOX_PATH ?? '/Farm/Inbox',
outbox: process.env.DROPBOX_OUTBOX_PATH ?? '/Farm/Summaries',
dairycomp: process.env.DROPBOX_DAIRYCOMP_PATH ?? '/Farm/DairyComp',
feedlync: process.env.DROPBOX_FEEDLYNC_PATH ?? '/Farm/FeedLync',
},
},
whatsapp: {
accessToken: process.env.WHATSAPP_ACCESS_TOKEN ?? '',
phoneId: process.env.WHATSAPP_PHONE_ID ?? '',
recipient: process.env.WHATSAPP_RECIPIENT ?? '',
enabled: !!(process.env.WHATSAPP_ACCESS_TOKEN && process.env.WHATSAPP_PHONE_ID),
},
tomtom: {
apiKey: process.env.TOMTOM_API_KEY ?? '',
},
server: {
port: parseInt(process.env.WEBHOOK_PORT ?? '3001', 10),
},
schedule: {
pulse: process.env.PULSE_SCHEDULE ?? '0 * * * *',
daily: process.env.DAILY_SCHEDULE ?? '0 6 * * *',
weekly: process.env.WEEKLY_SCHEDULE ?? '0 6 * * 1',
},
};

82
farm-aggregator/index.js Normal file
View file

@ -0,0 +1,82 @@
import 'dotenv/config';
import cron from 'node-cron';
import express from 'express';
import { config } from './config.js';
import { runPulse } from './jobs/pulse.js';
import { runDaily, runWeekly } from './jobs/digest.js';
import { handleGeofenceEvent, getRecentEvents } from './jobs/geofence-handler.js';
// ── HTTP server ──────────────────────────────────────────────────────────────
const app = express();
app.use(express.json());
app.get('/health', (_req, res) => {
res.json({ status: 'ok', ts: new Date().toISOString() });
});
// TomTom geofence webhook — configure this URL in TomTom Geofencing API
app.post('/webhook/tomtom', (req, res) => {
try {
const event = handleGeofenceEvent(req.body);
res.json({ ok: true, event });
} catch (e) {
console.error('[webhook/tomtom]', e.message);
res.status(400).json({ ok: false, error: e.message });
}
});
// WhatsApp inbound messages (Meta sends a GET for verification)
app.get('/webhook/whatsapp', (req, res) => {
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
const challenge = req.query['hub.challenge'];
if (mode === 'subscribe' && token === process.env.WHATSAPP_VERIFY_TOKEN) {
res.status(200).send(challenge);
} else {
res.status(403).send('Forbidden');
}
});
app.post('/webhook/whatsapp', (req, res) => {
console.log('[whatsapp webhook]', JSON.stringify(req.body).slice(0, 200));
res.sendStatus(200);
});
// Status dashboard — last N geofence events
app.get('/status', (_req, res) => {
res.json({ events: getRecentEvents(50) });
});
// Manual trigger endpoints (for testing)
app.post('/run/pulse', async (_req, res) => { const s = await runPulse(); res.json({ ok: true, summary: s }); });
app.post('/run/daily', async (_req, res) => { const s = await runDaily(); res.json({ ok: true, summary: s }); });
app.post('/run/weekly', async (_req, res) => { const s = await runWeekly(); res.json({ ok: true, summary: s }); });
app.listen(config.server.port, () => {
console.log(`[server] listening on :${config.server.port}`);
});
// ── Scheduler ────────────────────────────────────────────────────────────────
cron.schedule(config.schedule.pulse, async () => {
try { await runPulse(); }
catch (e) { console.error('[cron:pulse]', e.message); }
});
cron.schedule(config.schedule.daily, async () => {
try { await runDaily(); }
catch (e) { console.error('[cron:daily]', e.message); }
});
cron.schedule(config.schedule.weekly, async () => {
try { await runWeekly(); }
catch (e) { console.error('[cron:weekly]', e.message); }
});
console.log('[farm-aggregator] started');
console.log(` pulse: ${config.schedule.pulse}`);
console.log(` daily: ${config.schedule.daily}`);
console.log(` weekly: ${config.schedule.weekly}`);
console.log(` port: ${config.server.port}`);
console.log(` whatsapp: ${config.whatsapp.enabled ? 'enabled' : 'disabled (no credentials)'}`);

View file

@ -0,0 +1,76 @@
import { fetchSnapshot, summariseSnapshot } from '../services/agrefine.js';
import { readFolderFiles, readLatestSummaries, uploadSummary } from '../services/dropbox.js';
import { sendMessage } from '../services/whatsapp.js';
import { haiku, sonnet } from '../services/claude.js';
import { FARM_SYSTEM, DAILY_PROMPT, WEEKLY_PROMPT } from '../prompts/system.js';
import { config } from '../config.js';
async function buildContext() {
const [agSnap, dairycomp, feedlync] = await Promise.allSettled([
fetchSnapshot(),
readFolderFiles(config.dropbox.paths.dairycomp, 5),
readFolderFiles(config.dropbox.paths.feedlync, 5),
]);
const parts = [];
if (agSnap.status === 'fulfilled') parts.push(summariseSnapshot(agSnap.value));
if (dairycomp.status === 'fulfilled') dairycomp.value.forEach((f) => {
parts.push(`--- DairyComp: ${f.name} ---\n${f.text.slice(0, 2000)}`);
});
if (feedlync.status === 'fulfilled') feedlync.value.forEach((f) => {
parts.push(`--- FeedLync: ${f.name} ---\n${f.text.slice(0, 2000)}`);
});
return parts.join('\n\n');
}
export async function runDaily() {
const ts = new Date().toISOString().slice(0, 10);
console.log(`[daily] starting ${ts}`);
const [context, priorDocs] = await Promise.all([
buildContext(),
readLatestSummaries(3),
]);
const priorText = priorDocs.map((d) => `[${d.name}]\n${d.text}`).join('\n\n---\n\n');
const prompt = DAILY_PROMPT(context, priorText);
const summary = await haiku(FARM_SYSTEM, prompt, 1024);
console.log('[daily] summary:\n', summary);
const filename = `daily_${ts}.txt`;
await uploadSummary(filename, summary).catch((e) =>
console.warn('[daily] Dropbox upload failed:', e.message)
);
await sendMessage(`🌾 Daily Farm Digest — ${ts}\n\n${summary}`).catch((e) =>
console.warn('[daily] WhatsApp failed:', e.message)
);
return summary;
}
export async function runWeekly() {
const ts = new Date().toISOString().slice(0, 10);
console.log(`[weekly] starting ${ts}`);
const [context, priorDocs] = await Promise.all([
buildContext(),
readLatestSummaries(7),
]);
const priorText = priorDocs.map((d) => `[${d.name}]\n${d.text}`).join('\n\n---\n\n');
const prompt = WEEKLY_PROMPT(context, priorText);
const summary = await sonnet(FARM_SYSTEM, prompt, 2048);
console.log('[weekly] summary:\n', summary);
const filename = `weekly_${ts}.txt`;
await uploadSummary(filename, summary).catch((e) =>
console.warn('[weekly] Dropbox upload failed:', e.message)
);
await sendMessage(`📋 Weekly Farm Summary — week ending ${ts}\n\n${summary}`).catch((e) =>
console.warn('[weekly] WhatsApp failed:', e.message)
);
return summary;
}

View file

@ -0,0 +1,36 @@
import { parseWebhookEvent } from '../services/tomtom.js';
import { sendMessage } from '../services/whatsapp.js';
import { uploadSummary } from '../services/dropbox.js';
const eventLog = [];
export function handleGeofenceEvent(rawBody) {
const event = parseWebhookEvent(rawBody);
const ts = event.timestamp?.slice(0, 16).replace('T', ' ') ?? new Date().toISOString().slice(0, 16);
eventLog.push(event);
if (eventLog.length > 200) eventLog.shift();
const emoji = event.eventType === 'entry' ? '🟢' : '🔴';
const msg = `${emoji} [${ts}] ${event.objectId} ${event.eventType === 'entry' ? 'entered' : 'left'} ${event.fenceName}`;
console.log('[geofence]', msg);
sendMessage(msg).catch(() => {});
const logLine = `${ts} | ${event.eventType} | object: ${event.objectId} | fence: ${event.fenceName} | ${event.lat},${event.lon}\n`;
const today = new Date().toISOString().slice(0, 10);
uploadSummary(`geofence_log_${today}.txt`, getGeofenceLogText()).catch(() => {});
return event;
}
function getGeofenceLogText() {
return eventLog
.map((e) => `${e.timestamp?.slice(0, 16)} | ${e.eventType} | ${e.objectId} | ${e.fenceName}`)
.join('\n');
}
export function getRecentEvents(limit = 20) {
return eventLog.slice(-limit);
}

View file

@ -0,0 +1,56 @@
import { fetchSnapshot, summariseSnapshot } from '../services/agrefine.js';
import { readFolderFiles, uploadSummary, readLatestSummaries } from '../services/dropbox.js';
import { sendMessage } from '../services/whatsapp.js';
import { haiku } from '../services/claude.js';
import { FARM_SYSTEM, PULSE_PROMPT } from '../prompts/system.js';
import { config } from '../config.js';
export async function runPulse() {
const ts = new Date().toISOString().slice(0, 16).replace('T', ' ');
console.log(`[pulse] starting ${ts}`);
const [agSnap, dairycompFiles, feedlyncFiles] = await Promise.allSettled([
fetchSnapshot(),
readFolderFiles(config.dropbox.paths.dairycomp, 3),
readFolderFiles(config.dropbox.paths.feedlync, 3),
]);
const contextParts = [];
if (agSnap.status === 'fulfilled') {
contextParts.push(summariseSnapshot(agSnap.value));
} else {
contextParts.push(`AG-Refine: unavailable (${agSnap.reason?.message})`);
}
if (dairycompFiles.status === 'fulfilled' && dairycompFiles.value.length) {
contextParts.push('\n--- DairyComp logs ---');
dairycompFiles.value.forEach((f) => {
contextParts.push(`[${f.name}]\n${f.text.slice(0, 1000)}`);
});
}
if (feedlyncFiles.status === 'fulfilled' && feedlyncFiles.value.length) {
contextParts.push('\n--- FeedLync files ---');
feedlyncFiles.value.forEach((f) => {
contextParts.push(`[${f.name}]\n${f.text.slice(0, 1000)}`);
});
}
const context = contextParts.join('\n\n');
const prompt = PULSE_PROMPT(context).replace('{timestamp}', ts);
const summary = await haiku(FARM_SYSTEM, prompt, 512);
console.log('[pulse] summary:\n', summary);
const filename = `pulse_${ts.replace(/ /g, '_').replace(/:/g, '')}.txt`;
await uploadSummary(filename, summary).catch((e) =>
console.warn('[pulse] Dropbox upload failed:', e.message)
);
await sendMessage(summary).catch((e) =>
console.warn('[pulse] WhatsApp failed:', e.message)
);
return summary;
}

View file

@ -0,0 +1,19 @@
{
"name": "farm-aggregator",
"version": "0.1.0",
"description": "Farm data aggregator — pulls AG-Refine + Dropbox, synthesises with Haiku, pushes summaries",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0",
"dropbox": "^10.34.0",
"node-cron": "^3.0.3",
"node-fetch": "^3.3.2",
"dotenv": "^16.4.5",
"express": "^4.19.2"
}
}

View file

@ -0,0 +1,52 @@
export const FARM_SYSTEM = `You are an AI farm data analyst for a dairy operation. Your job is to synthesise incoming data from multiple sources — weigh tickets, lab samples, field operations, DairyComp herd logs, and FeedLync ration records — into clear, actionable summaries.
Rules:
- Be specific: cite actual numbers (DM%, net weights, field names, ticket IDs) rather than vague statements.
- Flag anomalies: DM below target, missing lab samples after harvest, gaps in ticket sequence, SCC trends.
- Separate facts from inference if you are estimating, say so.
- Keep it tight: farmers are busy. Lead with what matters most right now.
- Use plain language, not academic phrasing.`;
export const PULSE_PROMPT = (context) => `
Produce a brief hourly pulse report based on the farm data below.
Format:
📊 HOURLY PULSE {timestamp}
ACTIVITY (last hour):
- [list any new weigh tickets, operations, or field events]
FLAGS:
- [any anomalies or items requiring attention]
STANDING STATUS:
- [1-2 sentences on current harvest / ration / herd status]
Data:
${context}
`.trim();
export const DAILY_PROMPT = (context, priorSummaries) => `
Produce a daily farm digest. Cover:
1. Yesterday's harvest activity (loads, fields, total tonnage, average DM%)
2. Feed quality highlights (any lab results, NDF/NEL concerns)
3. Operational notes (any gaps, equipment issues, crew items visible in data)
4. Action items for today
${priorSummaries ? `Recent context from prior summaries:\n${priorSummaries}\n\n` : ''}
Today's data:
${context}
`.trim();
export const WEEKLY_PROMPT = (context, priorSummaries) => `
Produce a weekly farm summary for the week ending today. Cover:
1. Harvest totals by field and cut number
2. Feed quality trend (DM%, NDF, RFV averages vs. targets)
3. Income over feed cost signal (if ticket + lab data supports it)
4. Top 3 risk flags this week
5. Top 3 priorities for next week
${priorSummaries ? `Recent summaries for context:\n${priorSummaries}\n\n` : ''}
This week's data:
${context}
`.trim();

View file

@ -0,0 +1,73 @@
import { config } from '../config.js';
const BASE = config.agrefine.url;
function headers() {
const h = { 'Content-Type': 'application/json' };
if (config.agrefine.apiKey) h['Authorization'] = `Bearer ${config.agrefine.apiKey}`;
return h;
}
async function get(path) {
const res = await fetch(`${BASE}${path}`, { headers: headers() });
if (!res.ok) throw new Error(`AG-Refine ${res.status}: ${path}`);
return res.json();
}
export async function fetchSnapshot() {
const [fields, tickets, labSamples, harvestPlans, operations] = await Promise.allSettled([
get('/fields/'),
get('/scales/tickets/all'),
get('/intelligence/lab-samples'),
get('/harvest/plans'),
get('/operations/'),
]);
return {
fields: fields.status === 'fulfilled' ? fields.value : [],
tickets: tickets.status === 'fulfilled' ? tickets.value : [],
labSamples: labSamples.status === 'fulfilled' ? labSamples.value : [],
harvestPlans: harvestPlans.status === 'fulfilled' ? harvestPlans.value : [],
operations: operations.status === 'fulfilled' ? operations.value : [],
fetchedAt: new Date().toISOString(),
};
}
export function summariseSnapshot(snap) {
const lines = [`AG-Refine snapshot (${snap.fetchedAt?.slice(0, 16)} UTC)`];
if (snap.fields?.length) {
lines.push(`\nFields (${snap.fields.length}):`);
snap.fields.slice(0, 10).forEach((f) => {
lines.push(`${f.name}${f.acres ?? '?'} ac, ${f.soil_type ?? 'unknown soil'}`);
});
}
if (snap.tickets?.length) {
const recent = snap.tickets.slice(0, 5);
lines.push(`\nRecent weigh tickets (${snap.tickets.length} total):`);
recent.forEach((t) => {
const dm = t.dm_pct != null ? ` DM ${t.dm_pct}%` : '';
lines.push(`${t.date?.slice(0, 10) ?? '?'} ${t.commodity ?? t.label ?? 'load'}: net ${t.net_weight ?? '?'} ${t.unit ?? 'lb'}${dm}`);
});
}
if (snap.labSamples?.length) {
const recent = snap.labSamples.slice(0, 3);
lines.push(`\nRecent lab samples:`);
recent.forEach((s) => {
const parts = [];
if (s.dm_pct != null) parts.push(`DM ${s.dm_pct}%`);
if (s.ndf_pct != null) parts.push(`NDF ${s.ndf_pct}%`);
if (s.rfv != null) parts.push(`RFV ${s.rfv}`);
if (s.nel != null) parts.push(`NEL ${s.nel}`);
lines.push(`${s.field_name ?? s.field_id ?? '?'} (${s.sample_date?.slice(0, 10) ?? '?'}): ${parts.join(', ')}`);
});
}
if (snap.harvestPlans?.length) {
lines.push(`\nHarvest plans: ${snap.harvestPlans.length} active`);
}
return lines.join('\n');
}

View file

@ -0,0 +1,24 @@
import Anthropic from '@anthropic-ai/sdk';
import { config } from '../config.js';
const client = new Anthropic({ apiKey: config.anthropic.apiKey });
export async function haiku(systemPrompt, userMessage, maxTokens = 1024) {
const msg = await client.messages.create({
model: config.anthropic.model,
max_tokens: maxTokens,
system: systemPrompt,
messages: [{ role: 'user', content: userMessage }],
});
return msg.content[0]?.text ?? '';
}
export async function sonnet(systemPrompt, userMessage, maxTokens = 2048) {
const msg = await client.messages.create({
model: config.anthropic.modelDeep,
max_tokens: maxTokens,
system: systemPrompt,
messages: [{ role: 'user', content: userMessage }],
});
return msg.content[0]?.text ?? '';
}

View file

@ -0,0 +1,76 @@
import { Dropbox } from 'dropbox';
import { config } from '../config.js';
let _dbx = null;
function dbx() {
if (!_dbx) {
_dbx = new Dropbox({
clientId: config.dropbox.appKey,
clientSecret: config.dropbox.appSecret,
refreshToken: config.dropbox.refreshToken,
});
}
return _dbx;
}
export async function listNewFiles(folderPath, since) {
const res = await dbx().filesListFolder({ path: folderPath, recursive: false });
const entries = res.result.entries.filter(
(e) => e['.tag'] === 'file' && (!since || new Date(e.client_modified) > new Date(since))
);
return entries;
}
export async function downloadText(path) {
const res = await dbx().filesDownload({ path });
const buf = await res.result.fileBinary;
return Buffer.from(buf).toString('utf-8');
}
export async function uploadSummary(filename, content) {
const path = `${config.dropbox.paths.outbox}/${filename}`;
await dbx().filesUpload({
path,
contents: content,
mode: { '.tag': 'overwrite' },
autorename: false,
});
return path;
}
export async function readLatestSummaries(maxFiles = 5) {
try {
const res = await dbx().filesListFolder({ path: config.dropbox.paths.outbox, recursive: false });
const files = res.result.entries
.filter((e) => e['.tag'] === 'file' && e.name.endsWith('.txt'))
.sort((a, b) => new Date(b.client_modified) - new Date(a.client_modified))
.slice(0, maxFiles);
const texts = await Promise.all(files.map(async (f) => {
try { return { name: f.name, text: await downloadText(f.path_lower) }; }
catch { return null; }
}));
return texts.filter(Boolean);
} catch {
return [];
}
}
export async function readFolderFiles(folderPath, maxFiles = 10) {
try {
const res = await dbx().filesListFolder({ path: folderPath, recursive: false });
const files = res.result.entries
.filter((e) => e['.tag'] === 'file')
.sort((a, b) => new Date(b.client_modified) - new Date(a.client_modified))
.slice(0, maxFiles);
const texts = await Promise.all(files.map(async (f) => {
try { return { name: f.name, text: await downloadText(f.path_lower) }; }
catch { return null; }
}));
return texts.filter(Boolean);
} catch {
return [];
}
}

View file

@ -0,0 +1,70 @@
/**
* TomTom Geofencing
*
* This module:
* - Provides a webhook receiver for TomTom geofence entry/exit events
* - Creates geofences around field boundaries via the TomTom Geofencing API
* - Emits structured events consumed by jobs (e.g. log truck departure from field)
*
* TomTom Geofencing API docs:
* https://developer.tomtom.com/geofencing-api/documentation/product-information/introduction
*/
import { config } from '../config.js';
const BASE = 'https://api.tomtom.com';
export async function createGeofence({ name, lat, lon, radiusMeters = 500 }) {
const url = `${BASE}/geofencing/1/projects/project/fences?key=${config.tomtom.apiKey}`;
const body = {
type: 'Feature',
geometry: {
type: 'Point',
radius: radiusMeters,
coordinates: [lon, lat],
},
properties: { name },
};
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`TomTom geofence create ${res.status}: ${await res.text()}`);
return res.json();
}
export async function listGeofences() {
const url = `${BASE}/geofencing/1/projects/project/fences?key=${config.tomtom.apiKey}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`TomTom list fences ${res.status}`);
return res.json();
}
export async function registerObject({ objectId, externalId = objectId }) {
const url = `${BASE}/geofencing/1/projects/project/objects/${objectId}?key=${config.tomtom.apiKey}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ externalId }),
});
if (!res.ok) throw new Error(`TomTom register object ${res.status}`);
return res.json();
}
export async function reportPosition({ objectId, lat, lon }) {
const url = `${BASE}/geofencing/1/report/${objectId}?key=${config.tomtom.apiKey}&position=${lon},${lat}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`TomTom report position ${res.status}`);
return res.json();
}
export function parseWebhookEvent(body) {
return {
objectId: body.objectId ?? body.object_id,
fenceName: body.fenceName ?? body.fence_name,
eventType: body.eventType ?? body.event_type, // 'entry' | 'exit'
lat: body.lat ?? body.latitude,
lon: body.lon ?? body.longitude,
timestamp: body.timestamp ?? new Date().toISOString(),
};
}

View file

@ -0,0 +1,30 @@
import { config } from '../config.js';
const BASE = 'https://graph.facebook.com/v19.0';
export async function sendMessage(text, to = config.whatsapp.recipient) {
if (!config.whatsapp.enabled) {
console.log('[WhatsApp] disabled — message would have been:', text.slice(0, 120));
return;
}
const res = await fetch(`${BASE}/${config.whatsapp.phoneId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.whatsapp.accessToken}`,
},
body: JSON.stringify({
messaging_product: 'whatsapp',
to,
type: 'text',
text: { body: text.slice(0, 4096) },
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`WhatsApp ${res.status}: ${err}`);
}
return res.json();
}

14
farm-aggregator/start.bat Normal file
View file

@ -0,0 +1,14 @@
@echo off
cd /d "%~dp0"
if not exist node_modules (
echo Installing dependencies...
npm install
)
if not exist .env (
echo ERROR: .env file not found. Copy .env.example to .env and fill in your keys.
pause
exit /b 1
)
echo Starting Farm Aggregator...
node index.js
pause