Skip to content

Commit fafff06

Browse files
author
BeatLink
committed
feat(traccar): report Thor's position to the server
The server comes back on with a protocol enabled rather than none, which is why it never received a position before: OsmAnd on 5055, reachable to every peer because wireguard0 is a trusted interface. Its Vigil monitors come back with it. On the phone a timer posts one geoclue fix every five minutes and exits, rather than a daemon holding a client open, because a held client keeps the modem's receiver powered all day instead of for as long as a fix takes. Battery level rides along, the way the Traccar client app sends it. Traccar keeps its devices and users in its own database, so a device with the identifier `thor` and the read-only `vigil` user the staleness monitor authenticates as both have to be added in the web UI once.
1 parent f6ecba3 commit fafff06

5 files changed

Lines changed: 271 additions & 81 deletions

File tree

‎nix/2-server/3-services/home-automation/default.nix‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
./home-assistant
55
./lnxlink.nix
66
./mosquitto
7-
# ./traccar.nix # Switched off: no tracker protocol was ever enabled, so it never received a position
7+
./traccar.nix
88
./esphome
99

1010
];
Lines changed: 37 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,41 @@
1-
# Traccar
1+
# Traccar ############################################################################################################################################
22
#
3-
# Switched off: no tracker protocol was ever enabled, so it never received a position and its device monitor had nothing to watch.
4-
# The working configuration is kept below for when a protocol and devices are set up.
3+
# The GPS tracking server, receiving over the OsmAnd protocol alone: Thor posts its geoclue fix to port 5055, and no other decoder is started.
54
#
6-
{ ... }:
7-
{ }
5+
# 5055 is deliberately not in `allowedTCPPorts`. It is reachable because wireguard0 is a trusted interface, so every peer in the TechNet can post to
6+
# it; the device id in each report is what Traccar matches against a device, not the firewall.
7+
#
8+
# Devices and users live only in the database -- there is no declarative provisioning of either. Two things therefore have to be done once in the web
9+
# UI: add a device whose identifier is `thor`, and add a read-only `vigil` user whose password matches secrets/2-server/traccar.yaml, which is what the
10+
# device-staleness monitor authenticates as.
11+
#
12+
{ config, ... }:
13+
{
14+
# Read by whichever Vigil transport runs the `cat` -- the agent today, vigil-access as fallback
15+
sops.secrets.traccar_vigil_password = {
16+
sopsFile = "${config.technet.secrets.path}/traccar.yaml";
17+
group = "vigil-monitor";
18+
mode = "0440";
19+
};
20+
21+
services.traccar = {
22+
enable = true;
23+
settings = {
24+
web = {
25+
port = "9280";
26+
url = "traccar.heimdall.technet";
27+
};
28+
29+
# An allow-list rather than the whole decoder set, so the only port this listens on is the one Thor reports to
30+
protocols.enable = "osmand";
31+
osmand.port = "5055";
32+
};
33+
};
834

9-
/*
10-
{ config, ... }:
11-
{
12-
# Vigil's `traccar` plugin authenticates as a dedicated read-only user to
13-
# check device staleness via /api/devices. Traccar has no declarative
14-
# user provisioning (no config-file user list, no CLI, users live only in
15-
# its database) — unlike the other services' vigil accounts, this one
16-
# must be created once by hand in the Traccar UI (Settings > Users > add
17-
# "vigil", uncheck Administrator, grant it read access to the devices to
18-
# monitor), with its password then stored at
19-
# secrets/2-server/traccar.yaml as `vigil_password` to match.
20-
sops.secrets.traccar_vigil_password = {
21-
sopsFile = "${config.technet.secrets.path}/traccar.yaml";
22-
group = "vigil-monitor"; # Read by whichever Vigil transport runs the `cat` — the agent today, vigil-access as fallback
23-
mode = "0440";
24-
};
35+
environment.persistence."/Storage/Services/Traccar".directories = [ "/var/lib/private/traccar" ];
2536

26-
services.traccar = {
27-
enable = true;
28-
settings = {
29-
web = {
30-
port = "9280";
31-
url = "traccar.heimdall.technet";
32-
};
33-
protocols.enable = "";
34-
};
35-
};
36-
environment.persistence."/Storage/Services/Traccar".directories = [ "/var/lib/private/traccar" ];
37-
nginx-vhosts.traccar = {
38-
domain = "traccar.heimdall.technet";
39-
port = 9280;
40-
};
41-
}
42-
*/
37+
nginx-vhosts.traccar = {
38+
domain = "traccar.heimdall.technet";
39+
port = 9280;
40+
};
41+
}

‎nix/2-server/3-services/monitoring/vigil.nix‎

Lines changed: 39 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,48 +1318,45 @@ in
13181318
}
13191319
];
13201320
}
1321-
# Traccar is switched off: no tracker protocol was ever enabled, so it never had a device to watch.
1322-
/*
1323-
{
1324-
name = "Traccar";
1325-
id = "heimdall-svc-traccar";
1326-
type = "group";
1327-
children = [
1328-
{
1329-
name = "Service";
1330-
id = "heimdall-traccar";
1331-
type = "systemd_service";
1332-
interval = "1m";
1333-
service_name = "traccar.service";
1334-
agent = "heimdall";
1335-
}
1336-
{
1337-
# Device-staleness health, as opposed to
1338-
# the monitor above, which only proves the
1339-
# server is running. Authenticates as a
1340-
# dedicated read-only "vigil" user created
1341-
# once by hand (see traccar.nix, which has
1342-
# no declarative user provisioning at all)
1343-
# and computes staleness itself from each
1344-
# device's lastUpdate, rather than
1345-
# trusting Traccar's own status field —
1346-
# that field doesn't reliably reach
1347-
# "offline" on its own for a tracker that
1348-
# has simply gone silent.
1349-
name = "Devices";
1350-
id = "heimdall-traccar-devices";
1351-
type = "traccar";
1352-
interval = "15m";
1353-
api_url = "http://127.0.0.1:9280";
1354-
username = "vigil";
1355-
password_command = "cat /run/secrets/traccar_vigil_password";
1356-
stale_warning = 24;
1357-
stale_threshold = 72;
1358-
agent = "heimdall";
1359-
}
1360-
];
1361-
}
1362-
*/
1321+
{
1322+
name = "Traccar";
1323+
id = "heimdall-svc-traccar";
1324+
type = "group";
1325+
children = [
1326+
{
1327+
name = "Service";
1328+
id = "heimdall-traccar";
1329+
type = "systemd_service";
1330+
interval = "1m";
1331+
service_name = "traccar.service";
1332+
agent = "heimdall";
1333+
}
1334+
{
1335+
# Device-staleness health, as opposed to
1336+
# the monitor above, which only proves the
1337+
# server is running. Authenticates as a
1338+
# dedicated read-only "vigil" user created
1339+
# once by hand (see traccar.nix, which has
1340+
# no declarative user provisioning at all)
1341+
# and computes staleness itself from each
1342+
# device's lastUpdate, rather than
1343+
# trusting Traccar's own status field —
1344+
# that field doesn't reliably reach
1345+
# "offline" on its own for a tracker that
1346+
# has simply gone silent.
1347+
name = "Devices";
1348+
id = "heimdall-traccar-devices";
1349+
type = "traccar";
1350+
interval = "15m";
1351+
api_url = "http://127.0.0.1:9280";
1352+
username = "vigil";
1353+
password_command = "cat /run/secrets/traccar_vigil_password";
1354+
stale_warning = 24;
1355+
stale_threshold = 72;
1356+
agent = "heimdall";
1357+
}
1358+
];
1359+
}
13631360
{
13641361
name = "Jackett";
13651362
id = "heimdall-svc-jackett";

‎nix/5-phone/1-system/default.nix‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
./audio.nix
2626
./bluetooth.nix
2727
./location.nix
28+
./traccar-client.nix
2829
./gpu-meter.nix
2930
./webkit.nix
3031
./launchapp.nix
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
# Traccar client #####################################################################################################################################
2+
#
3+
# Reports this phone's position to Heimdall's Traccar over the OsmAnd protocol, which is the only one that server starts a listener for.
4+
#
5+
# A timer rather than a daemon, because a geoclue client held open keeps the modem's GNSS receiver powered: each run asks for one fix, posts it and
6+
# exits, so the radio is on for as long as a fix takes rather than all day. Five minutes is the interval the Traccar client app itself defaults to.
7+
#
8+
# The identifier below has to exist as a device in Traccar before a single report is stored. The server keeps its devices in its own database and
9+
# offers no way to declare one, so that is a job for the web UI, once.
10+
#
11+
{
12+
config,
13+
lib,
14+
pkgs,
15+
...
16+
}:
17+
let
18+
device = lib.toLower config.networking.hostName;
19+
desktopId = "traccar-client";
20+
server = "http://heimdall.technet:5055"; # Reached over the tunnel, so it answers on mobile data as well as at home
21+
fixTimeout = 120;
22+
23+
traccarClient =
24+
pkgs.writers.writePython3Bin "traccar-client"
25+
{
26+
libraries = [ pkgs.python3Packages.pygobject3 ];
27+
flakeIgnore = [ "E501" ];
28+
}
29+
''
30+
import glob
31+
import sys
32+
import time
33+
import urllib.error
34+
import urllib.parse
35+
import urllib.request
36+
37+
import gi
38+
gi.require_version("Gio", "2.0")
39+
from gi.repository import Gio, GLib # noqa: E402
40+
41+
SERVER = "${server}"
42+
DEVICE = "${device}"
43+
DESKTOP_ID = "${desktopId}"
44+
TIMEOUT = ${toString fixTimeout}
45+
GEOCLUE = "org.freedesktop.GeoClue2"
46+
47+
# Geoclue reports a double it has no value for as the most negative double there is.
48+
UNSET = -1.7976931348623157e308
49+
# 8 is GeoClue's exact level; anything lower rounds the fix to the city, which is not a track.
50+
ACCURACY_EXACT = 8
51+
52+
53+
def battery():
54+
"""Charge percentage and whether it is charging, as the Traccar client app reports them."""
55+
for path in sorted(glob.glob("/sys/class/power_supply/*")):
56+
try:
57+
if open(path + "/type").read().strip() != "Battery":
58+
continue
59+
capacity = open(path + "/capacity").read().strip()
60+
status = open(path + "/status").read().strip()
61+
except OSError:
62+
continue
63+
if capacity:
64+
return capacity, status == "Charging"
65+
return None, False
66+
67+
68+
def report(bus, path):
69+
"""Post one geoclue Location, returning whether the server took it."""
70+
location = Gio.DBusProxy.new_sync(
71+
bus, Gio.DBusProxyFlags.NONE, None,
72+
GEOCLUE, path, GEOCLUE + ".Location", None,
73+
)
74+
75+
def number(name):
76+
value = location.get_cached_property(name)
77+
return None if value is None else value.get_double()
78+
79+
latitude, longitude = number("Latitude"), number("Longitude")
80+
if latitude is None or longitude is None:
81+
return False
82+
83+
params = {"id": DEVICE, "lat": latitude, "lon": longitude, "timestamp": int(time.time())}
84+
85+
# Speed is metres per second, which is what geoclue reports and what the OsmAnd decoder converts from.
86+
for key, name in (("altitude", "Altitude"), ("accuracy", "Accuracy"),
87+
("speed", "Speed"), ("bearing", "Heading")):
88+
value = number(name)
89+
if value is not None and value != UNSET and value >= 0:
90+
params[key] = value
91+
92+
level, charging = battery()
93+
if level is not None:
94+
params["batt"] = level
95+
params["charge"] = "true" if charging else "false"
96+
97+
with urllib.request.urlopen(SERVER + "/?" + urllib.parse.urlencode(params), timeout=20) as response:
98+
print("reported %(lat)s %(lon)s" % params, "->", response.status, flush=True)
99+
return True
100+
101+
102+
def main():
103+
bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
104+
manager = Gio.DBusProxy.new_sync(
105+
bus, Gio.DBusProxyFlags.NONE, None,
106+
GEOCLUE, "/org/freedesktop/GeoClue2/Manager", GEOCLUE + ".Manager", None,
107+
)
108+
client_path = manager.call_sync("GetClient", None, Gio.DBusCallFlags.NONE, -1, None).unpack()[0]
109+
110+
# Geoclue hands a client no fix at all under an id its config does not allow.
111+
props = Gio.DBusProxy.new_sync(
112+
bus, Gio.DBusProxyFlags.NONE, None,
113+
GEOCLUE, client_path, "org.freedesktop.DBus.Properties", None,
114+
)
115+
for key, value in (("DesktopId", GLib.Variant("s", DESKTOP_ID)),
116+
("RequestedAccuracyLevel", GLib.Variant("u", ACCURACY_EXACT))):
117+
props.call_sync(
118+
"Set", GLib.Variant("(ssv)", (GEOCLUE + ".Client", key, value)),
119+
Gio.DBusCallFlags.NONE, -1, None)
120+
121+
client = Gio.DBusProxy.new_sync(
122+
bus, Gio.DBusProxyFlags.NONE, None,
123+
GEOCLUE, client_path, GEOCLUE + ".Client", None,
124+
)
125+
loop = GLib.MainLoop()
126+
sent = []
127+
128+
def on_signal(_proxy, _sender, name, params):
129+
if name == "LocationUpdated" and report(bus, params.unpack()[1]):
130+
sent.append(True)
131+
loop.quit()
132+
133+
client.connect("g-signal", on_signal)
134+
client.call_sync("Start", None, Gio.DBusCallFlags.NONE, -1, None)
135+
136+
# Geoclue only signals on change, so a fix it already holds would otherwise wait for the next move.
137+
held = client.get_cached_property("Location")
138+
if held is not None and held.get_string() not in ("", "/") and report(bus, held.get_string()):
139+
sent.append(True)
140+
else:
141+
GLib.timeout_add_seconds(TIMEOUT, loop.quit)
142+
loop.run()
143+
144+
client.call_sync("Stop", None, Gio.DBusCallFlags.NONE, -1, None)
145+
if not sent:
146+
print("no fix within", TIMEOUT, "seconds", flush=True)
147+
148+
149+
try:
150+
main()
151+
except urllib.error.URLError as err:
152+
# A phone with no route to the tunnel is an ordinary state, not a failure worth flagging
153+
print("traccar unreachable:", err, flush=True)
154+
except GLib.Error as err:
155+
print("geoclue:", err, file=sys.stderr, flush=True)
156+
sys.exit(1)
157+
'';
158+
in
159+
{
160+
services.geoclue2.appConfig.${desktopId} = {
161+
isAllowed = true;
162+
isSystem = true; # A background service with no session behind it, so there is no agent to ask on its behalf
163+
};
164+
165+
systemd.services.traccar-client = {
166+
description = "Report this phone's position to Traccar";
167+
after = [ "network-online.target" ];
168+
wants = [ "network-online.target" ];
169+
170+
serviceConfig = {
171+
Type = "oneshot";
172+
ExecStart = lib.getExe traccarClient;
173+
DynamicUser = true;
174+
ProtectHome = true;
175+
ProtectSystem = "strict";
176+
NoNewPrivileges = true;
177+
RestrictAddressFamilies = [
178+
"AF_UNIX"
179+
"AF_INET"
180+
"AF_INET6"
181+
];
182+
};
183+
};
184+
185+
systemd.timers.traccar-client = {
186+
wantedBy = [ "timers.target" ];
187+
timerConfig = {
188+
OnBootSec = "3m";
189+
OnUnitActiveSec = "5m";
190+
AccuracySec = "30s"; # A tracker whose points are a minute out of place is not a tracker
191+
};
192+
};
193+
}

0 commit comments

Comments
 (0)