Compare commits

...
3 Commits
Author SHA1 Message Date
randy 64a85afe6a Fix a filter-syntax warning found while investigating missing labels
CI / deploy (push) Successful in 19s
CI / check (push) Successful in 41s
Mixing the legacy "$type" filter with an expression (["get", "role"])
in the same filter logs as unsupported in newer MapLibre. Modernized
all three measure layer filters to expression syntax; the vertex
circle layer's filter simplifies to just checking "role" (it's only
ever set on Point features to begin with, so the geometry-type check
was redundant once "role" was added).

Found while trying to reproduce a report that per-segment distance
labels don't show on a longer, several-point measurement -- couldn't
reproduce it locally or against production itself (both showed labels
correctly on a fresh page load), so this may be unrelated; still
worth fixing on its own.
2026-09-08 00:39:02 +02:00
randy 3e81ee8015 Run the GNSS simulator in production too
Adds a gnss-simulator service to docker-compose.yaml, sharing
maptool's network namespace (network_mode: "service:maptool") so it's
reachable at the 127.0.0.1:2947 GpsdClient hardcodes -- there's no
real receiver attached to the server, so without this the "GNSS"
control there always shows "no fix". Both services now reference the
same image (maptool:latest) explicitly rather than each service with
its own `build: .` producing two separately-tagged (if
identically-layered) images.

Verified for real: built and ran the full compose stack in an
isolated project/port on cube (not the live one), confirmed
/api/gnss/position reports a moving fix across two polls through the
actual sidecar networking, then tore it down completely -- containers,
image, network, temp checkout. Production's own stack was untouched
throughout and confirmed still healthy afterward.
2026-09-08 00:33:44 +02:00
randy 61ba86c6e6 Add a GNSS simulator for dev without gpsd running
bin/gnss_simulator.rb speaks gpsd's own JSON wire protocol directly
on its default port -- GpsdClient connects to it exactly as it would
to real gpsd, no app changes needed, and no gpsd install/daemon
required either (unlike the existing fake-NMEA-source approach in
docs/development.adoc, which still needs gpsd itself in front of it).
Drives a loop around a center point at a driving-ish pace rather than
sitting still, since a fixed position isn't much of a "GNSS position"
to look at.

Verified end to end: started it, pointed a dev server at it, and
confirmed /api/gnss/position reports a moving fix across two polls.
2026-09-08 00:28:34 +02:00
6 changed files with 134 additions and 7 deletions

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
bundler-cache: true bundler-cache: true
- name: Ruby syntax check - name: Ruby syntax check
run: find app.rb config lib -name "*.rb" -print0 | xargs -0 -n1 ruby -c run: find app.rb config lib bin -name "*.rb" -print0 | xargs -0 -n1 ruby -c
- name: App loads without errors - name: App loads without errors
run: bundle exec ruby -e 'require_relative "app"' run: bundle exec ruby -e 'require_relative "app"'
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# Speaks gpsd's own JSON wire protocol (https://gpsd.io/gpsd_json.html)
# directly on gpsd's default port — GpsdClient (lib/maptool/gpsd_client.rb)
# connects to it exactly as it would to real gpsd, so no app code or config
# changes are needed. Unlike docs/development.adoc's fake-NMEA-source
# example, this doesn't need gpsd itself installed/running at all, and
# actually moves (a fixed point isn't much of a "GNSS position" to look
# at) — drives a loop around a center point at a driving-ish pace, one TPV
# report per second like a real receiver.
#
# Usage: bin/gnss_simulator.rb [lat] [lon] [radius_m] [port]
require "socket"
require "json"
$stdout.sync = true
LAT = (ARGV[0] || 48.1486).to_f
LON = (ARGV[1] || 17.1077).to_f
RADIUS_M = (ARGV[2] || 300).to_f
PORT = (ARGV[3] || 2947).to_i
LOOP_SECONDS = 120 # time to complete one full loop
EARTH_RADIUS_M = 6_371_000
def point_on_loop(center_lat, center_lon, radius_m, angle_rad)
# Flat-plane offset, not geodesic — fine at this radius (metres, not km),
# same reasoning as measure.js's own approximations at this scale.
lat_offset = (radius_m * Math.sin(angle_rad)) / EARTH_RADIUS_M
lon_offset = (radius_m * Math.cos(angle_rad)) / (EARTH_RADIUS_M * Math.cos(center_lat * Math::PI / 180))
[center_lat + lat_offset * 180 / Math::PI, center_lon + lon_offset * 180 / Math::PI]
end
server = TCPServer.new("127.0.0.1", PORT)
puts "[gnss-simulator] driving a #{RADIUS_M.to_i}m loop around #{LAT},#{LON} — listening on 127.0.0.1:#{PORT}"
loop do
socket = server.accept
puts "[gnss-simulator] client connected"
start = Time.now
begin
loop do
elapsed = Time.now - start
angle = (elapsed / LOOP_SECONDS) * 2 * Math::PI
lat, lon = point_on_loop(LAT, LON, RADIUS_M, angle)
# Bearing of travel: tangent to the circle, 90 degrees ahead of the
# radius vector at this point.
track = (angle * 180 / Math::PI + 90) % 360
speed = (2 * Math::PI * RADIUS_M) / LOOP_SECONDS # m/s, constant pace
report = {
class: "TPV",
mode: 3,
lat: lat,
lon: lon,
altHAE: 150.0,
speed: speed,
track: track,
eph: 3.0,
time: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ"),
}
socket.puts(JSON.generate(report))
sleep 1
end
rescue Errno::EPIPE, IOError
puts "[gnss-simulator] client disconnected"
ensure
socket.close
end
end
+17
View File
@@ -1,6 +1,7 @@
services: services:
maptool: maptool:
build: . build: .
image: maptool:latest
restart: unless-stopped restart: unless-stopped
ports: ports:
- "4567:4567" - "4567:4567"
@@ -26,3 +27,19 @@ services:
# docs/development.adoc for how the deploy job treats this # docs/development.adoc for how the deploy job treats this
# directory). # directory).
- ./data:/app/data - ./data:/app/data
# Fakes a GNSS fix (a loop around Bratislava, see
# bin/gnss_simulator.rb/docs/development.adoc) since there's no real
# receiver attached here — the "GNSS" control would otherwise just show
# "no fix" always. Remove this service (and nothing else) whenever a real
# receiver/gpsd setup replaces it. network_mode ties it to maptool's own
# network namespace rather than a bridge network, since GpsdClient
# connects to a hardcoded 127.0.0.1:2947 — same as gpsd itself normally
# being local to the machine it serves.
gnss-simulator:
image: maptool:latest
depends_on:
- maptool
restart: unless-stopped
network_mode: "service:maptool"
command: ["ruby", "bin/gnss_simulator.rb"]
+12
View File
@@ -107,6 +107,18 @@ id/secret to store as the two `OAUTH_CLIENT_*` secrets above.
usernames, e.g. `randy,friend1,friend2` — not their emails, the usernames, e.g. `randy,friend1,friend2` — not their emails, the
`login` field from Gitea's `/api/v1/user`. `login` field from Gitea's `/api/v1/user`.
== GNSS simulator
`docker-compose.yaml`'s `gnss-simulator` service (`bin/gnss_simulator.rb`,
also documented for local dev in `docs/development.adoc`) runs
alongside `maptool` in production too, sharing its network namespace
(`network_mode: "service:maptool"`) so it's reachable at the
hardcoded `127.0.0.1:2947` `Maptool::GpsdClient` expects — there's no
real GNSS receiver attached to the server, so without this the "GNSS"
control would just always show "no fix". It drives a loop around
Bratislava; remove the service block (nothing else needs to change)
whenever a real receiver/gpsd setup replaces it.
== Garmin LiveTrack session webhook == Garmin LiveTrack session webhook
`POST /api/livetrack/session` (`session_id`, `session_token`, `secret` form `POST /api/livetrack/session` (`session_id`, `session_token`, `secret` form
+22 -3
View File
@@ -52,9 +52,28 @@ up as). On openSUSE, `gpsd` is a package with systemd units
package manager. No app-side configuration is needed — it just package manager. No app-side configuration is needed — it just
connects to `127.0.0.1:2947`, gpsd's default port. connects to `127.0.0.1:2947`, gpsd's default port.
To test without hardware, feed gpsd a fake NMEA stream over TCP To test without hardware or gpsd itself installed/running, run
instead of a real serial device — gpsd accepts a `tcp://host:port` `bin/gnss_simulator.rb` — it speaks gpsd's own JSON wire protocol
source: directly on gpsd's default port (`127.0.0.1:2947`), so
`Maptool::GpsdClient` connects to it exactly as it would to real
gpsd, no app-side changes needed. Drives a loop around a center point
(Bratislava by default) at a driving-ish pace instead of sitting at a
fixed position, since a stationary "GNSS position" isn't much to look
at on the map:
[source,shell]
----
bin/gnss_simulator.rb # loop around Bratislava, 300m radius
bin/gnss_simulator.rb 48.85 2.35 500 # custom center/radius (Paris, 500m)
----
`GET /api/gnss/position` should report a fix within a couple of
seconds, moving on every poll.
To test against gpsd itself instead (e.g. to exercise the actual
NMEA-parsing path, not just gpsd's JSON output), feed it a fake NMEA
stream over TCP instead of a real serial device — gpsd accepts a
`tcp://host:port` source:
[source,shell] [source,shell]
---- ----
+10 -3
View File
@@ -105,21 +105,28 @@ export class MeasureControl {
id: FILL_LAYER_ID, id: FILL_LAYER_ID,
type: "fill", type: "fill",
source: SOURCE_ID, source: SOURCE_ID,
filter: ["==", "$type", "Polygon"], filter: ["==", ["geometry-type"], "Polygon"],
paint: { "fill-color": "#1e5fbf", "fill-opacity": 0.15 }, paint: { "fill-color": "#1e5fbf", "fill-opacity": 0.15 },
}); });
this._map.addLayer({ this._map.addLayer({
id: LINE_LAYER_ID, id: LINE_LAYER_ID,
type: "line", type: "line",
source: SOURCE_ID, source: SOURCE_ID,
filter: ["!=", "$type", "Point"], filter: ["!=", ["geometry-type"], "Point"],
paint: { "line-color": "#1e5fbf", "line-width": 2 }, paint: { "line-color": "#1e5fbf", "line-width": 2 },
}); });
// "role" alone (not also a geometry-type check) — it's only ever set on
// Point features to begin with (the line/polygon shape feature has no
// "role" property at all), so it already implies the geometry type.
// Mixing the legacy "$type" filter syntax with an expression like
// ["get", "role"] in the same filter logs as unsupported in newer
// MapLibre — this sidesteps that rather than fixing it by switching
// "$type" to ["geometry-type"] and keeping both checks.
this._map.addLayer({ this._map.addLayer({
id: POINT_LAYER_ID, id: POINT_LAYER_ID,
type: "circle", type: "circle",
source: SOURCE_ID, source: SOURCE_ID,
filter: ["all", ["==", "$type", "Point"], ["==", ["get", "role"], "vertex"]], filter: ["==", ["get", "role"], "vertex"],
paint: { "circle-radius": 4, "circle-color": "#fff", "circle-stroke-color": "#1e5fbf", "circle-stroke-width": 2 }, paint: { "circle-radius": 4, "circle-color": "#fff", "circle-stroke-color": "#1e5fbf", "circle-stroke-width": 2 },
}); });
// Distance mode only (see _render) — each segment's own length, not // Distance mode only (see _render) — each segment's own length, not