Squashed 'third_party/allwpilib/' changes from e4b91005cf..83f1860047

83f1860047 [wpilib] Add/update documentation to PneumaticBase and subclasses (NFC) (#4881)
9872e676d8 [commands] Make Subsystem destructor virtual (#4892)
25db20e49d [hal] Fix segfault in various HAL functions (#4891)
b0c6724eed [glass] Add hamburger menu icon to titlebars (#4874)
f0fa8205ac Add missing compiler flags and fix warnings (#4889)
42fc4cb6bc [wpiutil] SafeThread: Provide start/stop hooks (#4880)
cc166c98d2 [templates] Add Command-based skeleton template (#4861)
3f51f10ad3 [build] Update to 2023v3 image (#4886)
1562eae74a [ntcore] Refactor meta-topic decoding from glass (#4809)
b632b288a3 Fix usages of std::min and std::max to be windows safe (#4887)
c11bd2720f [wpilibc] Add internal function to reset Shuffleboard instance (#4884)
f1151d375f [ntcore] Add method to get server time offset (#4847)
fe1b62647f [hal,wpilib] Update documentation for getComments (NFC) (#4879)
c49a45abbd [build] Fix examples linking in incorrect jni library (#4873)
bc3d01a721 [build] Add platform check to doxygen plugin (#4862)
bc473240ae Add Jetbrains Fleet folder to .gitignore (#4872)
2121bd5fb8 [wpimath] Remove RKF45 (#4870)
835f8470d6 [build] Fix roborio cross-compiler on arm hosts (#4864)
6cfe5de00d [ntcore] Don't deadlock server on early destroy (#4863)
2ac41f3edc [hal, wpilib] Add RobotController.getComments()  (#4463)
26bdbf3d41 Java optimization and formatting fixes (#4857)
92149efa11 Spelling and grammar cleanups (#4849)
176fddeb4c [commands] Add functions to HID classes to allow use of axes as BooleanEvents/Triggers (#4762)
87a34af367 [templates] Add bindings to command-based template (#4838)
4534e75787 [examples] Remove redundant MotorControl example (#4837)
1cbebaa2f7 [commands] Remove final semicolon from test macro definition (#4859)
6efb9ee405 [commands] Add constructor for SwerveControllerCommand that takes a HolonomicDriveController (#4785)
1e7fcd5637 [cscore] Change run loop functions to not be mac specific (#4854)
1f940e2b60 [apriltag] Add C++ wrappers, rewrite Java/JNI to match (#4842)
a6d127aedf [build] Add missing task dependency in wpilibjExamples (#4852)
b893b3d6d3 [cscore] Add support for USB cameras on macOS (#4846)
1696a490fa [glass] Add support for alternate NT ports (#4848)
40a22d69bc [glass] Add support for alternate NT ports (#4848)
e84dbfede0 [wpilib] GenericHID: Add rumble both option (#4843)
8aa9dbfa90 [examples] Link apriltag package in examples build.gradle (#4845)
eda2fa8a17 [build] Update Spotless (#4840)
d20594db0d Fix typos (#4839)
dd8ecfdd54 [commands] Fix typo in waitUntil docs (NFC) (#4841)
17ceebfff4 [apriltag] Clean up apriltag JNI (#4823)
8b74ab389d [examples] RapidReactCommandBot: Fix array indices (#4833)
1aad3489c2 [sim] Implement PD total current and power (#4830)
2744991771 [wpimath] Fix docs in SwerveModulePosition (#4825)
ffbf6a1fa2 [commands] Disable regularly failing unit test (#4824)
fbabd0ef15 [commands] Enhance Command Sendable implementations (#4822)
7713f68772 [hal] Use atomic rather then mutex for DS Data updates (#4787)
701995d6cc [examples] Update Command-based starter project (#4778)
bf7068ac27 [wpilibc] Add missing PPS implementation for C++ (#4821)
aae0f52ca6 [ntcore] NetworkTable: fix visibility of get/set value (#4820)
ee02fb7ba7 [hal] Add support for Pulse-Per-Second signal (#4819)
518916ba02 [wpilib] Fix DS mode thread event being manual reset accidentally (#4818)
3997c6635b [hal] Update to new image, use new TCP notify callback and new duty cycle API (#4774)
cc8675a4e5 [examples] Add comment on how to view elevator sim (NFC) (#4482)
fb2c170b6e [ntcore] Simplify local startup (#4803)
7ba8a9ee1f [wpimath] ProfiledPIDController: Add to SendableRegistry (#4656)
c569d8e523 [wpilib] Joystick.getMagnitude(): use hypot() function (#4816)
2a5e89fa97 [apriltag] Improve description of pose coordinates (NFC) (#4810)
cc003c6c38 [apriltag] Fix AprilTagFieldLayout JSON name (#4814)
5522916123 [commands] CommandXBoxController bumper documentation fix (NFC) (#4815)
967b30de3a [glass] Fix NT view UpdateClients() bug (#4808)
3270d4fc86 [wpimath] Rewrite pose estimator docs (#4807)
be39678447 [apriltag] Add test to ensure apriltagjni loads (#4805)
61c75deb2a [commands] Test no-op behavior of scheduling a scheduled command (#4806)
a865f48e96 [ntcore] Pass pub/sub options as a unified PubSubOptions struct (#4794)
f66a667321 [commands] Fix incorrect Trigger docs (NFC) (#4792)
f8d4e9866e [ntcore] Clean up ntcore_test.h (#4804)
7e84ea891f [wpimath] Fix ComputerVisionUtil transform example in parameter docs (NFC) (#4800)
da3ec1be10 [wpimath] Change terminology for ArmFeedforward gravity gain (NFC) (#4791)
944dd7265d [wpilibc] Add C++ Notifier error handling, update java notifier error message (#4795)
6948cea67a [wpiutil] Fix MemoryBuffer initialization (#4797)
a31459bce6 [wpiutil] Fix UnescapeCString overflow when inputSize < 2 (#4796)
4a0ad6b48c [wpimath] Rotation2d: Add reference to angleModulus in docs (NFC) (#4786)
e6552d272e [ntcore] Remove table multi-subscriber (#4789)
bde383f763 [hal] Replace const char* with std::string_view in Driver Station sim functions (#4532)
5a52b51443 [hal] Add RobotController.getSerialNumber() (#4783)
69a66ec5ec [wpilib] Fix multiple motor safety issues (#4784)
989c9fb29a [wpimath] Revert Rotation2D change that limits angles (#4781)
0f5b08ec69 [wpigui] Update imgui to 1.89.1+ (#4780)
fba191099c [examples] AddressableLED: Add unit test (#4779)
b390cad095 [wpilibj] Consistently use ErrorMessages.requireNonNullParam (#4776)
b9772214d9 [wpilib] Sendable: Don't call setter for getter changes
342c375a71 [ntcore] Add subscriber option to exclude single publisher
b0e4053087 [ntcore] Use int for options instead of double
f3e666b7bb [cscore] Convert YUYV and UYVY directly to grayscale (#4777)
b300518bd1 [hal] Add CAN Stream API to Java through JNI bindings (#4193)
be27171236 [wpilibj] Shuffleboard: Check for null sendable (#4772)
4bbdbdfb48 [commands] Move GroupedCommands to CommandScheduler (#4728)
f18fd41ac3 [wpimath] Remove broken and obsoleted ComputerVisionUtil functions (#4775)
2d0faecf4f [glass] DataSource: Add spinlock to protect value (#4771)
348bd107fc [hal] Add CANManufacturer for The Thrifty Bot (#4773)
3149dc64b8 [examples] HatchbotInlined: Use Subsystem factories (#4765)
8618dd4160 [glass, wpilib] Replace remaining references to Speed Controller with Motor Controller (#4769)
72e21a1ed1 [apriltag] Use wpilibsuite fork of apriltag (#4764)
eab0d929e6 [commands] CommandGenericHID POV methods: Fix docs (NFC) (#4760)
6789869663 [wpilib] Call set(0) rather than disable for stopMotor (#4763)
c9dea2968d [cscore] Emit warning that USB Camera isn't supported on OSX (#4766)
8f402645f5 [commands] Fix PIDSubsystem setSetpoint behavior (#4759)
f24ad1d715 [build] Upgrade to googletest 1.12.1 (#4752)
ff88756864 [wpimath] Add new DCMotor functions for alternative calculations and reduction calculation (#4749)
f58873db8e [wpimath] Remove extra terms in matrix for pose estimator docs (#4756)
37e969b41a [wpimath] Add constructors to pose estimators with default standard deviations (#4754)
13cdc29382 [ci] Rename comment command from "/wpiformat" to "/format" (#4755)
6e23985ae6 [examples] Add main include directory to test builds (#4751)
66bb0ffb2c [examples] Add unit testing infrastructure (#4646)
74cc86c4c5 [wpimath] Make transform tests use pose/transform equality operators (#4675)
e22d8cc343 [wpimath] Use Odometry for internal state in Pose Estimation (#4668)
68dba92630 [ci] Update mac and windows builds to Java 17 (#4750)
23bfc2d9ab [sim] Remove unmaintained Gazebo support (#4736)
1f1461e254 [wpilib] Add method to enable/disable LiveWindow in test mode (#4678)
eae68fc165 [wpimath] Add tolerance for Rotation3d rotation matrix special orthogonality (#4744)
4c4545fb4b [apriltag] Suppress warning (#4743)
16ffaa754d [docs] Generate docs for apriltag subproject (#4745)
5e74ff26d8 [apriltag, build] Update native utils, add apriltag impl and JNI (#4733)
53875419a1 [hal] Allow overriding stderr printing by HAL_SendError (#4742)
aa6499e920 [ntcore] Fix special topic multi-subscriber handling (#4740)
df70351107 [build] Fix cmake install of thirdparty includes (#4741)
e9bd50ff9b [glass] NT view: clear meta-topic info on disconnect (#4732)
9b319fd56b [ntcore] Add sub option for local vs remote changes (#4731)
18d28ec5e3 [ntcore] Remove duplicate value checking from ClientImpl
bdfb625211 [ntcore] Send duplicate values to network if necessary
21003e34eb [commands] Update Subsystem factories and example to return CommandBase (#4729)
70080457d5 [commands] Refactor ProxyScheduleCommand, SelectCommand into ProxyCommand (#4534)
e82cd5147b [wpilib] Tweak Color HSV formula and use in AddressableLED (#4724)
ec124bb662 [commands] Allow unsetting a subsystem's default command (#4621)
2b2aa8eef7 [examples] Update all examples to use NWU coordinate conventions (#4725)
cb38bacfe8 [commands] Revert to original Trigger implementation (#4673)
15561338d5 [commands] Remove one more default command isFinished check (#4727)
ca35a2e097 Add simgui files to .gitignore (#4726)
20dbae0cee [examples] Renovate command-based examples (#4409)
1a59737f40 [commands] Add convenience factories (#4460)
42b6d4e3f7 Use defaulted comparison operators in C++ (#4723)
135c13958f [wpigui] Add FontAwesome (#4713)
ffbfc61532 [ntcore] Add NetworkTable table-specific listeners (#4640)
8958b2a4da [commands] Add property tests for command compositions (#4715)
e4ac09077c [wpilib] Add link to MotorSafety article (#4720)
f40de0c120 [commands] Add C++ factory templates (#4686)
51fa3e851f [build] cmake: Use FetchContent instead of ExternalProject (#4714)
1da84b2255 [wpigui] Reload fonts to scale rather than preloading (#4712)
e43e2fbc84 [wpiutil] StringExtras: Add UnescapeCString (#4707)
5804d8fa84 [ntcore] Server: Properly handle multiple subscribers (#4717)
169ef5fabf [glass] Update NT view for topicsOnly and sendAll changes (#4718)
148759ef54 [examples] CANPDP: Expand properties shown (#4687)
58ed112b51 [commands] RepeatCommand: restart on following iteration (#4706)
dd1da77d20 [readme] Fix broken CI badge (#4710)
7cda85df20 [build] Check Gradle plugin repo last to fix CI (#4711)
7ed9b13277 [build] Bump version plugin to fix null tag (#4705)
6b4f26225d [apriltag] Fix pluralization of apriltag artifacts (#4671)
b2d2924b72 [cscore] Add Y16 image support (#4702)
34ec89c041 [wpilibc] Shuffleboard SimpleWidget: Return pointer instead of reference (#4703)
e15200068d [ci] Disable HW testbench runs (#4704)
d5200db6cd [wpimath] Rename HolonomicDriveController.calculate params (#4683)
2ee3d86de4 [wpimath] Clarify Rotation3d roll-pitch-yaw direction (#4699)
9f0a8b930f [cscore] Use MFVideoFormat_L8 for Gray on Windows (#4701)
2bca43779e [cscore] Add UYVY image support (#4700)
4307d0ee8b [glass] Plot: allow for more than 11 plots (#4685)
3fe8d355a1 [examples] StateSpaceDifferentialDriveSimulation: Use encoder reversed constants (#4682)
b44034dadc [ntcore] Allow duplicate client IDs on server (#4676)
52d2c53888 [commands] Rename Java factory wait() to waitSeconds() (#4684)
76e918f71e [build] Fix JNI artifacts linking to incorrect libraries (#4680)
0bee875aff [commands] Change C++ CommandPtr to use CommandBase (#4677)
98e922313b [glass] Don't check IsConnected for NT widgets (#4674)
9a36373b8f [apriltag] Switch 2022 apriltag layout length and width values (#4670)
cf8faa9e67 [wpilib] Update values on controllable sendables (#4667)
5ec067c1f8 [ntcore] Implement keep duplicates pub/sub flag (#4666)
e962fd2916 [ntcore] Allow numeric-compatible value sets (#4620)
88bd67e7de [ci] Update clang repositories to jammy (#4665)
902e8686d3 [wpimath] Rework odometry APIs to improve feature parity (#4645)
e2d49181da Update to native utils 2023.8.0 (#4664)
149bac55b1 [cscore] Add Arducam OV9281 exposure quirk (#4663)
88f7a3ccb9 [wpimath] Fix Pose relativeTo documentation (#4661)
8acce443f0 [examples] Fix swerve examples to use getDistance for turning encoder (#4652)
295a1f8f3b [ntcore] Fix WaitForListenerQueue (#4662)
388e7a4265 [ntcore] Provide mechanism to reset internals of NT instance (#4653)
13aceea8dc [apriltag] Fix FieldDimensions argument order (#4659)
c203f3f0a9 [apriltag] Fix documentation for AprilTagFieldLayout (#4657)
f54d495c90 Fix non initialized hal functionality during motor safety init (#4658)
e6392a1570 [cmd] Change factories return type to CommandBase (#4655)
53904e7cf4 [apriltag] Split AprilTag functionality to a separate library (#4578)
2e88a496c2 [wpimath] Add support for swerve joystick normalization (#4516)
ce4c45df13 [wpimath] Rework function signatures for Pose Estimation / Odometry (#4642)
0401597d3b [readme] Add wpinet to MavenArtifacts.md (#4651)
2e5f9e45bb [wpimath] Remove encoder reset comments on Swerve, Mecanum Odometry and Pose Estimation (#4643)
e4b5795fc7 [docs] Disable Doxygen for memory to fix search (#4636)
03d0ea188c [build] cmake: Add missing wpinet to installed config file (#4637)
3082bd236b [build] Move version file to its own source set (#4638)
b7ca860417 [build] Use build cache for sign step (#4635)
64838e6367 [commands] Remove unsafe default command isFinished check (#4411)
1269d2b901 [myRobot] Disable spotbugs (#4565)
14d8506b72 [wpimath] Fix units docs for LinearSystemId::IdentifyDrivetrainSystem() (#4600)
d1d458db2b [wpimath] Constrain Rotation2d range to -pi to pi (#4611)
f656e99245 [readme] Add links to development build documentation (#4481)
6dd937cef7 [commands] Fix Trigger API docs (NFC) (#4599)
49047c85b9 [commands] Report error on C++ CommandPtr use-after-move (#4575)
d07267fed1 [ci] Upgrade containers to Ubuntu 22.04 and remove libclang installation (#4633)
b53ce1d3f0 [build, wpiutil] Switch macos to universal binaries (#4628)
5a320c326b [upstream_util, wpiutil] Refactor python scripts (#4614)
c4e526d315 [glass] Fix NT Mechanism2D (#4626)
d122e4254f [ci] Run spotlessApply after wpiformat in comment command (#4623)
5a1e7ea036 [wpilibj] FieldObject2d: Add null check to close() (#4619)
179f569113 [ntcore] Notify locally on SetDefault (#4617)
b0f6dc199d [wpilibc] ShuffleboardComponent.WithProperties: Update type (#4615)
7836f661cd [wpimath] Add missing open curly brace to units/base.h (#4613)
dbcc1de37f [wpimath] Add DifferentialDriveFeedforward classes which wrap LinearPlantInversionFeedforward (#4598)
93890c528b [wpimath] Add additional angular acceleration units (#4610)
3d8d5936f9 [wpimath] Add macro for disabling units fmt support (#4609)
2b04159dec [wpimath] Update units/base.h license header (#4608)
2764004fad [wpinet] Fix incorrect jni definitions (#4605)
85f1bb8f2b [wpiutil] Reenable jni check task (#4606)
231ae2c353 [glass] Plot: Fix Y-axis not being saved (#4594)
e92b6dd5f9 [wpilib] Fix AprilTagFieldLayout JSON property name typos (#4597)
2a8e0e1cc8 Update all dependencies that use grgit (#4596)
7d06e517e9 [commands] Move SelectCommand factory impl to header (#4581)
323524fed6 [wpimath] Remove deprecated units/units.h header (#4572)
d426873ed1 [commands] Add missing PS4 triangle methods (#4576)
5be5869b2f [apriltags] Use map as internal data model (#4577)
b1b4c1e9e7 [wpimath] Fix Pose3d transformBy rotation type (#4545)
a4054d702f [commands] Allow composing two triggers directly (#4580)
0190301e09 [wpilibc] Explicitly mark EventLoop as non-copyable/non-movable (#4579)
9d1ce6a6d9 [ntcore] Catch file open error when saving preferences (#4571)
5005e2ca04 [ntcore] Change Java event mask to EnumSet (#4564)
fa44a07938 [upstream-utils][mpack] Add upstream util for mpack (#4500)
4ba16db645 [ntcore] Various fixes and cleanups (#4544)
837415abfd [hal] Fix joysticks either crashing or returning 0 (#4570)
2c20fd0d09 [wpilib] SingleJointedArmSim: Check angle equals limit on wouldHit (#4567)
64a7136e08 [wpimath] SwerveDrivePoseEstimator: Restore comment about encoder reset (#4569)
b2b473b24a [wpilib] Add AprilTag and AprilTagFieldLayout (#4421)
7aab8fa93a [build] Update to Native Utils 2023.6.0 (#4563)
12c2851856 [commands] WrapperCommand: inherit from CommandBase (#4561)
0da169dd84 [wpimath] Remove template argument from ElevatorFeedforward (#4554)
2416827c25 [wpimath] Fix docs for pose estimator local measurement models (#4558)
1177a3522e [wpilib] Fix Xbox/PS4 POV sim for port number constructors (#4548)
102344e27a [commands] HID classes: Add missing methods, tweak return types (#4557)
1831ef3e19 [wpilib] Fix Shuffleboard SuppliedValueWidget (#4559)
a9606ce870 [wpilib] Fix Xbox/PS4 POV sim (#4546)
6c80d5eab3 [wpimath] Remove unused SymbolExports.h include from units/base.h (#4541)
b114006543 [ntcore] Unify listeners (#4536)
32fbfb7da6 [build] cmake: Install ntcore generated include files (#4540)
02465920fb [build] Update native utils to 2023.4.0 (#4539)
3a5a376465 [wpimath] Increase constexpr support in geometry data types (#4231)
1c3c86e9f1 [ntcore] Cache GetEntry(name) values (#4531)
dcda09f90a [command] Rename trigger methods (#4210)
66157397c1 [wpilib] Make drive classes follow NWU axes convention (#4079)
9e22ffbebf [ntcore] Fix null deref in NT3 client (#4530)
648ab6115c [wpigui,dlt,glass,ov] Support arm in GUI tools (#4527)
8bc3b04f5b [wpimath] Make ComputerVisionUtil use 3D geometry classes (#4528)
cfb84a6083 [wpilibc] Don't hang waiting for NT server to start (#4524)
02c47726e1 [wpimath] Remove unused odometry instance from DifferentialDrivePoseEstimator test (#4522)
b2a0093294 [ci] Revert upgrade of github-pages-deploy-action (#4521)
2a98d6b5d7 [wpimath] PIDController: Add getters for position & velocity tolerances (#4458)
9f36301dc8 [ci] Write wpiformat patch to job summary (#4519)
901fc555f4 [wpimath] Position Delta Odometry for Mecanum (#4514)
4170ec6107 [wpimath] Position Delta Odometry for Swerve (#4493)
fe400f68c5 [docs] Add wpinet to docs build (#4517)
794669b346 [ntcore] Revamp listeners (#4511)
dcfa85a5d5 [ci] Build sanitizers with clang-14 (#4518)
15ad855f1d [ntcore] Add UnitTopic<T> (C++ only) (#4497)
11244a49d9 [wpilib] Add IsConnected function to all gyros (#4465)
1d2e8eb153 [build] Update myRobot deployment (#4515)
ad53fb19b4 [hal] Use new HMB api for addressable LED (#4479)
ba850bac3b [hal] Add more shutdown checks and motor safety shutdown (#4510)
023a5989f8 [ntcore] Fix typo in NetworkServer client connect message (#4512)
c970011ccc [docs] Add Doxygen aliases used by Foonathan memory (#4509)
07a43c3d9a [readme] Document clang-format version and /wpiformat (#4503)
a05b212b04 [ci] Revert changes to wpiformat task from #4501 (#4508)
09faf31b67 [commands] Replace Command HID inheritance with delegation (#4470)
9e1f9c1133 [commands] Add command factories (#4476)
f19d2b9b84 [ci] Add NUMBER environment variable to comment command commit script (#4507)
a28f93863c [ci] Push comment command commit directly to PR (#4506)
c9f61669b8 [ci] Fix comment command commit push (#4505)
dcce5ad3b3 [ci] Update github-script API usage (#4504)
6836e5923d [wpilibc] Restore get duty cycle scale factor (#4502)
335188c652 [dlt] Add deselect/select all buttons to download view (#4499)
60a29dcb99 [glass] Field2D: Add "hidden" option for objects (#4498)
b55d5b3034 [ci] Update deprecated github actions (#4501)
10ed4b3969 [ntcore] Various NT4 fixes (#4474)
4a401b89d7 [hal, wpilib] New DS thread model and implementation (#3787)
c195b4fc46 [wpimath] Clean up PoseEstimator nominal dt docs (#4496)
8f2e34c6a3 [build] Remove wpilib prefix from CMake flat install (#4492)
150d692df7 [wpimath] Remove unused private PoseEstimator function (#4495)
3e5bfff1b5 [wpimath] FromFieldRelativeSpeeds: Add ChassisSpeeds overload (#4494)
9c7e66a27d [commands] C++: Add CommandPtr overload for SetDefaultCommand (#4488)
0ca274866b [build] Fix CMake system library opt-ins (#4487)
dc037f8d41 [commands] Remove EndlessCommand (#4483)
16cdc741cf [wpimath] Add Pose3d(Pose2d) constructor (#4485)
9d5055176d [build] cmake: Allow disabling ntcore build (#4486)
d1e66e1296 [build] Compile all java code with inline string concatenation (#4490)
1fc098e696 Enable log macros to work with no args (#4475)
878cc8defb [wpilib] LiveWindow: Add enableAllTelemetry() (#4480)
8153911160 [build] Fix MSVC runtime archiver to grab default runtime (#4478)
fbdc810887 Upgrade to C++20 (#4239)
396143004c [ntcore] Add ntcoreffi binary (#4471)
1f45732700 [build] Update to 2023.2.4 native-utils and new dependencies (#4473)
574cb41c18 [ntcore] Various fixes (#4469)
d9d6c425e7 [build] Force Java 11 source compatibility (#4472)
58b6484dbe Switch away from NI interrupt manager to custom implementation (#3705)
ca43fe2798 [wpimath] Use Units conversions in ComputerVisionUtil docs (NFC) (#4464)
87a64ccedc [hal] Convert DutyCycle Raw output to be a high time measurement (#4466)
89a3d00297 [commands] Add FinallyDo and HandleInterrupt decorators (#4412)
1497665f96 [commands] Add C++ versions of Java-only decorators (#4457)
27b173374e [wpimath] Add minLinearAccel parameter to DifferentialDriveAccelerationLimiter (#4422)
2a13dba8ac [wpilib] TrajectoryUtil: Fix ambiguous documentation (NFC) (#4461)
77301b126c [ntcore] NetworkTables 4 (#3217)
90cfa00115 [build] cmake: Fix libssh include directory order (#4459)
5cf961edb9 [commands] Refactor lambda-based commands to inherit FunctionalCommand (#4451)
b2276e47de [wpimath] Enable continuous angle input for HolonomicDriveController (#4453)
893b46139a [fieldImages] Add utilities to simplify loading of fields (#4456)
60e29627c0 [commands] C++ unique_ptr migration (#4319)
3b81cf6c35 [wpilib] Improve Color.toString (#4450)
5c067d30a0 [wpinet] WebSocket: Add SendFrames() (#4445)
ceaf493811 [wpiutil] MakeJByteArray: Use span<uint8> instead of string_view (#4446)
10e04e2b13 [examples] FrisbeeBot: Fix reference capture (#4449)
726f67c64b [build] Add exeSplitSetup (#4444)
c7b7624c1c [wpiutil] Add MessagePack utility functions (#4448)
d600529ec0 [wpinet] uv::Async: Add UnsafeSend() (#4447)
b53b3526a2 [wpimath] Add CoordinateSystem conversion for Transform3d (#4443)
38bb23eb18 [wpimath] Add scalar multiply and divide operators to all geometry classes (#4438)
3937ff8221 [wpilib] Remove deprecated Controller class (#4440)
abbfe244b5 [wpilib] Improve Color FromHSV (#4439)
4ddb8aa0dd [sim] Provide function that resets all simulation data (#4016)
a791470de7 Clean up Java warning suppressions (#4433)
17f504f548 [hal,wpilib] Fix SPI Mode Setting (#4434)
773198537c [wpiutil] Add wpi::scope_exit (#4432)
5ac658c8f0 [wpiutil] Logger: Conditionalize around WPI_LOG (#4431)
8767e4a941 [wpiutil] DataLog: Fix SetMetadata output (#4430)
8c4af073f4 [wpiutil] Synchronization: shutdown race protection (#4429)
c79f38584a [build] Fix Java integration tests (#4428)
36c08dd97c [build] Fix cmake install of fmtlib (#4426)
69b7b3dd7d [ci] Remove the Windows cmake job (#4425)
738c75fed8 [readme] Fix formatting/linting link (#4423)
4eb1d03fb3 [wpimath] Document C++ LinearFilter exception (#4417)
ba4ec6c967 [build] Fix clang-tidy false positive on Linux (#4406)
97836f0e55 [commands] Fix ProfiledPIDSubsystem setGoal behavior (#4414)
fdfb85f695 [wpimath] Remove Java LQR constructor that takes a controller gain matrix (#4419)
ab1baf4832 [wpimath] Add rotation matrix constructor to Rotation3d (#4413)
9730032866 [wpimath] Document LQR and KalmanFilter exceptions (#4418)
5b656eecf6 [wpimath] Fix HTML5 entity (#4420)
9ae38eaa7c [commands] Add owning overload to ProxyScheduleCommand (#4405)
cb33bd71df [commands] deprecate withInterrupt decorator (#4407)
d9b4e7b8bf [commands] Revert "Change grouping decorator impl to flatten nested group structures (#3335)" (#4402)
0389bf5214 [hal] REVPH: Improve handling of disconnected CAN Bus (#4169)
4267fa08d1 [wpilibc] ADIS IMUs: Fix memory leak (#4170)
65c8fbd452 [wpilib] MotorControllerGroup: Override setVoltage (#4403)
f36162fddc [wpimath] Improve Discretization internal docs (#4400)
5149f7d894 [wpimath] Add two-vector Rotation3d constructor (#4398)
20b5bed1cb [wpimath] Clean up Java Quaternion class (#4399)
f18dd1905d [build] Include all thirdparty sources in distribution (#4397)
aa9d7f1cdc [wpiutil] Import foonathan memory (#4306)
2742662254 [ci] Remove a couple of obsolete clang-tidy checks (#4396)
a5df391166 [hal, wpilib] Fix up DIO pulse API (#4387)
59e6706b75 [glass] Turn on docking by default
8461bb1e03 [glass] Add support for saving docking info
b873e208b4 [wpigui] Add support for imgui config flags
873e72df8c [build] Update imgui to 1.88 docking branch
c8bd6fc5b4 [ci] Fix comment-command (take 2) (#4395)
fed68b83b4 [ci] Fix comment-command action not running runners (#4393)
0ef8a4e1df [wpimath] Support formatting more Eigen types (#4391)
c393b3b367 [build] Update to native utils 2023.1.0 and Gradle 7.5.1 (#4392)
b5a17f762c [wpimath] Add direction to slew rate limiter (#4377)
fafc81ed1a [wpiutil] Upgrade to fmt 9.1.0 (#4389)
cc56bdc787 [wpiutil] SafeThread: Add Synchronization object variant (#4382)
4254438d8d [commands] Mark command group lifecycle methods as final (#4385)
97c15af238 [wpimath] LinearSystemId: Fix docs, move C++ impls out of header (#4388)
d22ff8a158 [wpiutil] Add JNI access to C++ stderr (#4381)
fdb5a2791f [wpiutil] jni_util: Add Mac-friendly MakeJLongArray/JArrayRef (#4383)
c3a93fb995 [commands] Revamp Interruptible (#4192)
f2a8d38d2a [commands] Rename Command.repeat to repeatedly (#4379)
9e24c6eac0 [wpiutil] Logger: paren-protect instance usage in macro (#4384)
fe4d12ce22 [wpimath] Add LTV controller derivations and make enums private (#4380)
eb08486039 [build] Fix MacOS binary rpath generation (#4376)
ccf83c634a [build] Use native-utils platform names instead of raw strings (#4375)
3fd69749e7 [docs] Upgrade to doxygen 1.9.4 (#4370)
594df5fc08 [wpinet] uv/util.h: Pull in ws2_32.lib on Windows for ntohs (#4371)
539070820d [ci] Enable asan for wpinet and wpiutil (#4369)
564a56d99b [wpinet] Fix memory leak in WorkerThreadTest (#4368)
5adf50d93c [upstream_utils] Refactor upstream_utils scripts (#4367)
d80e8039d7 [wpiutil] Suppress fmtlib clang-tidy warning in C++20 consteval contexts (#4364)
0e6d67b23b [upstream_utils] Remove yapf format disable comment (#4366)
be5270697a [build] Suppress enum-enum deprecation warning in OpenCV (#4365)
8d28851263 Add Rosetta install command to build requirements (#4363)
3d2115c93e [wpinet] include-what-you-use in MulticastTest (#4360)
91002ae3cc [wpimath] Upgrade to Drake 1.6.0 (#4361)
148c18e658 [wpinet] Upgrade to libuv 1.44.2 (#4362)
a2a5c926b6 Fix clang-tidy warnings (#4359)
ea6b1d8449 [wpiutil] Remove unused ManagedStatic class (#4358)
ac9be78e27 Use stricter C++ type conversions (#4357)
151dabb2af [wpiutil] Upgrade to fmt 9.0.0 (#4337)
340465c929 [ci] Upgrade to clang-format and clang-tidy 14 (NFC) (#4347)
d45bcddd15 [examples] Add comments to StateSpaceDifferentialDrive (#4341)
0e0786331a Update LLVM libraries to 14.0.6 (#4350)
c5db23f296 [wpimath] Add Eigen sparse matrix and iterative solver support (#4349)
44abc8dfa6 [upstream_utils] Remove git version from upstream patches (#4351)
3fdb2f767d [wpimath] Add comments with Ramsete equations (#4348)
0485f05da9 [wpilibjExamples] Upgrade jacoco to match allwpilib (#4346)
0a5eb65231 [wpinet] Handle empty txt block for mdns announcer (#4072)
19ffebaf3e [wpilib] Add reference to I2C Lockup to API Docs (NFC) (#4340)
ce1a90d639 [hal] Replace SerialHelper "goto done" with continue (#4342)
d25af48797 [ci] Make upstream_utils CI fail on untracked files (#4339)
ebb836dacb [examples] Fix negations in event loop examples (#4334)
d83e202f00 [upstream_utils] Update paths in update_fmt.py (#4338)
3ccf806064 [wpimath] Remove redundant LinearFilter.finiteDifference() argument (#4335)
6f1e01f8bd [wpimath] Document example of online filtering for LinearFilter.finiteDifference() (#4336)
1023c34b1c [readme] Update location of ni-libraries (#4333)
faa29d596c [wpilib] Improve Notifier docs (NFC) (#4326)
add00a96ed [wpimath] Improve DifferentialDriveAccelerationLimiter docs (NFC) (#4323)
82fac41244 [wpimath] Better document trackwidth parameters (NFC) (#4324)
5eb44e22a9 Format Python scripts with black (NFC) (#4325)
2e09fa7325 [build] Fix mpack cmake (#4322)
fe3c24b1ee [command] Add ignoringDisable decorator (#4305)
aa221597bc [build] Add M1 builds, change arm name, update to 2023 deps (#4315)
579a8ee229 [ci] Use one worker for Windows release Gradle build (#4318)
5105c5eab6 [wpilibj] Change "final" to "exit" in the IterativeRobotBase JavaDoc (NFC) (#4317)
787fe6e7a5 [wpiutil] Separate third party libraries (#4190)
6671f8d099 [wpigui] Update portable file dialogs (#4316)
9ac9b69aa2 [command] Reorder Scheduler operations (#4261)
e61028cb18 [build] halsim_gui: Add wpinet dependency (#4313)
661d23eaf5 [glass] Add precision setting for NetworkTable view (#4311)
666040e3e5 [hal] Throw exceptions for invalid sizes in I2C and SPI JNI (#4312)
aebc272449 [build] Upgrade to spotbugs Gradle plugin 5.0.8 (#4310)
fd884581e4 [wpilib] Add BooleanEvent/Trigger factories on HID classes (#4247)
9b1bf5c7f1 [wpimath] Move Drake and Eigen to thirdparty folders (#4307)
c9e620a920 [wpilibc] Change EventLoop data structure to vector (#4304)
41d40dd62f [wpinet] Fix libuv unused variable warning on Mac (#4299)
30f5b68264 [wpinet] Fix JNI loading error (#4295)
f7b3f4b90e [examples] Getting Started: Change Joystick to XboxController (#4194)
a99c11c14c [wpimath] Replace UKF implementation with square root form (#4168)
45b7fc445b [wpilib] Add EventLoop (#4104)
16a4888c52 [wpilib] Default off LiveWindow telemetry (#4301)
17752f1337 [ci] Split debug and release Windows builds (#4277)
abb45a68db [commands] Remove custom test wrappers (#4296)
1280a54ef3 [upstream_utils]: Make work with Python 3.8 (#4298)
f2d243fa68 [build] Change defaults for Java lints (#4300)
a4787130f4 Update using development build to work with 2023 gradlerio (#4294)
af7985e46c [wpiutil] Use invoke_result_t instead of result_of in future.h (#4293)
e9d1b5c2d0 [hal] Remove deprecated SimDevice functions (#4209)
45b598d236 [wpilibj] Add toString() methods to Color and Color8Bit (#4286)
fc37265da5 [wpimath] Add angle measurement convention to ArmFeedforward docs (NFC) (#4285)
a4ec13eb0e [wpilibjexamples] Remove unnecessary voltage desaturation
2fa52007af [wpilibc] Use GetBatteryVoltage() in MotorController::SetVoltage
d9f9cd1140 [wpimath] Reset prev_time on pose estimator reset (#4283)
8b6df88783 [wpilibj] Tachometer.getFrequency(): Fix bug (#4281)
345cff08c0 [wpiutil] Make wpi::array constexpr (#4278)
57428112ac [wpimath] Upgrade to Drake v1.3.0 (#4279)
a18d4ff154 [build] Fix tools not being copied when built with -Ponly* (#4276)
d1cd07b9f3 [wpigui] Add OpenURL (#4273)
e67f8e917a [glass] Use glfwSetKeyCallback for Enter key remap (#4275)
be2fedfe50 [wpimath] Add stdexcept include for std::invalid_argument (IWYU) (#4274)
7ad2be172e [build] Update native-utils to 2023.0.1 (#4272)
abc605c9c9 [ci] Update workflows to 20.04 base image (#4271)
3e94805220 [wpiutil] Reduce llvm collections patches (#4268)
db2e1d170e [upstream_utils] Document how to update thirdparty libraries (#4253)
96ebdcaf16 [wpimath] Remove unused Eigen AutoDiff module (#4267)
553b2a3b12 [upstream_utils] Fix stackwalker (#4265)
3e13ef42eb [wpilibc] Add missing std::array #include (include-what-you-use) (#4266)
d651a1fcec Fix internal deprecation warnings (#4257)
b193b318c1 [commands] Add unless() decorator (#4244)
ef3714223b [commands] Remove docs reference to obsolete interrupted() method (NFC) (#4262)
3d8dbbbac3 [readme] Add quickstart (#4225)
013efdde25 [wpinet] Wrap a number of newer libuv features (#4260)
816aa4e465 [wpilib] Add Pneumatics sim classes (#4033)
046c2c8972 [wpilibc] Rename SpeedControllerGroupTest.cpp (#4258)
d80e9cdf64 [upstream_utils] Use shallow clones for thirdparty repos (#4255)
7576136b4a [upstream_utils] Make update_llvm.py executable (#4254)
c3b223ce60 [wpiutil] Vendor llvm and update to 13.0.0 (#4224)
5aa67f56e6 [wpimath] Clean up math comments (#4252)
fff4d1f44e [wpimath] Extend Eigen warning suppression to GCC 12 (#4251)
0d9956273c [wpimath] Add CoordinateSystem.convert() translation and rotation overloads (#4227)
3fada4e0b4 [wpinet] Update to libuv 1.44.1 (#4232)
65b23ac45e [wpilibc] Fix return value of DriverStation::GetJoystickAxisType() (#4230)
4ac34c0141 [upstream_utils] Cleanup update_libuv.py (#4249)
8bd614bb1e [upstream_utils] Use "git am" instead of "git apply" for patches (#4248)
4253d6d5f0 [upstream_utils] Apply "git am" patches individually (#4250)
6a4752dcdc Fix GCC 12.1 warning false positives (#4246)
5876b40f08 [wpimath] Memoize CoordinateSystem and CoordinateAxis statics (#4241)
5983434a70 [cameraserver] Replace IterativeRobot in comment sample code with TimedRobot (#4238)
a3d44a1e69 [wpimath] Add Translation2d.getAngle() (#4217)
d364bbd5a7 [upstream_utils] Give vendor update scripts execute permissions (#4226)
f341e1b2be [wpimath] Document standard coordinate systems better (NFC) (#4228)
9af389b200 [wpinet] AddrToName: Initialize name (#4229)
2ae4adf2d7 [ci] Add wpiformat command to PRs (#4223)
178b2a1e88 Contributing.md: Correct version of clang-format used (#4222)
18db343cdc [wpiutil, wpinet] Vendor libuv, stack walker (#4219)
f0c821282a [build] Use artifactory mirror (#4220)
d673ead481 [wpinet] Move network portions of wpiutil into new wpinet library (#4077)
b33715db15 [wpimath] Add CoordinateSystem class (#4214)
99424ad562 [sim] Allow creating a PWMSim object from a PWMMotorController (#4039)
dc6f641fd2 [wpimath] PIDController: Reset position and velocity error when reset() is called. (#4064)
f20a20f3f1 [wpimath] Add 3D geometry classes (#4175)
708a4bc3bc [wpimath] Conserve previously calculated swerve module angles when updating states for stationary ChassisSpeeds (#4208)
ef7ed21a9d [wpimath] Improve accuracy of ComputerVisionUtil.calculateDistanceToTarget() (#4215)
b1abf455c1 [wpimath] LTVUnicycleController: Use LUT, provide default hyperparameters (#4213)
d5456cf278 [wpimath] LTVDifferentialDriveController: Remove unused variable (#4212)
99343d40ba [command] Remove old command-based framework (#4211)
ee03a7ad3b Remove most 2022 deprecations (#4205)
ce1a7d698a [wpimath] Refactor WheelVoltages inner class to a separate file (#4203)
87bf70fa8e [wpimath] Add LTV controllers (#4094)
ebd2a303bf [wpimath] Remove deprecated MakeMatrix() function (#4202)
e28776d361 [wpimath] LinearSystemLoop: Add extern templates for common cases
dac1429aa9 [wpimath] LQR: Use extern template instead of Impl class
e767605e94 [wpimath] Add typedefs for common types
97c493241f [wpimath] UnscentedKalmanFilter: Move implementation out-of-line
8ea90d8bc9 [wpimath] ExtendedKalmanFilter: Move implementation out-of-line
ae7b1851ec [wpimath] KalmanFilter: Use extern template instead of Impl class
e3d62c22d3 [wpimath] Add extern templates for common cases
7200c4951d [wpiutil] SymbolExports: Add WPILIB_IMPORTS for dllimport
84056c9347 [wpiutil] SymbolExports: Add EXPORT_TEMPLATE_DECLARE/DEFINE
09cf6eeecb [wpimath] ApplyDeadband: add a scale param (#3865)
03230fc842 [build,ci] Enable artifactory build cache (#4200)
63cf3aaa3f [examples] Don't square ArcadeDrive inputs in auto (#4201)
18ff694f02 [wpimath] Add Rotation2d.fromRadians factory (#4178)
4f79ceedd9 [wpilibc] Add missing #include (#4198)
f7ca72fb41 [command] Rename PerpetualCommand to EndlessCommand (#4177)
a06b3f0307 [hal] Correct documentation on updateNotifierAlarm (#4156)
d926dd1610 [wpimath] Fix pose estimator performance (#4111)
51bc893bc5 [wpiutil] CircularBuffer: Change Java package-private methods to public (#4181)
fbe761f7f6 [build] Increase Gradle JVM heap size (#4172)
5ebe911933 [wpimath] Add DifferentialDriveAccelerationLimiter (#4091)
3919250da2 [wpilibj] Remove finalizers (#4158)
b3aee28388 [commands] Allow BooleanSupplier for Trigger operations (#4103)
9d20ab3024 [wpilib] Allow disabling ElevatorSim gravity (#4145)
aaa69f6717 [ci] Remove 32-bit Windows builds (#4078)
355a11a414 Update Java linters and fix new PMD errors (#4157)
ffc69d406c [examples] Reduce suggested acceleration in Ramsete example (#4171)
922d50079a [wpimath] Units: fix comment in degreesToRotations (NFC) (#4159)
dd163b62ae [wpimath] Rotation2d: Add factory method that uses rotations (#4166)
bd80e220b9 [ci] Upgrade CMake actions (#4161)
aef4b16d4c [wpimath] Remove unnecessary NOLINT in LinearPlantInversionFeedforward (NFC) (#4155)
975171609e [wpilib] Compressor: Rename enabled to isEnabled (#4147)
5bf46a9093 [wpimath] Add ComputerVisionUtil (#4124)
f27a1f9bfb [commands] Fix JoystickButton.getAsBoolean (#4131)
1b26e2d5da [commands] Add RepeatCommand (#4009)
88222daa3d [hal] Fix misspelling in AnalogInput/Output docs (NFC) (#4153)
81c5b41ce1 [wpilibj] Document MechanismLigament2d angle unit (NFC) (#4142)
9650e6733e [wpiutil] DataLog: Document finish and thread safety (NFC) (#4140)
c8905ec29a [wpimath] Remove ImplicitModelFollower dt argument (#4119)
b4620f01f9 [wpimath] Fix Rotation2d interpolation in Java (#4125)
2e462a19d3 [wpimath] Constexprify units unary operators (#4138)
069f932e59 [build] Fix gl3w cmake build (#4139)
126e3de91a [wpilibc] Remove unused SetPriority() call from Ultrasonic (#4123)
ba0dccaae4 [wpimath] Fix reference to Rotation2d.fromRadians() (#4118)
e1b6e5f212 [wpilib] Improve MotorSafety documentation (NFC) (#4120)
8d79dc8738 [wpimath] Add ImplicitModelFollower (#4056)
78108c2aba [wpimath] Fix PIDController having incorrect error after calling SetSetpoint() (#4070)
cdafc723fb [examples] Remove unused LinearPlantInversionFeedforward includes (#4069)
0d70884dce [wpimath] Add InterpolatedTreeMap (#4073)
765efa325e [wpimath] Remove redundant column index from vectors (#4116)
89ffcbbe41 [wpimath] Update TrapezoidProfile class name in comment (NFC) (#4107)
95ae23b0e7 [wpimath] Improve EKF numerical stability (#4093)
d5cb6fed67 [wpimath] Support zero cost entries in MakeCostMatrix() (#4100)
d0fef18378 [wpimath] Remove redundant `this.` from ExtendedKalmanFilter.java (#4115)
d640c0f41f [wpimath] Fix pose estimator local measurement standard deviation docs (NFC) (#4113)
a2fa5e3ff7 [wpilibc] BatterySim: Provide non-initializer list versions of Calculate (#4076)
a3eea9958e [hal] Add link to FRC CAN Spec (NFC) (#4086)
db27331d7b [wpilib] Update DifferentialDrive docs (NFC) (#4085)
fdfb31f164 [dlt] Export boolean[] values (#4082)
f93c3331b3 [wpigui] disable changing directory when initializing on MacOS (#4092)
ab7ac4fbb9 [build] Fix various warnings in cmake builds (#4081)
bc39a1a293 [wpilibc] Fix moved pneumatics objects not destructing properly (#4068)
2668130e70 [wpimath] Remove SwerveDrivePoseEstimator encoder reset warning (#4066)
d27ed3722b [ci] Set actions workflow concurrency (#4060)
dae18308c9 [wpimath] Minor fixes to Rotation2d docs (NFC) (#4055)
d66555e42f [datalogtool] Add datalogtool
9f52d8a3b1 [wpilib] DriverStation: Add DataLog support for modes and joystick data
757ea91932 [wpilib] Add DataLogManager
02a804f1c5 [ntcore] Add DataLog support
9b500df0d9 [wpiutil] Add high speed data logging
5a89575b3a [wpiutil] Import customized LLVM MemoryBuffer
b8c4d7527b [wpiutil] Add MappedFileRegion
ac5d46cfa7 [wpilibc] Fix ProfiledPID SetTolerance default velocity value (#4054)
bc9e96e86f [wpilib] Absolute Encoder API and behavior fixes (#4052)
f88c435dd0 [hal] Add mechanism to cancel all periodic callbacks (#4049)

Change-Id: I49aa5b08abbefc7a045e99e19d48ce2cd8fc4d1b
git-subtree-dir: third_party/allwpilib
git-subtree-split: 83f1860047c86aa3330fcb41caf3b2047e074804
Signed-off-by: James Kuszmaul <jabukuszmaul+collab@gmail.com>
diff --git a/datalogtool/src/main/generate/WPILibVersion.cpp.in b/datalogtool/src/main/generate/WPILibVersion.cpp.in
new file mode 100644
index 0000000..b0a4490
--- /dev/null
+++ b/datalogtool/src/main/generate/WPILibVersion.cpp.in
@@ -0,0 +1,7 @@
+/*
+ * Autogenerated file! Do not manually edit this file. This version is regenerated
+ * any time the publish task is run, or when this file is deleted.
+ */
+const char* GetWPILibVersion() {
+  return "${wpilib_version}";
+}
diff --git a/datalogtool/src/main/native/cpp/App.cpp b/datalogtool/src/main/native/cpp/App.cpp
new file mode 100644
index 0000000..a4b466b
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/App.cpp
@@ -0,0 +1,156 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "App.h"
+
+#include <libssh/libssh.h>
+
+#include <memory>
+#include <string_view>
+
+#define IMGUI_DEFINE_MATH_OPERATORS
+
+#include <glass/Context.h>
+#include <glass/MainMenuBar.h>
+#include <glass/Storage.h>
+#include <imgui.h>
+#include <imgui_internal.h>
+#include <wpigui.h>
+
+#include "Downloader.h"
+#include "Exporter.h"
+
+namespace gui = wpi::gui;
+
+const char* GetWPILibVersion();
+
+namespace dlt {
+std::string_view GetResource_dlt_16_png();
+std::string_view GetResource_dlt_32_png();
+std::string_view GetResource_dlt_48_png();
+std::string_view GetResource_dlt_64_png();
+std::string_view GetResource_dlt_128_png();
+std::string_view GetResource_dlt_256_png();
+std::string_view GetResource_dlt_512_png();
+}  // namespace dlt
+
+bool gShutdown = false;
+
+static std::unique_ptr<Downloader> gDownloader;
+static bool* gDownloadVisible;
+static float gDefaultScale = 1.0;
+
+void SetNextWindowPos(const ImVec2& pos, ImGuiCond cond, const ImVec2& pivot) {
+  if ((cond & ImGuiCond_FirstUseEver) != 0) {
+    ImGui::SetNextWindowPos(pos * gDefaultScale, cond, pivot);
+  } else {
+    ImGui::SetNextWindowPos(pos, cond, pivot);
+  }
+}
+
+void SetNextWindowSize(const ImVec2& size, ImGuiCond cond) {
+  if ((cond & ImGuiCond_FirstUseEver) != 0) {
+    ImGui::SetNextWindowSize(size * gDefaultScale, cond);
+  } else {
+    ImGui::SetNextWindowPos(size, cond);
+  }
+}
+
+static void DisplayDownload() {
+  if (!*gDownloadVisible) {
+    return;
+  }
+  SetNextWindowPos(ImVec2{0, 250}, ImGuiCond_FirstUseEver);
+  SetNextWindowSize(ImVec2{375, 260}, ImGuiCond_FirstUseEver);
+  if (ImGui::Begin("Download", gDownloadVisible)) {
+    if (!gDownloader) {
+      gDownloader = std::make_unique<Downloader>(
+          glass::GetStorageRoot().GetChild("download"));
+    }
+    gDownloader->Display();
+  }
+  ImGui::End();
+}
+
+static void DisplayMainMenu() {
+  ImGui::BeginMainMenuBar();
+
+  static glass::MainMenuBar mainMenu;
+  mainMenu.WorkspaceMenu();
+  gui::EmitViewMenu();
+
+  if (ImGui::BeginMenu("Window")) {
+    ImGui::MenuItem("Download", nullptr, gDownloadVisible);
+    ImGui::EndMenu();
+  }
+
+  bool about = false;
+  if (ImGui::BeginMenu("Info")) {
+    if (ImGui::MenuItem("About")) {
+      about = true;
+    }
+    ImGui::EndMenu();
+  }
+
+  ImGui::EndMainMenuBar();
+
+  if (about) {
+    ImGui::OpenPopup("About");
+  }
+  if (ImGui::BeginPopupModal("About")) {
+    ImGui::Text("Datalog Tool");
+    ImGui::Separator();
+    ImGui::Text("v%s", GetWPILibVersion());
+    ImGui::Separator();
+    ImGui::Text("Save location: %s", glass::GetStorageDir().c_str());
+    if (ImGui::Button("Close")) {
+      ImGui::CloseCurrentPopup();
+    }
+    ImGui::EndPopup();
+  }
+}
+
+static void DisplayGui() {
+  DisplayMainMenu();
+  DisplayInputFiles();
+  DisplayEntries();
+  DisplayOutput(glass::GetStorageRoot().GetChild("output"));
+  DisplayDownload();
+}
+
+void Application(std::string_view saveDir) {
+  ssh_init();
+
+  gui::CreateContext();
+  glass::CreateContext();
+
+  // Add icons
+  gui::AddIcon(dlt::GetResource_dlt_16_png());
+  gui::AddIcon(dlt::GetResource_dlt_32_png());
+  gui::AddIcon(dlt::GetResource_dlt_48_png());
+  gui::AddIcon(dlt::GetResource_dlt_64_png());
+  gui::AddIcon(dlt::GetResource_dlt_128_png());
+  gui::AddIcon(dlt::GetResource_dlt_256_png());
+  gui::AddIcon(dlt::GetResource_dlt_512_png());
+
+  glass::SetStorageName("datalogtool");
+  glass::SetStorageDir(saveDir.empty() ? gui::GetPlatformSaveFileDir()
+                                       : saveDir);
+
+  gui::AddWindowScaler([](float scale) { gDefaultScale = scale; });
+  gui::AddLateExecute(DisplayGui);
+  gui::Initialize("Datalog Tool", 925, 510);
+
+  gDownloadVisible =
+      &glass::GetStorageRoot().GetChild("download").GetBool("visible", true);
+
+  gui::Main();
+
+  gShutdown = true;
+  glass::DestroyContext();
+  gui::DestroyContext();
+
+  gDownloader.reset();
+  ssh_finalize();
+}
diff --git a/datalogtool/src/main/native/cpp/App.h b/datalogtool/src/main/native/cpp/App.h
new file mode 100644
index 0000000..9d1520c
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/App.h
@@ -0,0 +1,11 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <imgui.h>
+
+void SetNextWindowPos(const ImVec2& pos, ImGuiCond cond = 0,
+                      const ImVec2& pivot = ImVec2(0, 0));
+void SetNextWindowSize(const ImVec2& size, ImGuiCond cond = 0);
diff --git a/datalogtool/src/main/native/cpp/DataLogThread.cpp b/datalogtool/src/main/native/cpp/DataLogThread.cpp
new file mode 100644
index 0000000..90c8b19
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/DataLogThread.cpp
@@ -0,0 +1,72 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "DataLogThread.h"
+
+#include <fmt/format.h>
+
+DataLogThread::~DataLogThread() {
+  if (m_thread.joinable()) {
+    m_active = false;
+    m_thread.join();
+  }
+}
+
+void DataLogThread::ReadMain() {
+  for (auto record : m_reader) {
+    if (!m_active) {
+      break;
+    }
+    ++m_numRecords;
+    if (record.IsStart()) {
+      wpi::log::StartRecordData data;
+      if (record.GetStartData(&data)) {
+        std::scoped_lock lock{m_mutex};
+        if (m_entries.find(data.entry) != m_entries.end()) {
+          fmt::print("...DUPLICATE entry ID, overriding\n");
+        }
+        m_entries[data.entry] = data;
+        m_entryNames.emplace(data.name, data);
+        sigEntryAdded(data);
+      } else {
+        fmt::print("Start(INVALID)\n");
+      }
+    } else if (record.IsFinish()) {
+      int entry;
+      if (record.GetFinishEntry(&entry)) {
+        std::scoped_lock lock{m_mutex};
+        auto it = m_entries.find(entry);
+        if (it == m_entries.end()) {
+          fmt::print("...ID not found\n");
+        } else {
+          m_entries.erase(it);
+        }
+      } else {
+        fmt::print("Finish(INVALID)\n");
+      }
+    } else if (record.IsSetMetadata()) {
+      wpi::log::MetadataRecordData data;
+      if (record.GetSetMetadataData(&data)) {
+        std::scoped_lock lock{m_mutex};
+        auto it = m_entries.find(data.entry);
+        if (it == m_entries.end()) {
+          fmt::print("...ID not found\n");
+        } else {
+          it->second.metadata = data.metadata;
+          auto nameIt = m_entryNames.find(it->second.name);
+          if (nameIt != m_entryNames.end()) {
+            nameIt->second.metadata = data.metadata;
+          }
+        }
+      } else {
+        fmt::print("SetMetadata(INVALID)\n");
+      }
+    } else if (record.IsControl()) {
+      fmt::print("Unrecognized control record\n");
+    }
+  }
+
+  sigDone();
+  m_done = true;
+}
diff --git a/datalogtool/src/main/native/cpp/DataLogThread.h b/datalogtool/src/main/native/cpp/DataLogThread.h
new file mode 100644
index 0000000..267aa1f
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/DataLogThread.h
@@ -0,0 +1,71 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <atomic>
+#include <functional>
+#include <map>
+#include <string>
+#include <string_view>
+#include <thread>
+#include <utility>
+
+#include <wpi/DataLogReader.h>
+#include <wpi/DenseMap.h>
+#include <wpi/Signal.h>
+#include <wpi/mutex.h>
+
+class DataLogThread {
+ public:
+  explicit DataLogThread(wpi::log::DataLogReader reader)
+      : m_reader{std::move(reader)}, m_thread{[=, this] { ReadMain(); }} {}
+  ~DataLogThread();
+
+  bool IsDone() const { return m_done; }
+  std::string_view GetBufferIdentifier() const {
+    return m_reader.GetBufferIdentifier();
+  }
+  unsigned int GetNumRecords() const { return m_numRecords; }
+  unsigned int GetNumEntries() const {
+    std::scoped_lock lock{m_mutex};
+    return m_entryNames.size();
+  }
+
+  // Passes wpi::log::StartRecordData to func
+  template <typename T>
+  void ForEachEntryName(T&& func) {
+    std::scoped_lock lock{m_mutex};
+    for (auto&& kv : m_entryNames) {
+      func(kv.second);
+    }
+  }
+
+  wpi::log::StartRecordData GetEntry(std::string_view name) const {
+    std::scoped_lock lock{m_mutex};
+    auto it = m_entryNames.find(name);
+    if (it == m_entryNames.end()) {
+      return {};
+    }
+    return it->second;
+  }
+
+  const wpi::log::DataLogReader& GetReader() const { return m_reader; }
+
+  // note: these are called on separate thread
+  wpi::sig::Signal_mt<const wpi::log::StartRecordData&> sigEntryAdded;
+  wpi::sig::Signal_mt<> sigDone;
+
+ private:
+  void ReadMain();
+
+  wpi::log::DataLogReader m_reader;
+  mutable wpi::mutex m_mutex;
+  std::atomic_bool m_active{true};
+  std::atomic_bool m_done{false};
+  std::atomic<unsigned int> m_numRecords{0};
+  std::map<std::string, wpi::log::StartRecordData, std::less<>> m_entryNames;
+  wpi::DenseMap<int, wpi::log::StartRecordData> m_entries;
+  std::thread m_thread;
+};
diff --git a/datalogtool/src/main/native/cpp/Downloader.cpp b/datalogtool/src/main/native/cpp/Downloader.cpp
new file mode 100644
index 0000000..3d19738
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/Downloader.cpp
@@ -0,0 +1,407 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "Downloader.h"
+
+#include <libssh/sftp.h>
+
+#ifdef _WIN32
+#include <fcntl.h>
+#include <io.h>
+#else
+#include <sys/fcntl.h>
+#endif
+
+#include <algorithm>
+#include <filesystem>
+
+#include <fmt/format.h>
+#include <glass/Storage.h>
+#include <imgui.h>
+#include <imgui_stdlib.h>
+#include <portable-file-dialogs.h>
+#include <wpi/StringExtras.h>
+#include <wpi/fs.h>
+
+#include "Sftp.h"
+
+Downloader::Downloader(glass::Storage& storage)
+    : m_serverTeam{storage.GetString("serverTeam")},
+      m_remoteDir{storage.GetString("remoteDir", "/home/lvuser")},
+      m_username{storage.GetString("username", "lvuser")},
+      m_localDir{storage.GetString("localDir")},
+      m_deleteAfter{storage.GetBool("deleteAfter", true)},
+      m_thread{[this] { ThreadMain(); }} {}
+
+Downloader::~Downloader() {
+  {
+    std::scoped_lock lock{m_mutex};
+    m_state = kExit;
+  }
+  m_cv.notify_all();
+  m_thread.join();
+}
+
+void Downloader::DisplayConnect() {
+  // IP or Team Number text box
+  ImGui::SetNextItemWidth(ImGui::GetFontSize() * 12);
+  ImGui::InputText("Team Number / Address", &m_serverTeam);
+
+  // Username/password
+  ImGui::SetNextItemWidth(ImGui::GetFontSize() * 12);
+  ImGui::InputText("Username", &m_username);
+  ImGui::SetNextItemWidth(ImGui::GetFontSize() * 12);
+  ImGui::InputText("Password", &m_password, ImGuiInputTextFlags_Password);
+
+  // Connect button
+  if (ImGui::Button("Connect")) {
+    m_state = kConnecting;
+    m_cv.notify_all();
+  }
+}
+
+void Downloader::DisplayDisconnectButton() {
+  if (ImGui::Button("Disconnect")) {
+    m_state = kDisconnecting;
+    m_cv.notify_all();
+  }
+}
+
+void Downloader::DisplayRemoteDirSelector() {
+  ImGui::SameLine();
+  if (ImGui::Button("Refresh")) {
+    m_state = kGetFiles;
+    m_cv.notify_all();
+  }
+
+  ImGui::SameLine();
+  if (ImGui::Button("Deselect All")) {
+    for (auto&& download : m_downloadList) {
+      download.enabled = false;
+    }
+  }
+
+  ImGui::SameLine();
+  if (ImGui::Button("Select All")) {
+    for (auto&& download : m_downloadList) {
+      download.enabled = true;
+    }
+  }
+
+  // Remote directory text box
+  ImGui::SetNextItemWidth(ImGui::GetFontSize() * 20);
+  if (ImGui::InputText("Remote Dir", &m_remoteDir,
+                       ImGuiInputTextFlags_EnterReturnsTrue)) {
+    m_state = kGetFiles;
+    m_cv.notify_all();
+  }
+
+  // List directories
+  for (auto&& dir : m_dirList) {
+    if (ImGui::Selectable(dir.c_str())) {
+      if (dir == "..") {
+        if (wpi::ends_with(m_remoteDir, '/')) {
+          m_remoteDir.resize(m_remoteDir.size() - 1);
+        }
+        m_remoteDir = wpi::rsplit(m_remoteDir, '/').first;
+        if (m_remoteDir.empty()) {
+          m_remoteDir = "/";
+        }
+      } else {
+        if (!wpi::ends_with(m_remoteDir, '/')) {
+          m_remoteDir += '/';
+        }
+        m_remoteDir += dir;
+      }
+      m_state = kGetFiles;
+      m_cv.notify_all();
+    }
+  }
+}
+
+void Downloader::DisplayLocalDirSelector() {
+  // Local directory text / select button
+  if (ImGui::Button("Select Download Folder...")) {
+    m_localDirSelector =
+        std::make_unique<pfd::select_folder>("Select Download Folder");
+  }
+  ImGui::TextUnformatted(m_localDir.c_str());
+
+  // Delete after download (checkbox)
+  ImGui::Checkbox("Delete after download", &m_deleteAfter);
+
+  // Download button
+  if (!m_localDir.empty()) {
+    if (ImGui::Button("Download")) {
+      m_state = kDownload;
+      m_cv.notify_all();
+    }
+  }
+}
+
+size_t Downloader::DisplayFiles() {
+  // List of files (multi-select) (changes to progress bar for downloading)
+  size_t fileCount = 0;
+  if (ImGui::BeginTable(
+          "files", 3,
+          ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp)) {
+    ImGui::TableSetupColumn("File");
+    ImGui::TableSetupColumn("Size");
+    ImGui::TableSetupColumn("Download");
+    ImGui::TableHeadersRow();
+    for (auto&& download : m_downloadList) {
+      if ((m_state == kDownload || m_state == kDownloadDone) &&
+          !download.enabled) {
+        continue;
+      }
+
+      ++fileCount;
+
+      ImGui::TableNextRow();
+      ImGui::TableNextColumn();
+      ImGui::TextUnformatted(download.name.c_str());
+      ImGui::TableNextColumn();
+      auto sizeText = fmt::format("{}", download.size);
+      ImGui::TextUnformatted(sizeText.c_str());
+      ImGui::TableNextColumn();
+      if (m_state == kDownload || m_state == kDownloadDone) {
+        if (!download.status.empty()) {
+          ImGui::TextUnformatted(download.status.c_str());
+        } else {
+          ImGui::ProgressBar(download.complete);
+        }
+      } else {
+        auto checkboxLabel = fmt::format("##{}", download.name);
+        ImGui::Checkbox(checkboxLabel.c_str(), &download.enabled);
+      }
+    }
+    ImGui::EndTable();
+  }
+
+  return fileCount;
+}
+
+void Downloader::Display() {
+  if (m_localDirSelector && m_localDirSelector->ready(0)) {
+    m_localDir = m_localDirSelector->result();
+    m_localDirSelector.reset();
+  }
+
+  std::scoped_lock lock{m_mutex};
+
+  if (!m_error.empty()) {
+    ImGui::TextUnformatted(m_error.c_str());
+  }
+
+  switch (m_state) {
+    case kDisconnected:
+      DisplayConnect();
+      break;
+    case kConnecting:
+      DisplayDisconnectButton();
+      ImGui::Text("Connecting to %s...", m_serverTeam.c_str());
+      break;
+    case kDisconnecting:
+      ImGui::TextUnformatted("Disconnecting...");
+      break;
+    case kConnected:
+    case kGetFiles:
+      DisplayDisconnectButton();
+      DisplayRemoteDirSelector();
+      if (DisplayFiles() > 0) {
+        DisplayLocalDirSelector();
+      }
+      break;
+    case kDownload:
+    case kDownloadDone:
+      DisplayDisconnectButton();
+      DisplayFiles();
+      if (m_state == kDownloadDone) {
+        if (ImGui::Button("Download complete!")) {
+          m_state = kGetFiles;
+          m_cv.notify_all();
+        }
+      }
+      break;
+    default:
+      break;
+  }
+}
+
+void Downloader::ThreadMain() {
+  std::unique_ptr<sftp::Session> session;
+
+  static constexpr size_t kBufSize = 32 * 1024;
+  std::unique_ptr<uint8_t[]> copyBuf = std::make_unique<uint8_t[]>(kBufSize);
+
+  std::unique_lock lock{m_mutex};
+  while (m_state != kExit) {
+    State prev = m_state;
+    m_cv.wait(lock, [&] { return m_state != prev; });
+    m_error.clear();
+    try {
+      switch (m_state) {
+        case kConnecting:
+          if (auto team = wpi::parse_integer<unsigned int>(m_serverTeam, 10)) {
+            // team number
+            session = std::make_unique<sftp::Session>(
+                fmt::format("roborio-{}-frc.local", team.value()), 22,
+                m_username, m_password);
+          } else {
+            session = std::make_unique<sftp::Session>(m_serverTeam, 22,
+                                                      m_username, m_password);
+          }
+          lock.unlock();
+          try {
+            session->Connect();
+          } catch (...) {
+            lock.lock();
+            throw;
+          }
+          lock.lock();
+          // FALLTHROUGH
+        case kGetFiles: {
+          std::string dir = m_remoteDir;
+          std::vector<sftp::Attributes> fileList;
+          lock.unlock();
+          try {
+            fileList = session->ReadDir(dir);
+          } catch (sftp::Exception& ex) {
+            lock.lock();
+            if (ex.err == SSH_FX_OK || ex.err == SSH_FX_CONNECTION_LOST) {
+              throw;
+            }
+            m_error = ex.what();
+            m_dirList.clear();
+            m_downloadList.clear();
+            m_state = kConnected;
+            break;
+          }
+          std::sort(
+              fileList.begin(), fileList.end(),
+              [](const auto& l, const auto& r) { return l.name < r.name; });
+          lock.lock();
+
+          m_dirList.clear();
+          m_downloadList.clear();
+          for (auto&& attr : fileList) {
+            if (attr.type == SSH_FILEXFER_TYPE_DIRECTORY) {
+              if (attr.name != ".") {
+                m_dirList.emplace_back(attr.name);
+              }
+            } else if (attr.type == SSH_FILEXFER_TYPE_REGULAR &&
+                       (attr.flags & SSH_FILEXFER_ATTR_SIZE) != 0 &&
+                       wpi::ends_with(attr.name, ".wpilog")) {
+              m_downloadList.emplace_back(attr.name, attr.size);
+            }
+          }
+
+          m_state = kConnected;
+          break;
+        }
+        case kDisconnecting:
+          session.reset();
+          m_state = kDisconnected;
+          break;
+        case kDownload: {
+          for (auto&& download : m_downloadList) {
+            if (m_state != kDownload) {
+              // user aborted
+              break;
+            }
+            if (!download.enabled) {
+              continue;
+            }
+
+            auto remoteFilename = fmt::format(
+                "{}{}{}", m_remoteDir,
+                wpi::ends_with(m_remoteDir, '/') ? "" : "/", download.name);
+            auto localFilename = fs::path{m_localDir} / download.name;
+            uint64_t fileSize = download.size;
+
+            lock.unlock();
+
+            // open local file
+            std::error_code ec;
+            fs::file_t of = fs::OpenFileForWrite(localFilename, ec,
+                                                 fs::CD_CreateNew, fs::OF_None);
+            if (ec) {
+              // failed to open
+              lock.lock();
+              download.status = ec.message();
+              continue;
+            }
+            int ofd = fs::FileToFd(of, ec, fs::OF_None);
+            if (ofd == -1 || ec) {
+              // failed to convert to fd
+              lock.lock();
+              download.status = ec.message();
+              continue;
+            }
+
+            try {
+              // open remote file
+              sftp::File f = session->Open(remoteFilename, O_RDONLY, 0);
+
+              // copy in chunks
+              uint64_t total = 0;
+              while (total < fileSize) {
+                uint64_t toCopy = (std::min)(fileSize - total,
+                                             static_cast<uint64_t>(kBufSize));
+                auto copied = f.Read(copyBuf.get(), toCopy);
+                if (write(ofd, copyBuf.get(), copied) !=
+                    static_cast<int64_t>(copied)) {
+                  // error writing
+                  close(ofd);
+                  fs::remove(localFilename, ec);
+                  lock.lock();
+                  download.status = "error writing local file";
+                  goto err;
+                }
+                total += copied;
+                lock.lock();
+                download.complete = static_cast<float>(total) / fileSize;
+                lock.unlock();
+              }
+
+              // close local file
+              close(ofd);
+              ofd = -1;
+
+              // delete remote file (if enabled)
+              if (m_deleteAfter) {
+                f = sftp::File{};
+                session->Unlink(remoteFilename);
+              }
+            } catch (sftp::Exception& ex) {
+              if (ofd != -1) {
+                // close local file and delete it (due to failure)
+                close(ofd);
+                fs::remove(localFilename, ec);
+              }
+              lock.lock();
+              download.status = ex.what();
+              if (ex.err == SSH_FX_OK || ex.err == SSH_FX_CONNECTION_LOST) {
+                throw;
+              }
+              continue;
+            }
+            lock.lock();
+          err : {}
+          }
+          if (m_state == kDownload) {
+            m_state = kDownloadDone;
+          }
+          break;
+        }
+        default:
+          break;
+      }
+    } catch (sftp::Exception& ex) {
+      m_error = ex.what();
+      session.reset();
+      m_state = kDisconnected;
+    }
+  }
+}
diff --git a/datalogtool/src/main/native/cpp/Downloader.h b/datalogtool/src/main/native/cpp/Downloader.h
new file mode 100644
index 0000000..f9bb32f
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/Downloader.h
@@ -0,0 +1,78 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <memory>
+#include <string>
+#include <thread>
+#include <vector>
+
+#include <wpi/condition_variable.h>
+#include <wpi/mutex.h>
+
+namespace glass {
+class Storage;
+}  // namespace glass
+
+namespace pfd {
+class select_folder;
+}  // namespace pfd
+
+class Downloader {
+ public:
+  explicit Downloader(glass::Storage& storage);
+  ~Downloader();
+
+  void Display();
+
+ private:
+  void DisplayConnect();
+  void DisplayDisconnectButton();
+  void DisplayRemoteDirSelector();
+  void DisplayLocalDirSelector();
+  size_t DisplayFiles();
+
+  void ThreadMain();
+
+  wpi::mutex m_mutex;
+  enum State {
+    kDisconnected,
+    kConnecting,
+    kConnected,
+    kDisconnecting,
+    kGetFiles,
+    kDownload,
+    kDownloadDone,
+    kExit
+  } m_state = kDisconnected;
+  std::condition_variable m_cv;
+
+  std::string& m_serverTeam;
+  std::string& m_remoteDir;
+  std::string& m_username;
+  std::string m_password;
+
+  std::string& m_localDir;
+  std::unique_ptr<pfd::select_folder> m_localDirSelector;
+
+  bool& m_deleteAfter;
+
+  std::vector<std::string> m_dirList;
+  struct DownloadState {
+    DownloadState(std::string_view name, uint64_t size)
+        : name{name}, size{size} {}
+
+    std::string name;
+    uint64_t size;
+    bool enabled = true;
+    float complete = 0.0;
+    std::string status;
+  };
+  std::vector<DownloadState> m_downloadList;
+
+  std::string m_error;
+
+  std::thread m_thread;
+};
diff --git a/datalogtool/src/main/native/cpp/Exporter.cpp b/datalogtool/src/main/native/cpp/Exporter.cpp
new file mode 100644
index 0000000..f28545f
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/Exporter.cpp
@@ -0,0 +1,661 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "Exporter.h"
+
+#include <atomic>
+#include <ctime>
+#include <future>
+#include <map>
+#include <memory>
+#include <set>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include <fmt/chrono.h>
+#include <fmt/format.h>
+#include <glass/Storage.h>
+#include <imgui.h>
+#include <imgui_internal.h>
+#include <imgui_stdlib.h>
+#include <portable-file-dialogs.h>
+#include <wpi/DenseMap.h>
+#include <wpi/MemoryBuffer.h>
+#include <wpi/SmallVector.h>
+#include <wpi/SpanExtras.h>
+#include <wpi/StringExtras.h>
+#include <wpi/fmt/raw_ostream.h>
+#include <wpi/fs.h>
+#include <wpi/mutex.h>
+#include <wpi/raw_ostream.h>
+
+#include "App.h"
+#include "DataLogThread.h"
+
+namespace {
+struct InputFile {
+  explicit InputFile(std::unique_ptr<DataLogThread> datalog);
+
+  InputFile(std::string_view filename, std::string_view status)
+      : filename{filename},
+        stem{fs::path{filename}.stem().string()},
+        status{status} {}
+
+  ~InputFile();
+
+  std::string filename;
+  std::string stem;
+  std::unique_ptr<DataLogThread> datalog;
+  std::string status;
+  bool highlight = false;
+};
+
+struct Entry {
+  explicit Entry(const wpi::log::StartRecordData& srd)
+      : name{srd.name}, type{srd.type}, metadata{srd.metadata} {}
+
+  std::string name;
+  std::string type;
+  std::string metadata;
+  std::set<InputFile*> inputFiles;
+  bool typeConflict = false;
+  bool metadataConflict = false;
+  bool selected = true;
+
+  // used only during export
+  int column = -1;
+};
+
+struct EntryTreeNode {
+  explicit EntryTreeNode(std::string_view name) : name{name} {}
+  std::string name;  // name of just this node
+  std::string path;  // full path if entry is nullptr
+  Entry* entry = nullptr;
+  std::vector<EntryTreeNode> children;  // children, sorted by name
+  int selected = 1;
+};
+}  // namespace
+
+static std::map<std::string, std::unique_ptr<InputFile>, std::less<>>
+    gInputFiles;
+static wpi::mutex gEntriesMutex;
+static std::map<std::string, std::unique_ptr<Entry>, std::less<>> gEntries;
+static std::vector<EntryTreeNode> gEntryTree;
+std::atomic_int gExportCount{0};
+
+// must be called with gEntriesMutex held
+static void RebuildEntryTree() {
+  gEntryTree.clear();
+  wpi::SmallVector<std::string_view, 16> parts;
+  for (auto& kv : gEntries) {
+    parts.clear();
+    // split on first : if one is present
+    auto [prefix, mainpart] = wpi::split(kv.first, ':');
+    if (mainpart.empty() || wpi::contains(prefix, '/')) {
+      mainpart = kv.first;
+    } else {
+      parts.emplace_back(prefix);
+    }
+    wpi::split(mainpart, parts, '/', -1, false);
+
+    // ignore a raw "/" key
+    if (parts.empty()) {
+      continue;
+    }
+
+    // get to leaf
+    auto nodes = &gEntryTree;
+    for (auto part : wpi::drop_back(std::span{parts.begin(), parts.end()})) {
+      auto it =
+          std::find_if(nodes->begin(), nodes->end(),
+                       [&](const auto& node) { return node.name == part; });
+      if (it == nodes->end()) {
+        nodes->emplace_back(part);
+        // path is from the beginning of the string to the end of the current
+        // part; this works because part is a reference to the internals of
+        // kv.first
+        nodes->back().path.assign(kv.first.data(),
+                                  part.data() + part.size() - kv.first.data());
+        it = nodes->end() - 1;
+      }
+      nodes = &it->children;
+    }
+
+    auto it = std::find_if(nodes->begin(), nodes->end(), [&](const auto& node) {
+      return node.name == parts.back();
+    });
+    if (it == nodes->end()) {
+      nodes->emplace_back(parts.back());
+      // no need to set path, as it's identical to kv.first
+      it = nodes->end() - 1;
+    }
+    it->entry = kv.second.get();
+  }
+}
+
+InputFile::InputFile(std::unique_ptr<DataLogThread> datalog_)
+    : filename{datalog_->GetBufferIdentifier()},
+      stem{fs::path{filename}.stem().string()},
+      datalog{std::move(datalog_)} {
+  datalog->sigEntryAdded.connect([this](const wpi::log::StartRecordData& srd) {
+    std::scoped_lock lock{gEntriesMutex};
+    auto it = gEntries.find(srd.name);
+    if (it == gEntries.end()) {
+      it = gEntries.emplace(srd.name, std::make_unique<Entry>(srd)).first;
+      RebuildEntryTree();
+    } else {
+      if (it->second->type != srd.type) {
+        it->second->typeConflict = true;
+      }
+      if (it->second->metadata != srd.metadata) {
+        it->second->metadataConflict = true;
+      }
+    }
+    it->second->inputFiles.emplace(this);
+  });
+}
+
+InputFile::~InputFile() {
+  if (gShutdown || !datalog) {
+    return;
+  }
+  std::scoped_lock lock{gEntriesMutex};
+  bool changed = false;
+  for (auto it = gEntries.begin(); it != gEntries.end();) {
+    it->second->inputFiles.erase(this);
+    if (it->second->inputFiles.empty()) {
+      it = gEntries.erase(it);
+      changed = true;
+    } else {
+      ++it;
+    }
+  }
+  if (changed) {
+    RebuildEntryTree();
+  }
+}
+
+static std::unique_ptr<InputFile> LoadDataLog(std::string_view filename) {
+  std::error_code ec;
+  auto buf = wpi::MemoryBuffer::GetFile(filename, ec);
+  std::string fn{filename};
+  if (ec) {
+    return std::make_unique<InputFile>(
+        fn, fmt::format("Could not open file: {}", ec.message()));
+  }
+
+  wpi::log::DataLogReader reader{std::move(buf)};
+  if (!reader.IsValid()) {
+    return std::make_unique<InputFile>(fn, "Not a valid datalog file");
+  }
+
+  return std::make_unique<InputFile>(
+      std::make_unique<DataLogThread>(std::move(reader)));
+}
+
+void DisplayInputFiles() {
+  static std::unique_ptr<pfd::open_file> dataFileSelector;
+
+  SetNextWindowPos(ImVec2{0, 20}, ImGuiCond_FirstUseEver);
+  SetNextWindowSize(ImVec2{375, 230}, ImGuiCond_FirstUseEver);
+  if (ImGui::Begin("Input Files")) {
+    if (ImGui::Button("Open File(s)...")) {
+      dataFileSelector = std::make_unique<pfd::open_file>(
+          "Select Data Log", "",
+          std::vector<std::string>{"DataLog Files", "*.wpilog"},
+          pfd::opt::multiselect);
+    }
+    ImGui::BeginTable(
+        "Input Files", 3,
+        ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp);
+    ImGui::TableSetupColumn("File");
+    ImGui::TableSetupColumn("Status");
+    ImGui::TableSetupColumn("X", ImGuiTableColumnFlags_WidthFixed |
+                                     ImGuiTableColumnFlags_NoHeaderLabel |
+                                     ImGuiTableColumnFlags_NoHeaderWidth);
+    ImGui::TableHeadersRow();
+    for (auto it = gInputFiles.begin(); it != gInputFiles.end();) {
+      ImGui::TableNextRow();
+      ImGui::TableNextColumn();
+      if (it->second->highlight) {
+        ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
+                               IM_COL32(0, 64, 0, 255));
+        it->second->highlight = false;
+      }
+      ImGui::TextUnformatted(it->first.c_str());
+      if (ImGui::IsItemHovered()) {
+        ImGui::SetTooltip("%s", it->second->filename.c_str());
+      }
+
+      ImGui::TableNextColumn();
+      if (it->second->datalog) {
+        ImGui::Text("%u records, %u entries%s",
+                    it->second->datalog->GetNumRecords(),
+                    it->second->datalog->GetNumEntries(),
+                    it->second->datalog->IsDone() ? "" : " (working)");
+      } else {
+        ImGui::TextUnformatted(it->second->status.c_str());
+      }
+
+      ImGui::TableNextColumn();
+      ImGui::PushID(it->first.c_str());
+      if (ImGui::SmallButton("X")) {
+        it = gInputFiles.erase(it);
+        gExportCount = 0;
+      } else {
+        ++it;
+      }
+      ImGui::PopID();
+    }
+    ImGui::EndTable();
+  }
+  ImGui::End();
+
+  // Load data file(s)
+  if (dataFileSelector && dataFileSelector->ready(0)) {
+    auto result = dataFileSelector->result();
+    for (auto&& filename : result) {
+      // don't allow duplicates
+      std::string stem = fs::path{filename}.stem().string();
+      auto it = gInputFiles.find(stem);
+      if (it == gInputFiles.end()) {
+        gInputFiles.emplace(std::move(stem), LoadDataLog(filename));
+        gExportCount = 0;
+      }
+    }
+    dataFileSelector.reset();
+  }
+}
+
+static bool EmitEntry(const std::string& name, Entry& entry) {
+  ImGui::TableNextColumn();
+  bool rv = ImGui::Checkbox(name.c_str(), &entry.selected);
+  if (ImGui::IsItemHovered() && gInputFiles.size() > 1) {
+    for (auto inputFile : entry.inputFiles) {
+      inputFile->highlight = true;
+    }
+  }
+
+  ImGui::TableNextColumn();
+  if (entry.typeConflict) {
+    ImGui::TextUnformatted("(Inconsistent)");
+    if (ImGui::IsItemHovered()) {
+      ImGui::BeginTooltip();
+      for (auto inputFile : entry.inputFiles) {
+        ImGui::Text(
+            "%s: %s", inputFile->stem.c_str(),
+            std::string{inputFile->datalog->GetEntry(entry.name).type}.c_str());
+      }
+      ImGui::EndTooltip();
+    }
+  } else {
+    ImGui::TextUnformatted(entry.type.c_str());
+  }
+
+  ImGui::TableNextColumn();
+  if (entry.metadataConflict) {
+    ImGui::TextUnformatted("(Inconsistent)");
+    if (ImGui::IsItemHovered()) {
+      ImGui::BeginTooltip();
+      for (auto inputFile : entry.inputFiles) {
+        ImGui::Text(
+            "%s: %s", inputFile->stem.c_str(),
+            std::string{inputFile->datalog->GetEntry(entry.name).metadata}
+                .c_str());
+      }
+      ImGui::EndTooltip();
+    }
+  } else {
+    ImGui::TextUnformatted(entry.metadata.c_str());
+  }
+  return rv;
+}
+
+static bool EmitEntryTree(std::vector<EntryTreeNode>& tree) {
+  bool rv = false;
+  for (auto&& node : tree) {
+    if (node.entry) {
+      if (EmitEntry(node.name, *node.entry)) {
+        rv = true;
+      }
+    }
+
+    if (!node.children.empty()) {
+      ImGui::TableNextColumn();
+      auto label = fmt::format("##check_{}", node.name);
+      if (node.selected == -1) {
+        ImGui::PushItemFlag(ImGuiItemFlags_MixedValue, true);
+        bool b = false;
+        if (ImGui::Checkbox(label.c_str(), &b)) {
+          node.selected = 3;  // 3 = enable group
+          rv = true;
+        }
+        ImGui::PopItemFlag();
+      } else {
+        bool b = node.selected == 1 || node.selected == 3;
+        if (ImGui::Checkbox(label.c_str(), &b)) {
+          node.selected = b ? 3 : 2;  // 2 = disable group
+          rv = true;
+        }
+      }
+      ImGui::SameLine();
+      bool open = ImGui::TreeNodeEx(node.name.c_str(),
+                                    ImGuiTreeNodeFlags_SpanFullWidth);
+      ImGui::TableNextColumn();
+      ImGui::TableNextColumn();
+      if (open) {
+        if (EmitEntryTree(node.children)) {
+          rv = true;
+        }
+        ImGui::TreePop();
+      }
+    }
+  }
+  return rv;
+}
+
+static void RefreshTreeCheckboxes(std::vector<EntryTreeNode>& tree,
+                                  int* selected) {
+  bool first = true;
+  for (auto&& node : tree) {
+    if (node.entry) {
+      if (first && *selected == -1) {
+        *selected = node.entry->selected ? 1 : 0;
+      }
+      if ((*selected == 0 && node.entry->selected) ||
+          (*selected == 1 && !node.entry->selected)) {
+        *selected = -1;             // inconsistent
+      } else if (*selected == 2) {  // disable group
+        node.entry->selected = false;
+      } else if (*selected == 3) {  // enable group
+        node.entry->selected = true;
+      }
+    }
+
+    if (!node.children.empty()) {
+      if (*selected == 2) {  // disable group
+        node.selected = 2;
+      } else if (*selected == 3) {  // enable group
+        node.selected = 3;
+      }
+      RefreshTreeCheckboxes(node.children, &node.selected);
+      if (node.selected == 2) {
+        node.selected = 0;
+      } else if (node.selected == 3) {
+        node.selected = 1;
+      }
+      if (first && *selected == -1) {
+        *selected = node.selected;
+      } else if (node.selected == -1 ||
+                 (*selected == 0 && node.selected == 1) ||
+                 (*selected == 1 && node.selected == 0)) {
+        *selected = -1;  // inconsistent
+      }
+    }
+
+    first = false;
+  }
+}
+
+void DisplayEntries() {
+  SetNextWindowPos(ImVec2{380, 20}, ImGuiCond_FirstUseEver);
+  SetNextWindowSize(ImVec2{540, 365}, ImGuiCond_FirstUseEver);
+  if (ImGui::Begin("Entries")) {
+    static bool treeView = true;
+    if (ImGui::BeginPopupContextItem()) {
+      ImGui::MenuItem("Tree View", "", &treeView);
+      ImGui::EndPopup();
+    }
+    std::scoped_lock lock{gEntriesMutex};
+    ImGui::BeginTable(
+        "Entries", 3,
+        ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp);
+    ImGui::TableSetupColumn("Name");
+    ImGui::TableSetupColumn("Type");
+    ImGui::TableSetupColumn("Metadata");
+    ImGui::TableHeadersRow();
+    if (treeView) {
+      if (EmitEntryTree(gEntryTree)) {
+        int selected = -1;
+        RefreshTreeCheckboxes(gEntryTree, &selected);
+      }
+    } else {
+      for (auto&& kv : gEntries) {
+        EmitEntry(kv.first, *kv.second);
+      }
+    }
+    ImGui::EndTable();
+  }
+  ImGui::End();
+}
+
+static wpi::mutex gExportMutex;
+static std::vector<std::string> gExportErrors;
+
+static void PrintEscapedCsvString(wpi::raw_ostream& os, std::string_view str) {
+  auto s = str;
+  while (!s.empty()) {
+    std::string_view fragment;
+    std::tie(fragment, s) = wpi::split(s, '"');
+    os << fragment;
+    if (!s.empty()) {
+      os << '"' << '"';
+    }
+  }
+  if (wpi::ends_with(str, '"')) {
+    os << '"' << '"';
+  }
+}
+
+static void ValueToCsv(wpi::raw_ostream& os, const Entry& entry,
+                       const wpi::log::DataLogRecord& record) {
+  // handle systemTime specially
+  if (entry.name == "systemTime" && entry.type == "int64") {
+    int64_t val;
+    if (record.GetInteger(&val)) {
+      std::time_t timeval = val / 1000000;
+      fmt::print(os, "{:%Y-%m-%d %H:%M:%S}.{:06}", *std::localtime(&timeval),
+                 val % 1000000);
+      return;
+    }
+  } else if (entry.type == "double") {
+    double val;
+    if (record.GetDouble(&val)) {
+      fmt::print(os, "{}", val);
+      return;
+    }
+  } else if (entry.type == "int64") {
+    int64_t val;
+    if (record.GetInteger(&val)) {
+      fmt::print(os, "{}", val);
+      return;
+    }
+  } else if (entry.type == "string" || entry.type == "json") {
+    std::string_view val;
+    record.GetString(&val);
+    os << '"';
+    PrintEscapedCsvString(os, val);
+    os << '"';
+    return;
+  } else if (entry.type == "boolean") {
+    bool val;
+    if (record.GetBoolean(&val)) {
+      fmt::print(os, "{}", val);
+      return;
+    }
+  } else if (entry.type == "boolean[]") {
+    std::vector<int> val;
+    if (record.GetBooleanArray(&val)) {
+      fmt::print(os, "{}", fmt::join(val, ";"));
+      return;
+    }
+  } else if (entry.type == "double[]") {
+    std::vector<double> val;
+    if (record.GetDoubleArray(&val)) {
+      fmt::print(os, "{}", fmt::join(val, ";"));
+      return;
+    }
+  } else if (entry.type == "float[]") {
+    std::vector<float> val;
+    if (record.GetFloatArray(&val)) {
+      fmt::print(os, "{}", fmt::join(val, ";"));
+      return;
+    }
+  } else if (entry.type == "int64[]") {
+    std::vector<int64_t> val;
+    if (record.GetIntegerArray(&val)) {
+      fmt::print(os, "{}", fmt::join(val, ";"));
+      return;
+    }
+  } else if (entry.type == "string[]") {
+    std::vector<std::string_view> val;
+    if (record.GetStringArray(&val)) {
+      os << '"';
+      bool first = true;
+      for (auto&& v : val) {
+        if (!first) {
+          os << ';';
+        }
+        first = false;
+        PrintEscapedCsvString(os, v);
+      }
+      os << '"';
+      return;
+    }
+  }
+  fmt::print(os, "<invalid>");
+}
+
+static void ExportCsvFile(InputFile& f, wpi::raw_ostream& os, int style) {
+  // header
+  if (style == 0) {
+    os << "Timestamp,Name,Value\n";
+  } else if (style == 1) {
+    // scan for exported fields for this file to print header and assign columns
+    os << "Timestamp";
+    int columnNum = 0;
+    for (auto&& entry : gEntries) {
+      if (entry.second->selected &&
+          entry.second->inputFiles.find(&f) != entry.second->inputFiles.end()) {
+        os << ',' << '"';
+        PrintEscapedCsvString(os, entry.first);
+        os << '"';
+        entry.second->column = columnNum++;
+      } else {
+        entry.second->column = -1;
+      }
+    }
+    os << '\n';
+  }
+
+  wpi::DenseMap<int, Entry*> nameMap;
+  for (auto&& record : f.datalog->GetReader()) {
+    if (record.IsStart()) {
+      wpi::log::StartRecordData data;
+      if (record.GetStartData(&data)) {
+        auto it = gEntries.find(data.name);
+        if (it != gEntries.end() && it->second->selected) {
+          nameMap[data.entry] = it->second.get();
+        }
+      }
+    } else if (record.IsFinish()) {
+      int entry;
+      if (record.GetFinishEntry(&entry)) {
+        nameMap.erase(entry);
+      }
+    } else if (!record.IsControl()) {
+      auto entryIt = nameMap.find(record.GetEntry());
+      if (entryIt == nameMap.end()) {
+        continue;
+      }
+      Entry* entry = entryIt->second;
+
+      if (style == 0) {
+        fmt::print(os, "{},\"", record.GetTimestamp() / 1000000.0);
+        PrintEscapedCsvString(os, entry->name);
+        os << '"' << ',';
+        ValueToCsv(os, *entry, record);
+        os << '\n';
+      } else if (style == 1 && entry->column != -1) {
+        fmt::print(os, "{},", record.GetTimestamp() / 1000000.0);
+        for (int i = 0; i < entry->column; ++i) {
+          os << ',';
+        }
+        ValueToCsv(os, *entry, record);
+        os << '\n';
+      }
+    }
+  }
+}
+
+static void ExportCsv(std::string_view outputFolder, int style) {
+  fs::path outPath{outputFolder};
+  for (auto&& f : gInputFiles) {
+    if (f.second->datalog) {
+      std::error_code ec;
+      auto of = fs::OpenFileForWrite(
+          outPath / fs::path{f.first}.replace_extension("csv"), ec,
+          fs::CD_CreateNew, fs::OF_Text);
+      if (ec) {
+        std::scoped_lock lock{gExportMutex};
+        gExportErrors.emplace_back(
+            fmt::format("{}: {}", f.first, ec.message()));
+        ++gExportCount;
+        continue;
+      }
+      wpi::raw_fd_ostream os{fs::FileToFd(of, ec, fs::OF_Text), true};
+      ExportCsvFile(*f.second, os, style);
+    }
+    ++gExportCount;
+  }
+}
+
+void DisplayOutput(glass::Storage& storage) {
+  static std::string& outputFolder = storage.GetString("outputFolder");
+  static std::unique_ptr<pfd::select_folder> outputFolderSelector;
+
+  SetNextWindowPos(ImVec2{380, 390}, ImGuiCond_FirstUseEver);
+  SetNextWindowSize(ImVec2{540, 120}, ImGuiCond_FirstUseEver);
+  if (ImGui::Begin("Output")) {
+    if (ImGui::Button("Select Output Folder...")) {
+      outputFolderSelector =
+          std::make_unique<pfd::select_folder>("Select Output Folder");
+    }
+    ImGui::TextUnformatted(outputFolder.c_str());
+
+    static const char* const options[] = {"List", "Table"};
+    static int style = 0;
+    ImGui::SetNextItemWidth(ImGui::GetFontSize() * 8);
+    ImGui::Combo("Style", &style, options,
+                 sizeof(options) / sizeof(const char*));
+
+    static std::future<void> exporter;
+    if (!gInputFiles.empty() && !outputFolder.empty() &&
+        ImGui::Button("Export CSV") &&
+        (gExportCount == 0 ||
+         gExportCount == static_cast<int>(gInputFiles.size()))) {
+      gExportCount = 0;
+      gExportErrors.clear();
+      exporter = std::async(std::launch::async, ExportCsv, outputFolder, style);
+    }
+    if (exporter.valid()) {
+      ImGui::SameLine();
+      ImGui::Text("Exported %d/%d", gExportCount.load(),
+                  static_cast<int>(gInputFiles.size()));
+    }
+    {
+      std::scoped_lock lock{gExportMutex};
+      for (auto&& err : gExportErrors) {
+        ImGui::TextUnformatted(err.c_str());
+      }
+    }
+  }
+  ImGui::End();
+
+  if (outputFolderSelector && outputFolderSelector->ready(0)) {
+    outputFolder = outputFolderSelector->result();
+    outputFolderSelector.reset();
+  }
+}
diff --git a/datalogtool/src/main/native/cpp/Exporter.h b/datalogtool/src/main/native/cpp/Exporter.h
new file mode 100644
index 0000000..8078243
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/Exporter.h
@@ -0,0 +1,15 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+namespace glass {
+class Storage;
+}  // namespace glass
+
+void DisplayInputFiles();
+void DisplayEntries();
+void DisplayOutput(glass::Storage& storage);
+
+extern bool gShutdown;
diff --git a/datalogtool/src/main/native/cpp/Sftp.cpp b/datalogtool/src/main/native/cpp/Sftp.cpp
new file mode 100644
index 0000000..3a33d13
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/Sftp.cpp
@@ -0,0 +1,215 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#include "Sftp.h"
+
+#include <fmt/format.h>
+
+using namespace sftp;
+
+Attributes::Attributes(sftp_attributes&& attr)
+    : name{attr->name}, flags{attr->flags}, type{attr->type}, size{attr->size} {
+  sftp_attributes_free(attr);
+}
+
+static std::string GetError(sftp_session sftp) {
+  switch (sftp_get_error(sftp)) {
+    case SSH_FX_EOF:
+      return "end of file";
+    case SSH_FX_NO_SUCH_FILE:
+      return "no such file";
+    case SSH_FX_PERMISSION_DENIED:
+      return "permission denied";
+    case SSH_FX_FAILURE:
+      return "SFTP failure";
+    case SSH_FX_BAD_MESSAGE:
+      return "SFTP bad message";
+    case SSH_FX_NO_CONNECTION:
+      return "SFTP no connection";
+    case SSH_FX_CONNECTION_LOST:
+      return "SFTP connection lost";
+    case SSH_FX_OP_UNSUPPORTED:
+      return "SFTP operation unsupported";
+    case SSH_FX_INVALID_HANDLE:
+      return "SFTP invalid handle";
+    case SSH_FX_NO_SUCH_PATH:
+      return "no such path";
+    case SSH_FX_FILE_ALREADY_EXISTS:
+      return "file already exists";
+    case SSH_FX_WRITE_PROTECT:
+      return "write protected filesystem";
+    case SSH_FX_NO_MEDIA:
+      return "no media inserted";
+    default:
+      return ssh_get_error(sftp->session);
+  }
+}
+
+Exception::Exception(sftp_session sftp)
+    : runtime_error{GetError(sftp)}, err{sftp_get_error(sftp)} {}
+
+File::~File() {
+  if (m_handle) {
+    sftp_close(m_handle);
+  }
+}
+
+Attributes File::Stat() const {
+  sftp_attributes attr = sftp_fstat(m_handle);
+  if (!attr) {
+    throw Exception{m_handle->sftp};
+  }
+  return Attributes{std::move(attr)};
+}
+
+size_t File::Read(void* buf, uint32_t count) {
+  auto rv = sftp_read(m_handle, buf, count);
+  if (rv < 0) {
+    throw Exception{m_handle->sftp};
+  }
+  return rv;
+}
+
+File::AsyncId File::AsyncReadBegin(uint32_t len) const {
+  int rv = sftp_async_read_begin(m_handle, len);
+  if (rv < 0) {
+    throw Exception{m_handle->sftp};
+  }
+  return rv;
+}
+
+size_t File::AsyncRead(void* data, uint32_t len, AsyncId id) {
+  auto rv = sftp_async_read(m_handle, data, len, id);
+  if (rv == SSH_ERROR) {
+    throw Exception{ssh_get_error(m_handle->sftp->session)};
+  }
+  if (rv == SSH_AGAIN) {
+    return 0;
+  }
+  return rv;
+}
+
+size_t File::Write(std::span<const uint8_t> data) {
+  auto rv = sftp_write(m_handle, data.data(), data.size());
+  if (rv < 0) {
+    throw Exception{m_handle->sftp};
+  }
+  return rv;
+}
+
+void File::Seek(uint64_t offset) {
+  if (sftp_seek64(m_handle, offset) < 0) {
+    throw Exception{m_handle->sftp};
+  }
+}
+
+uint64_t File::Tell() const {
+  return sftp_tell64(m_handle);
+}
+
+void File::Rewind() {
+  sftp_rewind(m_handle);
+}
+
+void File::Sync() {
+  if (sftp_fsync(m_handle) < 0) {
+    throw Exception{m_handle->sftp};
+  }
+}
+
+Session::Session(std::string_view host, int port, std::string_view user,
+                 std::string_view pass)
+    : m_host{host}, m_port{port}, m_username{user}, m_password{pass} {
+  // Create a new SSH session.
+  m_session = ssh_new();
+  if (!m_session) {
+    throw Exception{"The SSH session could not be allocated."};
+  }
+
+  // Set the host, user, and port.
+  ssh_options_set(m_session, SSH_OPTIONS_HOST, m_host.c_str());
+  ssh_options_set(m_session, SSH_OPTIONS_USER, m_username.c_str());
+  ssh_options_set(m_session, SSH_OPTIONS_PORT, &m_port);
+
+  // Set timeout to 3 seconds.
+  int64_t timeout = 3L;
+  ssh_options_set(m_session, SSH_OPTIONS_TIMEOUT, &timeout);
+
+  // Set other miscellaneous options.
+  ssh_options_set(m_session, SSH_OPTIONS_STRICTHOSTKEYCHECK, "no");
+}
+
+Session::~Session() {
+  if (m_sftp) {
+    sftp_free(m_sftp);
+  }
+  if (m_session) {
+    ssh_free(m_session);
+  }
+}
+
+void Session::Connect() {
+  // Connect to the server.
+  int rc = ssh_connect(m_session);
+  if (rc != SSH_OK) {
+    throw Exception{ssh_get_error(m_session)};
+  }
+
+  // Authenticate with password.
+  rc = ssh_userauth_password(m_session, nullptr, m_password.c_str());
+  if (rc != SSH_AUTH_SUCCESS) {
+    throw Exception{ssh_get_error(m_session)};
+  }
+
+  // Allocate the SFTP session.
+  m_sftp = sftp_new(m_session);
+  if (!m_sftp) {
+    throw Exception{ssh_get_error(m_session)};
+  }
+
+  // Initialize.
+  rc = sftp_init(m_sftp);
+  if (rc != SSH_OK) {
+    sftp_free(m_sftp);
+    m_sftp = nullptr;
+    throw Exception{ssh_get_error(m_session)};
+  }
+}
+
+void Session::Disconnect() {
+  if (m_sftp) {
+    sftp_free(m_sftp);
+    m_sftp = nullptr;
+  }
+  ssh_disconnect(m_session);
+}
+
+std::vector<Attributes> Session::ReadDir(const std::string& path) {
+  sftp_dir dir = sftp_opendir(m_sftp, path.c_str());
+  if (!dir) {
+    throw Exception{m_sftp};
+  }
+
+  std::vector<Attributes> rv;
+  while (sftp_attributes attr = sftp_readdir(m_sftp, dir)) {
+    rv.emplace_back(std::move(attr));
+  }
+
+  sftp_closedir(dir);
+  return rv;
+}
+
+void Session::Unlink(const std::string& filename) {
+  if (sftp_unlink(m_sftp, filename.c_str()) < 0) {
+    throw Exception{m_sftp};
+  }
+}
+
+File Session::Open(const std::string& filename, int accesstype, mode_t mode) {
+  sftp_file f = sftp_open(m_sftp, filename.c_str(), accesstype, mode);
+  if (!f) {
+    throw Exception{m_sftp};
+  }
+  return File{std::move(f)};
+}
diff --git a/datalogtool/src/main/native/cpp/Sftp.h b/datalogtool/src/main/native/cpp/Sftp.h
new file mode 100644
index 0000000..e6fec4a
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/Sftp.h
@@ -0,0 +1,143 @@
+// Copyright (c) FIRST and other WPILib contributors.
+// Open Source Software; you can modify and/or share it under the terms of
+// the WPILib BSD license file in the root directory of this project.
+
+#pragma once
+
+#include <libssh/libssh.h>
+#include <libssh/sftp.h>
+
+#include <span>
+#include <stdexcept>
+#include <string>
+#include <string_view>
+#include <vector>
+
+namespace sftp {
+
+struct Attributes {
+  Attributes() = default;
+  explicit Attributes(sftp_attributes&& attr);
+
+  std::string name;
+  uint32_t flags = 0;
+  uint8_t type = 0;
+  uint64_t size = 0;
+};
+
+/**
+ * This is the exception that will be thrown if something goes wrong.
+ */
+class Exception : public std::runtime_error {
+ public:
+  explicit Exception(const std::string& msg) : std::runtime_error{msg} {}
+  explicit Exception(sftp_session sftp);
+
+  int err = 0;
+};
+
+class File {
+ public:
+  File() = default;
+  explicit File(sftp_file&& handle) : m_handle{handle} {}
+  ~File();
+
+  Attributes Stat() const;
+
+  void SetNonblocking() { sftp_file_set_nonblocking(m_handle); }
+  void SetBlocking() { sftp_file_set_blocking(m_handle); }
+
+  using AsyncId = uint32_t;
+
+  size_t Read(void* buf, uint32_t count);
+  AsyncId AsyncReadBegin(uint32_t len) const;
+  size_t AsyncRead(void* data, uint32_t len, AsyncId id);
+  size_t Write(std::span<const uint8_t> data);
+
+  void Seek(uint64_t offset);
+  uint64_t Tell() const;
+  void Rewind();
+
+  void Sync();
+
+  std::string_view GetName() const { return m_handle->name; }
+  uint64_t GetOffset() const { return m_handle->offset; }
+  bool IsEof() const { return m_handle->eof; }
+  bool IsNonblocking() const { return m_handle->nonblocking; }
+
+ private:
+  sftp_file m_handle{nullptr};
+};
+
+/**
+ * This class is a C++ implementation of the SshSessionController in
+ * wpilibsuite/deploy-utils. It handles connecting to an SSH server, running
+ * commands, and transferring files.
+ */
+class Session {
+ public:
+  /**
+   * Constructs a new session controller.
+   *
+   * @param host The hostname of the server to connect to.
+   * @param port The port that the sshd server is operating on.
+   * @param user The username to login as.
+   * @param pass The password for the given username.
+   */
+  Session(std::string_view host, int port, std::string_view user,
+          std::string_view pass);
+
+  /**
+   * Destroys the controller object. This also disconnects the session from the
+   * server.
+   */
+  ~Session();
+
+  /**
+   * Opens the SSH connection to the given host.
+   */
+  void Connect();
+
+  /**
+   * Disconnects the SSH connection.
+   */
+  void Disconnect();
+
+  /**
+   * Reads directory entries
+   *
+   * @param path remote path
+   * @return vector of file attributes
+   */
+  std::vector<Attributes> ReadDir(const std::string& path);
+
+  /**
+   * Unlinks (deletes) a file.
+   *
+   * @param filename filename
+   */
+  void Unlink(const std::string& filename);
+
+  /**
+   * Opens a file.
+   *
+   * @param filename filename
+   * @param accesstype O_RDONLY, O_WRONLY, or O_RDWR, combined with O_CREAT,
+   *                   O_EXCL, or O_TRUNC
+   * @param mode permissions to use if a new file is created
+   * @return File
+   */
+  File Open(const std::string& filename, int accesstype, mode_t mode);
+
+ private:
+  ssh_session m_session{nullptr};
+  sftp_session m_sftp{nullptr};
+  std::string m_host;
+
+  int m_port;
+
+  std::string m_username;
+  std::string m_password;
+};
+
+}  // namespace sftp
diff --git a/datalogtool/src/main/native/cpp/main.cpp b/datalogtool/src/main/native/cpp/main.cpp
new file mode 100644
index 0000000..5f1261b
--- /dev/null
+++ b/datalogtool/src/main/native/cpp/main.cpp
@@ -0,0 +1,25 @@
+// Copyright (c) FIRST and other WPILib contributors.

+// Open Source Software; you can modify and/or share it under the terms of

+// the WPILib BSD license file in the root directory of this project.

+

+#include <string_view>

+

+void Application(std::string_view saveDir);

+

+#ifdef _WIN32

+int __stdcall WinMain(void* hInstance, void* hPrevInstance, char* pCmdLine,

+                      int nCmdShow) {

+  int argc = __argc;

+  char** argv = __argv;

+#else

+int main(int argc, char** argv) {

+#endif

+  std::string_view saveDir;

+  if (argc == 2) {

+    saveDir = argv[1];

+  }

+

+  Application(saveDir);

+

+  return 0;

+}

diff --git a/datalogtool/src/main/native/mac/datalogtool.icns b/datalogtool/src/main/native/mac/datalogtool.icns
new file mode 100644
index 0000000..583eaab
--- /dev/null
+++ b/datalogtool/src/main/native/mac/datalogtool.icns
Binary files differ
diff --git a/datalogtool/src/main/native/resources/dlt-128.png b/datalogtool/src/main/native/resources/dlt-128.png
new file mode 100644
index 0000000..b2ebf51
--- /dev/null
+++ b/datalogtool/src/main/native/resources/dlt-128.png
Binary files differ
diff --git a/datalogtool/src/main/native/resources/dlt-16.png b/datalogtool/src/main/native/resources/dlt-16.png
new file mode 100644
index 0000000..f7439c6
--- /dev/null
+++ b/datalogtool/src/main/native/resources/dlt-16.png
Binary files differ
diff --git a/datalogtool/src/main/native/resources/dlt-256.png b/datalogtool/src/main/native/resources/dlt-256.png
new file mode 100644
index 0000000..c1a40d2
--- /dev/null
+++ b/datalogtool/src/main/native/resources/dlt-256.png
Binary files differ
diff --git a/datalogtool/src/main/native/resources/dlt-32.png b/datalogtool/src/main/native/resources/dlt-32.png
new file mode 100644
index 0000000..1d9a212
--- /dev/null
+++ b/datalogtool/src/main/native/resources/dlt-32.png
Binary files differ
diff --git a/datalogtool/src/main/native/resources/dlt-48.png b/datalogtool/src/main/native/resources/dlt-48.png
new file mode 100644
index 0000000..119d054
--- /dev/null
+++ b/datalogtool/src/main/native/resources/dlt-48.png
Binary files differ
diff --git a/datalogtool/src/main/native/resources/dlt-512.png b/datalogtool/src/main/native/resources/dlt-512.png
new file mode 100644
index 0000000..6dfd821
--- /dev/null
+++ b/datalogtool/src/main/native/resources/dlt-512.png
Binary files differ
diff --git a/datalogtool/src/main/native/resources/dlt-64.png b/datalogtool/src/main/native/resources/dlt-64.png
new file mode 100644
index 0000000..4ad82c7
--- /dev/null
+++ b/datalogtool/src/main/native/resources/dlt-64.png
Binary files differ
diff --git a/datalogtool/src/main/native/win/datalogtool.ico b/datalogtool/src/main/native/win/datalogtool.ico
new file mode 100644
index 0000000..1b90647
--- /dev/null
+++ b/datalogtool/src/main/native/win/datalogtool.ico
Binary files differ
diff --git a/datalogtool/src/main/native/win/datalogtool.rc b/datalogtool/src/main/native/win/datalogtool.rc
new file mode 100644
index 0000000..d0a5fb4
--- /dev/null
+++ b/datalogtool/src/main/native/win/datalogtool.rc
@@ -0,0 +1 @@
+IDI_ICON1 ICON "datalogtool.ico"