Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,12 @@ def _get_env_bool(key: str, default: bool) -> bool:
MQTT_TOPIC_PREFIX = _get_env("MQTT_TOPIC_PREFIX", "intercept")
MQTT_RETAIN = _get_env_bool("MQTT_RETAIN", False)

# CoT (Cursor-on-Target) export for ATAK/WinTAK/TAK Server (optional — disabled when COT_HOST is empty)
COT_HOST = _get_env("COT_HOST", "")
COT_PORT = _get_env_int("COT_PORT", 6969)
COT_PROTO = _get_env("COT_PROTO", "udp") # udp or tcp
COT_STALE_SECONDS = _get_env_int("COT_STALE_SECONDS", 60)

# Admin credentials
ADMIN_USERNAME = _get_env("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = _get_env("ADMIN_PASSWORD", "admin")
Expand Down
4 changes: 3 additions & 1 deletion static/js/core/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const InterceptTime = (function() {
const TZ_MAP = {
'UTC': 'UTC',
'local': undefined,
'Africa/Johannesburg': 'Africa/Johannesburg',
'US/Eastern': 'America/New_York',
'US/Central': 'America/Chicago',
'US/Mountain': 'America/Denver',
Expand All @@ -72,13 +73,14 @@ const InterceptTime = (function() {
const TZ_LABELS = {
'UTC': 'UTC',
'local': '',
'Africa/Johannesburg': 'SAST',
'US/Eastern': 'ET',
'US/Central': 'CT',
'US/Mountain': 'MT',
'US/Pacific': 'PT',
};

let _timezone = localStorage.getItem('interceptTimezone') || 'US/Eastern';
let _timezone = localStorage.getItem('interceptTimezone') || 'Africa/Johannesburg';
let _hour12 = (localStorage.getItem('interceptHour12') || 'true') === 'true';
const _listeners = [];

Expand Down
11 changes: 9 additions & 2 deletions static/js/map-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,17 @@ const MapUtils = {
const clockEl = tr.querySelector('.map-hud-clock');
const dotEl = tr.querySelector('.map-hud-dot');

// Clock tick
// Clock tick — shows the user's configured local timezone (default SAST)
// alongside UTC, rather than UTC-only.
const updateClock = () => {
if (!document.body.contains(container)) return;
clockEl.textContent = new Date().toISOString().substring(11, 19) + ' UTC';
const now = new Date();
const utc = now.toISOString().substring(11, 19) + ' UTC';
if (typeof InterceptTime !== 'undefined' && InterceptTime.getIANA()) {
clockEl.textContent = InterceptTime.fullTime(now) + InterceptTime.tzSuffix() + ' · ' + utc;
} else {
clockEl.textContent = utc;
}
};
updateClock();
const clockInterval = setInterval(updateClock, 1000);
Expand Down
15 changes: 12 additions & 3 deletions static/js/modes/gps.js
Original file line number Diff line number Diff line change
Expand Up @@ -660,10 +660,10 @@ const GPS = (function() {
setText('gpsEpv', pos.epv != null ? pos.epv.toFixed(1) + ' m' : '---');
setText('gpsEps', pos.eps != null ? pos.eps.toFixed(2) + ' m/s' : '---');

// GPS time
// GPS time (local per InterceptTime prefs, default SAST; falls back to UTC)
if (pos.timestamp) {
const t = new Date(pos.timestamp);
setText('gpsTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
setText('gpsTime', formatGpsTime(t));
}

// Visuals: position panel
Expand All @@ -686,10 +686,19 @@ const GPS = (function() {
// Visuals: GPS time
if (pos.timestamp) {
const t = new Date(pos.timestamp);
setText('gpsVisTime', t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'));
setText('gpsVisTime', formatGpsTime(t));
}
}

/** GPS fix timestamp per InterceptTime prefs (default SAST), UTC appended for reference. */
function formatGpsTime(t) {
const utc = t.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
if (typeof InterceptTime !== 'undefined' && InterceptTime.getIANA()) {
return InterceptTime.dateTime(t) + InterceptTime.tzSuffix() + ' (' + utc + ')';
}
return utc;
}

function updateSkyUI(sky) {
// Sidebar sat counts
setText('gpsSatUsed', sky.usat != null ? sky.usat : '-');
Expand Down
11 changes: 11 additions & 0 deletions static/js/modes/meshtastic.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,8 @@ const Meshtastic = (function() {
const stripNodeName = document.getElementById('meshStripNodeName');
const stripNodeId = document.getElementById('meshStripNodeId');
const stripModel = document.getElementById('meshStripModel');
const stripSatsRow = document.getElementById('meshStripSatsRow');
const stripSats = document.getElementById('meshStripSats');

const nodeName = info.long_name || info.short_name || '--';
const nodeId = info.user_id || formatNodeId(info.num) || '--';
Expand All @@ -424,6 +426,14 @@ const Meshtastic = (function() {
} else {
if (posRow) posRow.style.display = 'none';
}

const sats = pos && pos.sats_in_view;
if (sats !== undefined && sats !== null) {
if (stripSatsRow) stripSatsRow.style.display = '';
if (stripSats) stripSats.textContent = String(sats);
} else if (stripSatsRow) {
stripSatsRow.style.display = 'none';
}
}

/**
Expand Down Expand Up @@ -761,6 +771,7 @@ const Meshtastic = (function() {
<span style="color: var(--text-dim);">Model:</span> ${node.hw_model || 'Unknown'}<br>
<span style="color: var(--text-dim);">Position:</span> ${node.latitude.toFixed(5)}, ${node.longitude.toFixed(5)}<br>
${node.altitude ? `<span style="color: var(--text-dim);">Altitude:</span> ${node.altitude}m<br>` : ''}
${node.sats_in_view !== null && node.sats_in_view !== undefined ? `<span style="color: var(--text-dim);">Sats:</span> ${node.sats_in_view}<br>` : ''}
${node.battery_level !== null ? `<span style="color: var(--text-dim);">Battery:</span> ${node.battery_level}%<br>` : ''}
${node.snr !== null ? `<span style="color: var(--text-dim);">SNR:</span> ${node.snr.toFixed(1)} dB<br>` : ''}
${node.last_heard ? `<span style="color: var(--text-dim);">Last heard:</span> ${new Date(node.last_heard).toLocaleTimeString()}<br>` : ''}
Expand Down
4 changes: 4 additions & 0 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2290,6 +2290,10 @@ <h3>Confirm Transmission</h3>
<span class="mesh-strip-value" id="meshStripModel">--</span>
<span class="mesh-strip-label">MODEL</span>
</div>
<div class="mesh-strip-stat" id="meshStripSatsRow" style="display: none;">
<span class="mesh-strip-value" id="meshStripSats">--</span>
<span class="mesh-strip-label">SATS</span>
</div>
</div>
<div class="mesh-strip-divider"></div>
<div class="mesh-strip-group">
Expand Down
16 changes: 12 additions & 4 deletions templates/layout/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
<div class="app-header-right">
{% block header_right %}
<div class="header-clock">
<span class="header-clock-label">UTC</span>
<span class="header-clock-label utc-label">UTC</span>
<span id="headerUtcTime">--:--:--</span>
</div>
{% endblock %}
Expand Down Expand Up @@ -97,15 +97,23 @@

{# Core JavaScript #}
<script>
// UTC Clock
// Header clock — uses InterceptTime prefs (default SAST), falls back to UTC
function updateUtcClock() {
const now = new Date();
const utc = now.toISOString().slice(11, 19);
const clockEl = document.getElementById('headerUtcTime');
if (clockEl) clockEl.textContent = utc;
const labelEl = document.querySelector('.header-clock-label');
if (typeof InterceptTime !== 'undefined') {
if (clockEl) clockEl.textContent = InterceptTime.fullTime(now);
if (labelEl) labelEl.textContent = InterceptTime.getLabel() || 'LOCAL';
} else if (clockEl) {
clockEl.textContent = now.toISOString().slice(11, 19);
}
}
setInterval(updateUtcClock, 1000);
updateUtcClock();
if (typeof InterceptTime !== 'undefined') {
InterceptTime.onChange(updateUtcClock);
}

// Mobile menu toggle
const hamburgerBtn = document.getElementById('hamburgerBtn');
Expand Down
1 change: 1 addition & 0 deletions templates/partials/settings-modal.html
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ <h2 id="settingsModalTitle">
<select id="globalTimezoneSelect" class="settings-select" onchange="InterceptTime.setTimezone(this.value)">
<option value="UTC">UTC</option>
<option value="local">Local (browser)</option>
<option value="Africa/Johannesburg">South Africa (SAST)</option>
<option value="US/Eastern">Eastern (ET)</option>
<option value="US/Central">Central (CT)</option>
<option value="US/Mountain">Mountain (MT)</option>
Expand Down
140 changes: 140 additions & 0 deletions tests/test_cot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Tests for utils/cot.py — CoT (Cursor-on-Target) export."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import utils.cot as cot


def _reset_module_state():
cot._enabled = None
cot._udp_socket = None


class FakeConfig:
COT_HOST = "239.2.3.1"
COT_PORT = 6969
COT_PROTO = "udp"
COT_STALE_SECONDS = 60


class DisabledConfig(FakeConfig):
COT_HOST = ""


def test_disabled_when_host_empty():
_reset_module_state()
with patch.object(cot, "_get_config", return_value=DisabledConfig):
cot.publish("adsb", {"icao": "ABC123", "lat": -33.9, "lon": 18.4})
assert cot._is_enabled() is False


def test_event_to_cot_xml_adsb():
_reset_module_state()
with patch.object(cot, "_get_config", return_value=FakeConfig):
event = {"icao": "ABC123", "callsign": "SAA123", "lat": -33.9, "lon": 18.4, "altitude": 35000}
xml = cot.event_to_cot_xml("adsb", event)

assert xml is not None
assert 'type="a-f-A"' in xml
assert 'uid="ICAO-ABC123"' in xml
assert 'lat="-33.9"' in xml
assert 'lon="18.4"' in xml
assert 'hae="35000"' in xml
assert 'callsign="SAA123"' in xml


def test_event_to_cot_xml_ais():
_reset_module_state()
with patch.object(cot, "_get_config", return_value=FakeConfig):
event = {"mmsi": "601234000", "callsign": "ZS1234", "lat": -33.9, "lon": 18.4}
xml = cot.event_to_cot_xml("ais", event)

assert xml is not None
assert 'type="a-f-S"' in xml
assert 'uid="MMSI-601234000"' in xml


def test_event_to_cot_xml_aprs():
_reset_module_state()
with patch.object(cot, "_get_config", return_value=FakeConfig):
event = {"callsign": "ZS1ABC-9", "lat": -33.9, "lon": 18.4}
xml = cot.event_to_cot_xml("aprs", event)

assert xml is not None
assert 'type="a-f-G-U-C"' in xml
assert 'uid="APRS-ZS1ABC-9"' in xml


def test_event_to_cot_xml_meshtastic():
_reset_module_state()
with patch.object(cot, "_get_config", return_value=FakeConfig):
event = {"id": "!55890aeb", "callsign": "Whskybadger47", "lat": -33.9, "lon": 18.4, "altitude": 15}
xml = cot.event_to_cot_xml("meshtastic", event)

assert xml is not None
assert 'type="a-f-G-U-C"' in xml
assert 'uid="MESH-!55890aeb"' in xml
assert 'callsign="Whskybadger47"' in xml


def test_event_to_cot_xml_missing_position_returns_none():
_reset_module_state()
with patch.object(cot, "_get_config", return_value=FakeConfig):
assert cot.event_to_cot_xml("adsb", {"icao": "ABC123"}) is None


def test_event_to_cot_xml_unknown_mode_returns_none():
_reset_module_state()
with patch.object(cot, "_get_config", return_value=FakeConfig):
assert cot.event_to_cot_xml("pager", {"lat": -33.9, "lon": 18.4}) is None


def test_publish_sends_udp_datagram():
_reset_module_state()
fake_socket = MagicMock()
with (
patch.object(cot, "_get_config", return_value=FakeConfig),
patch.object(cot, "_get_udp_socket", return_value=fake_socket),
):
cot.publish("adsb", {"icao": "ABC123", "lat": -33.9, "lon": 18.4})

fake_socket.sendto.assert_called_once()
args, _ = fake_socket.sendto.call_args
payload, addr = args
assert addr == ("239.2.3.1", 6969)
assert b"<event" in payload
assert b'uid="ICAO-ABC123"' in payload


def test_publish_sends_tcp_when_configured():
_reset_module_state()

class TcpConfig(FakeConfig):
COT_PROTO = "tcp"

fake_conn = MagicMock()
fake_conn.__enter__ = MagicMock(return_value=fake_conn)
fake_conn.__exit__ = MagicMock(return_value=False)

with (
patch.object(cot, "_get_config", return_value=TcpConfig),
patch("socket.create_connection", return_value=fake_conn) as mock_connect,
):
cot.publish("ais", {"mmsi": "601234000", "lat": -33.9, "lon": 18.4})

mock_connect.assert_called_once_with(("239.2.3.1", 6969), timeout=5)
fake_conn.sendall.assert_called_once()


def test_publish_swallows_send_errors():
_reset_module_state()
fake_socket = MagicMock()
fake_socket.sendto.side_effect = OSError("network unreachable")
with (
patch.object(cot, "_get_config", return_value=FakeConfig),
patch.object(cot, "_get_udp_socket", return_value=fake_socket),
):
# Should not raise.
cot.publish("adsb", {"icao": "ABC123", "lat": -33.9, "lon": 18.4})
57 changes: 57 additions & 0 deletions tests/test_gps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Tests for utils/gps.py — external (non-gpsd) position fallback."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import utils.gps as gps


def _reset_module_state():
gps._external_position = None
gps._gps_client = None


def test_get_current_position_returns_none_when_nothing_available():
_reset_module_state()
assert gps.get_current_position() is None


def test_external_position_used_when_no_gpsd_client():
_reset_module_state()
pos = gps.GPSPosition(latitude=-33.9, longitude=18.4, device="meshtastic:!55890aeb")
gps.set_external_position(pos)

result = gps.get_current_position()

assert result is pos
assert gps.get_external_position() is pos


def test_gpsd_position_takes_priority_over_external():
_reset_module_state()
gpsd_pos = gps.GPSPosition(latitude=1.0, longitude=2.0, device="gpsd://localhost:2947")
external_pos = gps.GPSPosition(latitude=-33.9, longitude=18.4, device="meshtastic:!55890aeb")
gps.set_external_position(external_pos)

fake_client = MagicMock()
fake_client.position = gpsd_pos

with patch.object(gps, "get_gps_reader", return_value=fake_client):
result = gps.get_current_position()

assert result is gpsd_pos


def test_external_position_used_when_gpsd_client_has_no_position():
_reset_module_state()
external_pos = gps.GPSPosition(latitude=-33.9, longitude=18.4, device="meshtastic:!55890aeb")
gps.set_external_position(external_pos)

fake_client = MagicMock()
fake_client.position = None

with patch.object(gps, "get_gps_reader", return_value=fake_client):
result = gps.get_current_position()

assert result is external_pos
Loading