diff --git a/farm-aggregator/README.md b/farm-aggregator/README.md new file mode 100644 index 0000000..b3a5eb5 --- /dev/null +++ b/farm-aggregator/README.md @@ -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 diff --git a/farm-aggregator/config.js b/farm-aggregator/config.js new file mode 100644 index 0000000..282c26b --- /dev/null +++ b/farm-aggregator/config.js @@ -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', + }, +}; diff --git a/farm-aggregator/index.js b/farm-aggregator/index.js new file mode 100644 index 0000000..db3627f --- /dev/null +++ b/farm-aggregator/index.js @@ -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)'}`); diff --git a/farm-aggregator/jobs/digest.js b/farm-aggregator/jobs/digest.js new file mode 100644 index 0000000..58d3e6f --- /dev/null +++ b/farm-aggregator/jobs/digest.js @@ -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; +} diff --git a/farm-aggregator/jobs/geofence-handler.js b/farm-aggregator/jobs/geofence-handler.js new file mode 100644 index 0000000..25783a5 --- /dev/null +++ b/farm-aggregator/jobs/geofence-handler.js @@ -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); +} diff --git a/farm-aggregator/jobs/pulse.js b/farm-aggregator/jobs/pulse.js new file mode 100644 index 0000000..446ae7d --- /dev/null +++ b/farm-aggregator/jobs/pulse.js @@ -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; +} diff --git a/farm-aggregator/package.json b/farm-aggregator/package.json new file mode 100644 index 0000000..744fab6 --- /dev/null +++ b/farm-aggregator/package.json @@ -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" + } +} diff --git a/farm-aggregator/prompts/system.js b/farm-aggregator/prompts/system.js new file mode 100644 index 0000000..d925cbe --- /dev/null +++ b/farm-aggregator/prompts/system.js @@ -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(); diff --git a/farm-aggregator/services/agrefine.js b/farm-aggregator/services/agrefine.js new file mode 100644 index 0000000..008cafe --- /dev/null +++ b/farm-aggregator/services/agrefine.js @@ -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'); +} diff --git a/farm-aggregator/services/claude.js b/farm-aggregator/services/claude.js new file mode 100644 index 0000000..039a999 --- /dev/null +++ b/farm-aggregator/services/claude.js @@ -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 ?? ''; +} diff --git a/farm-aggregator/services/dropbox.js b/farm-aggregator/services/dropbox.js new file mode 100644 index 0000000..c104f52 --- /dev/null +++ b/farm-aggregator/services/dropbox.js @@ -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 []; + } +} diff --git a/farm-aggregator/services/tomtom.js b/farm-aggregator/services/tomtom.js new file mode 100644 index 0000000..6e63f78 --- /dev/null +++ b/farm-aggregator/services/tomtom.js @@ -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(), + }; +} diff --git a/farm-aggregator/services/whatsapp.js b/farm-aggregator/services/whatsapp.js new file mode 100644 index 0000000..71dec6e --- /dev/null +++ b/farm-aggregator/services/whatsapp.js @@ -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(); +} diff --git a/farm-aggregator/start.bat b/farm-aggregator/start.bat new file mode 100644 index 0000000..50758d8 --- /dev/null +++ b/farm-aggregator/start.bat @@ -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