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.
This commit is contained in:
randy committed 2026-09-08 00:28:34 +02:00
1 parent 4aafe332a7
commit 61ba86c6e6
3 files changed
+95 -4

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
+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]
---- ----