Repository navigation
Expand file tree
/
Copy pathworker.js
More file actions
231 lines (202 loc) · 8.68 KB
/
Copy pathworker.js
File metadata and controls
231 lines (202 loc) · 8.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
/**
* Lightning Watch — always-on poller (Cloudflare Worker)
*
* Runs on a cron trigger, polls Xweather /lightning, keeps a 30-minute
* all-clear clock per park, and pushes an ntfy alert when a park goes on
* delay or clears. Works with no browser open.
*
* It also serves GET / as JSON so lightning-watch.html can read live state
* without ever seeing your API secret (and without CORS problems).
*
* Setup: see README.md
*/
/**
* BUDGET REALITY CHECK
* Free tier = 15,000 accesses/month. /lightning bills at 10x, so you get
* 1,500 queries/month — about 50 per day. That is NOT enough to watch the
* sky continuously. These defaults deliberately poll only during the hours
* you're likely to care, and lean on the schedule below.
*
* every 3 min, 4 h/day, 4 days/week -> ~1,280 queries/mo fits
* every 3 min, 12 h/day, every day -> ~7,200 queries/mo 5x over
* every 1 min, 12 h/day, every day -> ~21,600 queries/mo 14x over
*
* Widen ACTIVE_HOURS or SCHEDULE only if you're prepared to pay for overage.
*/
const DEFAULTS = {
allClearMin: 30, // minutes with no strike before all-clear
radiusMi: 10, // trigger radius per park
watchRadiusMi: 25, // strikes this close switch us to fast polling
idleMin: 30, // minutes between polls when quiet
activeSec: 180, // seconds between polls when active
// Young Pine Community Park hours (Orange County Parks, verified):
// Summer 8 a.m.–8 p.m. / Winter 8 a.m.–6 p.m., seven days a week.
// No point spending quota when the gates are shut. Narrow this to the hours
// you're actually there — every extra hour costs ~20 queries/day.
activeHours: [16, 20], // local-hour window to poll at all (start, end)
// Parks for the PARKS var in wrangler.toml (confirmed 2026-07-30):
// Young Pine Park 28.49338, -81.23126
// Barber Park 28.49678, -81.31833
// Airport Lakes Park 28.46043, -81.31575
activeDays: [0,1,2,3,4,5,6], // 0=Sun. Trim to just your game days to save quota.
tzOffset: -4, // hours from UTC for your local time (FL: -4 EDT, -5 EST)
monthlyCap: 14000, // stop before hitting the 15,000 free-tier ceiling
costPerCall: 10 // /lightning bills at a 10x multiplier
};
export default {
async scheduled(event, env, ctx) {
ctx.waitUntil(runCheck(env));
},
async fetch(request, env) {
const cors = {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json"
};
const url = new URL(request.url);
if (url.pathname === "/poll") { // manual trigger, handy for testing
const r = await runCheck(env, true);
return new Response(JSON.stringify(r, null, 2), { headers: cors });
}
const state = JSON.parse((await env.LW.get("state")) || "{}");
const usage = JSON.parse((await env.LW.get("usage")) || "{}");
const cfg = getConfig(env);
const now = Date.now();
const parks = cfg.parks.map(p => {
const s = state[p.name] || {};
const remain = s.lastStrike
? cfg.allClearMin * 60 - (now - s.lastStrike) / 1000
: -1;
return {
name: p.name,
status: remain > 0 ? "delay" : (s.lastStrike ? "clear" : "unknown"),
secondsToAllClear: remain > 0 ? Math.round(remain) : 0,
lastStrike: s.lastStrike || null,
lastStrikeDistanceMi: s.dist != null ? Math.round(s.dist * 10) / 10 : null
};
});
return new Response(JSON.stringify({
updated: state._lastPoll || null,
anyDelay: parks.some(p => p.status === "delay"),
parks,
usage
}, null, 2), { headers: cors });
}
};
function getConfig(env) {
const cfg = { ...DEFAULTS };
for (const k of Object.keys(DEFAULTS)) {
if (env[k] !== undefined) {
try { cfg[k] = JSON.parse(env[k]); } catch { cfg[k] = env[k]; }
}
}
cfg.parks = JSON.parse(env.PARKS || "[]");
return cfg;
}
function haversineMi(lat1, lon1, lat2, lon2) {
const R = 3958.8, p = Math.PI / 180;
const dLat = (lat2 - lat1) * p, dLon = (lon2 - lon1) * p;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * p) * Math.cos(lat2 * p) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
async function ntfy(topic, title, body, priority = "high") {
if (!topic) return;
try {
await fetch("https://ntfy.sh/" + encodeURIComponent(topic), {
method: "POST",
headers: { Title: title, Priority: priority, Tags: "zap" },
body
});
} catch (_) { /* never let a failed push break the run */ }
}
async function runCheck(env, force = false) {
const cfg = getConfig(env);
if (!cfg.parks.length) return { skipped: "no parks configured" };
const state = JSON.parse((await env.LW.get("state")) || "{}");
let usage = JSON.parse((await env.LW.get("usage")) || "{}");
const now = Date.now();
// ---- monthly quota bookkeeping -------------------------------------
const monthKey = new Date(now).toISOString().slice(0, 7);
if (usage.month !== monthKey) usage = { month: monthKey, used: 0 };
// ---- should we actually spend a call right now? --------------------
const localMs = now + cfg.tzOffset * 3600 * 1000;
const localHour = new Date(localMs).getUTCHours();
const localDay = new Date(localMs).getUTCDay();
const inWindow = cfg.activeDays.includes(localDay) &&
localHour >= cfg.activeHours[0] &&
localHour < cfg.activeHours[1];
const onDelay = cfg.parks.some(p => {
const s = state[p.name];
return s?.lastStrike && (now - s.lastStrike) / 1000 < cfg.allClearMin * 60;
});
const active = onDelay || state._recentActivity;
const gapMs = active ? cfg.activeSec * 1000 : cfg.idleMin * 60 * 1000;
const due = !state._lastPoll || (now - state._lastPoll) >= gapMs;
if (!force) {
if (!inWindow && !onDelay) return { skipped: "outside active hours" };
if (!due) return { skipped: "not due yet", nextIn: Math.round((gapMs - (now - state._lastPoll)) / 1000) };
if (usage.used + cfg.costPerCall > cfg.monthlyCap) {
return { skipped: "monthly cap reached", usage };
}
}
// ---- one query at the centroid covers every park (100km radius) -----
const lat = cfg.parks.reduce((s, p) => s + +p.lat, 0) / cfg.parks.length;
const lon = cfg.parks.reduce((s, p) => s + +p.lon, 0) / cfg.parks.length;
const url = "https://data.api.xweather.com/lightning/closest" +
`?p=${lat.toFixed(4)},${lon.toFixed(4)}` +
"&radius=100km&limit=1000&format=json" +
`&client_id=${encodeURIComponent(env.XW_ID)}` +
`&client_secret=${encodeURIComponent(env.XW_SECRET)}`;
let json;
try {
json = await (await fetch(url)).json();
} catch (e) {
return { error: "fetch failed: " + e.message };
}
usage.used += cfg.costPerCall;
await env.LW.put("usage", JSON.stringify(usage));
if (!json.success) {
// "no results" is a normal, healthy response — it means clear skies
const noneFound = json.error?.code === "warn_no_data";
if (!noneFound) return { error: json.error?.description || "api error" };
json.response = [];
}
const strikes = (json.response || []).map(o => ({
lat: o.loc.lat,
lon: o.loc.long,
ts: o.ob.timestampMS || o.ob.timestamp * 1000
}));
// ---- apply the 30-minute rule per park -----------------------------
const events = [];
let recentActivity = false;
for (const p of cfg.parks) {
const rad = +p.radius || cfg.radiusMi;
let newest = null;
for (const s of strikes) {
const d = haversineMi(+p.lat, +p.lon, s.lat, s.lon);
if (d <= cfg.watchRadiusMi) recentActivity = true;
if (d <= rad && (!newest || s.ts > newest.ts)) newest = { ts: s.ts, d };
}
const prev = state[p.name] || {};
const wasOnDelay = prev.lastStrike &&
(now - prev.lastStrike) / 1000 < cfg.allClearMin * 60;
if (newest && (!prev.lastStrike || newest.ts > prev.lastStrike)) {
state[p.name] = { lastStrike: newest.ts, dist: newest.d, notifiedClear: false };
if (!wasOnDelay) {
events.push({ park: p.name, event: "delay" });
await ntfy(env.NTFY_TOPIC, `⚡ DELAY — ${p.name}`,
`Strike ${newest.d.toFixed(1)} mi out. All-clear in ${cfg.allClearMin} min if nothing further.`);
}
} else if (wasOnDelay === false && prev.lastStrike && !prev.notifiedClear) {
// clock ran out since last check
state[p.name] = { ...prev, notifiedClear: true };
events.push({ park: p.name, event: "clear" });
await ntfy(env.NTFY_TOPIC, `✅ ALL CLEAR — ${p.name}`,
`${cfg.allClearMin} minutes with no strikes within ${rad} mi.`, "default");
}
}
state._lastPoll = now;
state._recentActivity = recentActivity;
await env.LW.put("state", JSON.stringify(state));
return { polled: true, strikes: strikes.length, events, usage, recentActivity };
}