feat(logloader): MAVLink FTP transport, request-driven fetching, and an HTTP API - #32
Open
dakejahl wants to merge 8 commits into
Open
feat(logloader): MAVLink FTP transport, request-driven fetching, and an HTTP API#32dakejahl wants to merge 8 commits into
dakejahl wants to merge 8 commits into
Conversation
LOG_DATA (msg 120) carries no target_system/target_component, so a router between logloader and the autopilot has no addressing information and copies every chunk to every endpoint it serves -- the telemetry radio included. On a node running mavlink-router a log download saturates links that have no interest in it. Move both halves of the job to MAVLink FTP: the listing that finds the logs and the transfer that downloads them. FILE_TRANSFER_PROTOCOL (msg 110) is addressed and both PX4 and ArduPilot reply to the requesting sysid/compid, so everything is unicast to logloader. The classic log protocol is gone entirely -- no LOG_REQUEST_LIST, no LOG_ENTRY, no LOG_DATA, and no MAVSDK LogFiles plugin. Dropping LOG_ENTRY means dropping the only thing that identified a log, so identity moves to the listing: the path below the log root plus the size. That is also what makes ArduPilot work without special cases. Its list entry numbering, its LOG_MAX_FILES wrap and its .BIN extension were all consequences of the old protocol; a directory listing just reports files. The log root is probed at "@MAV_LOG" -- the virtual directory the MAVLink FTP specification defines for this -- then at /fs/microsd/log and /APM/LOGS for firmware that predates it. Timestamps come from the listing when the vehicle implements ListDirectoryWithTime, otherwise from the start time PX4 encodes in the path; ArduPilot logs simply have no timestamp and nothing needs one. MAVSDK's Ftp plugin drops the size and modification time from list replies, so FtpListClient runs ListDirectory / ListDirectoryWithTime over MavlinkPassthrough and keeps the whole entry. Bulk transfers still go through the plugin. Databases written by earlier versions keyed logs on the LOG_ENTRY timestamp, which FTP cannot reproduce. Their rows are set aside as logs_legacy on first start and matched to the listing by size, so a fleet upgrading to this does not re-download and re-upload its history. Also: - A log is only queued once two consecutive listings agree on its size, so the log being written right now is not downloaded at a size it will not keep. - Downloads are staged and size-checked before being moved next to the finished logs, and a log that cannot be fetched sorts to the back of the queue instead of blocking the ones behind it. - Open FTP sessions are reset at connect, which is what left PX4 refusing transfers until reboot after logloader was killed mid-download. - The databases are created before they are opened, which failed when application_directory did not exist yet.
When the local flight-review server is down, the upload loop previously probed and logged a failure for every pending log. Probe once, log a single unreachable message with a 60s cooldown, and bail the current upload batch (HTTP 503) instead of walking the queue. (cherry picked from commit 7a23c8e)
Add remote_api_key config for ARK Flight Review (Authorization: Bearer and X-API-Key per flight_review api_key.py). Empty key still attempts remote upload without auth headers for open servers. Treat any HTTP response as reachable so 302 home pages (review.arkelectron.com) are not marked dead. Improve 401/403 handling with response body and stop retry spam. Slightly longer connection timeouts for probe and upload. (cherry picked from commit adc72da)
…equest-driven Replaces ServerInterface, which was a SQLite repository and an HTTP client sharing a name, with LogDatabase (one database, the single record of what the vehicle has and what we mean to do about it) and UploadTarget (one Flight Review endpoint, no state of its own). LogLoader no longer keeps two databases in step by calling every mutation twice. Behaviour change: logloader no longer mirrors the SD card. A log is fetched because it appeared while logloader was watching -- a flight just happened -- or because something asked for it. On a database that has never seen the vehicle only the newest log is taken, so a companion powered on for the first time does not pull and upload an entire card. A listing that turns up more than max_auto_queue new logs is treated as an unfamiliar card, not a burst of flights. Intent lives in the database, so automatic and requested work share one path. Adds an HTTP API on 127.0.0.1:3005 (list, request, cancel, delete, SSE) for the ARK-OS Logs page to drive. Also fixed along the way: - A pending log whose file was missing or empty was retried forever and blocked every older log behind it; only HTTP 400 escaped. Upload outcomes are now classified (Success/Rejected/Unauthorized/Unreachable/Retry) and the loop iterates a fetched batch instead of re-counting a query that only shrinks on success. - ServerInterface::_should_exit and LogLoader::_loop_disabled were plain bools written by one thread and read by another. Both are gone; pausing is derived from arm state and shutdown goes through a Waiter. - SIGINT/SIGTERM are blocked and consumed by a sigtimedwait thread. The handler used to take a mutex and signal a condition variable, neither of which is async-signal-safe. - CMAKE_BUILD_TYPE was never set, so the shipped binary was built at -O0. - Rows are marked absent rather than deleted when a log leaves the vehicle, so a downloaded log keeps its file and its upload history. - generate_uuid hashed with std::hash, which is implementation-defined, into persistent state; identity is now (path, size) with an integer primary key. - Dead code: num_logs_to_download, the .lock file check, the logger_running branch that was hardcoded false. - The staging directory is swept at startup so a run killed mid-transfer does not leak partial files. Verified end to end against PX4 SITL and a local flight-review: probe, index, first-start policy, download, upload, and manual selection through the API.
… an OOM upload Findings from two review passes over the rewrite, verified against PX4 SITL and a local flight-review. FtpListClient handed MAVSDK a callback capturing `this`. MAVSDK removes a message handler asynchronously and does not drain callbacks it has already queued, so a reply arriving after the client was destroyed -- which is what happens on SIGTERM during a transfer, since the vehicle keeps sending -- locked a destroyed mutex and wrote 251 bytes into freed heap. The callback now holds a weak_ptr to the state it touches. This is the same shape as the progress callback fixed earlier; it was the second instance. Transaction::commit cleared _open before running COMMIT, so a COMMIT that failed (a full data partition) left the transaction open with no rollback. Every later BEGIN would then fail, every write would be silently discarded, and the daemon would re-download the same logs forever while appearing healthy. Uploads serialised the whole log into memory twice: httplib's MultipartFormDataItems overload concatenates the entire body into a second string. A 400 MB log needed most of a gigabyte on a Pi, and the resulting OOM kill retried the same log on restart. The body is now streamed off disk with a computed Content-Length. Verified byte-identical on arrival. Also: - LogDatabase::ok() reported success when only sqlite3_open had succeeded, so a read-only data directory produced a daemon that ran and stored nothing. - A row claiming downloaded=1 with no local file was reachable from neither queue. The queues now treat it as not downloaded, and a missing local file at upload time is Outcome::Missing (re-fetch) rather than Rejected (give up). - The SSE keepalive wrote 14 bytes of a 13-byte string, putting a NUL on the wire; the frontend then dropped the next real event. Transfer progress also re-serialised the whole log table twice a second per client -- status and logs are now separate events. - Each SSE stream holds an httplib worker, and the default pool is 8, so a few stale tabs starved the whole API. Pool raised, concurrent streams capped. - newest_log_id ordered ArduPilot logs by name, which stops meaning age once the counter wraps at LOG_MAX_FILES; discovered_at now takes precedence. - iso8601_utc ignored strftime's return value, so a vehicle reporting an absurd mtime caused a stack over-read into the JSON response. - FtpListClient::stop() mutated its flag outside the lock (lost wakeup), and list_directory had no bound on a remote-driven loop. - download.auto = false no longer leaves latest_on_first_start active; a free space check runs before a transfer; `make debug` builds Debug again; astyle moved out of the default target.
…se with tests The pre-FTP migration was 28% of LogDatabase and the one thing in it that was not a repository: an extra table, a fuzzy timestamp heuristic, a reconstruction of the old file-naming scheme, a member, a constructor parameter and a branch inside sync_index. It moves to LegacyImport, whose header says plainly that the file is deletable once the fleet has upgraded. LogDatabase drops from 796 lines to 560 and is now only a repository. sync_index returns a SyncResult (what it inserted, how many are present, and whether this was the first listing with anything in it) instead of writing two out-parameters and leaving the caller to own the "first index" invariant. The subtlety -- that the first listing after startup legitimately reconciles nothing, because stability takes two listings to establish -- now lives in the one function that can see it. apply_auto_policy is two obvious halves. Adds tests/test_log_database.cpp: 14 cases over the first-index rule, present/ absent transitions, a growing log, request and cancel, the download-with-no-file recovery, rejected uploads not blocking the queue, failure ordering, ArduPilot name reuse, and four covering the legacy import -- which runs once against real user data and cannot be un-run, so it is the thing least testable any other way. Plus a GitHub Actions workflow that builds, tests and checks formatting. Writing the tests turned up a real defect: ordering fell back to discovered_at, a one-second wall clock, to decide which ArduPilot log is newest once the counter wraps at LOG_MAX_FILES. Two listings in the same second were indistinguishable, and on a companion with no RTC the clock can run backwards. Replaced with a monotonic discovery sequence. Also: ATTACH is now RAII, so a failed import cannot leave the old database attached and wedge the next one; the legacy adopt path no longer produces a row claiming to be downloaded with no file behind it; and equidistant timestamp candidates leave the claim with the first rather than the last.
…w findings A second review pass over the rewrite, verified against PX4 SITL. The last commit added a discovered_seq column but no migration. CREATE TABLE IF NOT EXISTS does nothing to a table that already exists, so on any database from an earlier build every query naming the column failed to prepare and returned nothing -- while commit succeeded and the daemon reported itself healthy. The log list would simply be empty forever. There is now an ALTER TABLE path, backfilled from discovered_at, and a test that opens an older schema; the existing tests only ever created fresh databases, which is why they missed it. A log still being written is on the vehicle. sync_index was only told about stable ones, so the first listing after any restart -- which has nothing stable yet, stability taking two listings -- marked every row absent, showed "not on vehicle" across the whole UI, and emptied the download queue for an interval. The whole listing now goes over with a stable flag; only stable logs are recorded, but everything listed counts as present. Outcome::Missing on a zero-byte log was an infinite loop: clear, re-request, download nothing successfully, clear again. An empty log is a property of the log, not of the local copy, so that case is Rejected again. Missing now means only that the file is gone. clear_local_file is conditional on the path the caller saw. The upload thread works from a snapshot that can be minutes old, and clearing unconditionally would wipe the bookkeeping of a download the index thread had just finished and re-fetch the whole log. Also: the SSE stream slot is released by httplib's resource releaser, which runs on every path -- the manual decrement only covered shutdown, so a browser refresh leaked a slot and eight of them wedged /events permanently; a database write now bumps the status board, which streams block on, instead of waiting out a keepalive; upload targets ship their base url, since Flight Review returns a relative redirect and a remote plot link was resolving against the ARK-OS host; a failed BEGIN no longer leaves a durable UPDATE behind; an unchanged listing no longer counts as a change; downloaded is never reported true for a row with no file; auto_upload keeps its old key and stops hardcoding its default; connect() cannot leave a fetcher running past stop(); and free space is printed as uintmax_t in the message that exists to diagnose a full disk. Config tables are one level deep now ([upload_local] rather than [upload.local]): ARK-OS's shared config editor renders exactly one level, and a setting the operator cannot reach from the web UI may as well not exist.
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Supersedes #29, #30 and #31 — this branch contains their commits unchanged, with authorship intact, so merging this merges all three. They can be closed.
Reviewing that stack turned up structural problems in the code underneath it, and fixing those meant rewriting the parts they sit on, so keeping four PRs would have meant reviewing #29's
ServerInterfacechanges and then reviewing their replacement. One branch instead: MAVLink FTP for transport, fetching driven by request rather than by mirroring the SD card, and an HTTP API for the ARK-OS Logs page to drive.Verified end to end against PX4 SITL and a local flight-review. Adds tests and CI, which the repo had none of.
Problem
Transport.
LOG_DATA(msg 120) has notarget_system/target_component, so a router between logloader and the autopilot has no addressing to work with and copies every chunk to every endpoint it serves, telemetry radio included.LOG_ENTRYcompounds it: it identifies a log by index and timestamp rather than by file, so anything downloading over FTP while listing over the log protocol has to map one onto the other by size, mtime or list ordering — and ArduPilot's numbering, itsLOG_MAX_FILESwrap and its.BINnaming all have to be modelled to do it. When that mapping guesses wrong, the wrong file transfers in full and is discarded.Structure.
ServerInterfacewas 860 lines doing two unrelated jobs: a SQLite repository and a Flight Review HTTP client. About 70% of it was database code and the name described the other 30%. Because the database lived inside the upload target, download state was duplicated acrosslocal_server.dbandremote_server.db, andLogLoaderkept them in step by calling every mutation twice. The download queue was read from whichever happened to be first.Behaviour. logloader tried to mirror the vehicle's SD card. A companion powered on for the first time, or one whose database had been reset, would pull and upload the entire history over a link with better things to do — and there was no way to see what it had or to ask for a particular log.
Defects. A pending log whose local file was missing or empty was retried forever and blocked every older log behind it; only HTTP 400 escaped the queue.
ServerInterface::_should_exitandLogLoader::_loop_disabledwere plain bools written by one thread and read by another. The signal handler took a mutex and signalled a condition variable, neither of which is async-signal-safe.CMAKE_BUILD_TYPEwas never set, so the shipped binary was built at-O0.generate_uuidhashed withstd::hash— implementation-defined — into state persisted on disk.Solution
Everything over MAVLink FTP (
FILE_TRANSFER_PROTOCOL, msg 110), for the listing and the transfer. It is addressed, and both stacks reply to the requesting sysid/compid, so transfers are unicast. Identity becomes path-below-the-log-root plus size, so there is no cross-protocol mapping left to get wrong and ArduPilot needs no special handling — a directory listing just reports files. The root is probed at@MAV_LOG, then/fs/microsd/log,/APM/LOGSand/log. MAVSDK'sFtpplugin discards size and mtime from list replies, soFtpListClientruns the listing overMavlinkPassthrough; worth upstreaming so it can be deleted.Storage split from transport.
LogDatabaseowns the inventory and the intent,UploadTargetis one Flight Review endpoint with no state of its own, andLegacyImportquarantines the pre-FTP migration behind a header saying it is deletable once the fleet has upgraded. One database, one place to look.Fetching is request-driven. A log is fetched because it appeared while logloader was watching — a flight just ended — or because something asked. On a database that has never seen the vehicle, only the newest is taken. A listing with more than
download.max_auto_queuenew logs is an unfamiliar card rather than a burst of flights, so it queues one and says so. Intent lives in the database, so automatic and requested work share one path.An HTTP API on
127.0.0.1:3005:GET /logs,/status,/events(SSE),POST /logs/{download,upload,cancel},DELETE /logs/{id}/file. ARK-Electronics/ARK-OS#109 is the page that drives it.Uploads stream off disk with a computed
Content-Length. httplib'sMultipartFormDataItemsoverload concatenates the whole body into a second string; a 400 MB log needed most of a gigabyte on a Pi, and the resulting OOM kill retried the same log on restart. Measured: 16 MB RSS while uploading a 76 MB log.Also fixed, most of it found by review or by the SITL run: a use-after-free where
FtpListClienthanded MAVSDK athis-capturing callback (MAVSDK unsubscribes asynchronously, so a reply arriving after shutdown wrote into freed heap); an ODR violation from definingCPPHTTPLIB_OPENSSL_SUPPORTper-file, which gave two translation units different socket layouts and segfaulted on the first real run;Transaction::commitclearing its flag beforeCOMMIT, so a failed commit left the transaction open and every later write was silently discarded while the daemon looked healthy; a missingALTER TABLEfor a column added to an existing schema; and a log still being written being treated as absent, which marked every row "not on vehicle" for one interval after each restart.Rows are marked absent rather than deleted when a log leaves the vehicle, so a downloaded log keeps its file and its upload history. Config tables are one level deep on purpose — ARK-OS's shared config editor renders exactly one level, and a setting the operator cannot reach from the web UI may as well not exist. The old flat keys are still read, with a deprecation warning.
Upgrades. Pre-FTP databases keyed logs on the
LOG_ENTRYtimestamp, which FTP cannot reproduce.local_server.dbandremote_server.dbare folded into onelogloader.dband matched to the listing by size, so a fleet upgrading does not re-download and re-upload its history. A log the old version had downloaded but not yet uploaded keeps its file and its state but is not queued — carrying that intent over would produce exactly the flood this version exists to avoid.tests/test_log_database.cppcovers 16 cases including the legacy import, which runs once against real user data and cannot be un-run. Writing them found two defects on its own.Carried over from #30 and #31
The reachability cooldown and the API-key headers are preserved in
UploadTarget, with one change: 401/403 no longer blacklist a log. Those statuses mean this account is not authorized yet, not this log is bad, and the expected first-run state returns 403 — blacklisting would silently discard one pending log per cycle. The batch still stops on them, which resumes on its own once the key is valid.Worth flagging: the API-key client has no server counterpart. The ARK
flight_reviewfork has noapi_key.pyon any branch, andUploadHandler.prepare()authenticates with a session cookie plus_is_uploader_approved, 403ing otherwise. The plumbing is harmless and forward-compatible so it is kept, but settingremote_api_keycurrently changes nothing.Follow-ups
-x; FTP is now load-bearing.