Repository: robotpy/mostrobotpy (language: python)

Issue:
Support new structured bindings for ntcore
I think the best way to make it dynamic is to accept any type that has a _wpistruct member (or something) that contains a serialization object of a known type. Then that thing does the conversion, no central registry needed. We would implement a C++ class that implements the correct concept and interacts with the serialization object dynamically, and any function that takes a structured binding concept would be bound with this base type.

Just have to remember to add the little factories for each type that supports it, but that should be easy enough and avoids any dependency issues, and if we're careful you should be able to easily support your own structured binding objects from python. 


Discussion / hints:
Blocked by https://github.com/wpilibsuite/allwpilib/issues/5964
Unblocked by https://github.com/wpilibsuite/allwpilib/pull/5992, need to sit down and do this.
Just kidding, still blocked by https://github.com/wpilibsuite/allwpilib/issues/5964

Fix patch:
diff --git a/docs/.gitignore b/docs/.gitignore
index a8b9c11b..0e0c20b4 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -45,6 +45,10 @@
 /wpimath.interpolation/
 /wpimath.kinematics.rst
 /wpimath.kinematics/
+/wpimath.optimization.rst
+/wpimath.optimization/
+/wpimath.path.rst
+/wpimath.path/
 /wpimath.rst
 /wpimath.spline.rst
 /wpimath.spline/
@@ -67,3 +71,5 @@
 /wpiutil.log/
 /wpiutil.sync.rst
 /wpiutil.sync/
+/wpiutil.wpistruct.rst
+/wpiutil.wpistruct/
diff --git a/docs/api.rst b/docs/api.rst
index 833cf289..092e4b78 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -64,6 +64,8 @@ tailored to the needs of robotics applications.
   wpimath.geometry
   wpimath.interpolation
   wpimath.kinematics
+  wpimath.optimization
+  wpimath.path
   wpimath.spline
   wpimath.system
   wpimath.system.plant
@@ -101,6 +103,7 @@ of things in it.
   wpiutil
   wpiutil.log
   wpiutil.sync
+  wpiutil.wpistruct
 
 
 .. _hal_api:
diff --git a/docs/conf.py b/docs/conf.py
index 04361221..5959bae9 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -98,6 +98,7 @@
 gen_package(root, "wpiutil")
 gen_package(root, "wpiutil.log")
 gen_package(root, "wpiutil.sync")
+gen_package(root, "wpiutil.wpistruct")
 
 # HAL
 gen_package(root, "hal", include=["Sim*"])
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 688d2683..24a12c57 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,3 +1,4 @@
 sphinx
 sphinx-rtd-theme
-robotpy-sphinx-plugin~=0.2
\ No newline at end of file
+robotpy-sphinx-plugin~=0.2
+opencv-python
diff --git a/rdev.toml b/rdev.toml
index 03263a77..be716ed0 100644
--- a/rdev.toml
+++ b/rdev.toml
@@ -4,16 +4,16 @@
 
 [params]
 
-wpilib_bin_url = "https://frcmaven.wpi.edu/artifactory/release"
-wpilib_bin_version = "2024.1.1-beta-4"
-# wpilib_bin_url = "https://frcmaven.wpi.edu/artifactory/development"
+# wpilib_bin_url = "https://frcmaven.wpi.edu/artifactory/release"
+wpilib_bin_version = "2024.1.1-beta-4-37-g6a2d3c3"
+wpilib_bin_url = "https://frcmaven.wpi.edu/artifactory/development"
 
 # Don't update these artifacts
 exclude_artifacts = [
     "opencv-cpp"
 ]
 
-robotpy_build_req = "<2025.0.0,~=2024.0.0b8"
+robotpy_build_req = "<2025.0.0,~=2024.0.0b9"
 
 #
 # Subproject configuration
diff --git a/subprojects/pyntcore/gen/NetworkTableInstance.yml b/subprojects/pyntcore/gen/NetworkTableInstance.yml
index e7e6eedb..b6c7bda0 100644
--- a/subprojects/pyntcore/gen/NetworkTableInstance.yml
+++ b/subprojects/pyntcore/gen/NetworkTableInstance.yml
@@ -13,10 +13,13 @@ extra_includes:
 - networktables/RawTopic.h
 - networktables/StringArrayTopic.h
 - networktables/StringTopic.h
+- networktables/StructArrayTopic.h
+- networktables/StructTopic.h
 - networktables/Topic.h
 - src/py2value.h
 - src/nt_instance.h
 - wpi/DataLog.h
+- wpystruct.h
 
 classes:
   NetworkTableInstance:
@@ -64,9 +67,23 @@ classes:
       GetProtobufTopic:
         ignore: true
       GetStructTopic:
-        ignore: true
+        param_override:
+          info:
+            name: type
+        cpp_code: |
+          [](const NetworkTableInstance &self, std::string_view name, const py::type &t) {
+            WPyStructInfo info(t);
+            return self.GetStructTopic<WPyStruct, WPyStructInfo>(name, info);
+          }
       GetStructArrayTopic:
-        ignore: true
+        param_override:
+          info:
+            name: type
+        cpp_code: |
+          [](const NetworkTableInstance &self, std::string_view name, const py::type &t) {
+            WPyStructInfo info(t);
+            return self.GetStructArrayTopic<WPyStruct, WPyStructInfo>(name, info);
+          }
       GetTopics:
         overloads:
           "":
diff --git a/subprojects/pyntcore/gen/StructArrayTopic.yml b/subprojects/pyntcore/gen/StructArrayTopic.yml
new file mode 100644
index 00000000..b072590b
--- /dev/null
+++ b/subprojects/pyntcore/gen/StructArrayTopic.yml
@@ -0,0 +1,115 @@
+---
+
+classes:
+  StructArraySubscriber:
+    template_params:
+    - T
+    - I
+    methods:
+      StructArraySubscriber:
+        overloads:
+          "":
+            ignore: true
+          NT_Subscriber, U&&, I...:
+            ignore: true
+      Get:
+        overloads:
+          '[const]':
+          U&& [const]:
+            ignore: true
+          std::span<const T> [const]:
+      GetAtomic:
+        overloads:
+          '[const]':
+          U&& [const]:
+            ignore: true
+          std::span<const T> [const]:
+      ReadQueue:
+      GetTopic:
+  StructArrayPublisher:
+    template_params:
+    - T
+    - I
+    methods:
+      StructArrayPublisher:
+        overloads:
+          "":
+            ignore: true
+          NT_Publisher, I...:
+            ignore: true
+      Set:
+        overloads:
+          U&&, int64_t:
+            ignore: true
+          std::span<const T>, int64_t:
+      SetDefault:
+        overloads:
+          U&&:
+            ignore: true
+          std::span<const T>:
+      GetTopic:
+  StructArrayEntry:
+    template_params:
+    - T
+    - I
+    base_qualnames:
+      StructArraySubscriber: nt::StructArraySubscriber<T, I>
+      StructArrayPublisher: nt::StructArrayPublisher<T, I>
+    methods:
+      StructArrayEntry:
+        overloads:
+          "":
+            ignore: true
+          NT_Entry, U&&, const I&...:
+            ignore: true
+      GetHandle:
+        ignore: true
+      GetTopic:
+      Unpublish:
+  StructArrayTopic:
+    template_params:
+    - T
+    - I
+    methods:
+      StructArrayTopic:
+        overloads:
+          "":
+            ignore: true
+          NT_Topic, I...:
+            ignore: true
+          Topic, I...:
+            ignore: true
+      Subscribe:
+        overloads:
+          U&&, const PubSubOptions&:
+            ignore: true
+          std::span<const T>, const PubSubOptions&:
+      Publish:
+      PublishEx:
+      GetEntry:
+        overloads:
+          U&&, const PubSubOptions&:
+            ignore: true
+          std::span<const T>, const PubSubOptions&:
+
+templates:
+  StructArraySubscriber:
+    qualname: nt::StructArraySubscriber
+    params:
+    - WPyStruct
+    - WPyStructInfo
+  StructArrayPublisher:
+    qualname: nt::StructArrayPublisher
+    params:
+    - WPyStruct
+    - WPyStructInfo
+  StructArrayEntry:
+    qualname: nt::StructArrayEntry
+    params:
+    - WPyStruct
+    - WPyStructInfo
+  StructArrayTopic:
+    qualname: nt::StructArrayTopic
+    params:
+    - WPyStruct
+    - WPyStructInfo
\ No newline at end of file
diff --git a/subprojects/pyntcore/gen/StructTopic.yml b/subprojects/pyntcore/gen/StructTopic.yml
new file mode 100644
index 00000000..2efb49e2
--- /dev/null
+++ b/subprojects/pyntcore/gen/StructTopic.yml
@@ -0,0 +1,97 @@
+---
+
+classes:
+  StructSubscriber:
+    template_params:
+    - T
+    - I
+    methods:
+      StructSubscriber:
+        overloads:
+          "":
+            ignore: true
+          NT_Subscriber, T, I...:
+            ignore: true
+      Get:
+        overloads:
+          '[const]':
+          const T& [const]:
+      GetInto:
+        ignore: true
+      GetAtomic:
+        overloads:
+          '[const]':
+          const T& [const]:
+      ReadQueue:
+      GetTopic:
+  StructPublisher:
+    template_params:
+    - T
+    - I
+    methods:
+      StructPublisher:
+        overloads:
+          "":
+            ignore: true
+          NT_Publisher, I...:
+            ignore: true
+      Set:
+      SetDefault:
+      GetTopic:
+  StructEntry:
+    template_params:
+    - T
+    - I
+    base_qualnames:
+      StructSubscriber: nt::StructSubscriber<T, I>
+      StructPublisher: nt::StructPublisher<T, I>
+    methods:
+      StructEntry:
+        overloads:
+          "":
+            ignore: true
+          NT_Entry, T, const I&...:
+            ignore: true
+      GetHandle:
+        ignore: true
+      GetTopic:
+      Unpublish:
+  StructTopic:
+    template_params:
+    - T
+    - I
+    methods:
+      StructTopic:
+        overloads:
+          "":
+            ignore: true
+          NT_Topic, I...:
+            ignore: true
+          Topic, I...:
+            ignore: true
+      Subscribe:
+      Publish:
+      PublishEx:
+      GetEntry:
+
+templates:
+  StructSubscriber:
+    qualname: nt::StructSubscriber
+    params:
+    - WPyStruct
+    - WPyStructInfo
+  StructPublisher:
+    qualname: nt::StructPublisher
+    params:
+    - WPyStruct
+    - WPyStructInfo
+  StructEntry:
+    qualname: nt::StructEntry
+    params:
+    - WPyStruct
+    - WPyStructInfo
+  StructTopic:
+    qualname: nt::StructTopic
+    params:
+    - WPyStruct
+    - WPyStructInfo
diff --git a/subprojects/pyntcore/gen/ntcore_cpp_types.yml b/subprojects/pyntcore/gen/ntcore_cpp_types.yml
index 7a125a05..aa0e866d 100644
--- a/subprojects/pyntcore/gen/ntcore_cpp_types.yml
+++ b/subprojects/pyntcore/gen/ntcore_cpp_types.yml
@@ -74,3 +74,11 @@ templates:
     qualname: nt::Timestamped
     params:
     - std::vector<std::string>
+  TimestampedStruct:
+    qualname: nt::Timestamped
+    params:
+    - WPyStruct
+  TimestampedStructArray:
+    qualname: nt::Timestamped
+    params:
+    - std::vector<WPyStruct>
\ No newline at end of file
diff --git a/subprojects/pyntcore/pyproject.toml b/subprojects/pyntcore/pyproject.toml
index aa747639..c99f73cd 100644
--- a/subprojects/pyntcore/pyproject.toml
+++ b/subprojects/pyntcore/pyproject.toml
@@ -12,7 +12,7 @@ install_requires = [
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
     "robotpy-wpiutil~=2024.0.0b4",
     "robotpy-wpinet~=2024.0.0b4",
 ]
@@ -23,8 +23,8 @@ base_package = "ntcore"
 [tool.robotpy-build.wrappers."ntcore".maven_lib_download]
 artifact_id = "ntcore-cpp"
 group_id = "edu.wpi.first.ntcore"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 libs = ["ntcore"]
 
@@ -61,6 +61,8 @@ NetworkTableListener = "networktables/NetworkTableListener.h"
 NetworkTableType = "networktables/NetworkTableType.h"
 NetworkTableValue = "networktables/NetworkTableValue.h"
 RawTopic = "networktables/RawTopic.h"
+StructTopic = "networktables/StructTopic.h"
+StructArrayTopic = "networktables/StructArrayTopic.h"
 StringArrayTopic = "networktables/StringArrayTopic.h"
 StringTopic = "networktables/StringTopic.h"
 Topic = "networktables/Topic.h"
diff --git a/subprojects/robotpy-apriltag/pyproject.toml b/subprojects/robotpy-apriltag/pyproject.toml
index 173ba5f2..8eadbd28 100644
--- a/subprojects/robotpy-apriltag/pyproject.toml
+++ b/subprojects/robotpy-apriltag/pyproject.toml
@@ -12,7 +12,7 @@ install_requires = [
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
     "robotpy-wpiutil~=2024.0.0b4",
     "robotpy-wpimath~=2024.0.0b4",
 ]
@@ -23,8 +23,8 @@ base_package = "robotpy_apriltag"
 [tool.robotpy-build.wrappers."robotpy_apriltag".maven_lib_download]
 artifact_id = "apriltag-cpp"
 group_id = "edu.wpi.first.apriltag"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 libs = ["apriltag"]
 
diff --git a/subprojects/robotpy-cscore/pyproject.toml b/subprojects/robotpy-cscore/pyproject.toml
index 1d4edd1f..9f80fd19 100644
--- a/subprojects/robotpy-cscore/pyproject.toml
+++ b/subprojects/robotpy-cscore/pyproject.toml
@@ -13,7 +13,7 @@ install_requires = [
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
     "robotpy-wpiutil~=2024.0.0b4",
     "robotpy-wpinet~=2024.0.0b4",
     "pyntcore~=2024.0.0b4",
@@ -26,16 +26,16 @@ base_package = "cscore"
 [tool.robotpy-build.static_libs."cscore_cpp".maven_lib_download]
 artifact_id = "cscore-cpp"
 group_id = "edu.wpi.first.cscore"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 libs = ["cscore"]
 
 [tool.robotpy-build.static_libs."cameraserver_cpp".maven_lib_download]
 artifact_id = "cameraserver-cpp"
 group_id = "edu.wpi.first.cameraserver"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 libs = ["cameraserver"]
 
diff --git a/subprojects/robotpy-hal/gen/LEDs.yml b/subprojects/robotpy-hal/gen/LEDs.yml
new file mode 100644
index 00000000..d8f4d5c5
--- /dev/null
+++ b/subprojects/robotpy-hal/gen/LEDs.yml
@@ -0,0 +1,7 @@
+---
+
+enums:
+  HAL_RadioLEDState:
+functions:
+  HAL_SetRadioLEDState:
+  HAL_GetRadioLEDState:
diff --git a/subprojects/robotpy-hal/gen/simulation/RoboRioData.yml b/subprojects/robotpy-hal/gen/simulation/RoboRioData.yml
index 2072579a..6a9b795d 100644
--- a/subprojects/robotpy-hal/gen/simulation/RoboRioData.yml
+++ b/subprojects/robotpy-hal/gen/simulation/RoboRioData.yml
@@ -107,3 +107,8 @@ functions:
   HALSIM_CancelRoboRioCPUTempCallback:
   HALSIM_GetRoboRioCPUTemp:
   HALSIM_SetRoboRioCPUTemp:
+  HALSIM_RegisterRoboRioRadioLEDStateCallback:
+    ignore: true
+  HALSIM_CancelRoboRioRadioLEDStateCallback:
+  HALSIM_GetRoboRioRadioLEDState:
+  HALSIM_SetRoboRioRadioLEDState:
\ No newline at end of file
diff --git a/subprojects/robotpy-hal/pyproject.toml b/subprojects/robotpy-hal/pyproject.toml
index b1f33a69..eb9ee69e 100644
--- a/subprojects/robotpy-hal/pyproject.toml
+++ b/subprojects/robotpy-hal/pyproject.toml
@@ -11,7 +11,7 @@ install_requires = [
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
     "robotpy-wpiutil~=2024.0.0b4",
 ]
 
@@ -23,8 +23,8 @@ base_package = "hal"
 artifact_id = "hal-cpp"
 group_id = "edu.wpi.first.hal"
 # repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 libs = ["wpiHal"]
 
@@ -69,6 +69,7 @@ HALBase = "hal/HALBase.h"
 I2C = "hal/I2C.h"
 I2CTypes = "hal/I2CTypes.h"
 Interrupts = "hal/Interrupts.h"
+LEDs = "hal/LEDs.h"
 Main = "hal/Main.h"
 Notifier = "hal/Notifier.h"
 PWM = "hal/PWM.h"
diff --git a/subprojects/robotpy-halsim-ds-socket/pyproject.toml b/subprojects/robotpy-halsim-ds-socket/pyproject.toml
index 4dac1fdd..2159487b 100644
--- a/subprojects/robotpy-halsim-ds-socket/pyproject.toml
+++ b/subprojects/robotpy-halsim-ds-socket/pyproject.toml
@@ -15,7 +15,7 @@ robotpysimext = ["ds-socket = halsim_ds_socket"]
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
     "robotpy-hal~=2024.0.0b4",
     "robotpy-wpinet~=2024.0.0b4",
 ]
@@ -27,8 +27,8 @@ base_package = "halsim_ds_socket"
 artifact_id = "halsim_ds_socket"
 group_id = "edu.wpi.first.halsim"
 # repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 dlopenlibs = ["halsim_ds_socket"]
 
diff --git a/subprojects/robotpy-halsim-gui/pyproject.toml b/subprojects/robotpy-halsim-gui/pyproject.toml
index a676cb69..e23a05d3 100644
--- a/subprojects/robotpy-halsim-gui/pyproject.toml
+++ b/subprojects/robotpy-halsim-gui/pyproject.toml
@@ -14,7 +14,7 @@ install_requires = [
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
     "robotpy-wpiutil~=2024.0.0b4",
     "robotpy-wpimath~=2024.0.0b4",
     "robotpy-hal~=2024.0.0b4",
@@ -29,8 +29,8 @@ base_package = "halsim_gui"
 artifact_id = "halsim_gui"
 group_id = "edu.wpi.first.halsim"
 # repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 dlopenlibs = ["halsim_gui"]
 
diff --git a/subprojects/robotpy-halsim-ws/pyproject.toml b/subprojects/robotpy-halsim-ws/pyproject.toml
index 57d101df..cfe03640 100644
--- a/subprojects/robotpy-halsim-ws/pyproject.toml
+++ b/subprojects/robotpy-halsim-ws/pyproject.toml
@@ -19,7 +19,7 @@ robotpysimext = [
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
     "robotpy-hal~=2024.0.0b4",
     "robotpy-wpinet~=2024.0.0b4",
 ]
@@ -31,8 +31,8 @@ base_package = "halsim_ws"
 artifact_id = "halsim_ws_server"
 group_id = "edu.wpi.first.halsim"
 # repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 dlopenlibs = ["halsim_ws_server"]
 
@@ -44,8 +44,8 @@ depends = ["wpiHal", "wpinet"]
 artifact_id = "halsim_ws_client"
 group_id = "edu.wpi.first.halsim"
 # repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 dlopenlibs = ["halsim_ws_client"]
 
diff --git a/subprojects/robotpy-wpilib/pyproject.toml b/subprojects/robotpy-wpilib/pyproject.toml
index 765bfc35..e04a3653 100644
--- a/subprojects/robotpy-wpilib/pyproject.toml
+++ b/subprojects/robotpy-wpilib/pyproject.toml
@@ -17,7 +17,7 @@ robotpy = ["run = wpilib._impl.start:Main"]
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
     "robotpy-wpiutil~=2024.0.0b4",
     "robotpy-wpimath~=2024.0.0b4",
     "robotpy-hal~=2024.0.0b4",
@@ -30,8 +30,8 @@ base_package = "wpilib"
 [tool.robotpy-build.wrappers."wpilib._impl".maven_lib_download]
 artifact_id = "wpilibc-cpp"
 group_id = "edu.wpi.first.wpilibc"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 libs = ["wpilibc"]
 
@@ -162,6 +162,7 @@ Jaguar = "frc/motorcontrol/Jaguar.h"
 MotorControllerGroup = "rpy/MotorControllerGroup.h"
 NidecBrushless = "frc/motorcontrol/NidecBrushless.h"
 PWMMotorController = "frc/motorcontrol/PWMMotorController.h"
+PWMSparkFlex = "frc/motorcontrol/PWMSparkFlex.h"
 PWMSparkMax = "frc/motorcontrol/PWMSparkMax.h"
 PWMTalonFX = "frc/motorcontrol/PWMTalonFX.h"
 PWMTalonSRX = "frc/motorcontrol/PWMTalonSRX.h"
diff --git a/subprojects/robotpy-wpimath/gen/controls/ArmFeedforward.yml b/subprojects/robotpy-wpimath/gen/controls/ArmFeedforward.yml
index e1981fbe..d55f091a 100644
--- a/subprojects/robotpy-wpimath/gen/controls/ArmFeedforward.yml
+++ b/subprojects/robotpy-wpimath/gen/controls/ArmFeedforward.yml
@@ -25,3 +25,9 @@ classes:
       MinAchievableVelocity:
       MaxAchievableAcceleration:
       MinAchievableAcceleration:
+
+extra_includes:
+- wpystruct.h
+
+inline_code: |
+  SetupWPyStruct<frc::ArmFeedforward>(cls_ArmFeedforward);
diff --git a/subprojects/robotpy-wpimath/gen/controls/DCMotor.yml b/subprojects/robotpy-wpimath/gen/controls/DCMotor.yml
index 8aac6476..2a8c6d8c 100644
--- a/subprojects/robotpy-wpimath/gen/controls/DCMotor.yml
+++ b/subprojects/robotpy-wpimath/gen/controls/DCMotor.yml
@@ -1,5 +1,8 @@
 ---
 
+extra_includes:
+- wpystruct.h
+
 classes:
   DCMotor:
     shared_ptr: true
@@ -36,3 +39,6 @@ classes:
       KrakenX60:
       KrakenX60FOC:
       NeoVortex:
+
+inline_code: |
+  SetupWPyStruct<frc::DCMotor>(cls_DCMotor);
diff --git a/subprojects/robotpy-wpimath/gen/controls/DifferentialDriveWheelVoltages.yml b/subprojects/robotpy-wpimath/gen/controls/DifferentialDriveWheelVoltages.yml
index 2eccadc4..238b882e 100644
--- a/subprojects/robotpy-wpimath/gen/controls/DifferentialDriveWheelVoltages.yml
+++ b/subprojects/robotpy-wpimath/gen/controls/DifferentialDriveWheelVoltages.yml
@@ -8,6 +8,9 @@ classes:
       left:
       right:
 
+extra_includes:
+- wpystruct.h
+
 inline_code:
   cls_DifferentialDriveWheelVoltages
     .def(py::init<units::volt_t, units::volt_t>(),
@@ -17,4 +20,6 @@ inline_code:
         "left=" + std::to_string(self->left()) + ","
         "right=" + std::to_string(self->right()) + ")";
     })
-  ;
\ No newline at end of file
+  ;
+
+  SetupWPyStruct<frc::DifferentialDriveWheelVoltages>(cls_DifferentialDriveWheelVoltages);
diff --git a/subprojects/robotpy-wpimath/gen/controls/ElevatorFeedforward.yml b/subprojects/robotpy-wpimath/gen/controls/ElevatorFeedforward.yml
index c5bfd662..3f4084e9 100644
--- a/subprojects/robotpy-wpimath/gen/controls/ElevatorFeedforward.yml
+++ b/subprojects/robotpy-wpimath/gen/controls/ElevatorFeedforward.yml
@@ -21,3 +21,9 @@ classes:
       MinAchievableVelocity:
       MaxAchievableAcceleration:
       MinAchievableAcceleration:
+
+extra_includes:
+- wpystruct.h
+
+inline_code: |
+  SetupWPyStruct<frc::ElevatorFeedforward>(cls_ElevatorFeedforward);
diff --git a/subprojects/robotpy-wpimath/gen/controls/LinearPlantInversionFeedforward.yml b/subprojects/robotpy-wpimath/gen/controls/LinearPlantInversionFeedforward.yml
index 365657a6..ba15bcbd 100644
--- a/subprojects/robotpy-wpimath/gen/controls/LinearPlantInversionFeedforward.yml
+++ b/subprojects/robotpy-wpimath/gen/controls/LinearPlantInversionFeedforward.yml
@@ -28,11 +28,6 @@ classes:
         overloads:
           const StateVector&:
           const StateVector&, const StateVector&:
-    template_inline_code: |
-      cls_LinearPlantInversionFeedforward
-        .def(py::init<const frc::LinearSystem<States, Inputs, 1>&, units::second_t>())
-        .def(py::init<const frc::LinearSystem<States, Inputs, 2>&, units::second_t>())
-        .def(py::init<const frc::LinearSystem<States, Inputs, 3>&, units::second_t>());
 
 templates:
   LinearPlantInversionFeedforward_1_1:
diff --git a/subprojects/robotpy-wpimath/gen/controls/SimpleMotorFeedforward.yml b/subprojects/robotpy-wpimath/gen/controls/SimpleMotorFeedforward.yml
index 2cf9fd50..6adbb0c9 100644
--- a/subprojects/robotpy-wpimath/gen/controls/SimpleMotorFeedforward.yml
+++ b/subprojects/robotpy-wpimath/gen/controls/SimpleMotorFeedforward.yml
@@ -35,11 +35,6 @@ classes:
       MinAchievableVelocity:
       MaxAchievableAcceleration:
       MinAchievableAcceleration:
-    template_inline_code: |
-      cls_SimpleMotorFeedforward
-        .def_readwrite("kS", &SimpleMotorFeedforward<Distance>::kS)
-        .def_readwrite("kV", &SimpleMotorFeedforward<Distance>::kV)
-        .def_readwrite("kA", &SimpleMotorFeedforward<Distance>::kA);
 
 templates:
   # Unfortunately this is broken because calculate requires an SI unit
diff --git a/subprojects/robotpy-wpimath/gen/geometry/Pose2d.yml b/subprojects/robotpy-wpimath/gen/geometry/Pose2d.yml
index dfc90f84..de536214 100644
--- a/subprojects/robotpy-wpimath/gen/geometry/Pose2d.yml
+++ b/subprojects/robotpy-wpimath/gen/geometry/Pose2d.yml
@@ -1,6 +1,7 @@
 ---
 extra_includes:
   - rpy/geometryToString.h
+  - wpystruct.h
 
 functions:
   to_json:
@@ -62,3 +63,5 @@ inline_code: |
       return self->Y();
     })
     .def("__repr__", py::overload_cast<const Pose2d&>(&rpy::toString));
+
+  SetupWPyStruct<frc::Pose2d>(cls_Pose2d);
diff --git a/subprojects/robotpy-wpimath/gen/geometry/Pose3d.yml b/subprojects/robotpy-wpimath/gen/geometry/Pose3d.yml
index afbce904..f8f6a5c4 100644
--- a/subprojects/robotpy-wpimath/gen/geometry/Pose3d.yml
+++ b/subprojects/robotpy-wpimath/gen/geometry/Pose3d.yml
@@ -1,6 +1,7 @@
 ---
 extra_includes:
   - rpy/geometryToString.h
+  - wpystruct.h
 
 functions:
   to_json:
@@ -53,3 +54,5 @@ inline_code: |
       return self->Z();
     })
     .def("__repr__", py::overload_cast<const Pose3d&>(&rpy::toString));
+
+  SetupWPyStruct<frc::Pose3d>(cls_Pose3d);
diff --git a/subprojects/robotpy-wpimath/gen/geometry/Quaternion.yml b/subprojects/robotpy-wpimath/gen/geometry/Quaternion.yml
index 212fc9cf..42e227af 100644
--- a/subprojects/robotpy-wpimath/gen/geometry/Quaternion.yml
+++ b/subprojects/robotpy-wpimath/gen/geometry/Quaternion.yml
@@ -1,6 +1,7 @@
 ---
 extra_includes:
   - rpy/geometryToString.h
+  - wpystruct.h
 
 functions:
   to_json:
@@ -44,4 +45,6 @@ classes:
 
 inline_code: |
   cls_Quaternion
-    .def("__repr__", py::overload_cast<const Quaternion&>(&rpy::toString));
\ No newline at end of file
+    .def("__repr__", py::overload_cast<const Quaternion&>(&rpy::toString));
+
+  SetupWPyStruct<frc::Quaternion>(cls_Quaternion);
diff --git a/subprojects/robotpy-wpimath/gen/geometry/Rotation2d.yml b/subprojects/robotpy-wpimath/gen/geometry/Rotation2d.yml
index 7655cd21..1d72f79e 100644
--- a/subprojects/robotpy-wpimath/gen/geometry/Rotation2d.yml
+++ b/subprojects/robotpy-wpimath/gen/geometry/Rotation2d.yml
@@ -1,6 +1,7 @@
 ---
 extra_includes:
   - rpy/geometryToString.h
+  - wpystruct.h
 
 functions:
   to_json:
@@ -42,3 +43,5 @@ inline_code: |
       return std::make_unique<Rotation2d>(value);
     }, py::arg("value"))
     .def("__repr__", py::overload_cast<const Rotation2d&>(&rpy::toString));
+
+  SetupWPyStruct<frc::Rotation2d>(cls_Rotation2d);
diff --git a/subprojects/robotpy-wpimath/gen/geometry/Rotation3d.yml b/subprojects/robotpy-wpimath/gen/geometry/Rotation3d.yml
index e614e5d4..d363ba0e 100644
--- a/subprojects/robotpy-wpimath/gen/geometry/Rotation3d.yml
+++ b/subprojects/robotpy-wpimath/gen/geometry/Rotation3d.yml
@@ -1,6 +1,7 @@
 ---
 extra_includes:
   - rpy/geometryToString.h
+  - wpystruct.h
 
 functions:
   to_json:
@@ -59,4 +60,6 @@ inline_code: |
     .def_property_readonly("angle_degrees", [](const Rotation3d * self) -> units::degree_t {
       return self->Angle();
     })
-    .def("__repr__", py::overload_cast<const Rotation3d&>(&rpy::toString));
\ No newline at end of file
+    .def("__repr__", py::overload_cast<const Rotation3d&>(&rpy::toString));
+
+  SetupWPyStruct<frc::Rotation3d>(cls_Rotation3d);
diff --git a/subprojects/robotpy-wpimath/gen/geometry/Transform2d.yml b/subprojects/robotpy-wpimath/gen/geometry/Transform2d.yml
index 05bb7641..900f623b 100644
--- a/subprojects/robotpy-wpimath/gen/geometry/Transform2d.yml
+++ b/subprojects/robotpy-wpimath/gen/geometry/Transform2d.yml
@@ -3,6 +3,7 @@
 extra_includes:
 - frc/geometry/Pose2d.h
 - rpy/geometryToString.h
+- wpystruct.h
 
 classes:
   Transform2d:
@@ -47,3 +48,5 @@ inline_code: |
     })
     .def("__repr__", py::overload_cast<const Transform2d&>(&rpy::toString));
   ;
+
+  SetupWPyStruct<frc::Transform2d>(cls_Transform2d);
diff --git a/subprojects/robotpy-wpimath/gen/geometry/Transform3d.yml b/subprojects/robotpy-wpimath/gen/geometry/Transform3d.yml
index 488f3cfb..b9692481 100644
--- a/subprojects/robotpy-wpimath/gen/geometry/Transform3d.yml
+++ b/subprojects/robotpy-wpimath/gen/geometry/Transform3d.yml
@@ -1,6 +1,7 @@
 ---
 extra_includes:
   - rpy/geometryToString.h
+  - wpystruct.h
 
 functions:
   to_json:
@@ -45,3 +46,5 @@ inline_code: |
       return self->Z();
     })
     .def("__repr__", py::overload_cast<const Transform3d&>(&rpy::toString));
+
+  SetupWPyStruct<frc::Transform3d>(cls_Transform3d);
diff --git a/subprojects/robotpy-wpimath/gen/geometry/Translation2d.yml b/subprojects/robotpy-wpimath/gen/geometry/Translation2d.yml
index e85f0bbc..21abb970 100644
--- a/subprojects/robotpy-wpimath/gen/geometry/Translation2d.yml
+++ b/subprojects/robotpy-wpimath/gen/geometry/Translation2d.yml
@@ -1,6 +1,7 @@
 ---
 extra_includes:
   - rpy/geometryToString.h
+  - wpystruct.h
 
 functions:
   to_json:
@@ -73,3 +74,5 @@ inline_code: |
       }
     })
     .def("__repr__", py::overload_cast<const Translation2d&>(&rpy::toString));
+
+  SetupWPyStruct<frc::Translation2d>(cls_Translation2d);
diff --git a/subprojects/robotpy-wpimath/gen/geometry/Translation3d.yml b/subprojects/robotpy-wpimath/gen/geometry/Translation3d.yml
index b99f29e3..52608c0f 100644
--- a/subprojects/robotpy-wpimath/gen/geometry/Translation3d.yml
+++ b/subprojects/robotpy-wpimath/gen/geometry/Translation3d.yml
@@ -1,6 +1,7 @@
 ---
 extra_includes:
   - rpy/geometryToString.h
+  - wpystruct.h
 
 functions:
   to_json:
@@ -71,3 +72,5 @@ inline_code: |
       }
     })
     .def("__repr__", py::overload_cast<const Translation3d&>(&rpy::toString));
+
+  SetupWPyStruct<frc::Translation3d>(cls_Translation3d);
diff --git a/subprojects/robotpy-wpimath/gen/geometry/Twist2d.yml b/subprojects/robotpy-wpimath/gen/geometry/Twist2d.yml
index 938c965b..00f4e628 100644
--- a/subprojects/robotpy-wpimath/gen/geometry/Twist2d.yml
+++ b/subprojects/robotpy-wpimath/gen/geometry/Twist2d.yml
@@ -1,5 +1,8 @@
 ---
 
+extra_includes:
+- wpystruct.h
+
 classes:
   Twist2d:
     force_no_default_constructor: true
@@ -53,3 +56,5 @@ inline_code: |
                      "dtheta=" + std::to_string(tw.dtheta()) + ")";
     })
   ;
+
+  SetupWPyStruct<frc::Twist2d>(cls_Twist2d);
diff --git a/subprojects/robotpy-wpimath/gen/kinematics/ChassisSpeeds.yml b/subprojects/robotpy-wpimath/gen/kinematics/ChassisSpeeds.yml
index 8c6ad889..920c9907 100644
--- a/subprojects/robotpy-wpimath/gen/kinematics/ChassisSpeeds.yml
+++ b/subprojects/robotpy-wpimath/gen/kinematics/ChassisSpeeds.yml
@@ -1,5 +1,8 @@
 ---
 
+extra_includes:
+- wpystruct.h
+
 classes:
   ChassisSpeeds:
     force_no_default_constructor: true
@@ -71,3 +74,5 @@ inline_code: |
                            "omega=" + std::to_string(cs.omega()) + ")";
     })
   ;
+
+  SetupWPyStruct<frc::ChassisSpeeds>(cls_ChassisSpeeds);
diff --git a/subprojects/robotpy-wpimath/gen/kinematics/DifferentialDriveKinematics.yml b/subprojects/robotpy-wpimath/gen/kinematics/DifferentialDriveKinematics.yml
index e09e5d51..36d43e91 100644
--- a/subprojects/robotpy-wpimath/gen/kinematics/DifferentialDriveKinematics.yml
+++ b/subprojects/robotpy-wpimath/gen/kinematics/DifferentialDriveKinematics.yml
@@ -1,5 +1,8 @@
 ---
 
+extra_includes:
+- wpystruct.h
+
 classes:
   DifferentialDriveKinematics:
     attributes:
@@ -9,3 +12,6 @@ classes:
       ToChassisSpeeds:
       ToWheelSpeeds:
       ToTwist2d:
+
+inline_code: |
+  SetupWPyStruct<frc::DifferentialDriveKinematics>(cls_DifferentialDriveKinematics);
diff --git a/subprojects/robotpy-wpimath/gen/kinematics/DifferentialDriveWheelSpeeds.yml b/subprojects/robotpy-wpimath/gen/kinematics/DifferentialDriveWheelSpeeds.yml
index db595d73..50f38916 100644
--- a/subprojects/robotpy-wpimath/gen/kinematics/DifferentialDriveWheelSpeeds.yml
+++ b/subprojects/robotpy-wpimath/gen/kinematics/DifferentialDriveWheelSpeeds.yml
@@ -1,5 +1,8 @@
 ---
 
+extra_includes:
+- wpystruct.h
+
 classes:
   DifferentialDriveWheelSpeeds:
     force_no_default_constructor: true
@@ -50,3 +53,5 @@ inline_code: |
     })
   ;
 
+  SetupWPyStruct<frc::DifferentialDriveWheelSpeeds>(cls_DifferentialDriveWheelSpeeds);
+
diff --git a/subprojects/robotpy-wpimath/gen/kinematics/MecanumDriveKinematics.yml b/subprojects/robotpy-wpimath/gen/kinematics/MecanumDriveKinematics.yml
index e1a5f71a..ba847854 100644
--- a/subprojects/robotpy-wpimath/gen/kinematics/MecanumDriveKinematics.yml
+++ b/subprojects/robotpy-wpimath/gen/kinematics/MecanumDriveKinematics.yml
@@ -1,5 +1,8 @@
 ---
 
+extra_includes:
+- wpystruct.h
+
 classes:
   MecanumDriveKinematics:
     methods:
@@ -37,3 +40,6 @@ classes:
       GetFrontRightWheel:
       GetRearLeftWheel:
       GetRearRightWheel:
+
+inline_code: |
+  SetupWPyStruct<frc::MecanumDriveKinematics>(cls_MecanumDriveKinematics);
diff --git a/subprojects/robotpy-wpimath/gen/kinematics/MecanumDriveWheelSpeeds.yml b/subprojects/robotpy-wpimath/gen/kinematics/MecanumDriveWheelSpeeds.yml
index 3a08e982..3e62b807 100644
--- a/subprojects/robotpy-wpimath/gen/kinematics/MecanumDriveWheelSpeeds.yml
+++ b/subprojects/robotpy-wpimath/gen/kinematics/MecanumDriveWheelSpeeds.yml
@@ -1,5 +1,8 @@
 ---
 
+extra_includes:
+- wpystruct.h
+
 classes:
   MecanumDriveWheelSpeeds:
     force_no_default_constructor: true
@@ -77,4 +80,6 @@ inline_code: |
                                      "rearLeft=" + std::to_string(ms.rearLeft()) + ", "
                                      "rearRight=" + std::to_string(ms.rearRight()) + ")";
     })
-  ;
\ No newline at end of file
+  ;
+
+  SetupWPyStruct<frc::MecanumDriveWheelSpeeds>(cls_MecanumDriveWheelSpeeds);
diff --git a/subprojects/robotpy-wpimath/gen/kinematics/SwerveModulePosition.yml b/subprojects/robotpy-wpimath/gen/kinematics/SwerveModulePosition.yml
index 59b9a4b6..f613d840 100644
--- a/subprojects/robotpy-wpimath/gen/kinematics/SwerveModulePosition.yml
+++ b/subprojects/robotpy-wpimath/gen/kinematics/SwerveModulePosition.yml
@@ -1,5 +1,8 @@
 ---
 
+extra_includes:
+- wpystruct.h
+
 classes:
   SwerveModulePosition:
     force_no_default_constructor: true
@@ -27,4 +30,7 @@ classes:
       .def("__repr__", [](const SwerveModulePosition &ss) -> std::string {
         return "SwerveModulePosition(distance=" + std::to_string(ss.distance()) + ", "
                                     "angle=" + std::to_string(ss.angle.Radians()()) + ")";
-      })
\ No newline at end of file
+      })
+
+inline_code: |
+  SetupWPyStruct<frc::SwerveModulePosition>(cls_SwerveModulePosition);
diff --git a/subprojects/robotpy-wpimath/gen/kinematics/SwerveModuleState.yml b/subprojects/robotpy-wpimath/gen/kinematics/SwerveModuleState.yml
index faff3bed..fca240ba 100644
--- a/subprojects/robotpy-wpimath/gen/kinematics/SwerveModuleState.yml
+++ b/subprojects/robotpy-wpimath/gen/kinematics/SwerveModuleState.yml
@@ -1,5 +1,8 @@
 ---
 
+extra_includes:
+- wpystruct.h
+
 classes:
   SwerveModuleState:
     force_no_default_constructor: true
@@ -32,3 +35,5 @@ inline_code: |
                                "angle=" + std::to_string(ss.angle.Radians()()) + ")";
     })
   ;
+
+  SetupWPyStruct<frc::SwerveModuleState>(cls_SwerveModuleState);
diff --git a/subprojects/robotpy-wpimath/pyproject.toml b/subprojects/robotpy-wpimath/pyproject.toml
index f95fb61b..a6167c37 100644
--- a/subprojects/robotpy-wpimath/pyproject.toml
+++ b/subprojects/robotpy-wpimath/pyproject.toml
@@ -11,7 +11,7 @@ install_requires = [
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
     "robotpy-wpiutil~=2024.0.0b4",
     # "numpy", # broken in raspbian CI
 ]
@@ -22,8 +22,8 @@ base_package = "wpimath"
 [tool.robotpy-build.wrappers."wpimath._impl".maven_lib_download]
 artifact_id = "wpimath-cpp"
 group_id = "edu.wpi.first.wpimath"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 
 libs = ["wpimath"]
diff --git a/subprojects/robotpy-wpinet/pyproject.toml b/subprojects/robotpy-wpinet/pyproject.toml
index e5927e91..e2b51963 100644
--- a/subprojects/robotpy-wpinet/pyproject.toml
+++ b/subprojects/robotpy-wpinet/pyproject.toml
@@ -14,8 +14,8 @@ generation_data = "gen"
 artifact_id = "wpinet-cpp"
 group_id = "edu.wpi.first.wpinet"
 # repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
-version = "2024.1.1-beta-4"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 libs = ["wpinet"]
 
@@ -36,6 +36,6 @@ install_requires = [
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
     "robotpy-wpiutil~=2024.0.0b4",
 ]
diff --git a/subprojects/robotpy-wpiutil/gen/DataLog.yml b/subprojects/robotpy-wpiutil/gen/DataLog.yml
index c80b9caa..96b772e7 100644
--- a/subprojects/robotpy-wpiutil/gen/DataLog.yml
+++ b/subprojects/robotpy-wpiutil/gen/DataLog.yml
@@ -29,7 +29,15 @@ classes:
       AddProtobufSchema:
         ignore: true
       AddStructSchema:
-        ignore: true
+        param_override:
+          info:
+            name: type
+        cpp_code: |
+          [](DataLog &self, const py::type &t, int64_t timestamp) {
+            WPyStructInfo info(t);
+            return self.AddStructSchema<WPyStruct, WPyStructInfo>(info, timestamp);
+          }
+
       Start:
       Finish:
       SetMetadata:
@@ -242,29 +250,66 @@ classes:
           std::initializer_list<std::string_view>, int64_t:
             ignore: true
   StructLogEntry:
-    subpackage: log
     force_no_trampoline: true
-    ignore: true
+    template_params:
+    - T
+    - I
     methods:
       StructLogEntry:
         overloads:
           "":
-          DataLog&, std::string_view, int64_t:
-          DataLog&, std::string_view, std::string_view, int64_t:
+            ignore: true
+          DataLog&, std::string_view, I..., int64_t:
+            param_override:
+              info:
+                name: type
+            cpp_code: |
+              [](DataLog &log, std::string_view name, const py::type &t, int64_t timestamp) {
+                WPyStructInfo info(t);
+                return std::make_shared<StructLogEntry<WPyStruct, WPyStructInfo>>(log, name, info, timestamp);
+              }
+          DataLog&, std::string_view, std::string_view, I..., int64_t:
+            param_override:
+              info:
+                name: type
+            cpp_code: |
+              [](DataLog &log, std::string_view name, std::string_view metadata, const py::type &t, int64_t timestamp) {
+                WPyStructInfo info(t);
+                return std::make_shared<StructLogEntry<WPyStruct, WPyStructInfo>>(log, name, metadata, info, timestamp);
+              }
       Append:
   StructArrayLogEntry:
-    subpackage: log
     force_no_trampoline: true
-    ignore: true
+    template_params:
+    - T
+    - I
     methods:
       StructArrayLogEntry:
         overloads:
           "":
-          DataLog&, std::string_view, int64_t:
-          DataLog&, std::string_view, std::string_view, int64_t:
+            ignore: true
+          DataLog&, std::string_view, I..., int64_t:
+            param_override:
+              info:
+                name: type
+            cpp_code: |
+              [](DataLog &log, std::string_view name, const py::type &t, int64_t timestamp) {
+                WPyStructInfo info(t);
+                return std::make_shared<StructArrayLogEntry<WPyStruct, WPyStructInfo>>(log, name, info, timestamp);
+              }
+          DataLog&, std::string_view, std::string_view, I..., int64_t:
+            param_override:
+              info:
+                name: type
+            cpp_code: |
+              [](DataLog &log, std::string_view name, std::string_view metadata, const py::type &t, int64_t timestamp) {
+                WPyStructInfo info(t);
+                return std::make_shared<StructArrayLogEntry<WPyStruct, WPyStructInfo>>(log, name, metadata, info, timestamp);
+              }
       Append:
         overloads:
           U&&, int64_t:
+            ignore: true
           std::span<const T>, int64_t:
   ProtobufLogEntry:
     subpackage: log
@@ -277,3 +322,17 @@ classes:
           DataLog&, std::string_view, int64_t:
           DataLog&, std::string_view, std::string_view, int64_t:
       Append:
+
+templates:
+  StructLogEntry:
+    qualname: wpi::log::StructLogEntry
+    subpackage: log
+    params:
+    - WPyStruct
+    - WPyStructInfo
+  StructArrayLogEntry:
+    qualname: wpi::log::StructArrayLogEntry
+    subpackage: log
+    params:
+    - WPyStruct
+    - WPyStructInfo
\ No newline at end of file
diff --git a/subprojects/robotpy-wpiutil/gen/WPyStruct.yml b/subprojects/robotpy-wpiutil/gen/WPyStruct.yml
new file mode 100644
index 00000000..f41affcf
--- /dev/null
+++ b/subprojects/robotpy-wpiutil/gen/WPyStruct.yml
@@ -0,0 +1,25 @@
+functions:
+  forEachNested:
+    subpackage: wpistruct
+    no_release_gil: true
+  getTypeString:
+    subpackage: wpistruct
+    no_release_gil: true
+  getSchema:
+    subpackage: wpistruct
+    no_release_gil: true
+  getSize:
+    subpackage: wpistruct
+    no_release_gil: true
+  pack:
+    subpackage: wpistruct
+    no_release_gil: true
+  packInto:
+    subpackage: wpistruct
+    no_release_gil: true
+  unpack:
+    subpackage: wpistruct
+    no_release_gil: true
+  unpackInto:
+    subpackage: wpistruct
+    no_release_gil: true
diff --git a/subprojects/robotpy-wpiutil/pyproject.toml b/subprojects/robotpy-wpiutil/pyproject.toml
index a1ceee85..0b6d0ba9 100644
--- a/subprojects/robotpy-wpiutil/pyproject.toml
+++ b/subprojects/robotpy-wpiutil/pyproject.toml
@@ -9,7 +9,7 @@ install_requires = []
 
 [build-system]
 requires = [
-    "robotpy-build<2025.0.0,~=2024.0.0b8",
+    "robotpy-build<2025.0.0,~=2024.0.0b9",
 ]
 
 [tool.robotpy-build]
@@ -20,18 +20,22 @@ name = "wpiutil"
 sources = [
     "wpiutil/src/main.cpp",
     "wpiutil/src/safethread_gil.cpp",
-    "wpiutil/src/stacktracehook.cpp"
+    "wpiutil/src/stacktracehook.cpp",
+    "wpiutil/src/wpistruct/wpystruct_fns.cpp",
+]
+extra_includes = [
+    "wpiutil/src/wpistruct",
+    "wpiutil/src/type_casters",
 ]
-extra_includes = ["wpiutil/src/type_casters"]
 
 generation_data = "gen"
 
 [tool.robotpy-build.wrappers."wpiutil".maven_lib_download]
 artifact_id = "wpiutil-cpp"
 group_id = "edu.wpi.first.wpiutil"
-repo_url = "https://frcmaven.wpi.edu/artifactory/release"
+repo_url = "https://frcmaven.wpi.edu/artifactory/development"
 # repo_url = "https://frcmaven.wpi.edu/artifactory/development"
-version = "2024.1.1-beta-4"
+version = "2024.1.1-beta-4-37-g6a2d3c3"
 
 libs = ["wpiutil"]
 
@@ -48,6 +52,8 @@ SendableBuilder = "wpi/sendable/SendableBuilder.h"
 #SendableHelper = "wpi/sendable/SendableHelper.h"
 SendableRegistry = "wpi/sendable/SendableRegistry.h"
 
+WPyStruct = "src/wpistruct/wpystruct_fns.h"
+
 [[tool.robotpy-build.wrappers."wpiutil".type_casters]]
 header = "wpi_array_type_caster.h"
 types = ["wpi::array"]
@@ -79,3 +85,7 @@ types = ["wpi::StringMap"]
 [[tool.robotpy-build.wrappers."wpiutil".type_casters]]
 header = "wpi_ct_string_type_caster.h"
 types = ["wpi::ct_string"]
+
+[[tool.robotpy-build.wrappers."wpiutil".type_casters]]
+header = "wpystruct.h"
+types = ["WPyStruct"]
\ No newline at end of file
diff --git a/subprojects/robotpy-wpiutil/wpiutil/src/wpistruct/wpystruct.h b/subprojects/robotpy-wpiutil/wpiutil/src/wpistruct/wpystruct.h
new file mode 100644
index 00000000..29f3a908
--- /dev/null
+++ b/subprojects/robotpy-wpiutil/wpiutil/src/wpistruct/wpystruct.h
@@ -0,0 +1,305 @@
+
+#pragma once
+
+#include <functional>
+#include <memory>
+#include <string_view>
+
+#include <fmt/format.h>
+#include <wpi/struct/Struct.h>
+
+#include <pybind11/functional.h>
+#include <robotpy_build.h>
+
+static inline std::string pytypename(const py::type &t) {
+  return ((PyTypeObject *)t.ptr())->tp_name;
+}
+
+//
+// Dynamic struct + type caster
+//
+
+// This merely holds the python object being operated on, the actual
+// serialization work is done in WPyStructConverter
+struct WPyStruct {
+
+  WPyStruct() = default;
+
+  WPyStruct(const WPyStruct &other) {
+    py::gil_scoped_acquire gil;
+    py = other.py;
+  }
+
+  WPyStruct &operator=(const WPyStruct &other) {
+    {
+      py::gil_scoped_acquire gil;
+      py = other.py;
+    }
+    return *this;
+  }
+
+  WPyStruct(WPyStruct &&) = default;
+
+  WPyStruct(const py::object &py) : py(py) {}
+
+  ~WPyStruct() {
+    py::gil_scoped_acquire gil;
+    py.release().dec_ref();
+  }
+
+  py::object py;
+};
+
+namespace pybind11 {
+namespace detail {
+
+template <> struct type_caster<WPyStruct> {
+  // TODO: wpiutil.struct.T/TV?
+  PYBIND11_TYPE_CASTER(WPyStruct, const_name("object"));
+
+  bool load(handle src, bool convert) {
+    // TODO: validation?
+    value.py = py::reinterpret_borrow<py::object>(src);
+    return true;
+  }
+
+  static handle cast(const WPyStruct &src, py::return_value_policy policy,
+                     py::handle parent) {
+    py::handle v = src.py;
+    v.inc_ref();
+    return v;
+  }
+};
+
+} // namespace detail
+} // namespace pybind11
+
+//
+// Struct info class implementation
+//
+
+// two types of converters: static C++ converter, and dynamic python converter
+struct WPyStructConverter {
+  virtual ~WPyStructConverter() = default;
+  virtual std::string_view GetTypeString() const = 0;
+
+  virtual size_t GetSize() const = 0;
+
+  virtual std::string_view GetSchema() const = 0;
+
+  virtual void Pack(std::span<uint8_t> data, const WPyStruct &value) const = 0;
+
+  virtual WPyStruct Unpack(std::span<const uint8_t> data) const = 0;
+
+  // virtual void UnpackInto(WPyStruct *pyv,
+  //                         std::span<const uint8_t> data) const = 0;
+
+  virtual void ForEachNested(
+      const std::function<void(std::string_view, std::string_view)> &fn)
+      const = 0;
+};
+
+// static C++ converter
+template <typename T> struct WPyStructCppConverter : WPyStructConverter {
+  std::string_view GetTypeString() const override {
+    return wpi::Struct<T>::GetTypeString();
+  }
+
+  size_t GetSize() const override { return wpi::Struct<T>::GetSize(); }
+
+  std::string_view GetSchema() const override {
+    return wpi::Struct<T>::GetSchema();
+  }
+
+  void Pack(std::span<uint8_t> data, const WPyStruct &value) const override {
+    py::gil_scoped_acquire gil;
+    const T &v = value.py.cast<const T &>();
+    wpi::Struct<T>::Pack(data, v);
+  }
+
+  WPyStruct Unpack(std::span<const uint8_t> data) const override {
+    py::gil_scoped_acquire gil;
+    return WPyStruct{py::cast(wpi::UnpackStruct<T>(data))};
+  }
+
+  // void UnpackInto(WPyStruct *pyv,
+  //                 std::span<const uint8_t> data) const override {
+  //   py::gil_scoped_acquire gil;
+  //   T *v = pyv->py.cast<T *>();
+  //   wpi::UnpackStructInto(v, data);
+  // }
+
+  void ForEachNested(
+      const std::function<void(std::string_view, std::string_view)> &fn)
+      const override {
+    if constexpr (wpi::HasNestedStruct<T>) {
+      wpi::Struct<T>::ForEachNested(fn);
+    }
+  }
+};
+
+template <typename T> void SetupWPyStruct(auto pycls) {
+
+  auto *sptr =
+      new std::shared_ptr<WPyStructConverter>(new WPyStructCppConverter<T>());
+
+  py::capsule c(sptr, "WPyStruct", [](void *ptr) {
+    delete (std::shared_ptr<WPyStructConverter> *)ptr;
+  });
+
+  pycls.def_property_readonly_static("WPIStruct",
+                                     [c](py::object pycls) { return c; });
+}
+
+// dynamic python converter
+struct WPyStructPyConverter : WPyStructConverter {
+
+  WPyStructPyConverter(py::object o) {
+    m_typestring = o.attr("typeString").cast<std::string>();
+    m_schema = o.attr("schema").cast<std::string>();
+    m_size = o.attr("size").cast<size_t>();
+
+    m_pack = py::reinterpret_borrow<py::function>(o.attr("pack"));
+    m_packInto = py::reinterpret_borrow<py::function>(o.attr("packInto"));
+    m_unpack = py::reinterpret_borrow<py::function>(o.attr("unpack"));
+    // m_unpackInto = py::reinterpret_borrow<py::function>(o.attr("unpackInto"));
+    m_forEachNested =
+        py::reinterpret_borrow<py::function>(o.attr("forEachNested"));
+  }
+
+  // copy all the relevant attributes locally
+  std::string m_typestring;
+  std::string m_schema;
+  size_t m_size;
+
+  py::function m_pack;
+  py::function m_packInto;
+  py::function m_unpack;
+  // py::function m_unpackInto;
+  py::function m_forEachNested; // might be none
+
+  std::string_view GetTypeString() const override { return m_typestring; }
+
+  size_t GetSize() const override { return m_size; }
+
+  std::string_view GetSchema() const override { return m_schema; }
+
+  void Pack(std::span<uint8_t> data, const WPyStruct &value) const override {
+    py::gil_scoped_acquire gil;
+    py::bytes result = m_pack(value.py);
+    std::string_view rview = result;
+    if (rview.size() != data.size()) {
+      std::string msg = fmt::format(
+          "{} pack did not return {} bytes (returned {})",
+          pytypename(py::type::of(value.py)), data.size(), rview.size());
+      throw py::value_error(msg);
+    }
+
+    rview.copy((char *)data.data(), rview.size());
+  }
+
+  WPyStruct Unpack(std::span<const uint8_t> data) const override {
+    py::gil_scoped_acquire gil;
+    auto view =
+        py::memoryview::from_memory((const void *)data.data(), data.size());
+    return WPyStruct(m_unpack(view));
+  }
+
+  // void UnpackInto(WPyStruct *pyv,
+  //                 std::span<const uint8_t> data) const override {
+  //   py::gil_scoped_acquire gil;
+  //   auto view =
+  //       py::memoryview::from_memory((const void *)data.data(), data.size());
+  //   m_unpackInto(pyv->py, view);
+  // }
+
+  void ForEachNested(
+      const std::function<void(std::string_view, std::string_view)> &fn)
+      const override {
+    py::gil_scoped_acquire gil;
+    if (!m_forEachNested.is_none()) {
+      m_forEachNested(fn);
+    }
+  }
+};
+
+// passed as I... to the wpi::Struct methods
+struct WPyStructInfo {
+
+  WPyStructInfo(const py::type &t) {
+    if (!py::hasattr(t, "WPIStruct")) {
+
+      throw py::type_error(
+          fmt::format("{} is not struct serializable (does not have WPIStruct)",
+                      pytypename(t)));
+    }
+
+    py::object s = t.attr("WPIStruct");
+
+    // C++ version
+    void *c = PyCapsule_GetPointer(s.ptr(), "WPyStruct");
+    if (c != NULL) {
+      cvt = *(std::shared_ptr<WPyStructConverter> *)c;
+      return;
+    }
+
+    PyErr_Clear();
+
+    // Python version
+    try {
+      cvt = std::make_shared<WPyStructPyConverter>(s);
+    } catch (py::error_already_set &e) {
+      std::string msg = fmt::format(
+          "{} is not struct serializable (invalid WPIStruct)", pytypename(t));
+      py::raise_from(e, PyExc_TypeError, msg.c_str());
+      throw py::error_already_set();
+    }
+  }
+
+  WPyStructInfo(const WPyStruct &v) : WPyStructInfo(py::type::of(v.py)) {}
+
+  // holds something used to do serialization
+  std::shared_ptr<WPyStructConverter> cvt;
+};
+
+// Leverages the converter stored in WPyStructInfo to do the actual work
+template <> struct wpi::Struct<WPyStruct, WPyStructInfo> {
+  static std::string_view GetTypeString(const WPyStructInfo &info) {
+    return info.cvt->GetTypeString();
+  }
+
+  static size_t GetSize(const WPyStructInfo &info) {
+    return info.cvt->GetSize();
+  }
+
+  static std::string_view GetSchema(const WPyStructInfo &info) {
+    return info.cvt->GetSchema();
+  }
+
+  static WPyStruct Unpack(std::span<const uint8_t> data,
+                          const WPyStructInfo &info) {
+    return info.cvt->Unpack(data);
+  }
+
+  // static void UnpackInto(WPyStruct *v, std::span<const uint8_t> data,
+  //                        const WPyStructInfo &info) {
+  //   info.cvt->UnpackInto(v, data);
+  // }
+
+  static void Pack(std::span<uint8_t> data, const WPyStruct &value,
+                   const WPyStructInfo &info) {
+    info.cvt->Pack(data, value);
+  }
+
+  static void
+  ForEachNested(std::invocable<std::string_view, std::string_view> auto fn,
+                const WPyStructInfo &info) {
+    info.cvt->ForEachNested(fn);
+  }
+};
+
+static_assert(wpi::StructSerializable<WPyStruct, WPyStructInfo>);
+static_assert(wpi::HasNestedStruct<WPyStruct, WPyStructInfo>);
+
+// This breaks on readonly structs, so we disable for now
+// static_assert(wpi::MutableStructSerializable<WPyStruct, WPyStructInfo>);
diff --git a/subprojects/robotpy-wpiutil/wpiutil/src/wpistruct/wpystruct_fns.cpp b/subprojects/robotpy-wpiutil/wpiutil/src/wpistruct/wpystruct_fns.cpp
new file mode 100644
index 00000000..95c719ee
--- /dev/null
+++ b/subprojects/robotpy-wpiutil/wpiutil/src/wpistruct/wpystruct_fns.cpp
@@ -0,0 +1,103 @@
+
+#include "wpystruct.h"
+
+void forEachNested(
+    const py::type &t,
+    const std::function<void(std::string_view, std::string_view)> &fn) {
+  WPyStructInfo info(t);
+  wpi::ForEachStructSchema<WPyStruct, WPyStructInfo>(fn, info);
+}
+
+py::str getTypeString(const py::type &t) {
+  WPyStructInfo info(t);
+  return wpi::GetStructTypeString<WPyStruct, WPyStructInfo>(info);
+}
+
+py::str getSchema(const py::type &t) {
+  WPyStructInfo info(t);
+  return wpi::GetStructSchema<WPyStruct, WPyStructInfo>(info);
+}
+
+size_t getSize(const py::type &t) {
+  WPyStructInfo info(t);
+  return wpi::GetStructSize<WPyStruct>(info);
+}
+
+py::bytes pack(const WPyStruct &v) {
+  WPyStructInfo info(v);
+
+  auto sz = wpi::GetStructSize<WPyStruct>(info);
+  PyObject *b = PyBytes_FromStringAndSize(NULL, sz);
+  if (b == NULL) {
+    throw py::error_already_set();
+  }
+
+  char *pybuf;
+  py::ssize_t pysz;
+  if (PyBytes_AsStringAndSize(b, &pybuf, &pysz) != 0) {
+    Py_DECREF(b);
+    throw py::error_already_set();
+  }
+
+  auto s = std::span((uint8_t *)pybuf, pysz);
+  wpi::PackStruct(s, v, info);
+
+  return py::reinterpret_steal<py::bytes>(b);
+}
+
+void packInto(const WPyStruct &v, py::buffer &b) {
+  WPyStructInfo info(v);
+  py::ssize_t sz = wpi::GetStructSize<WPyStruct>(info);
+
+  auto req = b.request();
+  if (req.itemsize != 1) {
+    throw py::value_error("buffer must only contain bytes");
+  } else if (req.ndim != 1) {
+    throw py::value_error("buffer must only have a single dimension");
+  }
+
+  if (req.size != sz) {
+    throw py::value_error("buffer must be " + std::to_string(sz) + " bytes");
+  }
+
+  auto s = std::span((uint8_t *)req.ptr, req.size);
+  wpi::PackStruct(s, v, info);
+}
+
+WPyStruct unpack(const py::type &t, const py::buffer &b) {
+  WPyStructInfo info(t);
+  py::ssize_t sz = wpi::GetStructSize<WPyStruct>(info);
+
+  auto req = b.request();
+  if (req.itemsize != 1) {
+    throw py::value_error("buffer must only contain bytes");
+  } else if (req.ndim != 1) {
+    throw py::value_error("buffer must only have a single dimension");
+  }
+
+  if (req.size != sz) {
+    throw py::value_error("buffer must be " + std::to_string(sz) + " bytes");
+  }
+
+  auto s = std::span((const uint8_t *)req.ptr, req.size);
+  return wpi::UnpackStruct<WPyStruct, WPyStructInfo>(s, info);
+}
+
+// void unpackInto(const py::buffer &b, WPyStruct *v) {
+//   WPyStructInfo info(*v);
+//   py::ssize_t sz = wpi::GetStructSize<WPyStruct>(info);
+
+//   auto req = b.request();
+//   if (req.itemsize != 1) {
+//     throw py::value_error("buffer must only contain bytes");
+//   } else if (req.ndim != 1) {
+//     throw py::value_error("buffer must only have a single dimension");
+//   }
+
+//   if (req.size != sz) {
+//     throw py::value_error("buffer must be " + std::to_string(sz) + " bytes");
+//   }
+
+//   auto s = std::span((const uint8_t *)req.ptr, req.size);
+//   wpi::UnpackStructInto<WPyStruct, WPyStructInfo>(v, s, info);
+// }
diff --git a/subprojects/robotpy-wpiutil/wpiutil/src/wpistruct/wpystruct_fns.h b/subprojects/robotpy-wpiutil/wpiutil/src/wpistruct/wpystruct_fns.h
new file mode 100644
index 00000000..bddbeb9a
--- /dev/null
+++ b/subprojects/robotpy-wpiutil/wpiutil/src/wpistruct/wpystruct_fns.h
@@ -0,0 +1,48 @@
+
+#pragma once
+
+#include "wpystruct.h"
+
+/**
+    Call a function to retrieve the (type string, schema) for each nested struct
+*/
+void forEachNested(
+    const py::type &t,
+    const std::function<void(std::string_view, std::string_view)> &fn);
+
+/**
+    Retrieve the type string for the specified type
+*/
+py::str getTypeString(const py::type &t);
+
+/**
+    Retrieve schema for the specified type
+*/
+py::str getSchema(const py::type &t);
+
+/**
+    Returns the serialized size in bytes
+*/
+size_t getSize(const py::type &t);
+
+/**
+    Serialize object into byte buffer
+*/
+py::bytes pack(const WPyStruct &v);
+
+/**
+    Serialize object into byte buffer. Buffer must be exact size.
+*/
+void packInto(const WPyStruct &v, py::buffer &b);
+
+/**
+    Convert byte buffer into object of specified type. Buffer must be exact
+    size.
+*/
+WPyStruct unpack(const py::type &t, const py::buffer &b);
+
+// /**
+//     Convert byte buffer into passed in object. Buffer must be exact
+//     size.
+// */
+// void unpackInto(const py::buffer &b, WPyStruct *v);
diff --git a/subprojects/robotpy-wpiutil/wpiutil/wpistruct/__init__.py b/subprojects/robotpy-wpiutil/wpiutil/wpistruct/__init__.py
new file mode 100644
index 00000000..9fa1b487
--- /dev/null
+++ b/subprojects/robotpy-wpiutil/wpiutil/wpistruct/__init__.py
@@ -0,0 +1,54 @@
+"""
+This package contains the WPILib Struct serialization functions, and a
+mechanism to implement your own custom structs using Python (see :func:`wpiutil.wpistruct.make_wpistruct`).
+"""
+
+# autogenerated by 'robotpy-build create-imports wpiutil.wpistruct wpiutil._wpiutil.wpistruct'
+from .._wpiutil.wpistruct import (
+    forEachNested,
+    getSchema,
+    getSize,
+    getTypeString,
+    pack,
+    packInto,
+    unpack,
+)
+
+__all__ = [
+    "forEachNested",
+    "getSchema",
+    "getSize",
+    "getTypeString",
+    "pack",
+    "packInto",
+    "unpack",
+]
+
+from .desc import StructDescriptor
+
+from .dataclass import (
+    make_wpistruct,
+    int8,
+    uint8,
+    int16,
+    uint16,
+    int32,
+    uint32,
+    int64,
+    uint64,
+    double,
+)
+
+__all__ += [
+    "StructDescriptor",
+    "make_wpistruct",
+    "int8",
+    "uint8",
+    "int16",
+    "uint16",
+    "int32",
+    "uint32",
+    "int64",
+    "uint64",
+    "double",
+]
diff --git a/subprojects/robotpy-wpiutil/wpiutil/wpistruct/dataclass.py b/subprojects/robotpy-wpiutil/wpiutil/wpistruct/dataclass.py
new file mode 100644
index 00000000..0c8b3baf
--- /dev/null
+++ b/subprojects/robotpy-wpiutil/wpiutil/wpistruct/dataclass.py
@@ -0,0 +1,229 @@
+import dataclasses
+import inspect
+import struct
+import typing
+
+
+from .desc import StructDescriptor
+from .._wpiutil import wpistruct
+
+#
+# Use these types to specify explicitly sized integers, but you can
+# also use int/bool/float
+#
+
+# fmt: off
+    
+if typing.TYPE_CHECKING:
+    int8 = int
+    uint8 = int
+    int16 = int
+    uint16 = int
+    int32 = int
+    uint32 = int
+    int64 = int
+    uint64 = int
+    double = float
+else:
+    class int8(int): pass
+    class uint8(int): pass
+    class int16(int): pass
+    class uint16(int): pass
+    class int32(int): pass
+    class uint32(int): pass
+    class int64(int): pass
+    class uint64(int): pass
+
+    class double(float): pass
+
+# fmt: on
+
+
+def make_wpistruct(cls=None, /, *, name: typing.Optional[str] = None):
+    """
+    This decorator allows you to easily define a custom type that can be
+    used with wpilib's custom serialization protocol (for use in datalog
+    and networktables). Just create a normal python dataclass, and apply
+    this decorator to the class.
+
+    For example, here's how you define a dataclass that contains an integer,
+    a boolean, and a double::
+
+        @wpiutil.wpistruct.make_wpistruct(name="mystruct")
+        @dataclasses.dataclass
+        class MyStruct:
+            x: wpiutil.wpistruct.int32
+            y: bool
+            z: wpiutil.struct.double
+
+    The types defined in the dataclass can be another WPIStruct compatible class
+    (either builtin or user defined); one of int, bool, or float; or you can
+    use one of the ``wpiutil.wpistruct.[u]int*`` values for explicitly sized
+    integer types.
+    """
+
+    def wrap(cls):
+        return _process_class(cls, name)
+
+    if cls is None:
+        return wrap
+
+    return wrap(cls)
+
+
+#
+# Internals
+#
+
+_type_to_fmt = {
+    bool: ("?", "bool"),
+    int8: ("b", "int8"),
+    uint8: ("B", "uint8"),
+    int16: ("h", "int16"),
+    uint16: ("H", "uint16"),
+    int: ("i", "int32"),
+    int32: ("i", "int32"),
+    uint32: ("I", "uint32"),
+    int64: ("q", "int64"),
+    uint64: ("Q", "uint64"),
+    float: ("f", "float"),
+    double: ("d", "double"),
+}
+
+
+def _process_class(cls, struct_name: typing.Optional[str]):
+    resolved_hints = typing.get_type_hints(cls)
+    field_names = [field.name for field in dataclasses.fields(cls)]
+    resolved_field_types = {name: resolved_hints[name] for name in field_names}
+
+    name_parts = []
+    name_parts.append(getattr(cls, "__module__", None))
+    name_parts.append(getattr(cls, "__qualname__", cls.__name__))
+    cls_name = ".".join([n for n in name_parts if n])
+
+    if struct_name is None:
+        struct_name = cls.__name__
+        err_name = cls_name
+    else:
+        err_name = f"{struct_name} ({cls_name})"
+
+    fmts = []
+    schema = []
+    cvvals = []
+    vvals = []
+    packs = []
+    unpacks = []
+    # unpackIntos = []
+    forEachNested = []
+
+    ctx: typing.Dict[str, typing.Any] = {"cls": cls}
+
+    for name, ftype in resolved_field_types.items():
+        if ftype in _type_to_fmt:
+            fmt, stype = _type_to_fmt[ftype]
+
+            fmts.append(fmt)
+            schema.append(f"{stype} {name}")
+            cvvals.append(f"arg_{name}")
+            vvals.append(f"v.{name}")
+
+        elif hasattr(ftype, "WPIStruct"):
+            # nested struct
+            argn = f"arg_{name}"
+            typn = f"type_{name}"
+
+            ctx[typn] = ftype
+            ts = wpistruct.getTypeString(ftype).split(":", 1)[1].strip()
+            schema.append(f"{ts} {name}")
+            sz = wpistruct.getSize(ftype)
+            fmts.append(f"{sz}s")
+            vvals.append(argn)
+            cvvals.append(argn)
+            packs.append(f"{argn} = wpistruct.pack(v.{name})")
+            unpacks.append(f"{argn} = wpistruct.unpack({typn}, {argn})")
+            # unpackIntos.append(f"wpistruct.unpackInto(v.{name}, {argn})")
+            forEachNested.append(f"wpistruct.forEachNested({typn}, fn)")
+
+        else:
+            supported_names = ", ".join(t.__name__ for t in _type_to_fmt.keys())
+            raise TypeError(
+                f"{cls_name}.{name} is not a wpistruct or does not have a supported type hint "
+                f"(supported: {supported_names})"
+            ) from None
+
+    s = struct.Struct(f"<{''.join(fmts)}")
+    cvals = ", ".join(cvvals)
+    vals = ", ".join(vvals)
+
+    padding = "\n" + " " * 16
+    pack_stmts = padding.join(packs)
+    unpack_stmts = padding.join(unpacks)
+    # unpackInto_stmts = padding.join(unpackIntos)
+
+    if not forEachNested:
+        forEachNested_stmt = "_forEachNested = None"
+    else:
+        forEachNested_stmt = f"def _forEachNested(fn):"
+        forEachNested_stmt += "\n" + " " * 12
+        forEachNested_stmt += f"try:{padding}"
+        forEachNested_stmt += padding.join(forEachNested)
+        forEachNested_stmt += "\n" + " " * 12
+        forEachNested_stmt += f"except Exception as e:"
+        forEachNested_stmt += (
+            f"{padding}raise ValueError(f'{err_name}: error in forEachNested') from e"
+        )
+
+    ctx["_s"] = s
+
+    # Construct the serialization functions using the same hack NamedTuple uses
+    fnsrc = inspect.cleandoc(
+        f"""
+        from wpiutil import wpistruct
+
+        def _pack(v):
+            try:
+                {pack_stmts}
+                return _s.pack({vals})
+            except Exception as e:
+                raise ValueError(f"{err_name}: error packing data") from e
+                            
+        def _packInto(v, b):
+            try:
+                {pack_stmts}
+                return _s.pack_into(b, 0, {vals})
+            except Exception as e:
+                raise ValueError(f"{err_name}: error packing data") from e
+
+        def _unpack(b):
+            try:
+                {cvals} = _s.unpack(b)
+                {unpack_stmts}
+                return cls({cvals})
+            except Exception as e:
+                raise ValueError(f"{err_name}: error unpacking data") from e
+        
+        #def _unpackInto(v, b):
+        #    try:
+        #        {vals} = _s.unpack(b)
+        #        {{unpackInto_stmts}}
+        #    except Exception as e:
+        #        raise ValueError(f"{err_name}: error unpacking data") from e
+
+        {forEachNested_stmt}
+    """
+    )
+
+    exec(fnsrc, ctx, ctx)
+
+    cls.WPIStruct = StructDescriptor(
+        typeString=f"struct:{struct_name}",
+        schema="; ".join(schema),
+        size=s.size,
+        pack=ctx["_pack"],
+        packInto=ctx["_packInto"],
+        unpack=ctx["_unpack"],
+        # unpackInto=ctx["_unpackInto"],
+        forEachNested=ctx["_forEachNested"],
+    )
+
+    return cls
diff --git a/subprojects/robotpy-wpiutil/wpiutil/wpistruct/desc.py b/subprojects/robotpy-wpiutil/wpiutil/wpistruct/desc.py
new file mode 100644
index 00000000..fce77d47
--- /dev/null
+++ b/subprojects/robotpy-wpiutil/wpiutil/wpistruct/desc.py
@@ -0,0 +1,48 @@
+import typing
+
+if typing.TYPE_CHECKING:
+    from typing_extensions import Buffer
+else:
+    # Avoiding typing_extensions runtime dependency
+    Buffer = bytearray
+
+
+class StructDescriptor(typing.NamedTuple):
+    """
+    To define a type in python that can use the wpilib serialization, the type must
+    have an attribute `WPIStruct` that contains this class (but C++ classes
+    do not have this).
+
+    It is not intended that you should create this class directly, something
+    else should generate it for you.
+
+    See :func:`wpiutil.wpistruct.make_wpistruct` for a easy to use mechanism
+    for defining custom structs using a dataclass.
+    """
+
+    #: The type string
+    typeString: str
+
+    #: The struct schema
+    schema: str
+
+    #: Size in bytes of binary representation of this struct
+    size: int
+
+    #: A function that converts the type to bytes
+    pack: typing.Callable[[typing.Any], bytes]
+
+    #: A function that converts the type to bytes
+    packInto: typing.Callable[[typing.Any, Buffer], None]
+
+    #: A function that converts bytes to an instance
+    unpack: typing.Callable[[Buffer], typing.Any]
+
+    #: A function that updates the given instance using the deserialized bytes
+    #: .. not supported
+    # unpackInto: typing.Callable[[typing.Any, Buffer], None]
+
+    #: If this contains nested structs, calls wpiutil.wpistruct.forEachNested for each
+    forEachNested: typing.Optional[
+        typing.Callable[[typing.Callable[[str, str], None]], None]
+    ]


Test patch:
diff --git a/subprojects/pyntcore/tests/test_struct_topic.py b/subprojects/pyntcore/tests/test_struct_topic.py
new file mode 100644
index 00000000..b45cd3f3
--- /dev/null
+++ b/subprojects/pyntcore/tests/test_struct_topic.py
@@ -0,0 +1,55 @@
+import dataclasses
+
+import ntcore
+from wpiutil import wpistruct
+
+
+@wpistruct.make_wpistruct
+@dataclasses.dataclass
+class MyStruct:
+    x: int
+    y: bool
+    z: wpistruct.double
+
+
+def test_mystruct(nt: ntcore.NetworkTableInstance):
+    topic = nt.getStructTopic("mystruct", MyStruct)
+    pub = topic.publish()
+    sub = topic.subscribe(MyStruct(1, True, 2.0))
+
+    assert topic.getTypeString() == "struct:MyStruct"
+
+    default = MyStruct(6, False, 7.0)
+    pub.setDefault(default)
+    val = sub.get()
+    assert val == default
+
+    # val2 = MyStruct(0, False, 0)
+    # sub.getInto(val2)
+    # assert val2 == default
+
+    vals = sub.readQueue()
+    assert len(vals) == 1
+    assert vals[0].value == default
+
+
+def test_mystruct_array(nt: ntcore.NetworkTableInstance):
+    topic = nt.getStructArrayTopic("mystruct", MyStruct)
+    pub = topic.publish()
+    sub = topic.subscribe([])
+
+    assert topic.getTypeString() == "struct:MyStruct[]"
+
+    default = MyStruct(8, True, 9.0)
+    pub.setDefault([default])
+    val = sub.get()
+    assert val == [default]
+
+    val2 = MyStruct(0, False, 0)
+    pub.set([val2])
+    atomicVal = sub.getAtomic()
+    assert atomicVal.value == [val2]
+
+    vals = sub.readQueue()
+    assert len(vals) == 1
+    assert vals[0].value == [val2]
diff --git a/subprojects/robotpy-wpiutil/tests/cpp/pyproject.toml b/subprojects/robotpy-wpiutil/tests/cpp/pyproject.toml
index a0364005..2555a0c6 100644
--- a/subprojects/robotpy-wpiutil/tests/cpp/pyproject.toml
+++ b/subprojects/robotpy-wpiutil/tests/cpp/pyproject.toml
@@ -11,6 +11,7 @@ depends = ["wpiutil"]
 sources = [
     "wpiutil_test/module.cpp",
     "wpiutil_test/sendable_test.cpp",
+    "wpiutil_test/struct_test.cpp",
 ]
 
 [tool.robotpy-build.metadata]
diff --git a/subprojects/robotpy-wpiutil/tests/cpp/wpiutil_test/module.cpp b/subprojects/robotpy-wpiutil/tests/cpp/wpiutil_test/module.cpp
index 261a7bb7..914b68f9 100644
--- a/subprojects/robotpy-wpiutil/tests/cpp/wpiutil_test/module.cpp
+++ b/subprojects/robotpy-wpiutil/tests/cpp/wpiutil_test/module.cpp
@@ -150,11 +150,13 @@ constexpr auto const_string() {
 }
 
 void sendable_test(py::module &m);
+void struct_test(py::module &m);
 
 
 RPYBUILD_PYBIND11_MODULE(m) {
 
     sendable_test(m);
+    struct_test(m);
 
     // array
     m.def("load_array_int", &load_array_int);
diff --git a/subprojects/robotpy-wpiutil/tests/cpp/wpiutil_test/struct_test.cpp b/subprojects/robotpy-wpiutil/tests/cpp/wpiutil_test/struct_test.cpp
new file mode 100644
index 00000000..d8ffe68b
--- /dev/null
+++ b/subprojects/robotpy-wpiutil/tests/cpp/wpiutil_test/struct_test.cpp
@@ -0,0 +1,84 @@
+
+#include <pybind11/operators.h>
+#include <wpystruct.h>
+
+//
+// Thing to serialize
+//
+
+struct ThingA {
+  ThingA() = default;
+  ThingA(int x) : x(x) {}
+
+  const int x = 0;
+
+  bool operator==(const ThingA &other) const { return x == other.x; }
+};
+
+template <> struct wpi::Struct<ThingA> {
+  static constexpr std::string_view GetTypeString() { return "struct:ThingA"; }
+  static constexpr size_t GetSize() { return 1; }
+  static constexpr std::string_view GetSchema() { return "uint8 value"; }
+  static ThingA Unpack(std::span<const uint8_t> data) {
+    return ThingA{data[0]};
+  }
+  static void Pack(std::span<uint8_t> data, const ThingA &value) {
+    data[0] = value.x;
+  }
+};
+
+struct Outer {
+  Outer() = default;
+  Outer(const ThingA &t, int c) : inner(t), c(c) {}
+
+  ThingA inner;
+  int c = 0;
+
+  bool operator==(const Outer &other) const {
+    return inner == other.inner && c == other.c;
+  }
+};
+
+template <>
+struct wpi::Struct<Outer> {
+  static constexpr std::string_view GetTypeString() { return "struct:Outer"; }
+  static constexpr size_t GetSize() { return wpi::GetStructSize<ThingA>() + 4; }
+  static constexpr std::string_view GetSchema() {
+    return "ThingA inner; int32 c";
+  }
+
+  static Outer Unpack(std::span<const uint8_t> data) {
+    constexpr size_t innerSize = wpi::GetStructSize<ThingA>();
+    return {wpi::UnpackStruct<ThingA, 0>(data),
+            wpi::UnpackStruct<int32_t, innerSize>(data)};
+  }
+  static void Pack(std::span<uint8_t> data, const Outer& value) {
+    constexpr size_t innerSize = wpi::GetStructSize<ThingA>();
+    wpi::PackStruct<0>(data, value.inner);
+    wpi::PackStruct<innerSize>(data, value.c);
+  }
+  static void ForEachNested(
+      std::invocable<std::string_view, std::string_view> auto fn) {
+    wpi::ForEachStructSchema<ThingA>(fn);
+  }
+};
+
+void struct_test(py::module &m) {
+
+  py::class_<ThingA> thingCls(m, "ThingA");
+  thingCls.def(py::init<>());
+  thingCls.def(py::init<int>());
+  thingCls.def_readonly("x", &ThingA::x);
+  thingCls.def(py::self == py::self);
+
+  SetupWPyStruct<ThingA>(thingCls);
+
+  py::class_<Outer> outerCls(m, "Outer");
+  outerCls.def(py::init<>());
+  outerCls.def(py::init<ThingA, int>());
+  outerCls.def_readonly("inner", &Outer::inner);
+  outerCls.def_readwrite("c", &Outer::c);
+  outerCls.def(py::self == py::self);
+
+  SetupWPyStruct<Outer>(outerCls);
+}
\ No newline at end of file
diff --git a/subprojects/robotpy-wpiutil/tests/test_struct.py b/subprojects/robotpy-wpiutil/tests/test_struct.py
new file mode 100644
index 00000000..0ae62ae5
--- /dev/null
+++ b/subprojects/robotpy-wpiutil/tests/test_struct.py
@@ -0,0 +1,231 @@
+import dataclasses
+import re
+
+import pytest
+
+from wpiutil import wpistruct
+from wpiutil_test import module
+
+#
+# Static serialization
+#
+
+
+# ensure that a type that doesn't work has a sane error message
+def test_invalid_type():
+    with pytest.raises(
+        TypeError,
+        match=re.escape("str is not struct serializable (does not have WPIStruct)"),
+    ):
+        wpistruct.getSchema(str)
+
+
+def test_for_each_nested():
+    l = []
+
+    def _fn(*args):
+        l.append(args)
+
+    wpistruct.forEachNested(module.ThingA, _fn)
+    assert l == [("struct:ThingA", "uint8 value")]
+
+
+def test_get_type_string():
+    assert wpistruct.getTypeString(module.ThingA) == "struct:ThingA"
+
+
+def test_get_schema():
+    assert wpistruct.getSchema(module.ThingA) == "uint8 value"
+
+
+def test_get_size():
+    assert wpistruct.getSize(module.ThingA) == 1
+
+
+def test_pack():
+    assert wpistruct.pack(module.ThingA(1)) == b"\x01"
+
+
+def test_pack_into():
+    buf = bytearray(1)
+    wpistruct.packInto(module.ThingA(1), buf)
+    assert buf == b"\x01"
+
+
+def test_pack_into_err():
+    buf = bytearray(2)
+    with pytest.raises(ValueError, match=re.escape("buffer must be 1 bytes")):
+        wpistruct.packInto(module.ThingA(1), buf)
+
+
+def test_unpack():
+    assert wpistruct.unpack(module.ThingA, b"\x01") == module.ThingA(1)
+
+
+# def test_unpack_into():
+#     r1 = module.ThingA(1)
+#     r2 = module.ThingA(2)
+#     assert r1 != r2
+#     wpistruct.unpackInto(b"\x01", r2)
+#     assert r1 == r2
+
+
+#
+# Nested struct
+#
+
+
+def test_nested_for_each_nested():
+    l = []
+
+    def _fn(*args):
+        l.append(args)
+
+    wpistruct.forEachNested(module.Outer, _fn)
+    assert l == [
+        ("struct:ThingA", "uint8 value"),
+        ("struct:Outer", "ThingA inner; int32 c"),
+    ]
+
+
+def test_nested_get_type_string():
+    assert wpistruct.getTypeString(module.ThingA) == "struct:ThingA"
+
+
+def test_nested_get_schema():
+    assert wpistruct.getSchema(module.Outer) == "ThingA inner; int32 c"
+
+
+def test_nested_get_size():
+    assert wpistruct.getSize(module.Outer) == 5
+
+
+def test_nested_pack():
+    v = module.Outer(module.ThingA(2), 4)
+    assert wpistruct.pack(v) == b"\x02\x04\x00\x00\x00"
+
+
+def test_nested_pack_into():
+    v = module.Outer(module.ThingA(3), 5)
+    buf = bytearray(5)
+    wpistruct.packInto(v, buf)
+    assert buf == b"\x03\x05\x00\x00\x00"
+
+
+def test_nested_unpack():
+    assert wpistruct.unpack(module.ThingA, b"\x01") == module.ThingA(1)
+
+
+#
+# User defined serialization
+#
+
+
+@wpistruct.make_wpistruct(name="mystruct")
+@dataclasses.dataclass
+class MyStruct:
+    x: int
+    y: bool
+    z: float
+
+
+def test_user_for_each_nested():
+    l = []
+
+    def _fn(*args):
+        l.append(args)
+
+    wpistruct.forEachNested(MyStruct, _fn)
+    assert l == [("struct:mystruct", "int32 x; bool y; float z")]
+
+
+def test_user_get_type_string():
+    assert wpistruct.getTypeString(MyStruct) == "struct:mystruct"
+
+
+def test_user_get_schema():
+    assert wpistruct.getSchema(MyStruct) == "int32 x; bool y; float z"
+
+
+def test_user_get_size():
+    assert wpistruct.getSize(MyStruct) == 9
+
+
+def test_user_pack():
+    v = MyStruct(2, True, 3.5)
+    assert wpistruct.pack(v) == b"\x02\x00\x00\x00\x01\x00\x00\x60\x40"
+
+
+def test_user_pack_into():
+    v = MyStruct(2, True, 3.5)
+    buf = bytearray(9)
+    wpistruct.packInto(v, buf)
+    assert buf == b"\x02\x00\x00\x00\x01\x00\x00\x60\x40"
+
+
+def test_user_unpack():
+    v = MyStruct(2, True, 3.5)
+    assert wpistruct.unpack(MyStruct, b"\x02\x00\x00\x00\x01\x00\x00\x60\x40") == v
+
+
+# def test_user_unpack_into():
+#     v1 = MyStruct(2, True, 3.5)
+#     v2 = MyStruct(3, True, 4.5)
+#     assert v1 != v2
+#     wpistruct.unpackInto(b"\x02\x00\x00\x00\x01\x00\x00\x60\x40", v2)
+#     assert v1 == v2
+
+
+#
+# User defined serialization (nested)
+#
+
+
+@wpistruct.make_wpistruct
+@dataclasses.dataclass
+class Outer:
+    x: int
+    inner: MyStruct
+
+
+def test_user_nested_for_each_nested():
+    l = []
+
+    def _fn(*args):
+        l.append(args)
+
+    wpistruct.forEachNested(Outer, _fn)
+    assert l == [
+        ("struct:mystruct", "int32 x; bool y; float z"),
+        ("struct:Outer", "int32 x; mystruct inner"),
+    ]
+
+
+def test_user_nested_get_type_string():
+    assert wpistruct.getTypeString(Outer) == "struct:Outer"
+
+
+def test_user_nested_get_schema():
+    assert wpistruct.getSchema(Outer) == "int32 x; mystruct inner"
+
+
+def test_user_nested_get_size():
+    assert wpistruct.getSize(Outer) == 4 + 9
+
+
+def test_user_nested_pack():
+    v = Outer(2, MyStruct(3, True, 4.0))
+    assert wpistruct.pack(v) == b"\x02\x00\x00\x00\x03\x00\x00\x00\x01\x00\x00\x80\x40"
+
+
+def test_user_nested_pack_into():
+    v = Outer(2, MyStruct(3, True, 4.0))
+    buf = bytearray(4 + 9)
+    wpistruct.packInto(v, buf)
+    assert buf == b"\x02\x00\x00\x00\x03\x00\x00\x00\x01\x00\x00\x80\x40"
+
+
+def test_user_nested_unpack():
+    assert wpistruct.unpack(
+        Outer, b"\x02\x00\x00\x00\x03\x00\x00\x00\x01\x00\x00\x80\x40"
+    ) == Outer(2, MyStruct(3, True, 4.0))