Out of respect for my neighbors, I only want flashy music sequences to play when a visitor is actually outside my house watching. Up until this year, I’ve relied on a system I built where visitors can trigger the show by sending a text message to a phone number I display on my garage door matrix.
The next step in evolution was to allow visitors to choose a specific sequence to play. This would require them to be presented with a numbered list to choose from, then text that sequence number. Then I thought, since they’re on my website anyway, can I just build the ability to play a song from the web page directly? Turns out I can. And I did.
Note that I am aware of Remote Falcon . I hear it’s really good as a near plug-and-play solution. In my case, though, I already use other tools for all of my show automations. Integrating Remote Falcon with all of that while not conflicting with my existing SMS/text-based on-demand feature would likely have been harder than just building it myself.
This guide walks through the architecture of a custom, secure, and reliable system that uses GPS and serverless code to make that happen.
The objective is to create a public web interface where a visitor can tap a button to play a specific sequence on your FPP, but only after their phone has verified they are within a specific geographic boundary (a “geo-fence”) around your display. This prevents someone from stumbling onto your website and maliciously triggering your show from across the country or otherwise playing when no one is there to watch.
To replicate this setup, you need access and familiarity with these core components:
This system is built on a chain of three distinct, yet connected, steps: the Client, the Gatekeeper, and the Controller.
The client’s job is to gather and submit location data. This is done using a simple JavaScript function on your website:
navigator.geolocation.getCurrentPosition).item_id).POST request to the Cloudflare Worker URL.The Worker acts as the single, publicly-exposed, and secured entry point.
403 Forbidden response.message=go7 to play sequence #7) and sends a non-geo-fenced request to the next component (the Controller).The Controller handles all the complex rules and logic before executing the final command.
go0 or go7) from the Worker.within-time-switch)As I mentioned before, you could bypass using Node-RED and instead just code any business logic directly into your Cloudflare Worker, and let the Worker communicate directly with your FPP instance.
One of the reasons to use a Cloudflare worker is to prevent exposing your home IP address or internal API endpoints to the public in your web page source. Instead, the browser only talks to the Worker, and the Worker is configured to reject any traffic not coming from your web page. Only the Worker talks to your home.
I use Nginx as a reverse proxy for this to receive the incoming request and forward to Node-RED. The details of configuring Nginx, punching holes in your firewall, optionally setting up DNS, etc, are beyond the scope of the post.
However, once you have all that, setting up Node-RED is as easy as creating an HTTP IN node with an obscure, not guessable URL to use as your endpoint in your Worker code (below).
The front-end code is responsible for two key tasks: retrieving the user’s GPS coordinates and handling the command structure for playing a sequence.
This JavaScript function requests the location and packages the data. Note that in my show, I also allow visitors to play a short set of random sequences, accomplished by sending item_id: '0'. That exception is handled in the business logic in Node-RED. This sends the data to your Worker and waits for a response.
// Function attached to your 'Play Song' or 'Play Random' button
function sendCommand(musicItemID) {
if (!navigator.geolocation) {
showToast("Geolocation not supported.", true);
return;
}
navigator.geolocation.getCurrentPosition(async function(position) {
// Payload sent to the Cloudflare Worker
const payload = {
// Sends '0' for randomize, or '7' for song #7, etc.
item_id: musicItemID,
user_lat: position.coords.latitude,
user_lon: position.coords.longitude
};
const response = await fetch('YOUR_WORKER_URL', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
// Handle Worker response (200 success or 403 denied)
const message = await response.text();
showToast(message, !response.ok);
});
}Example usage on a button click:
document.getElementById('btn-random').onclick = () => sendCommand('0');
document.getElementById('btn-song-7').onclick = () => sendCommand('7');This snippet shows the two most critical checks within your Worker: the geo-fencing calculation and the denial response.
Use this function to calculate the distance in miles based on the user’s location and your home coordinates (HOME_LAT/HOME_LON):
const HOME_LAT = 41.9484424; // YOUR HOME LATITUDE
const HOME_LON = -87.6579076; // YOUR HOME LONGITUDE
const MAX_DISTANCE_MILES = 0.3; // Geo-fence radius
const EARTH_RADIUS_MILES = 3958.8;
const toRad = (angle) => angle * (Math.PI / 180);
function calculateDistance(lat1, lon1, lat2, lon2) {
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_MILES * c; // Distance in miles
}Yes, the coordinates above are Wrigley Field. My tribute to The Blues Brothers .
This logic runs inside the Worker’s fetch handler:
// Worker receives userLat and userLon from the payload...
const distance = calculateDistance(HOME_LAT, HOME_LON, userLat, userLon);
if (distance > MAX_DISTANCE_MILES) {
// DENIAL: This immediately sends the 403 Forbidden response back to the browser.
return new Response('Access denied. You must be near the display to trigger a song.', {
status: 403,
headers: corsHeaders
});
}
// If allowed, construct the final command for Node-RED
const messageValue = `go${musicItemID}`;
// ... forward command to Node-RED endpoint ...With the HTTP IN node in-place and receiving data from your Worker, I convert the incoming JSON to a Javascript object, then send to a switch node. In the switch node, I handle these 3 cases:
message == go0 → Exact match. Routes to a Node-RED flow that randomizes all the sequences in an FPP playlist, then plays the first set (5 or 6 sequences).^go([1-9]\d*)$ → This regex matches ‘go’ followed by a non-zero digit, intentionally excluding go0 and catching all valid sequence numbers (eg, go1, go7).Once the command passes all the checks (Geo-fence, Daylight, FPP Status), the system needs to send a confirmation message back to the user’s browser at the same time it starts playing the sequence. This is a three-step process handled by the Controller and the Gatekeeper.
Node-RED’s job is simple: after triggering FPP, it sends a successful status code and a simple confirmation message.
200.And since I’m also checking to confirm it’s nighttime, nothing else is playing, etc, Node-RED sends appropriate response messages for those cases too.
The Cloudflare Worker receives the 200 Success or another message from Node-RED and simply passes that status and message back to the browser. The only other responses the Worker generates are the security denial (403 Forbidden).
response.status and response.text() from the Node-RED API call and uses it to construct the final response to the user’s browser.The client-side JavaScript receives the response and displays it using the custom showToast function:
200, it’s a success. If it’s anything else (like the Worker’s 403 or a Node-RED 500), it’s treated as an error.showToast function is called with the message and the error status.The core logic in your web page that handles the Worker’s response looks like this:
// Located in your button's click handler, after the fetch call:
const response = await fetch(endpoint, { /* ... */ });
const text = await response.text();
// Check if the response was successful (HTTP status code 200-299)
const isError = !response.ok;
// showToast handles styling: Green for Success (isError=false), Red for Error (isError=true)
showToast(text, isError);This structure ensures the user instantly gets clear feedback, whether it’s a successful start message, an unsuccessful “Come back after sunset”, from Node-RED, or a clear failure message (e.g., “Access denied.” from the Worker, or “Location access denied.” from the browser itself).
This CSS defines the look and positioning of the toast. You should place this within <style> tags in the <head> of your page or in a linked stylesheet.
<style>
.toast-container {
/* Positions the toast in the bottom center of the viewport */
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
pointer-events: none; /* Allows clicks to pass through container */
}
.toast-message {
/* Style for the actual toast box */
padding: 10px 20px;
border-radius: 6px;
color: white;
background-color: rgba(0, 0, 0, 0.75);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
/* Animation and initial state */
opacity: 0;
transition: opacity 0.5s, transform 0.5s;
transform: translateY(20px);
pointer-events: auto; /* Re-enables clicks on the toast itself */
max-width: 90vw;
text-align: center;
margin-top: 10px;
}
.toast-message.show {
opacity: 1;
transform: translateY(0);
}
.toast-message.error {
background-color: rgba(200, 0, 0, 0.85); /* Red background for errors */
}
.toast-message.success {
background-color: rgba(0, 150, 0, 0.85); /* Green background for success */
}
</style>
<div class="toast-container"></div>showToast Function
This function handles creating the toast element, applying the correct styling (success or error), displaying it, and automatically removing it after 5 seconds.
// Function to display the toast message
function showToast(message, isError = false) {
const container = document.querySelector('.toast-container');
const toast = document.createElement('div');
// Set content and class for styling
toast.textContent = message;
toast.classList.add('toast-message');
toast.classList.add(isError ? 'error' : 'success');
container.appendChild(toast);
// Force reflow/repaint before showing the transition
void toast.offsetWidth;
// Show the toast
toast.classList.add('show');
// Hide and remove after 5 seconds (5000ms)
setTimeout(() => {
toast.classList.remove('show');
// Wait for the transition to finish before removing from DOM
setTimeout(() => {
toast.remove();
}, 500);
}, 5000);
}At this time of this writing, you can see this in action on the currently active 2025 Thanksgiving Light Show. However, since I didn’t build in any determination of what season/show/playlist it should play from, I’ll move this functionality to the Christmas Show page when it goes live. After Christmas, I’ll disable it until next season. Also, since you’re likely not standing in front of my house, all you’re going to see are the pop-up toast messages stating you’re out of range.
The above is a simplified version of my implementation. I retained the SMS/Text functionality, which is set up to construct the same payload from an SMS/text message as received from the web. I then inject both payloads, regardless of source, into the same business logic flow, apply the same tests, respond with the same messages and route to the next actions (play single, play random, etc).
I hope this has been helpful to someone, or at least interesting.