// server.js require('dotenv').config(); const express = require('express'); const mysql = require('mysql2/promise'); const bodyParser = require('body-parser'); const path = require('path'); const axios = require('axios'); const { uploadTrendsCsv } = require('./s3'); const app = express(); const PORT = process.env.PORT || 3000; // In-memory shift counters const shiftCounters = {}; /** Helpers **/ // Format a JS Date in EST as SQL DATETIME function formatDateEST(date) { const pad = n => n.toString().padStart(2,'0'); return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ` + `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } // Format a JS Date in EST as an ISO-like string (no Z) function isoStringEST(date) { const pad = n => n.toString().padStart(2,'0'); return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}` + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } // Compute heat index (NOAA formula) function computeHeatIndex(T, R) { const [c1,c2,c3,c4,c5,c6,c7,c8,c9] = [-42.379,2.04901523,10.14333127,-0.22475541, -0.00683783,-0.05481717,0.00122874,0.00085282,-0.00000199]; const HI = c1 + c2*T + c3*R + c4*T*R + c5*T*T + c6*R*R + c7*T*T*R + c8*T*R*R + c9*T*T*R*R; return Math.round(HI*100)/100; } // Determine shift (Day/Night), shiftStart, key & estNow in EST function getShiftInfo(now) { const estNow = new Date(now.toLocaleString('en-US',{ timeZone:'America/New_York' })); const [h,m] = [estNow.getHours(), estNow.getMinutes()]; let shift, shiftStart = new Date(estNow); if (h>7 || (h===7 && m>=0)) { if (h<17 || (h===17 && m<30)) { shift = 'Day'; shiftStart.setHours(7,0,0,0); } else { shift = 'Night'; shiftStart.setHours(17,30,0,0); } } else { shift = 'Night'; shiftStart.setDate(shiftStart.getDate()-1); shiftStart.setHours(17,30,0,0); } const key = `${shift}-${shiftStart.toISOString().slice(0,10)}-` + `${shiftStart.getHours()}${shiftStart.getMinutes()}`; return { shift, shiftStart, key, estNow }; } // Fetch current weather from OpenWeatherMap async function fetchCurrentWeather() { const apiKey = process.env.WEATHER_API_KEY; const zip = process.env.ZIP_CODE; if (!apiKey || !zip) return null; try { const { data } = await axios.get( 'https://api.openweathermap.org/data/2.5/weather', { params: { zip:`${zip},us`, appid:apiKey, units:'imperial' } } ); return { description: data.weather[0].description, humidity: data.main.humidity, temperature: data.main.temp }; } catch (err) { console.error('Weather API error:', err.message); return null; } } /** MariaDB pool **/ const pool = mysql.createPool({ host: process.env.DB_HOST, port: parseInt(process.env.DB_PORT,10)||3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, connectTimeout: 10000, enableKeepAlive: true, keepAliveInitialDelay:10000 }); // Ensure readings table exists (for dock doors & areas) (async ()=>{ const createSQL = ` CREATE TABLE IF NOT EXISTS readings ( id INT AUTO_INCREMENT PRIMARY KEY, dockDoor INT, direction VARCHAR(10), region VARCHAR(20), stationCode VARCHAR(10), timestamp DATETIME NOT NULL, temperature DOUBLE, humidity DOUBLE, heatIndex DOUBLE ); `; await pool.execute(createSQL); })(); /** SSE for real-time updates **/ let clients = []; app.get('/api/stream',(req,res)=>{ res.set({ 'Content-Type':'text/event-stream', 'Cache-Control':'no-cache', Connection:'keep-alive' }); res.flushHeaders(); clients.push(res); req.on('close',()=>{ clients=clients.filter(c=>c!==res); }); }); function broadcast(event,data){ const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; clients.forEach(c=>c.write(msg)); } // Middleware & static app.use(bodyParser.json()); app.use(express.static(path.join(__dirname,'public'))); /** Dual-dock-door readings **/ app.post('/api/readings',async(req,res)=>{ try { const { inbound={}, outbound={} } = req.body; const { dockDoor: inDoor, temperature: inTemp, humidity: inHum } = inbound; const { dockDoor: outDoor,temperature: outTemp,humidity: outHum } = outbound; if ([inDoor,inTemp,inHum,outDoor,outTemp,outHum].some(v=>v===undefined)) return res.status(400).json({error:'Missing inbound/outbound fields'}); const hiIn = computeHeatIndex(inTemp,inHum); const hiOut = computeHeatIndex(outTemp,outHum); const { shift, shiftStart, key, estNow } = getShiftInfo(new Date()); shiftCounters[key]=(shiftCounters[key]||0)+1; const period = shiftCounters[key]; const sqlTs = formatDateEST(estNow); const bcTs = isoStringEST(estNow); // Insert dock readings const insertSQL = ` INSERT INTO readings (dockDoor,direction,timestamp,temperature,humidity,heatIndex) VALUES (?,?,?,?,?,?) `; await pool.execute(insertSQL,[inDoor,'inbound',sqlTs,inTemp,inHum,hiIn]); await pool.execute(insertSQL,[outDoor,'outbound',sqlTs,outTemp,outHum,hiOut]); // Broadcast SSE broadcast('new-reading',{ dockDoor:inDoor, direction:'inbound',timestamp:bcTs, temperature:inTemp, humidity:inHum, heatIndex:hiIn }); broadcast('new-reading',{ dockDoor:outDoor,direction:'outbound',timestamp:bcTs, temperature:outTemp,humidity:outHum,heatIndex:hiOut }); // Build CSV & upload const dateKey = `${estNow.getFullYear()}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`; const [rows] = await pool.execute( `SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp ASC` ); let csvUrl=null; try{ csvUrl = await uploadTrendsCsv(dateKey,rows); } catch(e){ console.error('CSV upload error:',e); } // Weather const weather = await fetchCurrentWeather(); // Slack payload const slackPayload = { text: 'New temperature readings recorded', inbound_dockDoor: inDoor, inbound_temperature: inTemp, inbound_humidity: inHum, hiIn, outbound_dockDoor: outDoor, outbound_temperature:outTemp, outbound_humidity: outHum, hiOut, shift, period, timestamp: bcTs, csvUrl, current_weather: weather?.description || null, current_humidity: weather?.humidity || null, current_temperature: weather?.temperature || null }; await axios.post(process.env.SLACK_WEBHOOK_URL,slackPayload); res.json({ success:true, shift,period,csvUrl }); } catch(err) { console.error('POST /api/readings error:',err); res.status(500).json({ error: err.message }); } }); /** Area (A-Mod / AFE-1 / AFE-2 / B-Mod) readings **/ app.post('/api/area-readings',async(req,res)=>{ try { const { area, stationCode, temperature, humidity } = req.body; if (!area||!stationCode||temperature==null||humidity==null) return res.status(400).json({error:'Missing area,stationCode,temp,humidity'}); const hi = computeHeatIndex(temperature,humidity); const { shift, shiftStart, key, estNow } = getShiftInfo(new Date()); shiftCounters[key]=(shiftCounters[key]||0)+1; const period = shiftCounters[key]; const sqlTs = formatDateEST(estNow); const bcTs = isoStringEST(estNow); // Insert area reading const sql = ` INSERT INTO readings (region,stationCode,timestamp,temperature,humidity,heatIndex) VALUES (?,?,?,?,?,?) `; await pool.execute(sql,[area,stationCode,sqlTs,temperature,humidity,hi]); // Broadcast SSE broadcast('new-area-reading',{ region:area,stationCode,timestamp:bcTs, temperature,humidity,heatIndex:hi }); // Build CSV & upload const dateKey = `${estNow.getFullYear()}${String(estNow.getMonth()+1).padStart(2,'0')}${String(estNow.getDate()).padStart(2,'0')}`; const [rows] = await pool.execute( `SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp ASC` ); let csvUrl=null; try{ csvUrl = await uploadTrendsCsv(dateKey,rows); } catch(e){ console.error('CSV upload error:',e); } // Weather const weather = await fetchCurrentWeather(); // Slack payload const slackPayload = { text: 'New area temperature reading', area, stationCode, temperature, humidity, heatIndex: hi, shift, period, timestamp: bcTs, csvUrl, current_weather: weather?.description || null, current_humidity: weather?.humidity || null, current_temperature: weather?.temperature || null }; await axios.post(process.env.SLACK_WEBHOOK_URL,slackPayload); res.json({ success:true, shift,period,csvUrl }); } catch(err) { console.error('POST /api/area-readings error:',err); res.status(500).json({ error: err.message }); } }); /** Return all readings **/ app.get('/api/readings',async(req,res)=>{ try { const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`); res.json(rows); } catch(err) { console.error('GET /api/readings error:',err); res.status(500).json({ error: err.message }); } }); /** Export CSV of all readings **/ app.get('/api/export',async(req,res)=>{ try { const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp ASC`); res.setHeader('Content-disposition','attachment; filename=readings.csv'); res.set('Content-Type','text/csv'); res.write('id,dockDoor,direction,region,stationCode,timestamp,temperature,humidity,heatIndex\n'); rows.forEach(r=>{ const ts = r.timestamp instanceof Date ? formatDateEST(r.timestamp) : r.timestamp; res.write(`${r.id},${r.dockDoor||''},${r.direction||''},${r.region||''},${r.stationCode||''},${ts},${r.temperature},${r.humidity},${r.heatIndex}\n`); }); res.end(); } catch(err) { console.error('GET /api/export error:',err); res.status(500).send(err.message); } }); // Start server app.listen(PORT,()=>{ console.log(`Server running on http://localhost:${PORT}`); });