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

258 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.

This file contains Unicode characters that might be confused with other characters. 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;
// Inmemory shift counters
const shiftCounters = {};
// ─── Helpers ─────────────────────────────────────────────────────────────────
// Format a millisecond timestamp into "M/D/YY @HH:mm" 24-hr in New York
function formatForSlack(epoch) {
return new Date(epoch).toLocaleString('en-US', {
timeZone: 'America/New_York',
month: 'numeric',
day: 'numeric',
year: '2-digit',
hour12: false,
hour: '2-digit',
minute: '2-digit'
}).replace(',', ' @');
}
// NOAA heat-index 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 Day/Night shift & period
function getShiftInfo(epoch) {
const estNow = new Date(epoch).toLocaleString('en-US',{timeZone:'America/New_York'});
const d = new Date(estNow);
const h = d.getHours(), m = d.getMinutes();
let shift, start = new Date(d);
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, key, estNow:d };
}
// Fetch weather
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());
return `${desc}. Hi of ${Math.round(data.main.temp_max)}, Humidity ${data.main.humidity}%`;
} catch(e){
console.error('Weather API error',e.message);
return 'Unavailable';
}
}
// ─── MariaDB Pool ─────────────────────────────────────────────────────────────
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: +process.env.DB_PORT||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 table exists (no-op if already ran above)
(async()=>{
await pool.execute(`
CREATE TABLE IF NOT EXISTS readings (
id INT AUTO_INCREMENT PRIMARY KEY,
location VARCHAR(20) NOT NULL,
stationDockDoor VARCHAR(10) NOT NULL,
epoch_ms BIGINT NOT NULL,
temperature DOUBLE, humidity DOUBLE, heatIndex DOUBLE
);
`);
})();
// ─── Middleware & Static ─────────────────────────────────────────────────────
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname,'public'), { 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(evt,data){
const msg = `event: ${evt}\ndata: ${JSON.stringify(data)}\n\n`;
clients.forEach(c=>c.write(msg));
}
// ─── Dual DockDoor 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 epoch = Date.now();
const hiIn = computeHeatIndex(inT,inH);
const hiOut = computeHeatIndex(outT,outH);
const { shift, key, estNow } = getShiftInfo(epoch);
shiftCounters[key] = (shiftCounters[key]||0)+1;
const period = shiftCounters[key];
// insert both readings
const ins = `
INSERT INTO readings(location,stationDockDoor,epoch_ms,temperature,humidity,heatIndex)
VALUES(?,?,?,?,?,?)`;
await pool.execute(ins,['Inbound', String(inD), epoch, inT, inH, hiIn]);
await pool.execute(ins,['Outbound',String(outD), epoch,outT,outH,hiOut]);
// broadcast SSE
const slackTs = formatForSlack(epoch);
broadcast('new-reading',{location:'Inbound',stationDockDoor:String(inD),timestamp:slackTs,temperature:inT,humidity:inH,heatIndex:hiIn});
broadcast('new-reading',{location:'Outbound',stationDockDoor:String(outD),timestamp:slackTs,temperature:outT,humidity:outH,heatIndex:hiOut});
// CSV upload
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(FROM_UNIXTIME(epoch_ms/1000))=CURDATE() ORDER BY epoch_ms`
);
let csvUrl=null;
try { csvUrl = await uploadTrendsCsv(dateKey, rows); }
catch(e){ console.error(e); }
// Slack
const weather = await fetchCurrentWeather();
const text =
`*_${shift} shift Period ${period} dock/trailer temperature checks for ${slackTs}_*\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 🥵`;
await axios.post(
process.env.SLACK_WEBHOOK_URL,
{ text },
{ headers:{ 'Content-Type':'application/json' } }
);
res.json({ success:true, shift, period, csvUrl });
} catch(err){
console.error(err);
res.status(500).json({ error: err.message });
}
});
// ─── Area/Mod 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 epoch = Date.now();
const hi = computeHeatIndex(T,H);
const { shift, estNow } = getShiftInfo(epoch);
await pool.execute(
`INSERT INTO readings(location,stationDockDoor,epoch_ms,temperature,humidity,heatIndex)
VALUES(?,?,?,?,?,?)`,
[area, stationCode, epoch, T, H, hi]
);
const slackTs = formatForSlack(epoch);
broadcast('new-area-reading',{location:area,stationDockDoor:stationCode,timestamp:slackTs,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(FROM_UNIXTIME(epoch_ms/1000))=CURDATE() ORDER BY epoch_ms`
);
let csvUrl=null;
try { csvUrl = await uploadTrendsCsv(dateKey, rows); }
catch(e){ console.error(e); }
const weather = await fetchCurrentWeather();
const text =
`*_${shift} shift ${area} temp check for ${slackTs}_*\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 🥵`;
await axios.post(
process.env.SLACK_WEBHOOK_URL,
{ text },
{ headers:{ 'Content-Type':'application/json' } }
);
res.json({ success:true, csvUrl });
} catch(err){
console.error(err);
res.status(500).json({ error: err.message });
}
});
// ─── Fetch & Export ─────────────────────────────────────────────────────────
app.get('/api/readings', async (req,res)=>{
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY epoch_ms`);
res.json(rows);
});
app.get('/api/export', async (req,res)=>{
const [rows] = await pool.execute(`SELECT * FROM readings ORDER BY epoch_ms`);
res.setHeader('Content-disposition','attachment; filename=readings.csv');
res.set('Content-Type','text/csv');
res.write('id,location,stationDockDoor,epoch_ms,temperature,humidity,heatIndex\n');
rows.forEach(r=>{
res.write(`${r.id},${r.location},${r.stationDockDoor},${r.epoch_ms},${r.temperature},${r.humidity},${r.heatIndex}\n`);
});
res.end();
});
// ─── Start ───────────────────────────────────────────────────────────────────
app.listen(PORT, ()=>console.log(`Server running on http://localhost:${PORT}`));