require "sinatra/base" require "sinatra/json" require_relative "lib/maptool/gpx" require_relative "lib/maptool/gpx_store" require_relative "lib/maptool/gpsd_client" module Maptool class App < Sinatra::Base set :public_folder, File.join(__dir__, "public") set :views, File.join(__dir__, "views") OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles") GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx")) # Live GNSS position, polled by the frontend from /api/gnss/position. A # single background thread reads gpsd (if it's running — harmless retry # loop if not, see GpsdClient) and keeps this updated; polling instead # of push (SSE/WebSocket) is deliberate — gpsd itself only reports at # ~1Hz, so there is nothing to gain from a push channel, and it avoids # Sinatra's `stream(:keep_open)` helper, which — absent an EventMachine # reactor (i.e. under plain threaded Puma, as here) — busy-loops calling # its block repeatedly instead of waiting for external writes; fine for # a single blocking per-message write, not for "register once, get # pushed to later" like this. # A fix is only reported back as "available" for this long after it # last arrived — otherwise a receiver that's lost its fix, or been # unplugged, would keep showing its last position forever as if it # were still live. GNSS_STALE_AFTER = 10 # seconds GNSS_LATEST = {} GNSS_LATEST_MUTEX = Mutex.new Thread.new do GpsdClient.new.each_position do |position| GNSS_LATEST_MUTEX.synchronize do GNSS_LATEST.replace(position) GNSS_LATEST[:received_at] = Time.now.to_f end end end get "/" do erb :index end get "/api/health" do json status: "ok" end get "/api/offline-tiles" do json available: File.exist?(OFFLINE_PMTILES_PATH) end # Served as a plain range-request-capable file, not decoded server-side: # pmtiles.js reads the archive's directory and fetches only the byte # ranges it needs for the tiles currently on screen. get "/tiles/offline.pmtiles" do halt 404 unless File.exist?(OFFLINE_PMTILES_PATH) send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream" end get "/api/gpx/tree" do json GPX_STORE.tree end post "/api/gpx" do file = params[:file] halt 400, json(error: "file is required") unless file && file[:tempfile] begin path = GPX_STORE.save(folder: params[:folder].to_s, filename: file[:filename], io: file[:tempfile]) rescue GpxStore::InvalidPath halt 400, json(error: "invalid folder") end json path: path, tree: GPX_STORE.tree end get "/api/gpx/file/*" do begin path = GPX_STORE.resolve_file(params[:splat].first) rescue GpxStore::InvalidPath halt 400 end halt 404 unless File.file?(path) begin json Gpx.to_geojson(path) rescue Gpx::ParseError halt 422, json(error: "could not parse GPX file") end end post "/api/gpx/folder" do begin GPX_STORE.create_folder(params[:path].to_s) rescue GpxStore::InvalidPath halt 400, json(error: "invalid path") end json tree: GPX_STORE.tree end delete "/api/gpx/file/*" do begin GPX_STORE.delete_file(params[:splat].first) rescue GpxStore::InvalidPath halt 400 rescue Errno::ENOENT halt 404 end json tree: GPX_STORE.tree end delete "/api/gpx/folder/*" do begin GPX_STORE.delete_folder(params[:splat].first) rescue GpxStore::InvalidPath halt 400 rescue Errno::ENOENT halt 404 end json tree: GPX_STORE.tree end post "/api/gpx/move" do begin path = GPX_STORE.move(from: params[:from].to_s, to_folder: params[:to_folder].to_s) rescue GpxStore::InvalidPath halt 400, json(error: "invalid path") rescue Errno::ENOENT halt 404 end json path: path, tree: GPX_STORE.tree end get "/api/gnss/position" do GNSS_LATEST_MUTEX.synchronize do received_at = GNSS_LATEST[:received_at] fresh = received_at && (Time.now.to_f - received_at) < GNSS_STALE_AFTER json(fresh ? GNSS_LATEST.merge(available: true) : { available: false }) end end end end