ftp: add ListDirectoryWithTime with change time (#701)

* ftp: add ListDirectoryWithTime with change time

The current ListDirectory command doesn't support the last changed time
of a file or directory. This is a limitation when it comes to using FTP
to download logs.

Therefore, I suggest to add ListDirectoryWithTime here, somewhat similar
to how it was done for in the internet FTP protocol which added the
machine-readable `MLSD` command alongside the original `LIST`.

See: RFC 3659: https://datatracker.ietf.org/doc/html/rfc3659

* docs(mavftp): List Directory with time - run prettier

---------

Co-authored-by: Hamish Willee <hamishwillee@gmail.com>
This commit is contained in:
Julian Oes
2026-05-23 18:46:24 +12:00
committed by GitHub
parent a1f4956456
commit 4c694c73f6
+54 -18
View File
@@ -73,24 +73,25 @@ The opcodes that may be sent by the GCS (client) to the drone (server) are enume
<!-- uint8_t enum Opcode: https://github.com/PX4/Firmware/blob/master/src/modules/mavlink/mavlink_ftp.h -->
| Opcode | Name | Description |
| ------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | None | Ignored, always ACKed |
| <a id="TerminateSession"></a> 1 | TerminateSession | Terminates open Read `session`.<br>- Closes the file associated with (`session`) and frees the session ID for re-use. |
| <a id="ResetSessions"></a> 2 | ResetSessions | Terminates _all_ open read sessions.<br>- Clears all state held by the drone (server); closes all open files, etc.<br>- Sends an ACK reply with no data. <!-- Note, is same as Terminate, but does not check if file session exists --> |
| <a id="ListDirectory"></a> 3 | [ListDirectory](#list_directory) | List directory entry information (files, folders etc.) in `<path>`, starting from a specified entry index (`<offset>`).<br>- Response is an ACK packet with one or more entries on success, otherwise a NAK packet with an error code.<br>- Completion is indicated by a NACK with EOF in response to a requested index (`offset`) beyond the list of entries.<br>- The directory is closed after the operation, so this leaves no state on the server. |
| <a id="OpenFileRO"></a> 4 | [OpenFileRO](#reading-a-file) | Opens file at `<path>` for reading, returns `<session>`<br>- The `path` is stored in the [payload](#payload) `data`. The drone opens the file (`path`) and allocates a _session number_. The file must exist.<br>- An ACK packet must include the allocated `session` and the data size of the file to be opened (`size`)<br>- A NAK packet must contain [error information](#error_codes) . Typical error codes for this command are `NoSessionsAvailable`, `FileExists`. <br>- The file remains open after the operation, and must eventually be closed by `Reset` or `Terminate`. |
| <a id="ReadFile"></a> 5 | [ReadFile](#reading-a-file-readfile) | Reads `<size>` bytes from `<offset>` in `<session>`.<br>- Seeks to (`offset`) in the file opened in (session) and reads (`size`) bytes into the result buffer.<br>- Sends an ACK packet with the result buffer on success, otherwise a NAK packet with an error code. For short reads or reads beyond the end of a file, the (`size`) field in the ACK packet will indicate the actual number of bytes read.<br>- Reads can be issued to any offset in the file for any number of bytes, so reconstructing portions of the file to deal with lost packets should be easy.<br>- For best download performance, try to keep two `Read` packets in flight. |
| <a id="CreateFile"></a> 6 | `CreateFile` | Creates file at `<path>` for writing, returns `<session>`.<br>- Creates the file (path) and allocates a _session number_. All parent directories must exist. If the file already existed, then this call will truncate it. Equivalent UNIX flags: (O_CREAT &#124; O_TRUNC &#124; O_WRONLY)<br>- Sends an ACK packet with the allocated session number on success, or a NAK packet with an error code on error (i.e. [FileExists](#FileExists) if the `path` already exists).<br>- The file remains open after the operation, and must eventually be closed by `Reset` or `Terminate`. |
| <a id="WriteFile"></a> 7 | `WriteFile` | Writes `<size>` bytes to `<offset>` in `<session>`.<br>- Sends an ACK reply with no data on success, otherwise a NAK packet with an error code. |
| <a id="RemoveFile"></a> 8 | [RemoveFile](#remove-file) | Remove file at `<path>`.<br>- ACK reply with no data on success.<br>- NAK packet with [error information](#error_codes) on failure. |
| <a id="CreateDirectory"></a> 9 | [CreateDirectory](#create-directory) | Creates directory at `<path>`.<br>- Sends an ACK reply with no data on success, otherwise a NAK packet with an error code. |
| <a id="RemoveDirectory"></a> 10 | [RemoveDirectory](#remove-directory) | Removes directory at `<path>`. The directory must be empty. <br>- Sends an ACK reply with no data on success, otherwise a NAK packet with an error code. |
| <a id="OpenFileWO"></a> 11 | OpenFileWO | Opens file at `<path>` for writing, returns `<session>`. <br>- Opens the file (`path`) and allocates a _session number_. The file will be created if it does not exist. Equivalent UNIX flags: (O*CREAT &#124; O_WRONLY)<br>- Sends an ACK packet with the allocated \_session number* on success, otherwise a NAK packet with an error code.<br>- The file remains open after the operation, and must eventually be closed by `Reset` or `Terminate`. |
| <a id="TruncateFile"></a> 12 | [TruncateFile](#truncate-file) | Truncate file at `<path>` to `<offset>` length.<br>- Sends an ACK reply with no data on success, otherwise a NAK packet with an error code. |
| <a id="Rename"></a> 13 | Rename | Rename `<path1>` to `<path2>`.<br>- Sends an ACK reply the no data on success, otherwise a NAK packet with an error code (i.e. if the source path does not exist). |
| <a id="CalcFileCRC32"></a> 14 | CalcFileCRC32 | Calculate CRC32 for file at `<path>`.<br>- Sends an ACK reply with the checksum on success, otherwise a NAK packet with an error code. |
| <a id="BurstReadFile"></a> 15 | [BurstReadFile](#reading-a-file-burstreadfile) | Burst-read parts of a file. Messages in the burst are streamed (without ACK) until the burst is complete (as indicated by the field `burst_complete` being set to `1`). Parts of a burst that are dropped may be fetched using [ReadFile](#ReadFile). |
| Opcode | Name | Description |
| ------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | None | Ignored, always ACKed |
| <a id="TerminateSession"></a> 1 | TerminateSession | Terminates open Read `session`.<br>- Closes the file associated with (`session`) and frees the session ID for re-use. |
| <a id="ResetSessions"></a> 2 | ResetSessions | Terminates _all_ open read sessions.<br>- Clears all state held by the drone (server); closes all open files, etc.<br>- Sends an ACK reply with no data. <!-- Note, is same as Terminate, but does not check if file session exists --> |
| <a id="ListDirectory"></a> 3 | [ListDirectory](#list_directory) | List directory entry information (files, folders etc.) in `<path>`, starting from a specified entry index (`<offset>`).<br>- Response is an ACK packet with one or more entries on success, otherwise a NAK packet with an error code.<br>- Completion is indicated by a NACK with EOF in response to a requested index (`offset`) beyond the list of entries.<br>- The directory is closed after the operation, so this leaves no state on the server. |
| <a id="OpenFileRO"></a> 4 | [OpenFileRO](#reading-a-file) | Opens file at `<path>` for reading, returns `<session>`<br>- The `path` is stored in the [payload](#payload) `data`. The drone opens the file (`path`) and allocates a _session number_. The file must exist.<br>- An ACK packet must include the allocated `session` and the data size of the file to be opened (`size`)<br>- A NAK packet must contain [error information](#error_codes) . Typical error codes for this command are `NoSessionsAvailable`, `FileExists`. <br>- The file remains open after the operation, and must eventually be closed by `Reset` or `Terminate`. |
| <a id="ReadFile"></a> 5 | [ReadFile](#reading-a-file-readfile) | Reads `<size>` bytes from `<offset>` in `<session>`.<br>- Seeks to (`offset`) in the file opened in (session) and reads (`size`) bytes into the result buffer.<br>- Sends an ACK packet with the result buffer on success, otherwise a NAK packet with an error code. For short reads or reads beyond the end of a file, the (`size`) field in the ACK packet will indicate the actual number of bytes read.<br>- Reads can be issued to any offset in the file for any number of bytes, so reconstructing portions of the file to deal with lost packets should be easy.<br>- For best download performance, try to keep two `Read` packets in flight. |
| <a id="CreateFile"></a> 6 | `CreateFile` | Creates file at `<path>` for writing, returns `<session>`.<br>- Creates the file (path) and allocates a _session number_. All parent directories must exist. If the file already existed, then this call will truncate it. Equivalent UNIX flags: (O_CREAT &#124; O_TRUNC &#124; O_WRONLY)<br>- Sends an ACK packet with the allocated session number on success, or a NAK packet with an error code on error (i.e. [FileExists](#FileExists) if the `path` already exists).<br>- The file remains open after the operation, and must eventually be closed by `Reset` or `Terminate`. |
| <a id="WriteFile"></a> 7 | `WriteFile` | Writes `<size>` bytes to `<offset>` in `<session>`.<br>- Sends an ACK reply with no data on success, otherwise a NAK packet with an error code. |
| <a id="RemoveFile"></a> 8 | [RemoveFile](#remove-file) | Remove file at `<path>`.<br>- ACK reply with no data on success.<br>- NAK packet with [error information](#error_codes) on failure. |
| <a id="CreateDirectory"></a> 9 | [CreateDirectory](#create-directory) | Creates directory at `<path>`.<br>- Sends an ACK reply with no data on success, otherwise a NAK packet with an error code. |
| <a id="RemoveDirectory"></a> 10 | [RemoveDirectory](#remove-directory) | Removes directory at `<path>`. The directory must be empty. <br>- Sends an ACK reply with no data on success, otherwise a NAK packet with an error code. |
| <a id="OpenFileWO"></a> 11 | OpenFileWO | Opens file at `<path>` for writing, returns `<session>`. <br>- Opens the file (`path`) and allocates a _session number_. The file will be created if it does not exist. Equivalent UNIX flags: (O*CREAT &#124; O_WRONLY)<br>- Sends an ACK packet with the allocated \_session number* on success, otherwise a NAK packet with an error code.<br>- The file remains open after the operation, and must eventually be closed by `Reset` or `Terminate`. |
| <a id="TruncateFile"></a> 12 | [TruncateFile](#truncate-file) | Truncate file at `<path>` to `<offset>` length.<br>- Sends an ACK reply with no data on success, otherwise a NAK packet with an error code. |
| <a id="Rename"></a> 13 | Rename | Rename `<path1>` to `<path2>`.<br>- Sends an ACK reply the no data on success, otherwise a NAK packet with an error code (i.e. if the source path does not exist). |
| <a id="CalcFileCRC32"></a> 14 | CalcFileCRC32 | Calculate CRC32 for file at `<path>`.<br>- Sends an ACK reply with the checksum on success, otherwise a NAK packet with an error code. |
| <a id="BurstReadFile"></a> 15 | [BurstReadFile](#reading-a-file-burstreadfile) | Burst-read parts of a file. Messages in the burst are streamed (without ACK) until the burst is complete (as indicated by the field `burst_complete` being set to `1`). Parts of a burst that are dropped may be fetched using [ReadFile](#ReadFile). |
| <a id="ListDirectoryWithTime"></a> 16 | [ListDirectoryWithTime](#list_directory_with_time) | List directory entry information as for [ListDirectory](#ListDirectory), but with each entry additionally including its last-modification time. Lets a GCS fetch timestamps without downloading file contents.<br>- A server that does not implement this command NAKs with [UnknownCommand](#UnknownCommand); clients should then fall back to [ListDirectory](#ListDirectory). |
The drone (server) will respond with/send the following opcodes for any of the above messages (ACK response on success or a NAK in the event of an error).
@@ -451,6 +452,41 @@ The GCS should create a timeout after the `ListDirectory` command is sent and re
The drone may also [NAK](#error_codes) with an unexpected error.
Generally errors are unrecoverable, and the drone must clean up all resources (i.e. close file handles) associated with the request after sending the NAK.
### List Directory with Timestamps {#list_directory_with_time}
[ListDirectoryWithTime](#ListDirectoryWithTime) works exactly like [ListDirectory](#list_directory), but each returned entry additionally includes its last-modification time.
This allows a GCS to display or compare timestamps (e.g. to show the most recent log, or to detect which files have changed), without having to download file contents.
::: info
This is a separate opcode rather than a change to [ListDirectory](#list_directory) so that existing clients and servers keep working.
A client that needs timestamps uses `ListDirectoryWithTime`, and falls back to [ListDirectory](#ListDirectory) if the server NAKs with [UnknownCommand](#UnknownCommand).
:::
The sequence of operations, offset-based paging, and [EOF](#EOF) termination are identical to [List Directory](#list_directory).
The only difference is the per-entry format returned in the ACK payload `data`.
Each entry is separated with a null terminator (`\0`), and has the following format (where `type` is one of the letters **F**(ile), **D**(irectory), **S**(kip)):
```txt
<type><file_or_folder_name>\t<file_size_in_bytes>\t<modification_time>\0
```
The `modification_time` is the entry's last-modification time as a decimal ASCII string, in **seconds since the UNIX epoch (UTC)**.
If the modification time is not known — for example the file system does not store it, or the component has no real-time clock — the server must send `0`.
For example, given five files named _TestFile1.xml_ to _TestFile5.xml_, the entries returned at offset 2 might look like:
`FTestFile3.xml\t223\t1747459200\0FTestFile4.xml\t755568\t1746940800\0FTestFile5.xml\t11111\t0\0`
(here the modification time of _TestFile5.xml_ is unknown).
::: info
Only modification time is reported (not creation time).
POSIX `struct stat` — the interface exposed by most flight-stack file systems (e.g. NuttX) — has no portable creation-time field, so modification time is the only timestamp that can be reliably provided across backends.
:::
The GCS should create a timeout after the `ListDirectoryWithTime` command is sent and resend the message as needed (and [described above](#timeouts)).
### Create Directory
The sequence of operations for creating a directory is shown below (assuming there are no timeouts and all operations/requests succeed).