Виртуальный полигон для дрона
Автор
# drone_graphics_app.jl — программа для визуализации движения авиационного аппарата
#
# Запуск: engee.genie.start(joinpath(@__DIR__, "drone_graphics_app.jl"), open_url = true)
# Стоп: engee.genie.stop(joinpath(@__DIR__, "drone_graphics_app.jl")); sleep(5)
using GenieFramework, Sockets
using Genie.Router
# Построчный лог каждого запроса отключён: /state опрашивается десятки раз
# в секунду и забивает log.txt строками "Info: GET /state 200".
Genie.config.log_requests = false
const GUI_IN_PORT = 5150 # порт, на который Engee шлёт пакеты
const APP_DIR = @__DIR__
# ==================================================================
# Состояние аппарата: 13 Float64 + модельное время t (итого 14 Float64).
# Порядок = порядок портов блока «3D сцена» (авиационный: курс, крен, тангаж):
# x, y, z, ψ (курс), γ (крен), θ (тангаж),
# vx, vy, vz, dψ, dγ, dθ, state_id, t
# Это же порядок отдаёт GET /state и ожидает public/flight.js.
# ==================================================================
const NPKT = 14
const _pkt = Ref(ntuple(_ -> 0.0, NPKT))
const _rx = Ref(0) # всего пакетов принято
const _bad = Ref(0) # пакетов отброшено (длина/разбор)
const _restarts = Ref(0) # сколько раз перезапускали приём
const _last_rx = Ref(0.0) # time() последнего пакета
const _hz = Ref(0.0) # пакетов в секунду
const _hz_n = Ref(0) # счётчик _rx на прошлом замере
const _hz_t = Ref(0.0) # time() прошлого замера
const _prev_t = Ref(0.0) # модельное время предыдущего пакета
const _sock = Ref{Any}(nothing)
# Кольцевой буфер последних пакетов: браузер добирает по /trace?since=rx то,
# что не успел забрать опросом /state (подвисания HTTP, паузы вкладки).
# Хватает ~1 минуты при 25 Гц и нескольких минут при 1 Гц.
const TRACE_MAX = 1200
const _trace = Vector{Any}()
const _trace_lock = ReentrantLock()
# ==================================================================
# UDP: ровно одна задача на сокет, без промежуточной очереди.
# ==================================================================
function _reader(sock::UDPSocket)
@info "UDP reader: старт, порт $(GUI_IN_PORT)"
while isopen(sock)
data = try
recv(sock)
catch e
isopen(sock) || break
@warn "UDP reader: ошибка" exception = e
sleep(0.1)
continue
end
if length(data) != 8 * NPKT
_bad[] += 1
continue
end
try
# пакет Engee (14×Float64, big-endian), порядок портов «3D сцены»:
# 1 x 2 y 3 z 4 ψ 5 γ 6 θ
# 7 vx 8 vy 9 vz 10 dψ 11 dγ 12 dθ 13 state_id 14 t
# Такой же порядок ждут приёмник и страница — перестановок нет.
f(k) = ntoh(reinterpret(Float64, data[8k-7:8k])[1])
pkt = ntuple(f, NPKT)
now = Base.time()
_pkt[] = pkt
_rx[] += 1
_last_rx[] = now
lock(_trace_lock) do
# Перезапуск модели: модельное время пошло назад —
# старая история больше не нужна.
if pkt[14] < _prev_t[] - 0.5
empty!(_trace)
end
_prev_t[] = pkt[14]
push!(_trace, (rx = _rx[], pkt = pkt, t = now))
while length(_trace) > TRACE_MAX
popfirst!(_trace)
end
end
catch e
_bad[] += 1
end
end
@info "UDP reader: остановлен"
end
function _stop_udp!()
s = _sock[]
s === nothing && return
_sock[] = nothing
try
close(s) # reader выйдет из while isopen(sock)
catch
end
end
function _start_udp!()
_stop_udp!()
sock = UDPSocket()
try
bind(sock, ip"127.0.0.1", GUI_IN_PORT)
catch e
@error "UDP: bind $(GUI_IN_PORT)" exception = e
return
end
_sock[] = sock
@async _reader(sock)
end
# Частота приёма оценивается в _state_csv по приросту _rx за ~0.25 с.
# ==================================================================
# Строка пакета: 13 чисел состояния + rx, bad, hz, age_ms, restarts, t.
# Формат один и тот же у /state и /trace — клиент разбирает одинаково.
# ==================================================================
function _fmt_packet(p, rx, age)
# Позиции — 4 знака (шаг между пакетами ~1e-2 м), углы и скорости — 5:
# меньшая точность даёт заметный шум производной на клиенте.
r4(x) = string(round(x, digits = 4))
r5(x) = string(round(x, digits = 5))
return join((
r4(p[1]), r4(p[2]), r4(p[3]),
r5(p[4]), r5(p[5]), r5(p[6]),
r4(p[7]), r4(p[8]), r4(p[9]),
r5(p[10]), r5(p[11]), r5(p[12]),
string(round(p[13], digits = 0)),
rx, _bad[], string(round(_hz[], digits = 1)), age, _restarts[],
string(round(p[14], digits = 6)),
), ",")
end
# ==================================================================
# GET /state — единственный канал «свежих» данных в браузер
# ==================================================================
function _state_csv()
age = _last_rx[] > 0 ? round(Int, (Base.time() - _last_rx[]) * 1000) : -1
# пакетов в секунду: приращение счётчика за прошедшее время
tnow = Base.time()
if _hz_t[] == 0.0
_hz_n[] = _rx[]
_hz_t[] = tnow
elseif tnow - _hz_t[] >= 0.25
_hz[] = (_rx[] - _hz_n[]) / (tnow - _hz_t[])
_hz_n[] = _rx[]
_hz_t[] = tnow
end
return _fmt_packet(_pkt[], _rx[], age)
end
# ==================================================================
# GET /trace?since=<rx> — все пакеты новее since, по строке на пакет.
# Нужен, чтобы добрать пропуски, пока браузер не опрашивал /state.
# ==================================================================
function _trace_csv(since::Int)
io = IOBuffer()
tnow = Base.time()
lock(_trace_lock) do
for e in _trace
e.rx > since || continue
age = round(Int, (tnow - e.t) * 1000)
println(io, _fmt_packet(e.pkt, e.rx, age))
end
end
return String(take!(io))
end
route("/state") do
_state_csv()
end
route("/trace") do
since = 0
try
q = params()
since = parse(Int, string(get(q, "since", get(q, :since, 0))))
catch
since = 0
end
_trace_csv(since)
end
route("/reset", method = POST) do
_restarts[] += 1
@info "HTTP POST /reset"
_start_udp!()
"ok"
end
# ==================================================================
# Страница и статика
# ==================================================================
@app begin
@out alive = 0 # Stipple-модель нужна только для отдачи страницы
end
const BTN = "font-size:14px;padding:6px 12px;color:#fff;border:none;" *
"border-radius:4px;cursor:pointer;margin-right:6px;"
function ui()
[
Html.div(
Html.pre("⚪ WAITING", id = "hud",
style = "font-family:'DejaVu Sans Mono',monospace;font-size:14px;" *
"background:#111;color:#7cf;padding:6px 10px;border-radius:4px;" *
"margin:4px 0;white-space:pre;"),
style = "width:100%;text-align:center;"),
Html.div([
Html.button("⟳ Camera",
onclick = "window.__toggleCam && window.__toggleCam();",
style = BTN * "background:#2a7;"),
Html.button("⟲ Reset UI",
onclick = "window.location.reload();",
style = BTN * "background:#d33;"),
Html.button("⟲ Reset UDP",
onclick = "fetch('reset',{method:'POST'}).then(r=>r.text())" *
".then(t=>console.log('reset:',t));",
style = BTN * "background:#37a;"),
], style = "text-align:center;margin:8px 0;"),
Html.canvas(id = "flightCanvas", width = "900", height = "600",
style = "display:block;margin:12px auto;background:#1a1a2e;border:1px solid #444;"),
Html.script(src = "flight.js"),
]
end
@page("/", ui)
_start_udp!()