Initial commit

This commit is contained in:
James Pattinson
2025-10-24 11:07:03 +01:00
commit 2ae556f9d9
8 changed files with 1618 additions and 0 deletions

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM alpine:latest
RUN apk add --no-cache nginx php83 php83-fpm php83-mysqli php83-curl
RUN mkdir -p /run/nginx
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD php-fpm83 -D && nginx -g 'daemon off;'

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# AGCS v2 Weather Display
A self-contained, dockerized weather display application with desktop and mobile views.
## Features
- Real-time weather data display via MQTT
- Compass visualization for wind direction
- Archive data retrieval from database
- WeatherLink API proxy
- Responsive design for mobile and desktop
## Setup
1. Ensure Docker and Docker Compose are installed.
2. Clone or copy this project to your local machine.
3. Run the application:
```bash
docker-compose up --build
```
4. Access the application:
- Desktop view: http://localhost:8080/agcsnew.html
- Mobile view: http://localhost:8080/mobile.html
- Archive: http://localhost:8080/archive.php
## Configuration
- MQTT broker: Configured to connect to 'ikarus.egfh.internal:8083'
- Database: MySQL with credentials (user: weewx, pass: weewx, db: weewx)
- WeatherLink API: Proxy configured with API key and secret
## Troubleshooting
- If MQTT connection fails, ensure the broker is running and accessible.
- For database issues, check if the db service is up and data is populated.
- PHP errors: Check container logs with `docker-compose logs web`
## Files
- `agcsnew.html`: Desktop weather display
- `mobile.html`: Mobile-optimized weather display
- `archive.php`: PHP script for database archive data
- `wlproxy.php`: Proxy for WeatherLink API

9
docker-compose.yaml Normal file
View File

@@ -0,0 +1,9 @@
version: '3.8'
services:
web:
build: .
ports:
- "8088:80"
volumes:
- ./src:/var/www/html

25
nginx.conf Normal file
View File

@@ -0,0 +1,25 @@
events {
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
listen 80;
root /var/www/html;
index index.html index.php;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
}

613
src/agcs.html Normal file
View File

@@ -0,0 +1,613 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<style>
#messages
{
background-color:yellow;
font-size:3;
font-weight:bold;
line-height:140%;
}
#status {
background-color: #FFFFFF;
border-radius: 15px;
font-family: 'andale mono', monospace;
font-size: 16pt;
width: 610px;
margin: 5px;
text-align: center;
line-height: 35px;
}
.myDiv
{
border: 5px outset red;
/*background-color: lightblue; */
text-align: center;
width: 500px;
font: andale mono, sans-serif;
margin: auto;
}
.flex-container {
display: flex;
flex-direction: row;
justify-content: center;
/*background-color: DodgerBlue;*/
}
.flex-container > div {
background-color: #f1f1f1;
border-radius: 15px;
font-family: 'andale mono', monospace;
font-size: xx-large;
width: 300px;
margin: 5px;
text-align: center;
line-height: 50px;
}
#QNH {
background-color: #f1f1f1;
border-radius: 15px;
font-family: 'andale mono', monospace;
font-size: 64pt;
width: 300px;
margin: 5px;
text-align: center;
line-height: 75px;
}
#QFE {
background-color: #f1f1f1;
border-radius: 15px;
font-family: 'andale mono', monospace;
font-size: 64pt;
width: 300px;
margin: 5px;
text-align: center;
line-height: 75px;
}
#windSpeed {
font-family: 'andale mono', monospace;
font-size: 48pt;
width: 300px;
margin: 5px;
text-align: center;
line-height: 75px;
}
#avgWindSpeed {
font-family: 'andale mono', monospace;
font-size: 48pt;
width: 300px;
margin: 5px;
text-align: center;
line-height: 75px;
}
#zuluTime {
background-color: #f1f1f1;
border-radius: 15px;
font-family: 'andale mono', monospace;
font-size: 64pt;
width: 610px;
margin: 5px;
text-align: center;
line-height: 75px;
}
#hPa {
font-size: 16px;
font-style: italic;
line-height: 20px;
}
</style>
<head>
<title>EGFH Tower</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js" type="text/javascript"></script>
<script type = "text/javascript">
function onConnectionLost(){
invalidateDisplay();
console.log("connection lost");
document.getElementById("status").style.backgroundColor = '#AA0000';
document.getElementById("status").innerHTML = "Connection Lost, reloading";
connected_flag=0;
setTimeout(location.reload(), reloadDelay);
}
function onFailure(message) {
invalidateDisplay();
console.log("Failed");
document.getElementById("status").style.backgroundColor = '#AA0000';
document.getElementById("status").innerHTML = "Connection Failed- Retrying";
setTimeout(MQTTconnect, reconnectTimeout);
}
function isValidWindData(speed, dir, gustSpeed, gustDir) {
if (isNaN(speed) || speed < 0 || speed > 200) return false;
if (isNaN(dir) || dir < 0 || dir > 360) return false;
if (gustSpeed !== undefined && (isNaN(gustSpeed) || gustSpeed < 0 || gustSpeed > 200)) return false;
if (gustDir !== undefined && (isNaN(gustDir) || gustDir < 0 || gustDir > 360)) return false;
return true;
}
function onMessageArrived(r_message){
try {
if (r_message.destinationName == "weather/loop"){
console.log("Got LOOP " + r_message.payloadString);
loopObj = JSON.parse(r_message.payloadString);
if (loopObj.interval_minute) {
console.log("AVG Speed " + loopObj.windSpeed_knot);
console.log("AVG Dir " + loopObj.windDir);
console.log(loopObj);
if (isValidWindData(loopObj.windSpeed_knot, loopObj.windDir, loopObj.windGust_knot, loopObj.windGustDir)) {
lastAvgWind = Date.now();
avgWindValid = 1;
avgWindSpeed = Number(loopObj.windSpeed_knot).toFixed(0);
roundedWind = (Math.round(loopObj.windDir / 10) * 10).toFixed(0);
if (roundedWind == 0) roundedWind = 360;
avgWindDir = ('000' + roundedWind).substr(-3);
avgWindGustSpeed = Number(loopObj.windGust_knot).toFixed(0);
roundedWind = (Math.round(loopObj.windGustDir / 10) * 10).toFixed(0);
if (roundedWind == 0) roundedWind = 360;
avgWindGustDir = ('000' + roundedWind).substr(-3);
updateAvgWind();
}
} else {
if (loopObj.windSpeed_knot && isValidWindData(loopObj.windSpeed_knot, loopObj.windDir)) {
lastWind = Date.now();
windValid = 1;
instantWindSpeed = Number(loopObj.windSpeed_knot).toFixed(0);
roundedWind = (Math.round(loopObj.windDir / 10) * 10).toFixed(0);
if (roundedWind == 0) roundedWind = 360;
zeroFilledDir = ('000' + roundedWind).substr(-3);
updateWind();
}
if (loopObj.outTemp_C && !isNaN(loopObj.outTemp_C)) {
console.log("LOOP OAT " + loopObj.outTemp_C);
lastOAT = Date.now();
oatValid = 1;
document.getElementById("OAT").innerHTML = Number(loopObj.outTemp_C).toFixed(0) + " °C";
}
}
}
} catch (e) {
console.error("Error processing MQTT message:", e);
}
}
function getInitialArchive() {
fetch(`archive.php`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(archive => {
let now = Math.floor(Date.now() / 1000);
if (now - archive.dateTime > 300) {
console.log("Archive record too old");
invalidateAvgWind();
invalidateOAT();
} else {
if (isValidWindData(archive.windSpeed * 0.868976, archive.windDir, archive.windGust * 0.868976, archive.windGustDir) && !isNaN((archive.outTemp - 32) * 5 / 9)) {
lastOAT = Date.now(); // Approximate, since archive time
oatValid = 1;
document.getElementById("OAT").innerHTML = Number((archive.outTemp - 32) * 5 / 9).toFixed(0) + " °C";
avgWindSpeed = (archive.windSpeed * 0.868976).toFixed(0);
avgWindGustSpeed = (archive.windGust * 0.868976).toFixed(0);
roundedWind = (Math.round(archive.windDir / 10) * 10).toFixed(0);
if (roundedWind == 0) roundedWind = 360;
avgWindDir = ('000' + roundedWind).substr(-3);
roundedWind = (Math.round(archive.windGustDir / 10) * 10).toFixed(0);
if (roundedWind == 0) roundedWind = 360;
avgWindGustDir = ('000' + roundedWind).substr(-3);
avgWindValid = 1;
updateAvgWind();
} else {
console.log("Invalid archive data");
invalidateAvgWind();
invalidateOAT();
}
}
})
.catch(error => {
console.error('Error fetching archive:', error);
invalidateAvgWind();
invalidateOAT();
});
}
function updateStatus() {
if (avgWindValid == 1 && windValid == 1 && pressureValid == 1 && oatValid == 1) {
document.getElementById("status").style.backgroundColor = '#00AA00';
document.getElementById("status").innerHTML = "OK";
} else {
document.getElementById("status").style.backgroundColor = '#FFFF00';
let statusMsg = "Waiting for: ";
let missing = [];
if (windValid == 0) missing.push("Wind");
if (avgWindValid == 0) missing.push("Avg Wind");
if (pressureValid == 0) missing.push("Pressure");
if (oatValid == 0) missing.push("OAT");
document.getElementById("status").innerHTML = statusMsg + missing.join(", ");
}
}
function updateWind() {
if (isNaN(zeroFilledDir)) {
document.getElementById("windSpeed").innerHTML = "NIL";
} else {
document.getElementById("windSpeed").innerHTML = zeroFilledDir + "/" + instantWindSpeed;
}
}
function updateAvgWind() {
if (isNaN(avgWindSpeed) || avgWindSpeed < 2) {
document.getElementById("avgWindSpeed").innerHTML = "CALM";
document.getElementById("avgWindGust").innerHTML = "CALM";
} else {
updateWindDirection(Number(avgWindDir));
document.getElementById("avgWindSpeed").innerHTML = avgWindDir + "/" + avgWindSpeed;
document.getElementById("avgWindGust").innerHTML = avgWindGustDir + "/" + avgWindGustSpeed;
}
}
function invalidateDisplay() {
invalidateWind();
invalidateAvgWind();
invalidateOAT();
}
function invalidateOAT() {
document.getElementById("OAT").innerHTML = "XXX";
oatValid = 0;
}
function invalidateWind() {
document.getElementById("windSpeed").innerHTML = "XXX/X";
windValid = 0;
}
function invalidateAvgWind() {
document.getElementById("avgWindSpeed").innerHTML = "XXX/X";
document.getElementById("avgWindGust").innerHTML = "XXX/X";
avgWindValid = 0;
}
function onConnect() {
connected_flag=1;
//document.getElementById("status").style.backgroundColor = '#00AA00';
//document.getElementById("status").innerHTML = "Connected";
console.log("Connected to MQTT broker");
sub_topics()
}
function disconnect() {
if (connected_flag==1)
mqtt.disconnect();
}
function MQTTconnect() {
clean_sessions=true
var x=Math.floor(Math.random() * 10000);
var cname="wx-"+x;
mqtt = new Paho.MQTT.Client(host,cname);
var options = {
timeout: 3,
cleanSession: clean_sessions,
onSuccess: onConnect,
onFailure: onFailure,
};
mqtt.onConnectionLost = onConnectionLost;
mqtt.onMessageArrived = onMessageArrived;
mqtt.connect(options);
return false;
}
function sub_topics() {
if (connected_flag==0) {
console.log("Not Connected so can't subscribe");
return false;
}
var sqos=0;
var soptions= {
qos:sqos,
};
mqtt.subscribe(topic,soptions);
return false;
}
async function getPressure() {
try {
const response = await fetch('/wlproxy.php?api=current/195562');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const names = await response.json();
const currentTime = Math.floor(Date.now() / 1000);
const pressureTimestamp = names.contents.sensors[2].data[0].ts;
if (currentTime - pressureTimestamp > 1800) { // 30 minutes = 1800 seconds
console.log("Pressure data too old");
document.getElementById("status").style.backgroundColor = '#AA0000';
document.getElementById("status").innerHTML = "Pressure data too old";
document.getElementById("QFE").innerHTML = "XXX";
document.getElementById("QNH").innerHTML = "XXX";
pressureValid = 0;
return;
}
if (isNaN(names.contents.sensors[2].data[0].bar_absolute)) {
throw new Error('Invalid pressure data');
}
lastPressure = Date.now();
pressureValid = 1;
QFERaw = names.contents.sensors[2].data[0].bar_absolute;
QFE = Number((QFERaw / 0.029529983071445).toFixed(0)) + qCorrection;
QNH = QFE + 11;
if (Number(QFE) < 1000) {
document.getElementById("QFE").innerHTML = + QFE + "<br><span id=\"hPa\">hectoPascals</span>";
} else {
document.getElementById("QFE").textContent = QFE;
}
if (Number(QNH) < 1000) {
document.getElementById("QNH").innerHTML = + QNH + "<br><span id=\"hPa\">hectoPascals</span>";
} else {
document.getElementById("QNH").textContent = QNH;
}
var ts = new Date(names.contents.sensors[0].data[0].ts * 1000);
console.log(names.contents.sensors); // Dump the whole JSON to console
} catch (error) {
console.error('Error fetching pressure:', error);
document.getElementById("QFE").innerHTML = "XXX";
document.getElementById("QNH").innerHTML = "XXX";
pressureValid = 0;
}
}
function updateClock() {
var dt = new Date();
var h = dt.getUTCHours().toString();
h = h.length == 1 ? '0' + h : h;
var m = dt.getUTCMinutes().toString();
m = m.length == 1 ? '0' + m : m;
var s = dt.getUTCSeconds().toString();
s = s.length == 1 ? '0' + s : s;
var result = ' ' + h + ':' + m + ':' + s + " Z";
document.getElementById("zuluTime").textContent = result;
window.addEventListener( 'DOMContentLoaded', function(e) { updateClock('date_time'); } )
secs = (dt.getTime() - lastWind)/1000;
if (secs > 10 && windValid == 1) {
invalidateWind();
console.log("Invalidating instant wind due to late message")
document.getElementById("status").innerHTML = "Missing Wind message";
document.getElementById("status").style.backgroundColor = '#AA0000';
}
secs = (dt.getTime() - lastAvgWind)/1000;
if (secs > 150 && avgWindValid == 1) {
invalidateAvgWind();
console.log("Invalidating average wind due to late message")
document.getElementById("status").innerHTML = "Missing avg Wind message";
document.getElementById("status").style.backgroundColor = '#AA0000';
}
secs = (dt.getTime() - lastPressure)/1000;
if (secs > 300 && pressureValid == 1) {
pressureValid = 0;
console.log("Calling update pressure")
getPressure();
}
secs = (dt.getTime() - lastOAT)/1000;
if (secs > 300 && oatValid == 1) {
invalidateOAT();
console.log("Invalidating OAT due to late message")
document.getElementById("status").innerHTML = "Missing OAT message";
document.getElementById("status").style.backgroundColor = '#AA0000';
}
setTimeout( updateClock.bind( this, "zuluTime" ), 500 );
updateStatus();
}
function drawCompass() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw runways
drawRunway(220, 120, 12, -20); // 22/04 runway moved to the left
drawRunway(280, 90, 8, 0, 40); // 10/28 runway moved down
// Draw wind direction arrow
if (avgWindValid == 1) {
drawArrow(windDirection);
}
}
function drawRunway(angle, length, width, xShift, yShift = 0) {
const radian = (angle - 90) * (Math.PI / 180); // Convert to radians
// Calculate runway endpoints with xShift (left/right) and yShift (up/down)
const xStart = centerX - length * Math.cos(radian) + xShift;
const yStart = centerY - length * Math.sin(radian) + yShift;
const xEnd = centerX + length * Math.cos(radian) + xShift;
const yEnd = centerY + length * Math.sin(radian) + yShift;
// Draw runway as a thick line
ctx.strokeStyle = "#333";
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(xStart, yStart);
ctx.lineTo(xEnd, yEnd);
ctx.stroke();
// Correctly assign runway numbers at each end
drawRunwayNumber(angle, xStart, yStart, radian, -20);
drawRunwayNumber(angle + 180, xEnd, yEnd, radian, 20);
}
function drawRunwayNumber(angle, x, y, radian, extension) {
let runwayNumber = Math.round(angle / 10);
if (runwayNumber == 0) runwayNumber = 36;
// Move the number along the extended centerline
const xOffset = x + extension * Math.cos(radian);
const yOffset = y + extension * Math.sin(radian);
ctx.font = "18px Arial";
ctx.fillStyle = "black";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(runwayNumber.toString().padStart(2, "0"), xOffset, yOffset);
}
function drawArrow(angle) {
const arrowLength = 80; // Extended to pass through center
const radian = (angle + 90) * (Math.PI / 180); // Adjusted for correct direction
// Compute start and end points for the arrow
const xStart = centerX - arrowLength * Math.cos(radian);
const yStart = centerY - arrowLength * Math.sin(radian);
const xEnd = centerX + arrowLength * Math.cos(radian);
const yEnd = centerY + arrowLength * Math.sin(radian);
// Draw main arrow line
ctx.strokeStyle = "red";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(xStart, yStart);
ctx.lineTo(xEnd, yEnd);
ctx.stroke();
// Draw arrowhead at the end
ctx.fillStyle = "red";
ctx.beginPath();
ctx.moveTo(xEnd, yEnd);
ctx.lineTo(
xEnd - 12 * Math.cos(radian - Math.PI / 6),
yEnd - 12 * Math.sin(radian - Math.PI / 6)
);
ctx.lineTo(
xEnd - 12 * Math.cos(radian + Math.PI / 6),
yEnd - 12 * Math.sin(radian + Math.PI / 6)
);
ctx.closePath();
ctx.fill();
}
function updateWindDirection(degrees) {
windDirection = degrees;
drawCompass();
}
</script>
</head>
<body>
<script type = "text/javascript">
</script>
<div class="flex-container">
<div>QNH</div>
<div>QFE</div>
</div>
<div class="flex-container">
<div id="QNH" class="data">XXXX</div>
<div id="QFE" class="data">XXXX</div>
</div>
<br>
<div class="flex-container">
<div>Surface Wind<div id="avgWindSpeed">1</div>Instant Wind<div id="windSpeed">1</div></div>
<div><canvas id="compass" width="300" height="300"></canvas></div>
</div>
<br>
<div class="flex-container">
<div>2min Gust</div>
<div>OAT</div>
</div>
<div class="flex-container">
<div id="avgWindGust">XXX</div>
<div id="OAT">XXX</div>
</div>
<br>
<div class="flex-container">
<div id="zuluTime">QNH: XXX</div>
</div>
<br>
<div class="flex-container">
<div id="status"></div>
</div>
<p></p>
<script>
var connected_flag=0
var mqtt;
var reconnectTimeout = 2000;
var reloadDelay = 5000;
var host="wss://wx.swansea-airport.wales/mqtt";
var qCorrection = 0; // Offset for QFE / QNH
var row=0;
var mcount=0;
var topic = "weather/#";
var windValid = 0;
var avgWindValid = 0;
var pressureValid = 0;
var oatValid = 0;
let lastWind = Date.now();
let lastAvgWind = Date.now();
let lastPressure = Date.now();
let lastOAT = Date.now();
const canvas = document.getElementById("compass");
const ctx = canvas.getContext("2d");
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
let windDirection = 0; // Initial wind direction in degrees
invalidateDisplay();
document.getElementById("status").innerHTML = "Initializing...";
document.getElementById("status").style.backgroundColor = '#FFFF00';
MQTTconnect();
getPressure();
getInitialArchive();
updateClock();
drawCompass();
</script>
</body>
</html>

42
src/archive.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
//
// Just returns the latest Archive data from WeeWx Database
//
// Database connection details
$host = 'ikarus.egfh.internal';
$username = 'weero';
$password = 'tH9o3eHgZ5WQ]ti7';
$database = 'weewx';
function connectDb() {
$conn = new mysqli( $GLOBALS['host'], $GLOBALS['username'], $GLOBALS['password'], $GLOBALS['database']);
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error);
}
return $conn;
}
$conn = connectDb();
$sql = "SELECT dateTime,windSpeed,windDir,windGust,windGustDir,outTemp FROM archive ORDER BY dateTime DESC LIMIT 1";
$result = $conn->query($sql);
$data = [];
if ($result->num_rows > 0) {
while ($row = $result->fetch_assoc()) {
$data[] = $row;
}
}
$conn->close();
header('Content-Type: application/json');
echo json_encode($data[0], JSON_PRETTY_PRINT);
?>

611
src/mobile.html Normal file
View File

@@ -0,0 +1,611 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<style>
#messages {
background-color:yellow;
font-size:3;
font-weight:bold;
line-height:140%;
}
@keyframes flash {
0% { background-color: yellow; }
100% { background-color: transparent; }
}
.flash-bg {
animation: flash 0.5s ease-in-out;
}
#status {
background-color: #FFFFFF;
border-radius: 15px;
font-family: 'andale mono', monospace;
font-size: 16pt;
width: 610px;
margin: 5px;
text-align: center;
line-height: 35px;
}
.flex-container {
display: flex;
flex-direction: row;
justify-content: center;
/*background-color: DodgerBlue;*/
}
.flex-container > div {
background-color: #f1f1f1;
border-radius: 15px;
font-family: 'andale mono', monospace;
font-size: x-large;
width: 300px;
margin: 5px;
text-align: center;
line-height: 40px;
}
</style>
<head>
<title>EGFH Info</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js" type="text/javascript"></script>
<script type = "text/javascript">
function onConnectionLost(){
invalidateDisplay();
console.log("connection lost");
document.getElementById("status").style.backgroundColor = '#AA0000';
document.getElementById("status").innerHTML = "connection Lost, reloading";
connected_flag=0;
setTimeout(location.reload(), reloadDelay);
}
function onFailure(message) {
invalidateDisplay();
console.log("Failed");
document.getElementById("status").style.backgroundColor = '#AA0000';
document.getElementById("status").innerHTML = "connection Failed- Retrying";
setTimeout(MQTTconnect, reconnectTimeout);
}
function isValidWindData(speed, dir, gustSpeed, gustDir) {
if (isNaN(speed) || speed < 0 || speed > 200) return false;
if (isNaN(dir) || dir < 0 || dir > 360) return false;
if (gustSpeed !== undefined && (isNaN(gustSpeed) || gustSpeed < 0 || gustSpeed > 200)) return false;
if (gustDir !== undefined && (isNaN(gustDir) || gustDir < 0 || gustDir > 360)) return false;
return true;
}
function onMessageArrived(r_message){
try {
if (r_message.destinationName == "weather/loop"){
console.log("Got LOOP " + r_message.payloadString);
loopObj = JSON.parse(r_message.payloadString);
if (loopObj.interval_minute) {
console.log("AVG Speed " + loopObj.windSpeed_knot);
console.log("AVG Dir " + loopObj.windDir);
console.log(loopObj);
if (isValidWindData(loopObj.windSpeed_knot, loopObj.windDir, loopObj.windGust_knot, loopObj.windGustDir)) {
lastAvgWind = Date.now();
avgWindValid = 1;
avgWindSpeed = Number(loopObj.windSpeed_knot).toFixed(0);
roundedWind = (Math.round(loopObj.windDir / 10) * 10).toFixed(0);
if (roundedWind == 0) roundedWind = 360;
avgWindDir = ('000' + roundedWind).substr(-3);
avgWindGustSpeed = Number(loopObj.windGust_knot).toFixed(0);
roundedWind = (Math.round(loopObj.windGustDir / 10) * 10).toFixed(0);
if (roundedWind == 0) roundedWind = 360;
avgWindGustDir = ('000' + roundedWind).substr(-3);
updateAvgWind();
}
} else {
if (loopObj.windSpeed_knot && isValidWindData(loopObj.windSpeed_knot, loopObj.windDir)) {
lastWind = Date.now();
windValid = 1;
instantWindSpeed = Number(loopObj.windSpeed_knot).toFixed(0);
roundedWind = (Math.round(loopObj.windDir / 10) * 10).toFixed(0);
if (roundedWind == 0) roundedWind = 360;
zeroFilledDir = ('000' + roundedWind).substr(-3);
updateWind();
}
if (loopObj.outTemp_C && !isNaN(loopObj.outTemp_C)) {
console.log("LOOP OAT " + loopObj.outTemp_C);
lastOAT = Date.now();
oatValid = 1;
document.getElementById("OAT").innerHTML = Number(loopObj.outTemp_C).toFixed(0) + " °C";
saveToLocalStorage();
}
}
}
} catch (e) {
console.error("Error processing MQTT message:", e);
}
}
function getInitialArchive() {
fetch(`archive.php`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(archive => {
let now = Math.floor(Date.now() / 1000);
if (now - archive.dateTime > 300) {
console.log("Archive record too old");
invalidateAvgWind();
invalidateOAT();
} else {
if (isValidWindData(archive.windSpeed * 0.868976, archive.windDir, archive.windGust * 0.868976, archive.windGustDir) && !isNaN((archive.outTemp - 32) * 5 / 9)) {
lastOAT = Date.now(); // Approximate, since archive time
oatValid = 1;
document.getElementById("OAT").innerHTML = Number((archive.outTemp - 32) * 5 / 9).toFixed(0) + " °C";
avgWindSpeed = (archive.windSpeed * 0.868976).toFixed(0);
avgWindGustSpeed = (archive.windGust * 0.868976).toFixed(0);
roundedWind = (Math.round(archive.windDir / 10) * 10).toFixed(0);
if (roundedWind == 0) roundedWind = 360;
avgWindDir = ('000' + roundedWind).substr(-3);
roundedWind = (Math.round(archive.windGustDir / 10) * 10).toFixed(0);
if (roundedWind == 0) roundedWind = 360;
avgWindGustDir = ('000' + roundedWind).substr(-3);
avgWindValid = 1;
updateAvgWind();
} else {
console.log("Invalid archive data");
invalidateAvgWind();
invalidateOAT();
}
}
})
.catch(error => {
console.error('Error fetching archive:', error);
invalidateAvgWind();
invalidateOAT();
});
}
function updateWind() {
myDiv = document.getElementById("windSpeed");
if (isNaN(zeroFilledDir)) {
newHtml = "NIL";
} else {
newHtml = zeroFilledDir + "/" + instantWindSpeed;
}
if (newHtml != myDiv.innerHTML) {
myDiv.innerHTML = newHtml;
flashBackground(myDiv);
}
}
function updateAvgWind() {
myDiv = document.getElementById("avgWindSpeed");
if (isNaN(avgWindSpeed) || avgWindSpeed < 2) {
newHtml = "CALM";
} else {
newHtml = avgWindDir + "/" + avgWindSpeed;
}
if (newHtml != myDiv.innerHTML) {
myDiv.innerHTML = newHtml;
flashBackground(myDiv);
updateWindDirection(avgWindDir);
}
myDiv = document.getElementById("avgWindGust");
if (isNaN(avgWindSpeed) || avgWindSpeed < 2) {
newHtml = "CALM";
} else {
newHtml = avgWindGustDir + "/" + avgWindGustSpeed;
}
if (newHtml != myDiv.innerHTML) {
myDiv.innerHTML = newHtml;
flashBackground(myDiv);
}
avgWindValid = 1;
saveToLocalStorage();
}
function invalidateDisplay() {
invalidateWind();
invalidateAvgWind();
invalidateOAT();
}
function invalidateOAT() {
document.getElementById("OAT").innerHTML = "XXX";
oatValid = 0;
}
function invalidateWind() {
document.getElementById("windSpeed").innerHTML = "XXX/X";
windValid = 0;
}
function invalidateAvgWind() {
document.getElementById("avgWindSpeed").innerHTML = "XXX/X";
document.getElementById("avgWindGust").innerHTML = "XXX/X";
avgWindValid = 0;
}
function updateStatus() {
if (avgWindValid == 1 && windValid == 1 && pressureValid == 1 && oatValid == 1) {
document.getElementById("status").style.backgroundColor = '#00AA00';
document.getElementById("status").innerHTML = "OK";
} else {
document.getElementById("status").style.backgroundColor = '#FFFF00';
let statusMsg = "Waiting for: ";
let missing = [];
if (windValid == 0) missing.push("Wind");
if (avgWindValid == 0) missing.push("Avg Wind");
if (pressureValid == 0) missing.push("Pressure");
if (oatValid == 0) missing.push("OAT");
document.getElementById("status").innerHTML = statusMsg + missing.join(", ");
}
}
function saveToLocalStorage() {
const data = {
avgWindSpeed,
avgWindDir,
avgWindGustSpeed,
avgWindGustDir,
instantWindSpeed,
zeroFilledDir,
oat: document.getElementById("OAT").innerHTML,
qfe: document.getElementById("QFE").innerHTML,
qnh: document.getElementById("QNH").innerHTML,
timestamp: Date.now()
};
localStorage.setItem('weatherData', JSON.stringify(data));
}
function loadFromLocalStorage() {
const data = localStorage.getItem('weatherData');
if (data) {
const parsed = JSON.parse(data);
const age = Date.now() - parsed.timestamp;
if (age < 3600000) { // 1 hour
if (parsed.avgWindSpeed && parsed.avgWindDir) {
avgWindSpeed = parsed.avgWindSpeed;
avgWindDir = parsed.avgWindDir;
avgWindGustSpeed = parsed.avgWindGustSpeed;
avgWindGustDir = parsed.avgWindGustDir;
avgWindValid = 1;
updateAvgWind();
}
if (parsed.instantWindSpeed && parsed.zeroFilledDir) {
instantWindSpeed = parsed.instantWindSpeed;
zeroFilledDir = parsed.zeroFilledDir;
windValid = 1;
updateWind();
}
if (parsed.oat) {
document.getElementById("OAT").innerHTML = parsed.oat;
oatValid = 1;
}
if (parsed.qfe) {
document.getElementById("QFE").innerHTML = parsed.qfe;
pressureValid = 1;
}
if (parsed.qnh) {
document.getElementById("QNH").innerHTML = parsed.qnh;
}
}
}
}
function onConnect() {
connected_flag=1;
document.getElementById("status").style.backgroundColor = '#00AA00';
document.getElementById("status").innerHTML = "connected";
console.log("Connected to MQTT broker");
sub_topics()
}
function disconnect() {
if (connected_flag==1)
mqtt.disconnect();
}
function MQTTconnect() {
clean_sessions=true
var x=Math.floor(Math.random() * 10000);
var cname="wx-"+x;
mqtt = new Paho.MQTT.Client(host,cname);
var options = {
timeout: 3,
cleanSession: clean_sessions,
onSuccess: onConnect,
onFailure: onFailure,
};
mqtt.onConnectionLost = onConnectionLost;
mqtt.onMessageArrived = onMessageArrived;
mqtt.connect(options);
return false;
}
function sub_topics() {
if (connected_flag==0) {
console.log("Not Connected so can't subscribe");
return false;
}
var sqos=0;
var soptions= {
qos:sqos,
};
mqtt.subscribe(topic,soptions);
return false;
}
async function getPressure() {
try {
const response = await fetch('/wlproxy.php?api=current/195562');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const names = await response.json();
const currentTime = Math.floor(Date.now() / 1000);
const pressureTimestamp = names.contents.sensors[2].data[0].ts;
if (currentTime - pressureTimestamp > 1800) { // 30 minutes = 1800 seconds
console.log("Pressure data too old");
document.getElementById("status").style.backgroundColor = '#AA0000';
document.getElementById("status").innerHTML = "Pressure data too old";
document.getElementById("QFE").innerHTML = "XXX";
document.getElementById("QNH").innerHTML = "XXX";
pressureValid = 0;
return;
}
if (isNaN(names.contents.sensors[2].data[0].bar_absolute)) {
throw new Error('Invalid pressure data');
}
lastPressure = Date.now();
pressureValid = 1;
QFERaw = names.contents.sensors[2].data[0].bar_absolute;
QFE = Number((QFERaw / 0.029529983071445).toFixed(0)) + qCorrection;
QNH = QFE + 11;
document.getElementById("QFE").textContent = "QFE: " + QFE;
document.getElementById("QNH").textContent = "QNH: " + QNH;
var ts = new Date(names.contents.sensors[0].data[0].ts * 1000);
console.log(names.contents.sensors); // Dump the whole JSON to console
saveToLocalStorage();
} catch (error) {
console.error('Error fetching pressure:', error);
document.getElementById("QFE").innerHTML = "XXX";
document.getElementById("QNH").innerHTML = "XXX";
pressureValid = 0;
}
}
function drawCompass() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw runways
drawRunway(220, 120, 12, -20); // 22/04 runway moved to the left
drawRunway(280, 90, 8, 0, 40); // 10/28 runway moved down
// Draw wind direction arrow
if (avgWindValid == 1) {
drawArrow(windDirection);
}
}
function drawRunway(angle, length, width, xShift, yShift = 0) {
const radian = (angle - 90) * (Math.PI / 180); // Convert to radians
// Calculate runway endpoints with xShift (left/right) and yShift (up/down)
const xStart = centerX - length * Math.cos(radian) + xShift;
const yStart = centerY - length * Math.sin(radian) + yShift;
const xEnd = centerX + length * Math.cos(radian) + xShift;
const yEnd = centerY + length * Math.sin(radian) + yShift;
// Draw runway as a thick line
ctx.strokeStyle = "#333";
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(xStart, yStart);
ctx.lineTo(xEnd, yEnd);
ctx.stroke();
// Correctly assign runway numbers at each end
drawRunwayNumber(angle, xStart, yStart, radian, -20);
drawRunwayNumber(angle + 180, xEnd, yEnd, radian, 20);
}
function drawRunwayNumber(angle, x, y, radian, extension) {
const runwayNumber = Math.round(angle / 10) % 36; // Convert heading to runway number
// Move the number along the extended centerline
const xOffset = x + extension * Math.cos(radian);
const yOffset = y + extension * Math.sin(radian);
ctx.font = "18px Arial";
ctx.fillStyle = "black";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(runwayNumber.toString().padStart(2, "0"), xOffset, yOffset);
}
function drawArrow(angle) {
const arrowLength = 80; // Extended to pass through center
const radian = (angle + 90) * (Math.PI / 180); // Adjusted for correct direction
// Compute start and end points for the arrow
const xStart = centerX - arrowLength * Math.cos(radian);
const yStart = centerY - arrowLength * Math.sin(radian);
const xEnd = centerX + arrowLength * Math.cos(radian);
const yEnd = centerY + arrowLength * Math.sin(radian);
// Draw main arrow line
ctx.strokeStyle = "red";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(xStart, yStart);
ctx.lineTo(xEnd, yEnd);
ctx.stroke();
// Draw arrowhead at the end
ctx.fillStyle = "red";
ctx.beginPath();
ctx.moveTo(xEnd, yEnd);
ctx.lineTo(
xEnd - 12 * Math.cos(radian - Math.PI / 6),
yEnd - 12 * Math.sin(radian - Math.PI / 6)
);
ctx.lineTo(
xEnd - 12 * Math.cos(radian + Math.PI / 6),
yEnd - 12 * Math.sin(radian + Math.PI / 6)
);
ctx.closePath();
ctx.fill();
}
function updateWindDirection(degrees) {
windDirection = degrees % 360;
drawCompass();
}
function flashBackground(element) {
element.classList.add("flash-bg");
setTimeout(() => element.classList.remove("flash-bg"), 500);
}
function statusLoop() {
var dt = new Date();
secs = (dt.getTime() - lastWind)/1000;
if (secs > 10 && windValid == 1) {
invalidateWind();
console.log("Invalidating instant wind due to late message")
document.getElementById("status").innerHTML = "Missing Wind message";
document.getElementById("status").style.backgroundColor = '#AA0000';
}
secs = (dt.getTime() - lastAvgWind)/1000;
if (secs > 150 && avgWindValid == 1) {
invalidateAvgWind();
console.log("Invalidating average wind due to late message")
document.getElementById("status").innerHTML = "Missing avg Wind message";
document.getElementById("status").style.backgroundColor = '#AA0000';
}
secs = (dt.getTime() - lastPressure)/1000;
if (secs > 300 && pressureValid == 1) {
pressureValid = 0;
console.log("Calling update pressure")
getPressure();
}
secs = (dt.getTime() - lastOAT)/1000;
if (secs > 300 && oatValid == 1) {
invalidateOAT();
console.log("Invalidating OAT due to late message")
document.getElementById("status").innerHTML = "Missing OAT message";
document.getElementById("status").style.backgroundColor = '#AA0000';
}
updateStatus();
}
</script>
</head>
<body>
<h2><center>Swansea EGFH - Live Conditions</center></h2>
<div class="flex-container">
<div id="QNH" class="data" aria-live="polite">XXXX</div>
<div id="QFE" class="data" aria-live="polite">XXXX</div>
</div>
<div class="flex-container">
<div>Sfc Wind<div id="avgWindSpeed" aria-live="polite">XXX/XX</div></div>
<div>Inst Wind<div id="windSpeed" aria-live="polite">XXX/XX</div></div>
</div>
<div class="flex-container"><canvas id="compass" width="300" height="300"></canvas></div>
<div class="flex-container">
<div>2min Gust<div id="avgWindGust" aria-live="polite">1</div></div>
<div>OAT<div id="OAT" aria-live="polite">1</div></div>
</div>
<center>Do not rely on this information for flight safety</center>
<div class="flex-container">
<div id="status"></div>
</div>
<script>
var connected_flag=0
var mqtt;
var reconnectTimeout = 2000;
var reloadDelay = 5000;
var host="wss://wx.swansea-airport.wales/mqtt";
var qCorrection = 0; // Offset for QFE / QNH
var row=0;
var mcount=0;
var topic = "weather/#";
var windValid = 0;
var avgWindValid = 0;
var pressureValid = 0;
var oatValid = 0;
let lastWind = Date.now();
let lastAvgWind = Date.now();
let lastPressure = Date.now();
let lastOAT = Date.now();
let instantWindSpeed = '';
let zeroFilledDir = '';
let avgWindSpeed = '';
let avgWindDir = '';
let avgWindGustSpeed = '';
let avgWindGustDir = '';
const canvas = document.getElementById("compass");
const ctx = canvas.getContext("2d");
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
let windDirection = 0; // Initial wind direction in degrees
loadFromLocalStorage();
document.getElementById("status").innerHTML = "Initializing...";
document.getElementById("status").style.backgroundColor = '#FFFF00';
invalidateDisplay();
MQTTconnect();
getPressure();
getInitialArchive();
drawCompass();
setInterval(statusLoop, 5000);
setInterval(getPressure, 600000);
</script>
</body>
</html>

260
src/wlproxy.php Normal file
View File

@@ -0,0 +1,260 @@
<?PHP
error_reporting(E_ERROR | E_PARSE);
// Script: Simple PHP Proxy: Get external HTML, JSON and more!
//
// *Version: 1.6, Last updated: 1/24/2009*
//
// Project Home - http://benalman.com/projects/php-simple-proxy/
// GitHub - http://github.com/cowboy/php-simple-proxy/
// Source - http://github.com/cowboy/php-simple-proxy/raw/master/ba-simple-proxy.php
//
// About: License
//
// Copyright (c) 2010 "Cowboy" Ben Alman,
// Dual licensed under the MIT and GPL licenses.
// http://benalman.com/about/license/
//
// About: Examples
//
// This working example, complete with fully commented code, illustrates one way
// in which this PHP script can be used.
//
// Simple - http://benalman.com/code/projects/php-simple-proxy/examples/simple/
//
// About: Release History
//
// 1.6 - (1/24/2009) Now defaults to JSON mode, which can now be changed to
// native mode by specifying ?mode=native. Native and JSONP modes are
// disabled by default because of possible XSS vulnerability issues, but
// are configurable in the PHP script along with a url validation regex.
// 1.5 - (12/27/2009) Initial release
//
// Topic: GET Parameters
//
// Certain GET (query string) parameters may be passed into ba-simple-proxy.php
// to control its behavior, this is a list of these parameters.
//
// url - The remote URL resource to fetch. Any GET parameters to be passed
// through to the remote URL resource must be urlencoded in this parameter.
// mode - If mode=native, the response will be sent using the same content
// type and headers that the remote URL resource returned. If omitted, the
// response will be JSON (or JSONP). <Native requests> and <JSONP requests>
// are disabled by default, see <Configuration Options> for more information.
// callback - If specified, the response JSON will be wrapped in this named
// function call. This parameter and <JSONP requests> are disabled by
// default, see <Configuration Options> for more information.
// user_agent - This value will be sent to the remote URL request as the
// `User-Agent:` HTTP request header. If omitted, the browser user agent
// will be passed through.
// send_cookies - If send_cookies=1, all cookies will be forwarded through to
// the remote URL request.
// send_session - If send_session=1 and send_cookies=1, the SID cookie will be
// forwarded through to the remote URL request.
// full_headers - If a JSON request and full_headers=1, the JSON response will
// contain detailed header information.
// full_status - If a JSON request and full_status=1, the JSON response will
// contain detailed cURL status information, otherwise it will just contain
// the `http_code` property.
//
// Topic: POST Parameters
//
// All POST parameters are automatically passed through to the remote URL
// request.
//
// Topic: JSON requests
//
// This request will return the contents of the specified url in JSON format.
//
// Request:
//
// > ba-simple-proxy.php?url=http://example.com/
//
// Response:
//
// > { "contents": "<html>...</html>", "headers": {...}, "status": {...} }
//
// JSON object properties:
//
// contents - (String) The contents of the remote URL resource.
// headers - (Object) A hash of HTTP headers returned by the remote URL
// resource.
// status - (Object) A hash of status codes returned by cURL.
//
// Topic: JSONP requests
//
// This request will return the contents of the specified url in JSONP format
// (but only if $enable_jsonp is enabled in the PHP script).
//
// Request:
//
// > ba-simple-proxy.php?url=http://example.com/&callback=foo
//
// Response:
//
// > foo({ "contents": "<html>...</html>", "headers": {...}, "status": {...} })
//
// JSON object properties:
//
// contents - (String) The contents of the remote URL resource.
// headers - (Object) A hash of HTTP headers returned by the remote URL
// resource.
// status - (Object) A hash of status codes returned by cURL.
//
// Topic: Native requests
//
// This request will return the contents of the specified url in the format it
// was received in, including the same content-type and other headers (but only
// if $enable_native is enabled in the PHP script).
//
// Request:
//
// > ba-simple-proxy.php?url=http://example.com/&mode=native
//
// Response:
//
// > <html>...</html>
//
// Topic: Notes
//
// * Assumes magic_quotes_gpc = Off in php.ini
//
// Topic: Configuration Options
//
// These variables can be manually edited in the PHP file if necessary.
//
// $enable_jsonp - Only enable <JSONP requests> if you really need to. If you
// install this script on the same server as the page you're calling it
// from, plain JSON will work. Defaults to false.
// $enable_native - You can enable <Native requests>, but you should only do
// this if you also whitelist specific URLs using $valid_url_regex, to avoid
// possible XSS vulnerabilities. Defaults to false.
// $valid_url_regex - This regex is matched against the url parameter to
// ensure that it is valid. This setting only needs to be used if either
// $enable_jsonp or $enable_native are enabled. Defaults to '/.*/' which
// validates all URLs.
//
// ############################################################################
// Change these configuration options if needed, see above descriptions for info.
$enable_jsonp = false;
$enable_native = false;
$valid_url_regex = '/.*/';
$apikey = "beliqdwnzkkeqdar4sb9xfhxzdv3rf03";
$apisecret = "zpdi1jrycd17gjmx0ldqydj9meboavwg";
$url = 'https://api.weatherlink.com/v2/' . $_GET['api'] . "?api-key=" . $apikey;
// ############################################################################
if ( !$url ) {
// Passed url not specified.
$contents = 'ERROR: url not specified';
$status = array( 'http_code' => 'ERROR' );
} else if ( !preg_match( $valid_url_regex, $url ) ) {
// Passed url doesn't match $valid_url_regex.
$contents = 'ERROR: invalid url';
$status = array( 'http_code' => 'ERROR' );
} else {
$ch = curl_init( $url );
if ( strtolower($_SERVER['REQUEST_METHOD']) == 'post' ) {
curl_setopt( $ch, CURLOPT_POST, true );
curl_setopt( $ch, CURLOPT_POSTFIELDS, $_POST );
}
if ( $_GET['send_cookies'] ) {
$cookie = array();
foreach ( $_COOKIE as $key => $value ) {
$cookie[] = $key . '=' . $value;
}
if ( $_GET['send_session'] ) {
$cookie[] = SID;
}
$cookie = implode( '; ', $cookie );
curl_setopt( $ch, CURLOPT_COOKIE, $cookie );
}
curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
curl_setopt( $ch, CURLOPT_HEADER, true );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-Api-Secret: $apisecret"] );
curl_setopt( $ch, CURLOPT_USERAGENT, $_GET['user_agent'] ? $_GET['user_agent'] : $_SERVER['HTTP_USER_AGENT'] );
list( $header, $contents ) = preg_split( '/([\r\n][\r\n])\\1/', curl_exec( $ch ), 2 );
$status = curl_getinfo( $ch );
curl_close( $ch );
}
// Split header text into an array.
$header_text = preg_split( '/[\r\n]+/', $header );
if ( $_GET['mode'] == 'native' ) {
if ( !$enable_native ) {
$contents = 'ERROR: invalid mode';
$status = array( 'http_code' => 'ERROR' );
}
// Propagate headers to response.
foreach ( $header_text as $header ) {
if ( preg_match( '/^(?:Content-Type|Content-Language|Set-Cookie):/i', $header ) ) {
header( $header );
}
}
print $contents;
} else {
// $data will be serialized into JSON data.
$data = array();
// Propagate all HTTP headers into the JSON data object.
if ( $_GET['full_headers'] ) {
$data['headers'] = array();
foreach ( $header_text as $header ) {
preg_match( '/^(.+?):\s+(.*)$/', $header, $matches );
if ( $matches ) {
$data['headers'][ $matches[1] ] = $matches[2];
}
}
}
// Propagate all cURL request / response info to the JSON data object.
if ( $_GET['full_status'] ) {
$data['status'] = $status;
} else {
$data['status'] = array();
$data['status']['http_code'] = $status['http_code'];
}
// Set the JSON data object contents, decoding it from JSON if possible.
$decoded_json = json_decode( $contents );
$data['contents'] = $decoded_json ? $decoded_json : $contents;
// Generate appropriate content-type header.
$is_xhr = strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest';
header( 'Content-type: application/' . ( $is_xhr ? 'json' : 'x-javascript' ) );
// Get JSONP callback.
$jsonp_callback = $enable_jsonp && isset($_GET['callback']) ? $_GET['callback'] : null;
// Generate JSON/JSONP string
$json = json_encode( $data );
print $jsonp_callback ? "$jsonp_callback($json)" : $json;
}
?>