Heat-Tracker/server.js
2025-04-30 00:33:02 +00:00

317 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
const pad2 = n => n.toString().padStart(2, '0');
function shortEST(date) {
const est = new Date(date.toLocaleString('en-US', { timeZone: 'America/New_York' }));
const M = est.getMonth() + 1, D = est.getDate(), YY = String(est.getFullYear()).slice(-2);
const hh = pad2(est.getHours()), mm = pad2(est.getMinutes());
return `${M}/${D}/${YY} @${hh}:${mm}`;
}
function formatDateEST(date) {
const est = new Date(date.toLocaleString('en-US', { timeZone: 'America/New_York' }));
const y = est.getFullYear(), M = pad2(est.getMonth()+1), D = pad2(est.getDate());
const hh = pad2(est.getHours()), mm = pad2(est.getMinutes()), ss = pad2(est.getSeconds());
return `${y}-${M}-${D} ${hh}:${mm}:${ss}`;
}
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;
}
function getShiftInfo(now) {
const est = new Date(now.toLocaleString('en-US', { timeZone:'America/New_York' }));
const h = est.getHours(), m = est.getMinutes();
let shift, start = new Date(est);
if (h > 7 || (h === 7 && m >= 0)) {
if (h < 17 || (h === 17 && m < 30)) {
shift = 'Day'; start.setHours(7,0,0,0);
} else {
shift = 'Night'; start.setHours(17,30,0,0);
}
} else {
shift = 'Night';
start.setDate(start.getDate() - 1);
start.setHours(17,30,0,0);
}
const key = `${shift}-${start.toISOString().slice(0,10)}-${start.getHours()}${start.getMinutes()}`;
return { shift, start, key, estNow: est };
}
async function fetchCurrentWeather() {
const key = process.env.WEATHER_API_KEY, zip = process.env.ZIP_CODE;
if (!key || !zip) return 'Unavailable';
try {
const { data } = await axios.get(
'https://api.openweathermap.org/data/2.5/weather',
{ params: { zip:`${zip},us`, appid:key, units:'imperial' } }
);
const desc = data.weather[0].description.replace(/^\w/,c=>c.toUpperCase());
const hi = Math.round(data.main.temp_max);
const hum = data.main.humidity;
return `${desc}. Hi of ${hi}, Humidity ${hum}%`;
} catch (err) {
console.error('Weather API error:', err.message);
return 'Unavailable';
}
}
// 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
});
// Ensure readings table exists
(async () => {
const sql = `
CREATE TABLE IF NOT EXISTS readings (
id INT AUTO_INCREMENT PRIMARY KEY,
location VARCHAR(20) NOT NULL,
stationDockDoor VARCHAR(10) NOT NULL,
timestamp DATETIME NOT NULL,
temperature DOUBLE,
humidity DOUBLE,
heatIndex DOUBLE
);`;
await pool.execute(sql);
})();
// Middleware & static (serve heatmap.html at '/')
app.use(bodyParser.json());
const publicDir = path.join(__dirname, 'public');
app.use(express.static(publicDir, { index: 'heatmap.html' }));
// SSE setup
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));
}
// === Dual dock-door readings endpoint ===
app.post('/api/readings', async (req, res) => {
try {
const { inbound={}, outbound={} } = req.body;
const { dockDoor: inD, temperature: inT, humidity: inH } = inbound;
const { dockDoor: outD, temperature: outT, humidity: outH } = outbound;
if ([inD,inT,inH,outD,outT,outH].some(v => v == null)) {
return res.status(400).json({ error: 'Missing fields' });
}
const hiIn = computeHeatIndex(inT, inH);
const hiOut = computeHeatIndex(outT, outH);
const now = new Date();
const { shift, key, estNow } = getShiftInfo(now);
shiftCounters[key] = (shiftCounters[key] || 0) + 1;
const period = shiftCounters[key];
const sqlTs = formatDateEST(estNow);
const shortTs = shortEST(estNow);
// Insert inbound + outbound
const ins = `
INSERT INTO readings(location,stationDockDoor,timestamp,temperature,humidity,heatIndex)
VALUES(?,?,?,?,?,?)`;
await pool.execute(ins, ['Inbound', String(inD), sqlTs, inT, inH, hiIn]);
await pool.execute(ins, ['Outbound', String(outD), sqlTs, outT, outH, hiOut]);
// SSE broadcast
broadcast('new-reading', {
location: 'Inbound',
stationDockDoor: String(inD),
timestamp: shortTs,
temperature: inT,
humidity: inH,
heatIndex: hiIn
});
broadcast('new-reading', {
location: 'Outbound',
stationDockDoor: String(outD),
timestamp: shortTs,
temperature: outT,
humidity: outH,
heatIndex: hiOut
});
// Generate/upload CSV
const y = estNow.getFullYear(), m = pad2(estNow.getMonth()+1), d = pad2(estNow.getDate());
const dateKey = `${y}${m}${d}`;
const [rows] = await pool.execute(
`SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp`
);
let csvUrl = null;
try { csvUrl = await uploadTrendsCsv(dateKey, rows); }
catch (e) { console.error('CSV upload error:', e); }
// Fetch weather
const weather = await fetchCurrentWeather();
// Build Slack message text
const text =
`*_${shift} shift Period ${period} dock/ trailer temperature checks for ${shortTs}_*\n` +
`*_Current Weather Forecast for Baltimore: ${weather}_*\n\n` +
`*_⬇ Inbound Dock Door 🚛 :_* ${inD}\n` +
`*_Temp:_* ${inT} °F 🌡️\n` +
`*_Humidity:_* ${inH} % 💦\n` +
`*_Heat Index:_* ${hiIn} °F 🥵\n\n` +
`*_⬆ Outbound Dock Door 🚛 :_* ${outD}\n` +
`*_Temp:_* ${outT} °F 🌡️\n` +
`*_Humidity:_* ${outH} % 💦\n` +
`*_Heat Index:_* ${hiOut} °F 🥵`;
// Send JSON with top-level "text" field
await axios.post(
process.env.SLACK_WEBHOOK_URL,
{ text, shift, period, timestamp: shortTs },
{ headers: { 'Content-Type': 'application/json' } }
);
res.json({ success: true, shift, period, csvUrl });
} catch (err) {
console.error('POST /api/readings error:', err);
res.status(500).json({ error: err.message });
}
});
// === Area/mod readings endpoint ===
app.post('/api/area-readings', async (req, res) => {
try {
const { area, stationCode, temperature: T, humidity: H } = req.body;
if (!area || !stationCode || T == null || H == null) {
return res.status(400).json({ error: 'Missing fields' });
}
const hi = computeHeatIndex(T, H);
const now = new Date();
const { shift, estNow } = getShiftInfo(now);
const sqlTs = formatDateEST(estNow);
const shortTs = shortEST(estNow);
// Insert
const ins = `
INSERT INTO readings(location,stationDockDoor,timestamp,temperature,humidity,heatIndex)
VALUES(?,?,?,?,?,?)`;
await pool.execute(ins, [area, stationCode, sqlTs, T, H, hi]);
// SSE
broadcast('new-area-reading', {
location: area,
stationDockDoor: stationCode,
timestamp: shortTs,
temperature: T,
humidity: H,
heatIndex: hi
});
// CSV
const y = estNow.getFullYear(), m = pad2(estNow.getMonth()+1), d = pad2(estNow.getDate());
const dateKey = `${y}${m}${d}`;
const [rows] = await pool.execute(
`SELECT * FROM readings WHERE DATE(timestamp)=CURDATE() ORDER BY timestamp`
);
let csvUrl = null;
try { csvUrl = await uploadTrendsCsv(dateKey, rows); }
catch (e) { console.error('CSV upload error:', e); }
// Fetch weather
const weather = await fetchCurrentWeather();
// Build Slack message text
const text =
`*_${shift} shift ${area} temp check for ${shortTs}_*\n` +
`*_Current Weather Forecast for Baltimore: ${weather}_*\n\n` +
`*_${area.toUpperCase()} station:_* ${stationCode}\n` +
`*_Temp:_* ${T} °F 🌡️\n` +
`*_Humidity:_* ${H} % 💦\n` +
`*_Heat Index:_* ${hi} °F 🥵`;
// Send JSON with top-level "text" field
await axios.post(
process.env.SLACK_WEBHOOK_URL,
{ text, location: area, station_dock_door: stationCode, temperature: T, humidity: H, heat_index: hi },
{ headers: { 'Content-Type': 'application/json' } }
);
res.json({ success: true, csvUrl });
} catch (err) {
console.error('POST /api/area-readings error:', err);
res.status(500).json({ error: err.message });
}
});
// GET all readings
app.get('/api/readings', async (req, res) => {
try {
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp`);
res.json(rows);
} catch (err) {
console.error('GET /api/readings error:', err);
res.status(500).json({ error: err.message });
}
});
// Export CSV
app.get('/api/export', async (req, res) => {
try {
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY timestamp`);
res.setHeader('Content-disposition', 'attachment; filename=readings.csv');
res.set('Content-Type', 'text/csv');
res.write('id,location,stationDockDoor,timestamp,temperature,humidity,heatIndex\n');
rows.forEach(r => {
const ts = r.timestamp instanceof Date ? formatDateEST(r.timestamp) : r.timestamp;
res.write(`${r.id},${r.location},${r.stationDockDoor},${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}`);
});