Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ sloth = { module = "dev.frozenmilk.sinister:Sloth", version.ref = "sloth" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
ivy = { module = "com.pedropathing.ivy:core", version.ref = "ivy" }
blaze = { module = "dev.anygeneric:blazeftc", version.ref = "blaze"}
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
Expand Down
7 changes: 7 additions & 0 deletions telemetry-ftc/Module.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ FtcTelemetry.config.serverPort = 5810
Telemetry.log("Drive/Pose", pose)
```

While the server is running, it also serves your logs. `/download` is a page that lists every
log with its OpMode name and start time, newest first. On a Control Hub that is
`http://192.168.43.1:5810/download`, and on a Robot Controller phone it is
`http://192.168.49.1:5810/download` over Wi-Fi Direct. `/logs` returns the same list as JSON, and
`/logs/<file name>` downloads one file. A log that is still being written is listed but can't be
downloaded until its OpMode stops.

Two utility OpModes, "Disable NextFTC Telemetry Server" and "Enable NextFTC Telemetry Server", turn
the NetworkTables server off for competition and back on afterward. The choice is saved on the Robot
Controller, so it holds after the app restarts. `FtcTelemetry.setServerEnabled` does the same from
Expand Down
1 change: 1 addition & 0 deletions telemetry-ftc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies {
}
compileOnly(libs.bundles.ftc)
implementation(libs.sloth)
implementation(libs.kotlinx.serialization.json)

// The backend implements telemetry types that live in the SDK, so tests need it on their classpath
testImplementation(libs.ftc.robot.core) { isTransitive = false }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import dev.nextftc.telemetry.nt.NT4Backend
import dev.nextftc.telemetry.nt.NT4Server
import dev.nextftc.telemetry.wpilog.WPILogBackend
import dev.nextftc.telemetry.wpilog.WPILogWriter
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.firstinspires.ftc.robotcore.internal.system.AppUtil
import java.io.File
import java.io.FileOutputStream
Expand Down Expand Up @@ -99,6 +101,14 @@ object FtcTelemetry : OnCreateEventLoop, OpModeManagerNotifier.Notifications {
private var eventLoop: FtcEventLoop? = null
private var server: NT4Server? = null
private var log: WPILogBackend? = null

/** The file [log] writes to, which the download page won't serve until it is closed. */
@Volatile
private var activeLogFile: File? = null

/** Serves `/download`, `/logs` and `/logs/<name>` on the server's port. */
private val logDownloads =
LogDownloads(directory = { config.logDirectory }, activeFile = { activeLogFile })
private var live: NT4Backend? = null
private var driverStation: DriverStationBackend? = null
private var prefs: SharedPreferences? = null
Expand Down Expand Up @@ -169,12 +179,16 @@ object FtcTelemetry : OnCreateEventLoop, OpModeManagerNotifier.Notifications {
File(config.logDirectory, "${stamp}__${name.replace(Regex("[^A-Za-z0-9_.-]"), "_")}.wpilog")
val writer = WPILogWriter(
FileOutputStream(file),
extraHeader = """{"source":"NextFTC","opMode":"${name.replace("\"", "'")}"}""",
extraHeader = buildJsonObject {
put("source", "NextFTC")
put("opMode", name)
}.toString(),
timeSource = registry.timeSource,
origin = registry.origin,
)
WPILogBackend(writer).also {
log = it
activeLogFile = file
lastFlush = registry.timeSource.markNow()
registry.register(it)
}
Expand All @@ -189,6 +203,7 @@ object FtcTelemetry : OnCreateEventLoop, OpModeManagerNotifier.Notifications {
config.serverPort,
timeSource = registry.timeSource,
origin = registry.origin,
routes = { logDownloads.install(this) },
).also {
it.start()
server = it
Expand Down Expand Up @@ -270,6 +285,7 @@ object FtcTelemetry : OnCreateEventLoop, OpModeManagerNotifier.Notifications {
}
}
log = null
activeLogFile = null
live = null
driverStation = null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
* Copyright (c) 2026 NextFTC Team
*
* Use of this source code is governed by an BSD-3-clause
* license that can be found in the LICENSE.md file at the root of this repository or at
* https://opensource.org/license/bsd-3-clause.
*/

package dev.nextftc.telemetry.ftc

import io.ktor.http.CacheControl
import io.ktor.http.ContentDisposition
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.http.content.LocalFileContent
import io.ktor.server.http.content.staticResources
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Routing
import io.ktor.server.routing.get
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import java.io.DataInputStream
import java.io.File
import java.io.IOException
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale

/**
* Routes that serve the WPILOGs [FtcTelemetry] wrote over HTTP, on the NetworkTables server's port:
*
* - `/download` is a page that lists them, newest first, with a link for each. It is the static
* files in [PAGE_RESOURCES], and a script in it reads `/logs`.
* - `/logs` is the list as JSON.
* - `/logs/<name>` downloads one.
*
* Only `.wpilog` files directly inside the directory can be reached, and a log that is still being
* written is listed but can't be downloaded, since the file would be cut off.
*
* @param directory Where the logs are, read on every request so a change to the setting applies
* @param activeFile The log being written right now, if any
*/
internal class LogDownloads(private val directory: () -> File, private val activeFile: () -> File?) {
internal class Entry(
val file: File,
/** The OpMode's name, from the file's header or else its name. */
val opMode: String,
/** When the OpMode was initialized, in the Robot Controller's time zone, or null if the name has no stamp. */
val started: Date?,
val sizeBytes: Long,
val modifiedMillis: Long,
val recording: Boolean,
) {
val name: String get() = file.name
}

fun install(routing: Routing) = with(routing) {
staticResources(PAGE_PATH, PAGE_RESOURCES) {
cacheControl { listOf(CacheControl.NoCache(null)) }
}

get("/logs") {
call.respondText(withContext(Dispatchers.IO) { json(entries()) }, ContentType.Application.Json)
}

get("/logs/{name}") {
val name = call.parameters["name"]
val entry = withContext(Dispatchers.IO) { entries().firstOrNull { it.name == name } }
when {
entry == null -> call.respondText("Not found", status = HttpStatusCode.NotFound)
entry.recording -> call.respondText(
"That log is still being written. Stop the OpMode first.",
status = HttpStatusCode.Conflict,
)
else -> {
call.response.header(
HttpHeaders.ContentDisposition,
ContentDisposition.Attachment.withParameter(
ContentDisposition.Parameters.FileName,
entry.name,
).toString(),
)
call.respond(LocalFileContent(entry.file, ContentType.Application.OctetStream))
}
}
}
}

/** Every log in the directory, newest first. */
internal fun entries(): List<Entry> {
val active = activeFile()?.let { runCatching { it.canonicalFile }.getOrNull() }
val files = directory().listFiles { f -> f.isFile && f.name.endsWith(EXTENSION) } ?: return emptyList()
return files
.map { file ->
val parsed = NAME.matchEntire(file.name)
val started = parsed?.let { parseStamp(it.groupValues[1]) }
Entry(
file = file,
opMode =
readOpMode(file) ?: parsed?.groupValues?.get(2)?.replace('_', ' ')
?: file.name.removeSuffix(EXTENSION),
started = started,
sizeBytes = file.length(),
modifiedMillis = file.lastModified(),
recording = active != null && runCatching { file.canonicalFile }.getOrNull() == active,
)
}
.sortedWith(
compareByDescending<Entry> {
it.started?.time ?: it.modifiedMillis
}.thenByDescending { it.name },
)
}

private fun json(entries: List<Entry>): String {
val shown = SimpleDateFormat("EEE MMM d, yyyy h:mm:ss a", Locale.US)
return buildJsonArray {
for (entry in entries) {
addJsonObject {
put("name", entry.name)
put("opMode", entry.opMode)
put("started", entry.started?.time)
put("startedText", shown.format(entry.started ?: Date(entry.modifiedMillis)))
put("sizeBytes", entry.sizeBytes)
put("modified", entry.modifiedMillis)
put("recording", entry.recording)
}
}
}
.toString()
}

private companion object {
const val EXTENSION = ".wpilog"

/** Where the page is served. */
const val PAGE_PATH = "/download"

/**
* Where the page is located in the resources directory.
*/
const val PAGE_RESOURCES = "dev/nextftc/telemetry/ftc/download"

/** The name [FtcTelemetry] gives a log: the stamp, then the sanitized OpMode name. */
val NAME = Regex("""(\d{4}_\d{2}_\d{2}__\d{2}_\d{2}_\d{2})__(.*)\.wpilog""")

fun parseStamp(stamp: String): Date? = try {
SimpleDateFormat("yyyy_MM_dd__HH_mm_ss", Locale.US).apply { isLenient = false }.parse(stamp)
} catch (e: ParseException) {
null
}

/** Reads the OpMode's real name from the header [FtcTelemetry] wrote, since the file name is sanitized. */
fun readOpMode(file: File): String? = try {
DataInputStream(file.inputStream().buffered()).use { input ->
// "WPILOG", a 2-byte version, a 4-byte little-endian length, then the extra header text
val fixed = ByteArray(12)
input.readFully(fixed)
if (String(fixed, 0, 6, Charsets.US_ASCII) != "WPILOG") return null
val length = (fixed[8].toInt() and 0xFF) or ((fixed[9].toInt() and 0xFF) shl 8) or
((fixed[10].toInt() and 0xFF) shl 16) or ((fixed[11].toInt() and 0xFF) shl 24)
if (length !in 1..4096) return null
val text = ByteArray(length).also { input.readFully(it) }.toString(Charsets.UTF_8)
val header = try {
Json.parseToJsonElement(text) as? JsonObject
} catch (e: SerializationException) {
null
}
(header?.get("opMode") as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
}
} catch (e: IOException) {
null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>NextFTC Logs</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20width=%2245%22%20height=%2232%22%20viewBox=%220%200%2045%2032%22%20fill=%22none%22%20xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cpath%20d=%22M18.8338%2023.0179L25.7509%2031.7063H34.5496L23.4187%2018.3029L18.8338%2023.0179Z%22%20fill=%22white%22/%3E%3Cpath%20d=%22M14.9207%2023.0179L7.79165%2031.7063H0L11.1044%2018.7267L0.159008%205.21741H8.79874L15.6893%2014.2236L19.5056%2018.3029L14.9207%2023.0179Z%22%20fill=%22white%22/%3E%3Cpath%20d=%22M19.6024%2014.2236L23.4187%2018.3029L27.7594%2013.1756C28.5194%2012.2779%2029.636%2011.7601%2030.8123%2011.7601H36.6739C36.9501%2011.7601%2037.1739%2011.984%2037.1739%2012.2601V15.6049C37.1739%2016.0691%2037.7515%2016.2827%2038.0536%2015.9303L44.6987%208.17764C44.8687%207.97927%2044.8574%207.68343%2044.6726%207.49869L38.0275%200.853554C37.7125%200.538572%2037.1739%200.761654%2037.1739%201.20711V4.71739C37.1739%204.99353%2036.9501%205.21739%2036.6739%205.21739H34.4966H28.8657C27.6659%205.21739%2026.5294%205.75599%2025.7697%206.68466L19.6024%2014.2236Z%22%20fill=%22%230099FF%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/download/logs.css">
</head>
<body>
<main>
<svg class="wordmark" viewBox="0 0 206 29" role="img" aria-label="NextFTC" xmlns="http://www.w3.org/2000/svg">
<path class="blue" d="M120 28.7157V0.284312H144.382V5.2395H125.364V11.7787H135.97V16.7339H125.364V28.7157H120Z"/>
<path class="blue" d="M158.117 28.7157V5.2395H146.82V0.284312H174.778V5.2395H163.481V28.7157H158.117Z"/>
<path class="blue" d="M179.036 5.56442C179.036 3.57423 179.571 2.15266 180.641 1.29972C181.725 0.43324 183.506 0 185.985 0H198.542C201.048 0 202.836 0.43324 203.906 1.29972C204.989 2.15266 205.531 3.57423 205.531 5.56442V9.54482L200.167 10.5196V4.95518H184.4V24.0448H200.33V17.9524L205.694 19.0084V23.1919C205.694 25.2904 205.159 26.7864 204.089 27.68C203.032 28.56 201.237 29 198.704 29H185.985C183.452 29 181.657 28.56 180.6 27.68C179.557 26.7864 179.036 25.2904 179.036 23.1919V5.56442Z"/>
<path class="ink" d="M107.696 28.4664C105.813 28.4664 104.472 28.1347 103.673 27.4713C102.888 26.8079 102.495 25.6909 102.495 24.1205V12.098H99V7.95518H102.495V3H107.534V7.95518H113.67V12.098H107.534V24.3235H113.467L112.613 28.4664H107.696Z"/>
<path class="ink" d="M0 28.4314V0H4.14495L24.0976 19.9832V0H29.0553V28.4314H24.9103L4.95768 8.57003V28.4314H0Z"/>
<path class="ink" d="M36.2089 23.3543V12.388C36.2089 10.9393 36.6559 9.8359 37.5499 9.07773C38.4575 8.30602 39.8391 7.92017 41.6949 7.92017H52.4636C54.3193 7.92017 55.701 8.31279 56.6085 9.09804C57.5296 9.86975 57.9902 10.9935 57.9902 12.4692V20.2675H41.2479V24.4916H52.9512V22.2577L57.9902 23.1513V23.3543C57.9902 25.2498 57.4958 26.6036 56.507 27.416C55.5181 28.2283 53.8588 28.6345 51.529 28.6345H42.2638C40.2184 28.6345 38.6945 28.2351 37.6922 27.4363C36.7033 26.6375 36.2089 25.2768 36.2089 23.3543ZM41.2479 16.1247H52.9512V12.063H41.2479V16.1247Z"/>
<path class="ink" d="M76.4392 21.6471L81.7423 28.3081H88.488L79.9543 18.0322L76.4392 21.6471Z"/>
<path class="ink" d="M73.4392 21.6471L67.9736 28.3081H62L70.5134 18.3571L62.1219 8H68.7457L74.0285 14.9048L76.9543 18.0322L73.4392 21.6471Z"/>
<path class="blue" d="M77.0285 14.9048L79.9543 18.0322L83.0025 14.4316C83.7626 13.5338 84.8792 13.0161 86.0555 13.0161H90C90.2761 13.0161 90.5 13.24 90.5 13.5161V15.6484C90.5 16.1126 91.0775 16.3262 91.3796 15.9738L96.1987 10.3515C96.3687 10.1532 96.3573 9.85734 96.1726 9.6726L91.3536 4.85355C91.0386 4.53857 90.5 4.76165 90.5 5.20711V7.5C90.5 7.77614 90.2761 8 90 8H88.4474H84.5727C83.3728 8 82.2364 8.5386 81.4767 9.46727L77.0285 14.9048Z"/>
</svg>
<h1>Logs</h1>
<p class="sub" id="summary">Loading…</p>
<div class="card">
<p class="message" id="message" hidden></p>
<table id="table" hidden>
<thead><tr><th>OpMode</th><th>Started</th><th class="num">Size</th><th></th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
</main>
<script src="/download/logs.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/* The colors, and the card and button shapes, follow the NextFTC docs (starlight-theme-galaxy). */
:root {
color-scheme: dark;
--accent-low: #113145;
--accent: #0099ff;
--accent-high: #b0dbf7;
--text: #ffffff;
--text-2: #e8eff6;
--muted: #7d8e9e;
--bg: #17181c;
--hairline: #212929;
--card-bg: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.03) 100%);
--card-border: rgba(255, 255, 255, 0.12);
--card-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.1);
--row-hover: rgba(255, 255, 255, 0.04);
--font: "Inter Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--font-title: "Science Gothic", var(--font);
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "Cascadia Mono", "Segoe UI Mono", monospace;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--accent-low: #c6e6fa;
--accent-high: #0b5a8e;
--text: #0c1014;
--text-2: #1b2835;
--muted: #4b5a69;
--bg: #fbfcfc;
--hairline: #edeef3;
--card-bg: linear-gradient(135deg, rgba(0, 0, 0, 0.025) 0%, rgba(0, 0, 0, 0.015) 100%);
--card-border: rgba(0, 0, 0, 0.08);
--card-shadow: 0 4px 16px rgba(0, 0, 0, 0.03), 0 1px 4px rgba(0, 0, 0, 0.02), inset 0 1px 0 rgba(255, 255, 255, 0.8);
--row-hover: rgba(0, 0, 0, 0.03);
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text-2); font: 16px/1.65 var(--font); }
main { max-width: 60rem; margin: 0 auto; padding: 2.5rem 1rem 4rem; }
.wordmark { display: block; height: 1.75rem; width: auto; margin-bottom: 2rem; }
.wordmark .ink { fill: var(--text); }
.wordmark .blue { fill: var(--accent); }
h1 { font: 700 2rem/1.2 var(--font-title); color: var(--text); margin: 0 0 .5rem; }
h1::after { content: ""; display: block; width: 3rem; height: 3px; margin-top: .6rem; border-radius: 2px;
background: linear-gradient(90deg, var(--accent), transparent); }
p.sub { margin: 1rem 0 1.75rem; color: var(--muted); }
.card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: 1rem;
box-shadow: var(--card-shadow); overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: .8rem 1.25rem; border-bottom: 1px solid var(--hairline); white-space: nowrap; }
tbody tr { transition: background .2s ease; }
tbody tr:hover { background: var(--row-hover); }
tbody tr:last-child td { border-bottom: 0; }
th { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
td:first-child { white-space: normal; min-width: 14rem; }
.opmode { display: block; color: var(--text); font-weight: 500; }
.file { display: block; color: var(--muted); font: .75rem/1.4 var(--font-mono); word-break: break-all; }
td:last-child { text-align: right; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
a.button { display: inline-block; padding: .35rem 1rem; border-radius: .75rem; color: #fff; font-weight: 600;
font-size: .875rem; text-decoration: none;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-high) 100%);
box-shadow: 0 4px 16px color-mix(in srgb, var(--accent) 25%, transparent);
transition: transform .3s ease, box-shadow .3s ease; }
a.button:hover { transform: translateY(-2px); box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 40%, transparent); }
a.button:focus-visible { outline: 2px solid var(--accent-high); outline-offset: 2px; }
.badge { display: inline-block; padding: .15rem .7rem; border-radius: 999px; font-size: .8rem; font-weight: 600;
color: var(--accent-high); background: var(--accent-low); }
.message { padding: 3rem 1rem; text-align: center; color: var(--muted); }
@media (max-width: 40rem) { th, td { padding: .7rem .8rem; } main { padding-top: 1.5rem; } }
Loading
Loading