mqtt live info from psk

pull/38/head
accius 2 days ago
parent 7ad6fbc86f
commit 96099dfb69

@ -1,6 +1,6 @@
{ {
"name": "openhamclock", "name": "openhamclock",
"version": "3.11.0", "version": "3.12.0",
"description": "Amateur Radio Dashboard - A modern web-based HamClock alternative", "description": "Amateur Radio Dashboard - A modern web-based HamClock alternative",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@ -18,6 +18,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"mqtt": "^5.3.4",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"satellite.js": "^5.0.0", "satellite.js": "^5.0.0",
"ws": "^8.14.2" "ws": "^8.14.2"
@ -35,7 +36,8 @@
"dx-cluster", "dx-cluster",
"propagation", "propagation",
"pota", "pota",
"satellite-tracking" "satellite-tracking",
"pskreporter"
], ],
"author": "K0CJH", "author": "K0CJH",
"license": "MIT" "license": "MIT"

@ -1952,7 +1952,8 @@ app.get('/api/pskreporter/config', (req, res) => {
// Fallback HTTP endpoint for when MQTT isn't available // Fallback HTTP endpoint for when MQTT isn't available
// Uses the traditional retrieve API with caching // Uses the traditional retrieve API with caching
let pskHttpCache = {}; let pskHttpCache = {};
const PSK_HTTP_CACHE_TTL = 5 * 60 * 1000; // 5 minutes const PSK_HTTP_CACHE_TTL = 10 * 60 * 1000; // 10 minutes - PSKReporter rate limits aggressively
let psk503Backoff = 0; // Timestamp when we can try again after 503
app.get('/api/pskreporter/http/:callsign', async (req, res) => { app.get('/api/pskreporter/http/:callsign', async (req, res) => {
const callsign = req.params.callsign.toUpperCase(); const callsign = req.params.callsign.toUpperCase();
@ -1964,11 +1965,26 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => {
const cacheKey = `${direction}:${callsign}:${minutes}`; const cacheKey = `${direction}:${callsign}:${minutes}`;
const now = Date.now(); const now = Date.now();
// Check cache // Check cache first
if (pskHttpCache[cacheKey] && (now - pskHttpCache[cacheKey].timestamp) < PSK_HTTP_CACHE_TTL) { if (pskHttpCache[cacheKey] && (now - pskHttpCache[cacheKey].timestamp) < PSK_HTTP_CACHE_TTL) {
return res.json({ ...pskHttpCache[cacheKey].data, cached: true }); return res.json({ ...pskHttpCache[cacheKey].data, cached: true });
} }
// If we're in 503 backoff period, return cached data or empty result
if (psk503Backoff > now) {
console.log(`[PSKReporter HTTP] In backoff period, ${Math.round((psk503Backoff - now) / 1000)}s remaining`);
if (pskHttpCache[cacheKey]) {
return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true });
}
return res.json({
callsign,
direction,
count: 0,
reports: [],
backoff: true
});
}
try { try {
const param = direction === 'tx' ? 'senderCallsign' : 'receiverCallsign'; const param = direction === 'tx' ? 'senderCallsign' : 'receiverCallsign';
// Add appcontact parameter as requested by PSKReporter developer docs // Add appcontact parameter as requested by PSKReporter developer docs
@ -1989,6 +2005,11 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => {
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) { if (!response.ok) {
// On 503, set backoff period (15 minutes) to avoid hammering
if (response.status === 503) {
psk503Backoff = Date.now() + (15 * 60 * 1000);
console.log(`[PSKReporter HTTP] Got 503, backing off for 15 minutes`);
}
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
@ -2039,6 +2060,9 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => {
// Sort by timestamp (newest first) // Sort by timestamp (newest first)
reports.sort((a, b) => b.timestamp - a.timestamp); reports.sort((a, b) => b.timestamp - a.timestamp);
// Clear backoff on success
psk503Backoff = 0;
const result = { const result = {
callsign, callsign,
direction, direction,
@ -2057,18 +2081,17 @@ app.get('/api/pskreporter/http/:callsign', async (req, res) => {
} catch (error) { } catch (error) {
logErrorOnce('PSKReporter HTTP', error.message); logErrorOnce('PSKReporter HTTP', error.message);
// Return cached data if available // Return cached data if available (without error flag)
if (pskHttpCache[cacheKey]) { if (pskHttpCache[cacheKey]) {
return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true }); return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true });
} }
// Return empty result without error flag for 503s (rate limiting is expected)
res.json({ res.json({
callsign, callsign,
direction, direction,
count: 0, count: 0,
reports: [], reports: []
error: error.message,
hint: 'Consider using MQTT WebSocket connection for real-time data'
}); });
} }
}); });

@ -1,15 +1,15 @@
/** /**
* PSKReporter Panel * PSKReporter Panel
* Shows where your digital mode signals are being received * Shows where your digital mode signals are being received
* Styled to match DXClusterPanel * Uses MQTT WebSocket for real-time data
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { usePSKReporter } from '../hooks/usePSKReporter.js'; import { usePSKReporter } from '../hooks/usePSKReporter.js';
import { getBandColor } from '../utils/callsign.js'; import { getBandColor } from '../utils/callsign.js';
const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => { const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => {
const [timeWindow, setTimeWindow] = useState(15); const [timeWindow] = useState(15); // Keep spots for 15 minutes
const [activeTab, setActiveTab] = useState('rx'); // Default to 'rx' (Hearing) - more useful const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard)
const { const {
txReports, txReports,
@ -18,6 +18,8 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
rxCount, rxCount,
loading, loading,
error, error,
connected,
source,
refresh refresh
} = usePSKReporter(callsign, { } = usePSKReporter(callsign, {
minutes: timeWindow, minutes: timeWindow,
@ -25,7 +27,6 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
}); });
const reports = activeTab === 'tx' ? txReports : rxReports; const reports = activeTab === 'tx' ? txReports : rxReports;
const count = activeTab === 'tx' ? txCount : rxCount;
// Get band color from frequency // Get band color from frequency
const getFreqColor = (freqMHz) => { const getFreqColor = (freqMHz) => {
@ -41,6 +42,20 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
return `${Math.floor(minutes/60)}h`; return `${Math.floor(minutes/60)}h`;
}; };
// Get status indicator
const getStatusIndicator = () => {
if (connected) {
return <span style={{ color: '#4ade80', fontSize: '10px' }}> LIVE</span>;
}
if (source === 'connecting' || source === 'reconnecting') {
return <span style={{ color: '#fbbf24', fontSize: '10px' }}> {source}</span>;
}
if (error) {
return <span style={{ color: '#ef4444', fontSize: '10px' }}> offline</span>;
}
return null;
};
if (!callsign || callsign === 'N0CALL') { if (!callsign || callsign === 'N0CALL') {
return ( return (
<div className="panel" style={{ padding: '10px' }}> <div className="panel" style={{ padding: '10px' }}>
@ -72,30 +87,12 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center' alignItems: 'center'
}}> }}>
<span>📡 PSKReporter</span> <span>📡 PSKReporter {getStatusIndicator()}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<select
value={timeWindow}
onChange={(e) => setTimeWindow(parseInt(e.target.value))}
style={{
background: 'rgba(100, 100, 100, 0.3)',
border: '1px solid #666',
color: '#aaa',
padding: '2px 4px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
>
<option value={5}>5m</option>
<option value={15}>15m</option>
<option value={30}>30m</option>
<option value={60}>1h</option>
</select>
<button <button
onClick={refresh} onClick={refresh}
disabled={loading} disabled={loading}
title={connected ? 'Reconnect' : 'Connect'}
style={{ style={{
background: 'rgba(100, 100, 100, 0.3)', background: 'rgba(100, 100, 100, 0.3)',
border: '1px solid #666', border: '1px solid #666',
@ -170,17 +167,24 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) =>
</div> </div>
{/* Reports list - matches DX Cluster style */} {/* Reports list - matches DX Cluster style */}
{error ? ( {error && !connected ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}> <div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Temporarily unavailable Connection failed - click 🔄 to retry
</div> </div>
) : loading && reports.length === 0 ? ( ) : loading && reports.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}> <div style={{ textAlign: 'center', padding: '15px', color: 'var(--text-muted)', fontSize: '11px' }}>
<div className="loading-spinner" /> <div className="loading-spinner" style={{ margin: '0 auto 8px' }} />
Connecting to MQTT...
</div>
) : !connected && reports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
Waiting for connection...
</div> </div>
) : reports.length === 0 ? ( ) : reports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}> <div style={{ textAlign: 'center', padding: '10px', color: 'var(--text-muted)', fontSize: '11px' }}>
No {activeTab === 'tx' ? 'reception reports' : 'stations heard'} {activeTab === 'tx'
? 'Waiting for spots... (TX to see reports)'
: 'No stations heard yet'}
</div> </div>
) : ( ) : (
<div style={{ <div style={{

@ -1,12 +1,12 @@
/** /**
* usePSKReporter Hook * usePSKReporter Hook
* Fetches PSKReporter data showing where your digital mode signals are being received * Fetches PSKReporter data via MQTT WebSocket connection
* *
* Uses HTTP API with server-side caching to respect PSKReporter rate limits. * Uses real-time MQTT feed from mqtt.pskreporter.info for live spots
* * No HTTP API calls - direct WebSocket connection from browser
* For real-time MQTT updates, see mqtt.pskreporter.info (requires mqtt.js library)
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import mqtt from 'mqtt';
// Convert grid square to lat/lon // Convert grid square to lat/lon
function gridToLatLon(grid) { function gridToLatLon(grid) {
@ -31,116 +31,287 @@ function gridToLatLon(grid) {
return { lat: finalLat, lon: finalLon }; return { lat: finalLat, lon: finalLon };
} }
// Get band name from frequency in Hz
function getBandFromHz(freqHz) {
const freqMHz = freqHz / 1000000;
if (freqMHz >= 1.8 && freqMHz <= 2) return '160m';
if (freqMHz >= 3.5 && freqMHz <= 4) return '80m';
if (freqMHz >= 5.3 && freqMHz <= 5.4) return '60m';
if (freqMHz >= 7 && freqMHz <= 7.3) return '40m';
if (freqMHz >= 10.1 && freqMHz <= 10.15) return '30m';
if (freqMHz >= 14 && freqMHz <= 14.35) return '20m';
if (freqMHz >= 18.068 && freqMHz <= 18.168) return '17m';
if (freqMHz >= 21 && freqMHz <= 21.45) return '15m';
if (freqMHz >= 24.89 && freqMHz <= 24.99) return '12m';
if (freqMHz >= 28 && freqMHz <= 29.7) return '10m';
if (freqMHz >= 50 && freqMHz <= 54) return '6m';
if (freqMHz >= 144 && freqMHz <= 148) return '2m';
if (freqMHz >= 420 && freqMHz <= 450) return '70cm';
return 'Unknown';
}
export const usePSKReporter = (callsign, options = {}) => { export const usePSKReporter = (callsign, options = {}) => {
const { const {
minutes = 15, // Time window in minutes (default 15) minutes = 15, // Time window to keep spots
enabled = true, // Enable/disable fetching enabled = true, // Enable/disable fetching
refreshInterval = 300000, // Refresh every 5 minutes (PSKReporter friendly) maxSpots = 100 // Max spots to keep
maxSpots = 100 // Max spots to display
} = options; } = options;
const [txReports, setTxReports] = useState([]); const [txReports, setTxReports] = useState([]);
const [rxReports, setRxReports] = useState([]); const [rxReports, setRxReports] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [connected, setConnected] = useState(false);
const [lastUpdate, setLastUpdate] = useState(null); const [lastUpdate, setLastUpdate] = useState(null);
const [source, setSource] = useState('connecting');
const clientRef = useRef(null);
const txReportsRef = useRef([]);
const rxReportsRef = useRef([]);
const mountedRef = useRef(true);
const fetchData = useCallback(async () => { // Clean old spots (older than specified minutes)
if (!callsign || callsign === 'N0CALL' || !enabled) { const cleanOldSpots = useCallback((spots, maxAgeMinutes) => {
setTxReports([]); const cutoff = Date.now() - (maxAgeMinutes * 60 * 1000);
setRxReports([]); return spots.filter(s => s.timestamp > cutoff).slice(0, maxSpots);
setLoading(false); }, [maxSpots]);
return;
}
// Process incoming MQTT message
const processMessage = useCallback((topic, message) => {
if (!mountedRef.current) return;
try { try {
setError(null); const data = JSON.parse(message.toString());
// PSKReporter MQTT message format
// sa=sender callsign, sl=sender locator, ra=receiver callsign, rl=receiver locator
// f=frequency, md=mode, rp=snr (report), t=timestamp
const {
sa: senderCallsign,
sl: senderLocator,
ra: receiverCallsign,
rl: receiverLocator,
f: frequency,
md: mode,
rp: snr,
t: timestamp
} = data;
if (!senderCallsign || !receiverCallsign) return;
const senderLoc = gridToLatLon(senderLocator);
const receiverLoc = gridToLatLon(receiverLocator);
const freq = parseInt(frequency) || 0;
const now = Date.now();
const report = {
sender: senderCallsign,
senderGrid: senderLocator,
receiver: receiverCallsign,
receiverGrid: receiverLocator,
freq,
freqMHz: freq ? (freq / 1000000).toFixed(3) : '?',
band: getBandFromHz(freq),
mode: mode || 'Unknown',
snr: snr !== undefined ? parseInt(snr) : null,
timestamp: timestamp ? timestamp * 1000 : now,
age: 0,
lat: null,
lon: null
};
// Fetch combined endpoint from our server (handles caching) const upperCallsign = callsign?.toUpperCase();
const response = await fetch(`/api/pskreporter/${encodeURIComponent(callsign)}?minutes=${minutes}`); if (!upperCallsign) return;
if (response.ok) { // If I'm the sender, this is a TX report (someone heard me)
const data = await response.json(); if (senderCallsign.toUpperCase() === upperCallsign) {
report.lat = receiverLoc?.lat;
report.lon = receiverLoc?.lon;
// Process TX reports (where I'm being heard) // Add to front, dedupe by receiver+freq, limit size
const txData = data.tx?.reports || []; txReportsRef.current = [report, ...txReportsRef.current]
const processedTx = txData .filter((r, i, arr) =>
.map(r => ({ i === arr.findIndex(x => x.receiver === r.receiver && Math.abs(x.freq - r.freq) < 1000)
...r, )
// Ensure we have location data
lat: r.lat || (r.receiverGrid ? gridToLatLon(r.receiverGrid)?.lat : null),
lon: r.lon || (r.receiverGrid ? gridToLatLon(r.receiverGrid)?.lon : null),
age: r.age || Math.floor((Date.now() - r.timestamp) / 60000)
}))
.filter(r => r.lat && r.lon)
.slice(0, maxSpots); .slice(0, maxSpots);
// Process RX reports (what I'm hearing) setTxReports(cleanOldSpots([...txReportsRef.current], minutes));
const rxData = data.rx?.reports || []; setLastUpdate(new Date());
const processedRx = rxData }
.map(r => ({
...r, // If I'm the receiver, this is an RX report (I heard someone)
lat: r.lat || (r.senderGrid ? gridToLatLon(r.senderGrid)?.lat : null), if (receiverCallsign.toUpperCase() === upperCallsign) {
lon: r.lon || (r.senderGrid ? gridToLatLon(r.senderGrid)?.lon : null), report.lat = senderLoc?.lat;
age: r.age || Math.floor((Date.now() - r.timestamp) / 60000) report.lon = senderLoc?.lon;
}))
.filter(r => r.lat && r.lon) rxReportsRef.current = [report, ...rxReportsRef.current]
.filter((r, i, arr) =>
i === arr.findIndex(x => x.sender === r.sender && Math.abs(x.freq - r.freq) < 1000)
)
.slice(0, maxSpots); .slice(0, maxSpots);
setTxReports(processedTx); setRxReports(cleanOldSpots([...rxReportsRef.current], minutes));
setRxReports(processedRx);
setLastUpdate(new Date()); setLastUpdate(new Date());
// Check for errors in response
if (data.error || data.tx?.error || data.rx?.error) {
setError(data.error || data.tx?.error || data.rx?.error);
}
} else {
throw new Error(`HTTP ${response.status}`);
} }
} catch (err) { } catch (err) {
console.error('PSKReporter fetch error:', err); // Silently ignore parse errors - malformed messages happen
setError(err.message);
} finally {
setLoading(false);
} }
}, [callsign, minutes, enabled, maxSpots]); }, [callsign, minutes, maxSpots, cleanOldSpots]);
// Connect to MQTT
useEffect(() => { useEffect(() => {
fetchData(); mountedRef.current = true;
if (enabled && refreshInterval > 0) { if (!callsign || callsign === 'N0CALL' || !enabled) {
const interval = setInterval(fetchData, refreshInterval); setTxReports([]);
return () => clearInterval(interval); setRxReports([]);
setLoading(false);
setSource('disabled');
setConnected(false);
return;
} }
}, [fetchData, enabled, refreshInterval]);
// Computed stats const upperCallsign = callsign.toUpperCase();
const txBands = [...new Set(txReports.map(r => r.band))].filter(b => b && b !== 'Unknown');
const txModes = [...new Set(txReports.map(r => r.mode))].filter(Boolean); // Clear old data
txReportsRef.current = [];
const stats = { rxReportsRef.current = [];
txCount: txReports.length, setTxReports([]);
rxCount: rxReports.length, setRxReports([]);
txBands, setLoading(true);
txModes, setError(null);
bestSnr: txReports.length > 0 setSource('connecting');
? txReports.reduce((max, r) => (r.snr || -99) > (max?.snr || -99) ? r : max, null)
: null console.log(`[PSKReporter MQTT] Connecting for ${upperCallsign}...`);
};
// Connect to PSKReporter MQTT via WebSocket
const client = mqtt.connect('wss://mqtt.pskreporter.info:1886/mqtt', {
clientId: `ohc_${upperCallsign}_${Math.random().toString(16).substr(2, 6)}`,
clean: true,
connectTimeout: 15000,
reconnectPeriod: 60000,
keepalive: 60
});
clientRef.current = client;
client.on('connect', () => {
if (!mountedRef.current) return;
console.log('[PSKReporter MQTT] Connected!');
setConnected(true);
setLoading(false);
setSource('mqtt');
setError(null);
// Subscribe to spots where we are the sender (being heard by others)
// Topic format: pskr/filter/v2/{mode}/{band}/{senderCall}/{senderLoc}/{rxCall}/{rxLoc}/{freq}/{snr}
const txTopic = `pskr/filter/v2/+/+/${upperCallsign}/#`;
client.subscribe(txTopic, { qos: 0 }, (err) => {
if (err) {
console.error('[PSKReporter MQTT] TX subscribe error:', err);
} else {
console.log(`[PSKReporter MQTT] Subscribed TX: ${txTopic}`);
}
});
// Subscribe to spots where we are the receiver (hearing others)
const rxTopic = `pskr/filter/v2/+/+/+/+/${upperCallsign}/#`;
client.subscribe(rxTopic, { qos: 0 }, (err) => {
if (err) {
console.error('[PSKReporter MQTT] RX subscribe error:', err);
} else {
console.log(`[PSKReporter MQTT] Subscribed RX: ${rxTopic}`);
}
});
});
client.on('message', processMessage);
client.on('error', (err) => {
if (!mountedRef.current) return;
console.error('[PSKReporter MQTT] Error:', err.message);
setError('Connection error');
setConnected(false);
setLoading(false);
});
client.on('close', () => {
if (!mountedRef.current) return;
console.log('[PSKReporter MQTT] Disconnected');
setConnected(false);
});
client.on('offline', () => {
if (!mountedRef.current) return;
console.log('[PSKReporter MQTT] Offline');
setConnected(false);
setSource('offline');
});
client.on('reconnect', () => {
if (!mountedRef.current) return;
console.log('[PSKReporter MQTT] Reconnecting...');
setSource('reconnecting');
});
// Cleanup on unmount or callsign change
return () => {
mountedRef.current = false;
if (client) {
console.log('[PSKReporter MQTT] Cleaning up...');
client.end(true);
}
};
}, [callsign, enabled, processMessage]);
// Periodically clean old spots and update ages
useEffect(() => {
if (!enabled) return;
const interval = setInterval(() => {
// Update ages and clean old spots
const now = Date.now();
setTxReports(prev => prev.map(r => ({
...r,
age: Math.floor((now - r.timestamp) / 60000)
})).filter(r => r.age <= minutes));
setRxReports(prev => prev.map(r => ({
...r,
age: Math.floor((now - r.timestamp) / 60000)
})).filter(r => r.age <= minutes));
}, 30000); // Every 30 seconds
return () => clearInterval(interval);
}, [enabled, minutes]);
// Manual refresh - force reconnect
const refresh = useCallback(() => {
if (clientRef.current) {
clientRef.current.end(true);
clientRef.current = null;
}
setConnected(false);
setLoading(true);
setSource('reconnecting');
// useEffect will reconnect due to state change
}, []);
return { return {
txReports, txReports,
txCount: txReports.length, txCount: txReports.length,
rxReports, rxReports,
rxCount: rxReports.length, rxCount: rxReports.length,
stats,
loading, loading,
error, error,
connected: false, // HTTP mode - not real-time connected connected,
source: 'http', source,
lastUpdate, lastUpdate,
refresh: fetchData refresh
}; };
}; };

@ -22,6 +22,13 @@ export default defineConfig({
'@styles': path.resolve(__dirname, './src/styles') '@styles': path.resolve(__dirname, './src/styles')
} }
}, },
define: {
// mqtt.js needs these for browser
global: 'globalThis',
},
optimizeDeps: {
include: ['mqtt']
},
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: false, sourcemap: false,
@ -29,7 +36,8 @@ export default defineConfig({
output: { output: {
manualChunks: { manualChunks: {
vendor: ['react', 'react-dom'], vendor: ['react', 'react-dom'],
satellite: ['satellite.js'] satellite: ['satellite.js'],
mqtt: ['mqtt']
} }
} }
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.