<?xml version="1.0" encoding="UTF-8" ?>
<?xml-stylesheet type="text/xsl" href="https://community.element14.com/cfs-file/__key/system/syndication/rss.xsl" media="screen"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:wfw="http://wellformedweb.org/CommentAPI/"><channel><title>Experimenting with Single Pair Ethernet</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/</link><description>Experiment with Single Pair Ethernet to learn its benefits for industrial automation. Ethernet is the most popular communication protocol today for residential and commercial applications. It&amp;#39;s also widely used in industrial automation. Traditional Eth</description><dc:language>en-US</dc:language><generator>Telligent Community 12</generator><item><title /><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/b/projects/posts/subject-placeholder?CommentId=74163722-0382-412b-af87-6dd0118d7b7c</link><pubDate>Wed, 08 Apr 2026 07:06:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:74163722-0382-412b-af87-6dd0118d7b7c</guid><dc:creator>JWx</dc:creator><description>Thanks a lot kmikemoo ! That was a really interesting challenge - maybe E14 could run some continuation next year (similar to annual repetition of Extreme Env experimenting challenges)? There are still some subjects I would like to explore but didn&amp;#39;t manage to include in the current timeframe...</description></item><item><title /><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/b/projects/posts/subject-placeholder?CommentId=08790343-8eda-4f15-970b-55b6a7866ba5</link><pubDate>Wed, 08 Apr 2026 01:33:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:08790343-8eda-4f15-970b-55b6a7866ba5</guid><dc:creator>kmikemoo</dc:creator><description>JWx Fantastic project! You hit on everything that I was curious about AND you found and then created the mysterious power supplies that we all wondered about when this was first brought up. Top notch work.</description></item><item><title /><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/b/projects/posts/advanced-dashcam-and-monitoring-system?CommentId=ad07dc51-491f-4587-88af-21a2c588f04b</link><pubDate>Mon, 06 Apr 2026 23:46:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:ad07dc51-491f-4587-88af-21a2c588f04b</guid><dc:creator>genebren</dc:creator><description>Very cool product. Well done!</description></item><item><title /><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/b/projects/posts/advanced-dashcam-and-monitoring-system?CommentId=c62d7162-2910-4984-9af0-07b12b792ea1</link><pubDate>Sun, 05 Apr 2026 20:12:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:c62d7162-2910-4984-9af0-07b12b792ea1</guid><dc:creator>JWx</dc:creator><description>Great project!</description></item><item><title /><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/b/projects/posts/advanced-dashcam-and-monitoring-system?CommentId=c18cb626-d42f-4643-a67d-24e10f0ef94f</link><pubDate>Sat, 04 Apr 2026 19:18:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:c18cb626-d42f-4643-a67d-24e10f0ef94f</guid><dc:creator>DAB</dc:creator><description>Nice build and installation. Well done.</description></item><item><title /><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/b/projects/posts/advanced-dashcam-and-monitoring-system?CommentId=e2266953-b1d9-4b77-8ac0-38ec61cfa977</link><pubDate>Sat, 04 Apr 2026 01:28:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:e2266953-b1d9-4b77-8ac0-38ec61cfa977</guid><dc:creator>dougw</dc:creator><description>Impressive project...</description></item><item><title>File: cut</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/m/managed-videos/151168</link><pubDate>Sat, 04 Apr 2026 00:38:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:49016680-f73a-4b84-a096-a8d6c602557e</guid><dc:creator>vmate</dc:creator><description /></item><item><title>Blog Post: Advanced Dashcam and Monitoring System</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/b/projects/posts/advanced-dashcam-and-monitoring-system</link><pubDate>Sat, 04 Apr 2026 00:36:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:278ae83e-8e40-4040-a841-34d5cf29ad32</guid><dc:creator>vmate</dc:creator><description>Project Introduction The overall goal of this project is to build an always on monitoring and dashcam system for a car. The primary functionality is to stream the camera feed in real-time to a remote server, even when the car is parked. This immediately resolves two of the biggest problems with regular dashcams: If an accident happens, a regular dashcam only stores video locally, and the storage media may get destroyed in the accident. If the vehicle or dashcam gets stolen, the storage media gets stolen along with it. Not relying on local storage at all fixes these two issues, but brings a mountain of challenges with it. To mention just a few: Stream latency is critical: an accident can happen in less than a second in the worst cases. Imagine an oncoming car experiencing a tire blowout just meters in front, and colliding with our vehicle, destroying the dashcam. If the video stream was not encoded and transmitted within less than a second, zero footage of the accident will have been recorded. Handling network connection dropouts: It is almost certain that the network connection’s quality will degrade or completely drop out at certain times. This makes the previous latency goal impossible to achieve in certain scenarios, and very difficult in others. WWAN hardware consumes a lot of power. Running it for an extended period of time requires a massive battery. A battery in the trunk is charged from the vehicle’s generator when driving, and the stored energy is used to power the entire system 24/7, without draining the main 12V battery. Various other monitoring systems are also provided, including GPS tracking and vehicle CAN bus sniffing. The Linux system located in the front of the car needs to have control over the battery system in the rear. This is where Single Pair Ethernet comes in. For such applications, there are a few important features needed in the communication link: Isolation – while the entire setup will be sharing a common ground, it is still a good idea to have an isolated interface. Stray currents in sensitive, low noise data connections can mess things up and cause headaches. Pulling significant amps would also result in a ground offset, potentially destroying the signal. Isolation is technically overkill, but it is cheap insurance to make sure everything works perfectly. Resiliency – the cable run between the Linux system and the power management module could be as long as 4 meters depending on where I end up positioning things, and the cables will be running alongside super noisy, high current wires. It is crucial that the link is robust, tolerates noise, and supports (relatively) long cable runs. Simplicity – running gigabit ethernet over a fiber link would certainly meet both of the requirements above, but it’s not exactly simple, cheap, or designed for this purpose. There are 3 reasonable options in my opinion, that fit the bill: RS485 : a full-duplex RS485 link would need two twisted pairs, and could be hooked up to a regular UART interface on a microcontroller. This option can essentially be thought of as making a regular UART/Serial interface reliable for long ranges and in high noise environments. CAN bus : ubiquitous for exactly this sort of application. Requires only a single pair, fixes the downsides mentioned for RS485. The protocol itself is higher level, instead of sending individual bits, data is sent in messages, with a priority system, error detection, automatic retransmission, etc. Single Pair Ethernet : even more advanced and faster than CAN bus. Still requires a single pair only, and can be used with an entire Ethernet stack. That means things like IP addressing, QoS, VLANs, flow control, etc. SPE has the following upsides compared to CAN bus for my project: Way more bandwidth available : 10x faster than classic CAN. Competitive price : while a regular, non-isolated CAN PHY is cheaper, once isolation is required, a Single Pair Ethernet PHY and magnetics end up being roughly in the same price range as an isolated CAN transceiver and controller. Much more features and options for high level protocols . One disadvantage that could be a dealbreaker in some use cases: SPE (or, to be more precise, 10BASE-T1L, the version used in this challenge) is NOT a multi-drop bus. This means that a single physical bus can only connect two devices together, much like ‘regular’ Ethernet, and a switch is required to connect more than 2 devices together. However, there is another SPE standard, 10BASE-T1S, which solves this exact problem, making SPE work just like CAN bus in this regard: multiple devices can be connected to a single, one pair bus, without any switches or other active devices. Cameras The current setup uses four cameras. The front camera is an ISX031 based, USB3.2 Gen 1 camera module The left, right, and rear cameras are IMX662 based, USB2.0 camera modules This setup provides a near 360 degree view of the vehicle. The ISX031 sensor was chosen for the front camera for its extremely high dynamic range and low light sensitivity, along with the Sony tuned built-in ISP. The IMX662 sensors are a clear downgrade from the ISX031, but the modules cost around 6 times less. They still have extreme low light sensitivity, but lack the subpixel HDR capability of the ISX031. The Compute Unit The heart of the system is the Compute Unit, located in the glovebox. At its core, it contains a Linux SBC and 4G modem, to ingest and process the video from the cameras, do H264/H265 compression, and send the resulting stream to the remote server over 4G. The SoC choice was narrowed down to the Rockchip RK3588, for the following reasons: It has 4x Cortex-A76 cores and 4x Cortex-A55 cores, powerful yet low power An integrated hardware video encoder supports both H264 and H265 There is an NPU available for neural network acceleration My final choice ended up being the Orange Pi 5 Plus, instead of using a SoM/Compute Module. The GPIO provides almost all interfaces required, and vastly simplifies PCB design. The custom HAT The main responsibilities of the custom PCB on top of the Orange Pi 5 Plus are the following: Accept a 12-15V input source Generate voltage rails to power the Orange Pi 5 Plus, 4G modem, and various electronics on the HAT itself Include a microcontroller to manage power and handle low level communication Integrate the 4G modem Four TPS566231 buck converter ICs are used to provide the voltage rails: An EM7455 4G modem is used for network connectivity, as there are no reasonably priced better alternatives for the moment. The next meaningful step-up would be a 5G modem, but they are around 200USD, need PCIe, and 4 well matched antennas. The modem itself uses an M.2 B-key connector, but a SIM card slot is also needed. The modem supports USB3, but USB2 is sufficient for this use case, and avoids the USB3 routing and interference issues. An ESP32-S3 was chosen to control power management and handle communication with various subsystems. An onboard USB hub, specifically a USB2514B was added, so the entire HAT only needs a single USB2 uplink to the Orange Pi 5 Plus. Various interfacing options were added, including RS485, dual CAN transceivers, and a header to connect an ADIN1110 Single Pair Ethernet MAC + transceiver. The whole setup was squeezed into a rectangle matching the dimensions of the Orange Pi 5 Plus. An Intel AX200 WiFi + Bluetooth card was added to the Orange Pi 5 Plus, along with a u-blox Neo M8N GNSS module. Then I made a custom, minimal ADIN1110 module. After confirming my board works, by using the EVAL-ADIN1100 development kit and Wireshark, I designed and 3D printed an enclosure for the Compute Unit. The ugly blob above the USB connector is a custom short USB2 cable, going into the USB2514 hub IC on the HAT, since the GPIO connector lacks any USB interfaces. With that, the Compute Unit hardware is ready. Here’s the block diagram of the finished setup: The Battery After some research, I got a 1.28kWh, 100Ah LiFePO4 battery. There is one major issue with this chemistry: LiFePO4 batteries cannot be charged in cold temperatures. Some more expensive battery models include a built-in heater, and in cold weather, the BMS disables charging, and uses the input energy to run the heaters instead. This seems like a decent solution at first, but it’s a black box system. The BMS controls the heater somehow, with no explicit ability to turn it on, and the temperature sensor is almost certainly just glued to the outside of the cells. When the heater gets the outer shell of the batteries to a few degrees above freezing, I assume the BMS just enables charging immediately, and the battery gets damaged. The LiFePO4 cells are massive, and heating their outer shells to even 10+ degrees means nothing, if their centers are still below freezing. Instead, some logic is needed in the heater control system to account for the heat having to reach the center of the cells, but purely based on temperature readings from the outer shell. My solution was to add my own external heating. This is significantly worse in the sense that the outer plastic is being heated, which needs to heat the inside air, which needs to heat the cells, instead of just directly heating the cells. However, I get direct control over everything. Flexible heating films with glue on one side were used, that consist of a long, snaking trace covering the surface. I put two of these on the battery’s outer casing. For safety, two bimetallic thermal fuses and two DS18B20 temperature sensors were also added. The next step was to thermally insulate the battery, to minimize energy needed for heating. A relative of mine made a lovely wooden box, that I proceeded to pad with XPS foam. Charging System After some quick calculations, I came to the conclusion that a 20A+ charger was needed to keep the battery at a sufficient State of Charge, for indefinite periods of time. I had several attempts at designing a 20A buck-boost charger module, but ultimately decided to pivot. Instead of a custom module, I went with the Victron Energy Orion XS 1400. Orion XS is a DC/DC battery charger, designed for the exact purpose I need it for: charging a secondary battery in campers, from a car’s generator or main battery. It is also insanely efficient, and can do up to 50A. It uses Bluetooth and a smartphone app for management and control, but it also includes a “VE.Direct” port, for more manual control. This interface is essentially just a UART, with a custom command set over it. I also used a Victron Energy SmartShunt, to not have to deal with State of Charge calculation and designing another custom PCB. This device also has a VE.Direct port. I got a large waterproof box to put the entire charging system inside, and 3D printed some mounting hardware. Then I wired up all the charging hardware. To control everything, I started designing another PCB. Its responsibilities are the following: Communicate over VE.Direct with the Orion XS (50A charger) and SmartShunt Control the heating elements in the battery Control and monitor fans based on temperature of various components Communicate with the Compute Unit over Single Pair Ethernet Communicate with the Bluetooth BMS inside the LiFePO4 battery For VE.Direct interfacing, I went with two ADuM1201 dual channel isolator ICs. To control the heating elements, an EMC2305 fan controller IC was used, which can provide 5 PWM signals, controlled through I2C. Only four heating elements are supported, with the remaining channel used to low-side switch a 2 pin fan. The board also supports two 12V, 4-pin fans, using an EMC2302 for control and monitoring. This chip is essentially identical to the EMC2305, except for channel count. Having a CAN bus interface is always useful, so I added an isolated one for good measure. The choice for this was the ADM3053, which I also used on the Compute Unit, to later hook up to the car’s CAN network. The power rails are created using three TPS543021 buck converter ICs. I went with an ESP32-S3 here too. I needed Bluetooth capability to communicate with the BMS inside the LiFePO4 battery, and it makes sense to use the same MCU as in the Compute Unit, to make code reuse easier, and be able to focus on mastering a single device. For debugging, I added an isolated USB interface to the ESP32-S3’s native USB port, using an ADUM3160, which is a USB1.1 isolator IC. This is what the layout ended up looking like: The fan and heater control is in the bottom, power supplies in the middle, MCU at the top. The Single Pair Ethernet transceiver is not on this board, as I didn’t have enough space for it, so I just added an 8 pin JST-PH connector, and a separate PCB will have the ADIN1110 and various other SPE related components, just like with the Compute Unit. I added the new controller board to the charger box, and also drilled a hole for the Single Pair Ethernet connector. Here&amp;#39;s the block diagram: Car Wiring The next step was wiring up everything in the car for the charger. This involved routing the Single Pair Ethernet cable from the trunk to the glovebox in the front, and also installing a robust, 40A capable 12V source for the charger. To do this properly, another grey box was added, housing a relay and some fuses. The purpose of the relay is to only supply 12V to the charger when the engine is running. Technically, this is not required, as the Orion XS and the Charging Controller board both implement a low voltage cutoff, but better safe than sorry. This also provides a simple way to hook up other loads later, like a high power laptop charger or cabin heater in the winter, if I ever decide to add one. The thick blue cable exiting at the top is the 12V input to the Orion XS. It is not fused in this box, to prevent unnecessary voltage drop. This is safe, because the entire setup got a 40A fuse, right at the battery terminals. The smaller blue cable exiting at the top is for a subwoofer, which got a second, 20A fuse. Then, I mounted the cameras. I had quite a lot of trouble with USB3 interference for the front camera, mainly regarding the GPS setup. The solution ended up being a very fancy, USB3.2 Gen2 cable, with USB-C connectors on both ends. The rear and side cameras are only USB2, so I didn’t have any issues regarding those. I added a 2J Antennas 2J4950PGF antenna to the windshield, which includes one 4G antenna, one 2.4/5GHz antenna, and an active GNSS antenna. The cabling for the antennas and front camera were routed down the A pillar, to the glovebox. With everything wired, this is what the glovebox looked like. I temporarily added a USB connection to the Charger Control PCB, so I can debug the ESP32. This will be removed once the firmware is up and running, and all communication will happen over SPE. Then I wired up the Compute Unit in the glovebox, to finish the hardware. Software This part was a massive undertaking, and I&amp;#39;d need several blog posts to cover everything in sufficient detail, so I&amp;#39;ll skip to the more interesting portions. Video Streaming This is the most crucial part of this entire project, so let&amp;#39;s talk about the implementation. I ended up using GStreamer and Python for the video streaming code. I managed to piece together a working GStreamer RKMPP and RKRGA plugin, which are the hardware video encoder and video processing blocks in the RK3588. I also ported the HQDN3D denoising algorithm from avisynth/ffmpeg to GStreamer, because noisy video and compression don&amp;#39;t go well together. The hardware accelerated H265 encoding was temporarily put aside, because I still need to tune denoising and other preprocessing steps, and software H264 handles experimentation way better. This is what the current pipeline looks like: - Ingest video from camera - Crop the bottom 184 pixels - Flip the video - Drop every second frame to get 15FPS - Convert into I420 - Run HQDN3D noise filtering - Encode with H264 - Send stream to server The exact GStreamer pipeline: pipeline_str = ( f&amp;quot;v4l2src device={DEVICE_PATH} name=src ! &amp;quot; &amp;quot;video/x-raw,format=YUY2,width=1920,height=1080,framerate=30/1 ! &amp;quot; &amp;quot;videocrop top=184 ! &amp;quot; &amp;quot;videoflip video-direction=2 ! &amp;quot; &amp;quot;queue ! &amp;quot; &amp;quot;videorate ! video/x-raw,framerate=15/1 ! &amp;quot; &amp;quot;videoconvert ! video/x-raw,format=I420 ! &amp;quot; &amp;quot;tee name=t &amp;quot; # --- Branch 1: ML stream to python Syncbuf --- &amp;quot;t. ! queue max-size-bytes=0 max-size-buffers=100 max-size-time=0 ! &amp;quot; &amp;quot;hqdn3d luma-spatial=4 chroma-spatial=8 luma-temporal=6 chroma-temporal=8 ! &amp;quot; &amp;quot;x264enc name=enc speed-preset=veryfast key-int-max=30 pass=qual quantizer=24 bitrate=12000 vbv-buf-capacity=4000 noise-reduction=0 aud=true ! &amp;quot; &amp;quot;h264parse config-interval=-1 ! &amp;quot; &amp;quot;mpegtsmux name=mux alignment=7 pat-interval=2000 pmt-interval=2000 ! &amp;quot; &amp;quot;appsink name=sink_stream emit-signals=True sync=False &amp;quot; # --- Branch 2: Lossless Dataset Tap --- &amp;quot;t. ! queue max-size-bytes=0 max-size-buffers=30 max-size-time=0 leaky=downstream ! &amp;quot; &amp;quot;appsink name=raw_sink emit-signals=True sync=False&amp;quot; ) There is also a second output stream, which does not have noise filtering or H264 encoding. I use this to gather high quality footage to an external 1TB SSD in the car, with hopes to later train an AI model to restore some details and de-artifact the H264 streams. There&amp;#39;s also some statistics logging to InfluxDB. Here&amp;#39;s the entire front camera code: import sys import time import signal import threading import os from datetime import datetime from typing import Dict, Optional import gi gi.require_version(&amp;#39;Gst&amp;#39;, &amp;#39;1.0&amp;#39;) from gi.repository import Gst, GLib import influxdb_client from influxdb_client.client.write_api import SYNCHRONOUS from SyncbufSourceV2 import SyncbufSourceV2 # --- Configuration --- DEVICE_PATH = &amp;quot;/dev/v4l/by-id/usb-Arducam_Arducam_B0624_3MP_HDR_Arducam_20260227_0001-video-index0&amp;quot; SERVER_URL = &amp;quot;tcp://10.255.3.2:5555&amp;quot; # --- SSD Config --- DATASET_MOUNT_POINT = &amp;quot;/mnt/ssd&amp;quot; SSD_UUID = &amp;quot;bb8a9aab-ac7b-4a1f-b8d8-884b0e652803&amp;quot; SSD_DEV_PATH = f&amp;quot;/dev/disk/by-uuid/{SSD_UUID}&amp;quot; # Resiliency WATCHDOG_TIMEOUT = 5.0 STATUS_PRINT_INTERVAL = 3 # InfluxDB Config INFLUXDB_URL = &amp;quot;10.253.0.5:8086&amp;quot; INFLUXDB_TOKEN = &amp;quot;x&amp;quot; INFLUXDB_ORG = &amp;quot;org&amp;quot; INFLUXDB_BUCKET = &amp;quot;ascs&amp;quot; INFLUXDB_TIMEOUT = 2000 Gst.init(None) class StatsCollector: &amp;quot;&amp;quot;&amp;quot;Thread-safe shared state for tracking bandwidth and framerate.&amp;quot;&amp;quot;&amp;quot; def __init__(self): self.lock = threading.Lock() self.total_bytes = 0 self.frame_count = 0 self.last_buffer_ts = 0 def update(self, byte_size: int): with self.lock: self.total_bytes += byte_size self.frame_count += 1 self.last_buffer_ts = time.time() def get_last_ts(self): with self.lock: return self.last_buffer_ts def get_snapshot(self) -&amp;gt; Dict[str, int]: with self.lock: return { &amp;quot;total_bytes&amp;quot;: self.total_bytes, &amp;quot;frame_count&amp;quot;: self.frame_count, } class GStreamerStreamer: def __init__(self): self.syncbuf = SyncbufSourceV2(SERVER_URL, write_callback=self._write_callback) self.stats = StatsCollector() # Main Loop for GStreamer Bus handling self.loop = GLib.MainLoop() # Two entirely decoupled pipelines self.pipeline: Optional[Gst.Pipeline] = None self.dataset_pipeline: Optional[Gst.Pipeline] = None self.running = True self.recording = False # InfluxDB Init self.influx_client = None self.write_api = None self._init_influx() # Start background threads self.influx_thread = threading.Thread(target=self._influx_worker, daemon=True) self.influx_thread.start() self.watchdog_thread = threading.Thread(target=self._watchdog_worker, daemon=True) self.watchdog_thread.start() self.ssd_thread = threading.Thread(target=self._ssd_worker, daemon=True) self.ssd_thread.start() def _init_influx(self): try: self.influx_client = influxdb_client.InfluxDBClient( url=INFLUXDB_URL, token=INFLUXDB_TOKEN, org=INFLUXDB_ORG, timeout=INFLUXDB_TIMEOUT ) self.write_api = self.influx_client.write_api(write_options=SYNCHRONOUS) print(&amp;quot;InfluxDB Client initialized.&amp;quot;) except Exception as e: print(f&amp;quot;Failed to init InfluxDB: {e}&amp;quot;) def _write_callback(self, size): pass def build_main_pipeline(self): &amp;quot;&amp;quot;&amp;quot;Builds the primary ML stream pipeline running 24/7.&amp;quot;&amp;quot;&amp;quot; pipeline_str = ( f&amp;quot;v4l2src device={DEVICE_PATH} name=src ! &amp;quot; &amp;quot;video/x-raw,format=YUY2,width=1920,height=1080,framerate=30/1 ! &amp;quot; &amp;quot;videocrop top=184 ! &amp;quot; &amp;quot;videoflip video-direction=2 ! &amp;quot; &amp;quot;queue ! &amp;quot; &amp;quot;videorate ! video/x-raw,framerate=15/1 ! &amp;quot; &amp;quot;videoconvert ! video/x-raw,format=I420 ! &amp;quot; &amp;quot;tee name=t &amp;quot; # --- Branch 1: ML stream to python Syncbuf --- &amp;quot;t. ! queue max-size-bytes=0 max-size-buffers=100 max-size-time=0 ! &amp;quot; &amp;quot;hqdn3d luma-spatial=4 chroma-spatial=8 luma-temporal=6 chroma-temporal=8 ! &amp;quot; &amp;quot;x264enc name=enc speed-preset=veryfast key-int-max=30 pass=qual quantizer=24 bitrate=12000 vbv-buf-capacity=4000 noise-reduction=0 aud=true ! &amp;quot; &amp;quot;h264parse config-interval=-1 ! &amp;quot; &amp;quot;mpegtsmux name=mux alignment=7 pat-interval=2000 pmt-interval=2000 ! &amp;quot; &amp;quot;appsink name=sink_stream emit-signals=True sync=False &amp;quot; # --- Branch 2: Lossless Dataset Tap --- # Leaky queue ensures if RK3588 struggles to encode FFV1, this queue drops frames here, keeping the ML stream perfectly smooth. &amp;quot;t. ! queue max-size-bytes=0 max-size-buffers=30 max-size-time=0 leaky=downstream ! &amp;quot; &amp;quot;appsink name=raw_sink emit-signals=True sync=False&amp;quot; ) print(&amp;quot;Building Main Pipeline...&amp;quot;) try: self.pipeline = Gst.parse_launch(pipeline_str) except Exception as e: print(f&amp;quot;Error building pipeline: {e}&amp;quot;) sys.exit(1) # Connect Sinks sink_stream = self.pipeline.get_by_name(&amp;quot;sink_stream&amp;quot;) sink_stream.connect(&amp;quot;new-sample&amp;quot;, self._on_stream_sample) raw_sink = self.pipeline.get_by_name(&amp;quot;raw_sink&amp;quot;) raw_sink.connect(&amp;quot;new-sample&amp;quot;, self._on_raw_sample) # Connect Bus bus = self.pipeline.get_bus() bus.add_signal_watch() bus.connect(&amp;quot;message&amp;quot;, self._on_bus_message) def _start_dataset_pipeline(self): &amp;quot;&amp;quot;&amp;quot;Dynamically spins up the dataset pipeline when SSD is detected.&amp;quot;&amp;quot;&amp;quot; splitmux_time_ns = 60000000000 dataset_str = ( &amp;quot;appsrc name=dataset_src is-live=true format=time ! &amp;quot; &amp;quot;videoconvert ! &amp;quot; # Safety net for caps negotiation &amp;quot;avenc_ffv1 ! &amp;quot; f&amp;quot;splitmuxsink name=splitmux muxer-factory=matroskamux max-size-time={splitmux_time_ns}&amp;quot; ) try: self.dataset_pipeline = Gst.parse_launch(dataset_str) splitmux = self.dataset_pipeline.get_by_name(&amp;quot;splitmux&amp;quot;) splitmux.connect(&amp;quot;format-location&amp;quot;, self._on_format_location) bus = self.dataset_pipeline.get_bus() bus.add_signal_watch() bus.connect(&amp;quot;message&amp;quot;, self._on_dataset_bus_message) self.dataset_pipeline.set_state(Gst.State.PLAYING) self.recording = True except Exception as e: print(f&amp;quot;Failed to start dataset pipeline: {e}&amp;quot;) def _stop_dataset_pipeline(self): &amp;quot;&amp;quot;&amp;quot;Gracefully destroys the dataset pipeline.&amp;quot;&amp;quot;&amp;quot; self.recording = False if self.dataset_pipeline: self.dataset_pipeline.set_state(Gst.State.NULL) self.dataset_pipeline = None def _on_format_location(self, splitmux, fragment_id): &amp;quot;&amp;quot;&amp;quot;Called by splitmuxsink every 60 seconds to name the new file.&amp;quot;&amp;quot;&amp;quot; timestamp = datetime.now().strftime(&amp;quot;%Y-%m-%d_%H-%M-%S&amp;quot;) filepath = os.path.join(DATASET_MOUNT_POINT, f&amp;quot;dataset_{timestamp}_{fragment_id:04d}.mkv&amp;quot;) print(f&amp;quot;SSD Write: Starting new dataset chunk -&amp;gt; {filepath}&amp;quot;) return filepath def _on_stream_sample(self, sink): &amp;quot;&amp;quot;&amp;quot;Callback for the MAIN video stream (H264).&amp;quot;&amp;quot;&amp;quot; sample = sink.emit(&amp;quot;pull-sample&amp;quot;) if not sample: return Gst.FlowReturn.ERROR buf = sample.get_buffer() success, map_info = buf.map(Gst.MapFlags.READ) if not success: return Gst.FlowReturn.ERROR try: data_copy = bytes(map_info.data) self.syncbuf.write(data_copy) self.stats.update(len(data_copy)) except Exception as e: print(f&amp;quot;Stream write error: {e}&amp;quot;) return Gst.FlowReturn.ERROR finally: buf.unmap(map_info) return Gst.FlowReturn.OK def _on_raw_sample(self, sink): &amp;quot;&amp;quot;&amp;quot;Callback for the dataset branch. Forwards pointers zero-copy.&amp;quot;&amp;quot;&amp;quot; sample = sink.emit(&amp;quot;pull-sample&amp;quot;) if not sample: return Gst.FlowReturn.ERROR # If SSD is mounted, instantly push the pointer across the bridge if self.recording and self.dataset_pipeline: appsrc = self.dataset_pipeline.get_by_name(&amp;quot;dataset_src&amp;quot;) if appsrc: try: appsrc.emit(&amp;quot;push-sample&amp;quot;, sample) except Exception: pass return Gst.FlowReturn.OK def _on_bus_message(self, bus, message): t = message.type if t == Gst.MessageType.EOS: print(&amp;quot;End of Stream received.&amp;quot;) self.quit() elif t == Gst.MessageType.ERROR: err, debug = message.parse_error() print(f&amp;quot;GStreamer Main Pipeline Error: {err}&amp;quot;) self.quit() def _on_dataset_bus_message(self, bus, message): t = message.type if t == Gst.MessageType.ERROR: err, debug = message.parse_error() print(f&amp;quot;Dataset Branch Error (SSD Yanked or Full?): {err}&amp;quot;) # Do NOT crash the script. Just stop the isolated dataset pipeline! self._stop_dataset_pipeline() def _ssd_worker(self): &amp;quot;&amp;quot;&amp;quot;Monitors the physical presence of the external SSD bypassing VFS mount cache.&amp;quot;&amp;quot;&amp;quot; print(&amp;quot;SSD Monitor started.&amp;quot;) was_recording = False while self.running: # 1. Is the hardware physically plugged in? (Bypasses ghost mounts) physical_present = os.path.exists(SSD_DEV_PATH) # 2. Is the file system mounted? is_mounted = os.path.ismount(DATASET_MOUNT_POINT) # Start logic: Must be physically present AND mounted if physical_present and is_mounted and not was_recording: print(&amp;quot;SSD DETECTED &amp;amp; MOUNTED. Booting isolated lossless dataset pipeline.&amp;quot;) self._start_dataset_pipeline() was_recording = True # Stop logic: Hardware physically vanished! elif not physical_present and was_recording: print(&amp;quot;SSD PHYSICALLY DISCONNECTED. Destroying pipeline and releasing file handles...&amp;quot;) self._stop_dataset_pipeline() # Closes the file, allowing Linux to kill the zombie mount was_recording = False time.sleep(1) def _watchdog_worker(self): print(&amp;quot;Watchdog started.&amp;quot;) while self.running: time.sleep(1) last_ts = self.stats.get_last_ts() if last_ts == 0: if time.time() - self.start_time &amp;gt; 10: print(&amp;quot;Watchdog: Startup timeout.&amp;quot;) self.quit() continue if time.time() - last_ts &amp;gt; WATCHDOG_TIMEOUT: print(f&amp;quot;Watchdog: No data for {WATCHDOG_TIMEOUT}s. Exiting.&amp;quot;) self.quit() def _influx_worker(self): prev_bytes = 0 prev_time = time.time() while self.running: time.sleep(STATUS_PRINT_INTERVAL) now = time.time() dt = now - prev_time if dt {SERVER_URL}&amp;quot;) self.build_main_pipeline() ret = self.pipeline.set_state(Gst.State.PLAYING) if ret == Gst.StateChangeReturn.FAILURE: print(&amp;quot;Unable to set main pipeline to playing state.&amp;quot;) sys.exit(1) try: self.loop.run() except KeyboardInterrupt: pass finally: self.cleanup() def quit(self): self.running = False self.loop.quit() def cleanup(self): print(&amp;quot;Cleaning up...&amp;quot;) if self.dataset_pipeline: self.dataset_pipeline.set_state(Gst.State.NULL) if self.pipeline: self.pipeline.set_state(Gst.State.NULL) if self.influx_client: self.influx_client.close() if __name__ == &amp;quot;__main__&amp;quot;: streamer = GStreamerStreamer() def signal_handler(sig, frame): print(&amp;quot;\nShutdown signal received.&amp;quot;) streamer.quit() signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) streamer.run() The video streaming protocol is quite barebones for now, but it has been working reliably so far. This is the sender side: import io, time, zmq, collections, hashlib, threading, struct, random from typing import Deque MAX_BUF_SIZE = 512_000_000 # 512MB ACK_NACK = b&amp;#39;\xFF\xFF\xFF\xFF&amp;#39; HEADER_FORMAT = &amp;#39; MAX_BUF_SIZE and self.bytes_buffer: trimmed_chunk = self.bytes_buffer.popleft() trimmed_size = len(trimmed_chunk) total_size -= trimmed_size # print(f&amp;quot;Trimmed {trimmed_size} bytes from buffer&amp;quot;) def init_zmq(self): self.zmq_socket = self.zmq_context.socket(zmq.REQ) self.zmq_socket.connect(self.server_address) self.zmq_socket.setsockopt(zmq.SNDTIMEO, 0) self.zmq_socket.setsockopt(zmq.LINGER, 0) def get_buffer_stats(self): with self.buffer_lock: return { &amp;quot;buffer_size&amp;quot;: sum(len(chunk) for chunk in self.bytes_buffer), &amp;quot;buffer_len&amp;quot;: len(self.bytes_buffer) } def zmq_socket_reset(self): if self.zmq_socket: self.zmq_socket.setsockopt(zmq.LINGER, 0) self.zmq_socket.close() self.init_zmq() def zmq_reliable_communicate(self, message): if not self.zmq_socket: self.init_zmq() try: self.zmq_socket.send(message) except zmq.ZMQError as e: print(f&amp;quot;ZMQ send error: {e}, resetting socket&amp;quot;) self.zmq_socket_reset() # Try one more time after reset try: self.zmq_socket.send(message) except zmq.ZMQError as e: print(f&amp;quot;ZMQ send error after reset: {e}&amp;quot;) return None start_time = time.time() while True: try: if self.zmq_socket.poll(1000) &amp;amp; zmq.POLLIN != 0: return self.zmq_socket.recv() except zmq.ZMQError as e: print(f&amp;quot;ZMQ poll/recv error: {e}, resetting socket&amp;quot;) self.zmq_socket_reset() return None if time.time() - start_time &amp;gt; 10: # We call the *now-threadsafe* trim_buffer self.trim_buffer() print(&amp;quot;No ZMQ response received after 10 seconds, resetting socket&amp;quot;) self.zmq_socket_reset() return None def sync_loop(self, target_size: int = 128_000, min_size: int = 16_000): while self.sync_thread_run: current_size = self.buffer_size() if current_size MAX_BUF_SIZE: self.trim_buffer() header_bytes = struct.pack(HEADER_FORMAT, self.stream_id, self.transmit_seq) chunks = bytearray(header_bytes) with self.buffer_lock: while self.bytes_buffer and len(chunks) deterministic stream restart. if self.current_stream_id is None: self.current_stream_id = received_stream_id self.expected_seq = received_seq print(f&amp;quot;[PROTO] New stream attached. stream={received_stream_id}, seq={received_seq}&amp;quot;) elif received_stream_id != self.current_stream_id: print( f&amp;quot;[PROTO] Stream restart detected. &amp;quot; f&amp;quot;old_stream={self.current_stream_id}, new_stream={received_stream_id}. Restarting FFmpeg.&amp;quot; ) self.current_stream_id = received_stream_id self.expected_seq = received_seq self.stop_ffmpeg() self.start_ffmpeg() time.sleep(0.2) # Safety net in case protocol state was reset unexpectedly. if self.expected_seq is None: self.expected_seq = received_seq def u32_delta(start, end): return (end - start) &amp;amp; SEQ_MASK # Stop-and-Wait + loss-tolerant resync logic (modulo uint32 sequence numbers) if received_seq == self.expected_seq: # Good packet self.write_to_ffmpeg(video_data) self.expected_seq = (self.expected_seq + 1) &amp;amp; SEQ_MASK return struct.pack(&amp;#39; Optional[str]: &amp;quot;&amp;quot;&amp;quot;Executes an mmcli command and returns its stdout.&amp;quot;&amp;quot;&amp;quot; try: full_command = [&amp;quot;sudo&amp;quot;, &amp;quot;mmcli&amp;quot;, &amp;quot;-m&amp;quot;, &amp;quot;any&amp;quot;, f&amp;quot;--command={command}&amp;quot;] result = subprocess.run( full_command, capture_output=True, text=True, check=True, timeout=self.command_timeout ) return result.stdout.split(&amp;#39;response: &amp;#39;, 1)[1] if &amp;#39;response: &amp;#39; in result.stdout else result.stdout except Exception as e: self.logger.error(f&amp;quot;Command &amp;#39;{command}&amp;#39; failed: {e}&amp;quot;) return None def _safe_int(self, value: str) -&amp;gt; Optional[int]: &amp;quot;&amp;quot;&amp;quot;Safely convert string to int, return None if conversion fails or value is &amp;#39;--&amp;#39;.&amp;quot;&amp;quot;&amp;quot; if not value or value.strip() == &amp;#39;--&amp;#39;: return None try: return int(value.strip()) except (ValueError, AttributeError): return None def _safe_float(self, value: str) -&amp;gt; Optional[float]: &amp;quot;&amp;quot;&amp;quot;Safely convert string to float, return None if conversion fails or value is &amp;#39;--&amp;#39;.&amp;quot;&amp;quot;&amp;quot; if not value or value.strip() == &amp;#39;--&amp;#39;: return None try: return float(value.strip()) except (ValueError, AttributeError): return None def _validate_metric(self, name: str, value: Optional[float]) -&amp;gt; Optional[float]: &amp;quot;&amp;quot;&amp;quot; Checks if a metric&amp;#39;s value is within its predefined sane range. Returns the value if valid, otherwise logs a warning and returns None. &amp;quot;&amp;quot;&amp;quot; if value is None: return None # Pass through None values without checking for key, (min_val, max_val) in self._VALID_RANGES.items(): if key in name: # e.g., &amp;#39;rsrp&amp;#39; is in &amp;#39;pcc_rxm_rsrp&amp;#39; if not (min_val Optional[int]: &amp;quot;&amp;quot;&amp;quot;Extract decimal value from parentheses, e.g., &amp;#39;2EE5 (12005)&amp;#39; -&amp;gt; 12005.&amp;quot;&amp;quot;&amp;quot; match = re.search(r&amp;#39;\((\d+)\)&amp;#39;, text) if match: return self._safe_int(match.group(1)) # Fallback: try to convert the hex value before the parens hex_match = re.search(r&amp;#39;^([0-9A-Fa-f]+)&amp;#39;, text.strip()) if hex_match: try: return int(hex_match.group(1), 16) except ValueError: return None return None def _parse_gstatus(self, output: str) -&amp;gt; Dict[str, Any]: &amp;quot;&amp;quot;&amp;quot;Parse AT!GSTATUS? output and return a dict of metrics.&amp;quot;&amp;quot;&amp;quot; metrics = {} def parse_and_validate(metric_name, regex): match = re.search(regex, output) if match: val = self._safe_float(match.group(1)) return self._validate_metric(metric_name, val) return None metrics[&amp;#39;temperature_c&amp;#39;] = parse_and_validate(&amp;#39;temperature_c&amp;#39;, r&amp;#39;Temperature:\s*&amp;#39; + self._NUMERIC_PATTERN) mode_match = re.search(r&amp;#39;Mode:\s*(\w+)&amp;#39;, output) if mode_match: mode = mode_match.group(1).strip().upper() metrics[&amp;#39;mode_online&amp;#39;] = 1 if mode == &amp;#39;ONLINE&amp;#39; else 0 # Carrier Aggregation State ca_state_match = re.search(r&amp;#39;LTE CA state:\s*(\w+)&amp;#39;, output) if ca_state_match: # Strip whitespace and check for the exact string &amp;quot;ACTIVE&amp;quot; state = ca_state_match.group(1).strip().upper() metrics[&amp;#39;lte_ca_active&amp;#39;] = 1 if state == &amp;#39;ACTIVE&amp;#39; else 0 # Primary cell band band_match = re.search(r&amp;#39;LTE band:\s*B?(\d+)&amp;#39;, output) if band_match: metrics[&amp;#39;lte_band&amp;#39;] = self._safe_int(band_match.group(1)) # Secondary cell band (often present even if CA is inactive) scc_band_match = re.search(r&amp;#39;LTE Scell band:\s*B?(\d+)&amp;#39;, output) if scc_band_match: metrics[&amp;#39;lte_band_sec&amp;#39;] = self._safe_int(scc_band_match.group(1)) # PCC/SCC Metrics metrics[&amp;#39;pcc_rxm_rssi&amp;#39;] = parse_and_validate(&amp;#39;pcc_rxm_rssi&amp;#39;, r&amp;#39;PCC RxM RSSI:\s*&amp;#39; + self._NUMERIC_PATTERN) metrics[&amp;#39;pcc_rxm_rsrp&amp;#39;] = parse_and_validate(&amp;#39;pcc_rxm_rsrp&amp;#39;, r&amp;#39;PCC RxM RSSI:.*RSRP \(dBm\):\s*&amp;#39; + self._NUMERIC_PATTERN) metrics[&amp;#39;pcc_rxd_rssi&amp;#39;] = parse_and_validate(&amp;#39;pcc_rxd_rssi&amp;#39;, r&amp;#39;PCC RxD RSSI:\s*&amp;#39; + self._NUMERIC_PATTERN) metrics[&amp;#39;pcc_rxd_rsrp&amp;#39;] = parse_and_validate(&amp;#39;pcc_rxd_rsrp&amp;#39;, r&amp;#39;PCC RxD RSSI:.*RSRP \(dBm\):\s*&amp;#39; + self._NUMERIC_PATTERN) metrics[&amp;#39;scc_rxm_rssi&amp;#39;] = parse_and_validate(&amp;#39;scc_rxm_rssi&amp;#39;, r&amp;#39;SCC RxM RSSI:\s*&amp;#39; + self._NUMERIC_PATTERN) metrics[&amp;#39;scc_rxm_rsrp&amp;#39;] = parse_and_validate(&amp;#39;scc_rxm_rsrp&amp;#39;, r&amp;#39;SCC RxM RSSI:.*RSRP \(dBm\):\s*&amp;#39; + self._NUMERIC_PATTERN) metrics[&amp;#39;scc_rxd_rssi&amp;#39;] = parse_and_validate(&amp;#39;scc_rxd_rssi&amp;#39;, r&amp;#39;SCC RxD RSSI:\s*&amp;#39; + self._NUMERIC_PATTERN) metrics[&amp;#39;scc_rxd_rsrp&amp;#39;] = parse_and_validate(&amp;#39;scc_rxd_rsrp&amp;#39;, r&amp;#39;SCC RxD RSSI:.*RSRP \(dBm\):\s*&amp;#39; + self._NUMERIC_PATTERN) # Other metrics metrics[&amp;#39;tx_power&amp;#39;] = parse_and_validate(&amp;#39;tx_power&amp;#39;, r&amp;#39;Tx Power:\s*&amp;#39; + self._NUMERIC_PATTERN) metrics[&amp;#39;rsrq_db&amp;#39;] = parse_and_validate(&amp;#39;rsrq_db&amp;#39;, r&amp;#39;RSRQ \(dB\):\s*&amp;#39; + self._NUMERIC_PATTERN) metrics[&amp;#39;sinr_db&amp;#39;] = parse_and_validate(&amp;#39;sinr_db&amp;#39;, r&amp;#39;SINR \(dB\):\s*&amp;#39; + self._NUMERIC_PATTERN) tac_match = re.search(r&amp;#39;TAC:\s*(\S+(?:\s*\(\d+\))?)&amp;#39;, output) if tac_match: metrics[&amp;#39;tac&amp;#39;] = self._extract_decimal_from_parens(tac_match.group(1)) cellid_match = re.search(r&amp;#39;Cell ID:\s*(\S+(?:\s*\(\d+\))?)&amp;#39;, output) if cellid_match: metrics[&amp;#39;cell_id&amp;#39;] = self._extract_decimal_from_parens(cellid_match.group(1)) return metrics def _write_to_influxdb(self, metrics: Dict[str, Any]): &amp;quot;&amp;quot;&amp;quot;Write metrics to InfluxDB.&amp;quot;&amp;quot;&amp;quot; if not self.write_api: return try: point = influxdb_client.Point(&amp;quot;modem&amp;quot;) has_data = False for key, value in metrics.items(): if value is not None: # InfluxDB client is type-sensitive, ensure proper casting if isinstance(value, float): point.field(key, float(value)) elif isinstance(value, int): point.field(key, int(value)) else: # Handle strings or other types if necessary point.field(key, value) has_data = True # Only write the point if it contains at least one valid field if has_data: self.write_api.write(bucket=INFLUXDB_BUCKET, org=INFLUXDB_ORG, record=[point]) except Exception as e: self.logger.error(f&amp;quot;Error writing to InfluxDB: {e}&amp;quot;) def _reset_modem(self): &amp;quot;&amp;quot;&amp;quot;Performs a sysfs authorization reset on the USB device.&amp;quot;&amp;quot;&amp;quot; self.logger.critical(f&amp;quot;--- TRIGGERING MODEM RESET ON PORT {USB_DEVICE_PATH} ---&amp;quot;) auth_path = f&amp;quot;/sys/bus/usb/devices/{USB_DEVICE_PATH}/authorized&amp;quot; try: # Software Unplug self.logger.info(f&amp;quot;De-authorizing {auth_path}...&amp;quot;) subprocess.run([&amp;quot;sudo&amp;quot;, &amp;quot;tee&amp;quot;, auth_path], input=&amp;quot;0&amp;quot;, text=True, check=True, capture_output=True) time.sleep(RESET_PAUSE_SEC) # Software Replug self.logger.info(f&amp;quot;Re-authorizing {auth_path}...&amp;quot;) subprocess.run([&amp;quot;sudo&amp;quot;, &amp;quot;tee&amp;quot;, auth_path], input=&amp;quot;1&amp;quot;, text=True, check=True, capture_output=True) self.logger.info(&amp;quot;Modem reset sequence completed.&amp;quot;) except Exception as e: self.logger.critical(f&amp;quot;MODEM RESET FAILED: {e}&amp;quot;) def is_healthy(self, metrics: Dict[str, Any]) -&amp;gt; bool: &amp;quot;&amp;quot;&amp;quot;Determine if the modem connection is healthy based on mode.&amp;quot;&amp;quot;&amp;quot; mode_online = metrics.get(&amp;#39;mode_online&amp;#39;) if mode_online is None: return False return mode_online == 1 def run(self) -&amp;gt; None: &amp;quot;&amp;quot;&amp;quot;The main monitoring loop that runs until killed.&amp;quot;&amp;quot;&amp;quot; self.logger.info(&amp;quot;Starting modem monitor...&amp;quot;) while not self.shutdown_requested: try: # Get modem status gstatus_output = self._run_command(&amp;#39;AT!GSTATUS?&amp;#39;) if not gstatus_output: self.logger.warning(&amp;quot;Modem is unresponsive.&amp;quot;) self.failure_count += 1 else: # Parse metrics metrics = self._parse_gstatus(gstatus_output) # Write to InfluxDB self._write_to_influxdb(metrics) # Check health if self.is_healthy(metrics): if self.failure_count &amp;gt; 0: self.logger.info(f&amp;quot;Connection restored. Resetting failure count from {self.failure_count} to 0.&amp;quot;) self.failure_count = 0 else: self.failure_count += 1 self.logger.warning(f&amp;quot;Health check failed (failure #{self.failure_count}). Mode online: {metrics.get(&amp;#39;mode_online&amp;#39;)}&amp;quot;) # Check if reset is needed if self.failure_count &amp;gt;= FAILURE_THRESHOLD: elapsed_since_reset = time.monotonic() - self.last_reset_time if elapsed_since_reset &amp;gt; RESET_COOLDOWN_SEC: self._reset_modem() self.last_reset_time = time.monotonic() self.failure_count = 0 # Wait longer after reset for modem to recover self.logger.info(&amp;quot;Waiting for 60 seconds post-reset for modem to stabilize...&amp;quot;) time.sleep(60) continue # Skip the normal sleep and re-check immediately else: time_remaining = RESET_COOLDOWN_SEC - elapsed_since_reset self.logger.info(f&amp;quot;Reset condition met but in cooldown period ({time_remaining:.0f}s remaining).&amp;quot;) except Exception as e: self.logger.error(f&amp;quot;Error in main loop: {e}&amp;quot;) # Sleep before next check if not self.shutdown_requested: time.sleep(CHECK_INTERVAL_SEC) # Cleanup self.logger.info(&amp;quot;Shutting down gracefully...&amp;quot;) if self.influx_client: self.influx_client.close() if __name__ == &amp;quot;__main__&amp;quot;: logging.basicConfig( level=logging.INFO, format=&amp;#39;%(levelname)s: %(message)s&amp;#39;, stream=sys.stdout ) monitor = ModemMonitor() monitor.run() Compute Unit MCU Communication The serial port to the ESP32-S3 controlling the Compute Unit is multiplexed using a ZeroMQ pub/sub server. import zmq import json import serial import serial.serialutil import threading import subprocess import queue import time # --- Configuration --- SERIAL_PORT = &amp;#39;/dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_FC:01:2C:DC:5D:9C-if00&amp;#39; BAUD_RATE = 115200 SERIAL_RETRY_DELAY = 3 # seconds between reconnect attempts # Commands the MCU can send over serial to trigger system actions. # These are intercepted and never forwarded to ZMQ subscribers. MAGIC_COMMANDS = { &amp;quot;!REBOOT&amp;quot;: &amp;quot;sudo reboot&amp;quot;, &amp;quot;!SHUTDOWN&amp;quot;: &amp;quot;sudo poweroff&amp;quot;, } _stop_event = threading.Event() def _run_command(command_string): &amp;quot;&amp;quot;&amp;quot;Runs a shell command in a subprocess, non-blocking.&amp;quot;&amp;quot;&amp;quot; def _target(): try: subprocess.run(command_string, shell=True, check=True) except subprocess.CalledProcessError as e: print(f&amp;quot;Command &amp;#39;{command_string}&amp;#39; failed with exit code {e.returncode}&amp;quot;) threading.Thread(target=_target, daemon=True).start() def serial_broker(outgoing_q, incoming_q): &amp;quot;&amp;quot;&amp;quot; The only thread that touches the serial port. Handles all reconnection logic internally so a lost serial link doesn&amp;#39;t cascade into a broken process. - Lines starting with &amp;#39;!&amp;#39; are checked against MAGIC_COMMANDS and executed locally. Unknown magic commands are logged and dropped. - Valid JSON lines are parsed and forwarded to incoming_q for ZMQ publishing. - Messages in outgoing_q are written to the serial port. &amp;quot;&amp;quot;&amp;quot; while not _stop_event.is_set(): ser = None try: print(f&amp;quot;Connecting to serial port {SERIAL_PORT}...&amp;quot;) ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=1) print(&amp;quot;Serial port connected.&amp;quot;) while not _stop_event.is_set(): # --- Read from serial --- try: line = ser.readline() if line: decoded = line.decode(&amp;#39;utf-8&amp;#39;).strip() if decoded.startswith(&amp;#39;!&amp;#39;): command = MAGIC_COMMANDS.get(decoded) if command: print(f&amp;quot;Magic command received: {decoded} -&amp;gt; &amp;#39;{command}&amp;#39;&amp;quot;) _run_command(command) else: print(f&amp;quot;Unknown magic command from MCU, ignoring: {decoded}&amp;quot;) else: try: data = json.loads(decoded) incoming_q.put(data) except json.JSONDecodeError: print(f&amp;quot;Received non-JSON, non-command line: {decoded}&amp;quot;) except UnicodeDecodeError as e: print(f&amp;quot;Decode error on serial data: {e}&amp;quot;) # --- Write to serial (drain all pending) --- while True: try: message = outgoing_q.get_nowait() ser.write((message + &amp;#39;\n&amp;#39;).encode(&amp;#39;utf-8&amp;#39;)) print(f&amp;quot;Forwarded to serial: {message}&amp;quot;) except queue.Empty: break except serial.SerialException as e: print(f&amp;quot;Serial error: {e}. Retrying in {SERIAL_RETRY_DELAY}s...&amp;quot;) finally: if ser and ser.is_open: ser.close() # Use event wait instead of sleep so shutdown can interrupt the delay _stop_event.wait(SERIAL_RETRY_DELAY) def zmq_to_serial(outgoing_q): &amp;quot;&amp;quot;&amp;quot; Receives command strings from ZMQ REQ clients and queues them for the serial broker to forward to the MCU. &amp;quot;&amp;quot;&amp;quot; context = zmq.Context.instance() socket = context.socket(zmq.REP) socket.setsockopt(zmq.LINGER, 0) socket.bind(&amp;quot;tcp://*:5557&amp;quot;) while not _stop_event.is_set(): # Poll with a timeout so we can check the stop event periodically if socket.poll(1000): message = socket.recv_string().strip() outgoing_q.put(message) socket.send_string(&amp;quot;OK&amp;quot;) socket.close() def serial_to_zmq(incoming_q): &amp;quot;&amp;quot;&amp;quot; Publishes parsed MCU data to all ZMQ subscribers. CONFLATE ensures slow subscribers only ever receive the latest message rather than building up a backlog. Appropriate here since the MCU only sends periodic readings where only the freshest value matters. &amp;quot;&amp;quot;&amp;quot; context = zmq.Context.instance() socket = context.socket(zmq.PUB) socket.setsockopt(zmq.CONFLATE, 1) socket.setsockopt(zmq.LINGER, 0) socket.bind(&amp;quot;tcp://*:5556&amp;quot;) while not _stop_event.is_set(): try: data = incoming_q.get(timeout=1) socket.send_json(data) except queue.Empty: pass socket.close() if __name__ == &amp;quot;__main__&amp;quot;: to_serial_queue = queue.Queue() from_serial_queue = queue.Queue() threads = [ threading.Thread(target=serial_broker, args=(to_serial_queue, from_serial_queue), daemon=True), threading.Thread(target=zmq_to_serial, args=(to_serial_queue,), daemon=True), threading.Thread(target=serial_to_zmq, args=(from_serial_queue,), daemon=True), ] for t in threads: t.start() print(&amp;quot;Serial multiplexer started.&amp;quot;) try: while True: time.sleep(0.2) except KeyboardInterrupt: print(&amp;quot;\nShutting down...&amp;quot;) _stop_event.set() # Terminate the ZMQ context immediately so sockets don&amp;#39;t linger zmq.Context.instance().term() for t in threads: t.join(timeout=5) In plain terms, any script can connect to this ZeroMQ socket, and receive all the data the ESP32 has sent, and also send data to the ZeroMQ socket, and have it forwarded to the ESP. One of the scripts that use this socket is the MCU Logger code, which just logs metrics from the ESP32-S3 to InfluxDB. Since the Single Pair Ethernet link from the Battery and Charging Unit terminates at the ESP32-S3, this includes all battery metrics as well. import influxdb_client from influxdb_client.client.write_api import SYNCHRONOUS import zmq # ── InfluxDB ────────────────────────────────────────────────────────── INFLUXDB_URL = &amp;quot;10.253.0.5:8086&amp;quot; INFLUXDB_TOKEN = &amp;quot;x&amp;quot; INFLUXDB_ORG = &amp;quot;org&amp;quot; INFLUXDB_BUCKET = &amp;quot;ascs&amp;quot; _influx_client = influxdb_client.InfluxDBClient( url=INFLUXDB_URL, token=INFLUXDB_TOKEN, org=INFLUXDB_ORG ) _write_api = _influx_client.write_api(write_options=SYNCHRONOUS) # ── ZMQ ─────────────────────────────────────────────────────────────── context = zmq.Context() socket = context.socket(zmq.SUB) socket.setsockopt(zmq.LINGER, 0) socket.setsockopt_string(zmq.SUBSCRIBE, &amp;quot;&amp;quot;) # subscribe to all messages socket.connect(&amp;quot;tcp://127.0.0.1:5556&amp;quot;) try: while True: data = socket.recv_json() msg_type = data.get(&amp;quot;type&amp;quot;) if msg_type == &amp;quot;cu_power&amp;quot;: point = ( influxdb_client.Point(&amp;quot;power&amp;quot;) .field(&amp;quot;cu_voltage&amp;quot;, float(data[&amp;quot;voltage&amp;quot;])) .field(&amp;quot;cu_current&amp;quot;, float(data[&amp;quot;current&amp;quot;])) ) _write_api.write(bucket=INFLUXDB_BUCKET, org=INFLUXDB_ORG, record=point) elif msg_type == &amp;quot;charger_smartshunt&amp;quot;: point = ( influxdb_client.Point(&amp;quot;smartshunt&amp;quot;) .field(&amp;quot;voltage&amp;quot;, float(data[&amp;quot;battery_v&amp;quot;])) .field(&amp;quot;current&amp;quot;, float(data[&amp;quot;current_a&amp;quot;])) .field(&amp;quot;soc&amp;quot;, float(data[&amp;quot;soc_percent&amp;quot;])) ) _write_api.write(bucket=INFLUXDB_BUCKET, org=INFLUXDB_ORG, record=point) elif msg_type == &amp;quot;charger_orion&amp;quot;: off_reason_hex = data.get(&amp;quot;off_reason_hex&amp;quot;, &amp;quot;0&amp;quot;) off_reason = int(off_reason_hex, 16) point = ( influxdb_client.Point(&amp;quot;orion&amp;quot;) .field(&amp;quot;output_voltage&amp;quot;, float(data[&amp;quot;output_v&amp;quot;])) .field(&amp;quot;output_current&amp;quot;, float(data[&amp;quot;output_a&amp;quot;])) .field(&amp;quot;input_voltage&amp;quot;, float(data[&amp;quot;input_v&amp;quot;])) .field(&amp;quot;input_current&amp;quot;, float(data[&amp;quot;input_a&amp;quot;])) .field(&amp;quot;off_reason&amp;quot;, off_reason) .field(&amp;quot;charge_state&amp;quot;, int(data[&amp;quot;charge_state&amp;quot;])) ) _write_api.write(bucket=INFLUXDB_BUCKET, org=INFLUXDB_ORG, record=point) except KeyboardInterrupt: pass finally: socket.close() context.term() _influx_client.close() There&amp;#39;s about a dozen other Python scripts that perform various logging and control, but I&amp;#39;m going to cut this short here, and move on to ESP32-S3 code. ESP32 Firmware Some time ago, I started working on a framework on top of ESP-IDF, to be able to use super nice and modern abstractions in ESP-IDF code. I could make a separate blog post and talk forever about this, so I&amp;#39;ll just put my README here, for anyone interested. # Corestone **Corestone** is a foundation library for ESP-IDF projects. It replaces unsafe C patterns with robust C++ objects, focusing on safety, ergonomics, and clean resource management. ## Installation 1. Copy the `corestone` folder into your project&amp;#39;s `components/` directory. 2. Add it to your `CMakeLists.txt`: ```cmake idf_component_register( ... REQUIRES &amp;quot;corestone&amp;quot; ) ```` ----- ## 1\. TypeCore: Error Handling **Header:** `#include &amp;quot;corestone/TypeCore.hpp&amp;quot;` TypeCore replaces `esp_err_t` return codes with a distinct `Result ` type (based on C++23 `std::expected`). This forces you to handle errors and enables powerful functional programming patterns. ### The Paradigm Shift #### Old Way (C-Style) Returns an error code and uses a pointer to return data. It is easy to ignore errors or dereference uninitialized data. ```cpp esp_err_t read_sensor(int* value) { if (!hardware_ready) return ESP_ERR_TIMEOUT; *value = 42; return ESP_OK; } ``` #### New Way (Corestone) Explicit success or failure. You cannot access the value without checking for success first. ```cpp corestone::Result read_sensor() { if (!hardware_ready) { // Return a rich error object return corestone::Error(ESP_ERR_TIMEOUT, &amp;quot;Sensor offline&amp;quot;); } return 42; // Implicit conversion to success } // For void functions: corestone::Result init_gpio() { // ... return {}; // Success } ``` ### Usage Patterns #### Pattern 1: Check and Use The most explicit form. Useful when you need to handle the error locally. ```cpp // 1. Capture the result if (auto result = read_sensor()) { // 2. Access value with * or -&amp;gt; ESP_LOGI(&amp;quot;APP&amp;quot;, &amp;quot;Sensor: %d&amp;quot;, *result); } else { // 3. Log the error string safely ESP_LOGE(&amp;quot;APP&amp;quot;, &amp;quot;Error: %s&amp;quot;, ERRSTR(result)); } ``` #### Pattern 2: The `TRY` Macro (Early Return) This acts like the `UNWRAP` pattern or Rust&amp;#39;s `?` operator. 1. Calls the function. 2. If it fails, **returns the error from the current function immediately**. 3. If it succeeds, yields the value. ```cpp corestone::Result process_data() { // If read_sensor fails, process_data returns the error NOW. // If it succeeds, &amp;#39;val&amp;#39; holds the int. int val = TRY(read_sensor()); process_value(val); return {}; } ``` #### Pattern 3: Default Values (`value_or`) Use this when you have a safe fallback and don&amp;#39;t care about the specific error. ```cpp // If read_sensor() fails, we get -1. The error is discarded. int safe_value = read_sensor().value_or(-1); ``` #### Pattern 4: Transform Chains (`and_then` / `or_else`) Chain operations together without checking for errors at every step (Monadic style). ```cpp auto final_result = read_sensor() // 1. If success: Transform int -&amp;gt; string .and_then([](int val) -&amp;gt; corestone::Result { if (val corestone::Result { ESP_LOGW(&amp;quot;APP&amp;quot;, &amp;quot;Recovering from: %s&amp;quot;, err.message.c_str()); return std::string(&amp;quot;Default&amp;quot;); }); ``` #### Pattern 5: Automatic Retries Pass a configuration object to `TRY` to automatically retry the operation. ```cpp corestone::Result connect_cloud() { // Attempt 5 times. Wait 1000ms between attempts. // If all 5 fail, returns the error from the last attempt. TRY(network_connect(), {.attempts = 5, .delay_ms = 1000}); return {}; } ``` ### Advanced: Lightweight Results (`SimpleResult`) `Result ` uses \~70 bytes of stack. For low-level drivers, ISR interactions, or deep recursion, this may be too heavy. Use `SimpleResult ` (defaults to `void`), which uses only \~4-8 bytes overhead and holds a raw `esp_err_t`. ```cpp // [Driver] Returns SimpleResult corestone::SimpleResult read_reg(uint8_t reg) { uint8_t val; esp_err_t err = i2c_read(reg, &amp;amp;val); if (err != ESP_OK) return std::unexpected(err); return val; } // [App] TRY automatically promotes SimpleResult -&amp;gt; Result corestone::Result app_init() { // The TRY macro handles the conversion from &amp;#39;esp_err_t&amp;#39; to &amp;#39;Error&amp;#39;. uint8_t id = TRY(read_reg(0x01)); return {}; } ``` &amp;gt; [\!CAUTION] &amp;gt; **ERRSTR Lifetime:** The `ERRSTR(res)` macro returns a temporary C-string pointer. &amp;gt; &amp;gt; * **Do:** Use it immediately in a function call (like `ESP_LOGE`). &amp;gt; * **Do Not:** Store the pointer (`const char* p = ERRSTR(res);`). It will be dangling immediately. ----- ## 2\. Task Management **Header:** `#include &amp;quot;corestone/Task.hpp&amp;quot;` Corestone provides a C++ wrapper for FreeRTOS tasks. It manages the boilerplate of `xTaskCreate`, stack allocation, and cleanup synchronization. ### The Standard Task (`Task`) Inherit from `corestone::Task` for long-running services (e.g., WiFi handling, Data Processing). #### Example: Continuous Operation ```cpp #include &amp;quot;corestone/Task.hpp&amp;quot; #include &amp;quot;esp_log.h&amp;quot; class BlinkTask : public corestone::Task { public: // Name=&amp;quot;Blinker&amp;quot;, Stack=2048, Priority=5, Core=1 BlinkTask() : Task(&amp;quot;Blinker&amp;quot;, 2048, 5, 1) {} protected: // The main loop implementation. // &amp;#39;token&amp;#39; works like a boolean flag. Check it constantly. void run(corestone::StopToken token) override { ESP_LOGI(get_name(), &amp;quot;Task Started&amp;quot;); while (token) { // Do work gpio_set_level(GPIO_NUM_2, 1); // Sleep. If stop() is called, this delay is naturally waited out, // then the &amp;#39;while(token)&amp;#39; check fails. vTaskDelay(pdMS_TO_TICKS(500)); gpio_set_level(GPIO_NUM_2, 0); vTaskDelay(pdMS_TO_TICKS(500)); } ESP_LOGI(get_name(), &amp;quot;Task Stopping...&amp;quot;); } }; ``` #### Lifecycle Management ```cpp void app_main() { BlinkTask task; // 1. Start the task task.start(); // 2. Wait vTaskDelay(pdMS_TO_TICKS(5000)); // 3. Stop Gracefully // Signals the token to be false and waits up to 1000ms for run() to return. if (!task.stop(1000)) { ESP_LOGE(&amp;quot;APP&amp;quot;, &amp;quot;Task is stuck!&amp;quot;); } } ``` &amp;gt; [\!CAUTION] &amp;gt; **The &amp;quot;Zombie&amp;quot; Task State:** &amp;gt; If `stop(timeout)` returns `false`, the underlying FreeRTOS task is stuck (e.g., blocked on a semaphore or loop without checking token). &amp;gt; &amp;gt; If the `Task` object goes out of scope while the task is stuck, **the destructor will block forever** (deadlock) trying to clean it up. &amp;gt; &amp;gt; * **Ensure:** Your `run()` loops check `token` frequently. &amp;gt; * **Ensure:** You use timeouts on blocking calls inside `run()`. ### The Periodic Task (`PeriodicTask`) Use this for operations that execute at a fixed frequency (e.g., Control Loops). The framework handles the precise timing using `vTaskDelayUntil`. ```cpp class SensorLoop : public corestone::PeriodicTask { public: // Run every 10ms SensorLoop() : PeriodicTask(&amp;quot;Sensors&amp;quot;, 10) {} protected: // This executes once per period. // DO NOT write a loop here. Just do the work and return. void periodic_run() override { read_accelerometer(); update_filters(); } }; ``` ----- ## 3\. Safe Strings (`StackString`) **Header:** `#include &amp;quot;corestone/StackString.hpp&amp;quot;` `StackString ` is a comprehensive, heap-free string implementation. It is designed to be a drop-in replacement for `std::string` in embedded environments where dynamic allocation is forbidden. It supports standard iterators, views, comparisons, and formatting. ### 1\. Construction &amp;amp; Implicit Truncation Constructors are designed for convenience. They **truncate silently** if the input exceeds the capacity. ```cpp // Capacity is 4. Input is 5. // Result: &amp;quot;Hell&amp;quot; (Valid null-terminated string) corestone::StackString s(&amp;quot;Hello&amp;quot;); ``` &amp;gt; [\!NOTE] &amp;gt; If you need to detect truncation during initialization, **do not** use the constructor. Use `assign()`. ### 2\. Modification: `append` vs `operator+=` Corestone distinguishes between &amp;quot;convenient&amp;quot; and &amp;quot;checked&amp;quot; modification. * **`operator+=`**: Convenient. Ignores truncation. * **`append()`**: Explicit. Returns `false` if truncation occurred. ```cpp corestone::StackString s(&amp;quot;Start&amp;quot;); // Option A: Convenience (Result: &amp;quot;StartAdd&amp;quot;) // We don&amp;#39;t know if it fit or not. s += &amp;quot;Add&amp;quot;; // Option B: Safety // We check if the operation succeeded. if (!s.append(&amp;quot;SuperLongAddition&amp;quot;)) { ESP_LOGW(&amp;quot;STR&amp;quot;, &amp;quot;String was truncated!&amp;quot;); } ``` ### 3\. Formatting Standard `printf`-style formatting is built-in. ```cpp corestone::StackString msg; // 1. Static Factory auto s1 = corestone::StackString ::format(&amp;quot;Val: %d&amp;quot;, 42); // 2. Assignment msg.assign_format(&amp;quot;Temp: %.2f&amp;quot;, 25.5f); // 3. Appending msg.append_format(&amp;quot; Unit: %s&amp;quot;, &amp;quot;Celsius&amp;quot;); ``` ### 4\. API Reference #### Capacity &amp;amp; Size Fully supports standard container checks. * `size()`, `length()`: Current number of characters. * `capacity()`: Maximum number of characters (N). * `empty()`: Returns true if size is 0. #### Standard Operations * `clear()`: Resets size to 0. * `pop_back()`: Removes the last character. * `operator[]`: Mutable and const access to characters. * `c_str()`: Returns null-terminated `const char*`. * `data()`: Returns pointer to buffer. #### Views &amp;amp; Interop `StackString` plays nicely with the modern C++ ecosystem. * **`std::string_view`**: Implicit conversion allows you to pass a `StackString` to any function expecting a view. * **`operator==` / ` `**: Supports comparisons with: * Other `StackString` objects (even of different sizes). * `std::string_view`. * C-strings (`const char*`). ### 5\. Example: Full Capability ```cpp void process_name(std::string_view input) { corestone::StackString buffer; // 1. Assignment with check if (!buffer.assign(input)) { ESP_LOGW(&amp;quot;APP&amp;quot;, &amp;quot;Input truncated to: %s&amp;quot;, buffer.c_str()); } // 2. Manipulation if (!buffer.empty()) { buffer.pop_back(); // Remove last char buffer += &amp;#39;!&amp;#39;; // Add exclamation } // 3. Comparison (using operator== with string_view) if (buffer == &amp;quot;Admin!&amp;quot;) { grant_access(); } // 4. View passing // Implicitly converts to std::string_view some_std_function(buffer); } ``` I wrote a minimal but modern driver for the ADIN1110, utilizing my framework. #pragma once #include #include #include #include #include &amp;quot;esp_log.h&amp;quot; #include &amp;quot;esp_heap_caps.h&amp;quot; #include &amp;quot;driver/spi_master.h&amp;quot; #include &amp;quot;driver/gpio.h&amp;quot; #include &amp;quot;corestone/TypeCore.hpp&amp;quot; #include &amp;quot;corestone/Task.hpp&amp;quot; struct __attribute__((packed)) BasePayload { const uint8_t id; protected: BasePayload(uint8_t msg_id) : id(msg_id) {} }; template struct CbTraits; template struct CbTraits &amp;gt; { using Type = Arg; }; template class ADIN1110; template class ADIN1110 &amp;gt; : public corestone::PeriodicTask { private: static constexpr const char* TAG = &amp;quot;ADIN1110&amp;quot;; static constexpr uint8_t RX_FALLBACK_DIVIDER = 10; spi_device_handle_t spi_; gpio_num_t rst_pin_; gpio_num_t int_pin_; uint8_t poll_divider_ = 0; uint8_t src_mac_[6]; uint8_t dest_mac_[6]; std::tuple ...&amp;gt; callbacks_; const uint16_t ETHER_TYPE = 0x1337; uint8_t* dma_tx_buf_; uint8_t* dma_rx_buf_; static constexpr size_t DMA_BUF_SIZE = 2048; static constexpr uint16_t REG_CONFIG0 = 0x04; static constexpr uint16_t REG_CONFIG2 = 0x06; static constexpr uint16_t REG_RESET = 0x03; static constexpr uint16_t REG_STATUS0 = 0x08; static constexpr uint16_t REG_STATUS1 = 0x09; static constexpr uint16_t REG_TX_FSIZE = 0x30; static constexpr uint16_t REG_TX = 0x31; static constexpr uint16_t REG_RX_FSIZE = 0x90; static constexpr uint16_t REG_RX = 0x91; corestone::SimpleResult readReg(uint16_t reg) { memset(dma_tx_buf_, 0, 7); memset(dma_rx_buf_, 0, 7); dma_tx_buf_[0] = 0x80 | ((reg &amp;gt;&amp;gt; 8) &amp;amp; 0x1F); dma_tx_buf_[1] = reg &amp;amp; 0xFF; spi_transaction_t t{}; t.length = 7 * 8; t.tx_buffer = dma_tx_buf_; t.rx_buffer = dma_rx_buf_; esp_err_t err = spi_device_transmit(spi_, &amp;amp;t); if (err != ESP_OK) return std::unexpected(err); uint32_t val = (dma_rx_buf_[3] writeReg(uint16_t reg, uint32_t val) { dma_tx_buf_[0] = 0xA0 | ((reg &amp;gt;&amp;gt; 8) &amp;amp; 0x1F); dma_tx_buf_[1] = reg &amp;amp; 0xFF; dma_tx_buf_[2] = (val &amp;gt;&amp;gt; 24) &amp;amp; 0xFF; dma_tx_buf_[3] = (val &amp;gt;&amp;gt; 16) &amp;amp; 0xFF; dma_tx_buf_[4] = (val &amp;gt;&amp;gt; 8) &amp;amp; 0xFF; dma_tx_buf_[5] = val &amp;amp; 0xFF; spi_transaction_t t{}; t.length = 6 * 8; t.tx_buffer = dma_tx_buf_; esp_err_t err = spi_device_transmit(spi_, &amp;amp;t); if (err != ESP_OK) return std::unexpected(err); return {}; } corestone::Result process_rx() { uint32_t status1 = TRY(readReg(REG_STATUS1)); if ((status1 &amp;amp; 0x00000010) == 0) return {}; uint32_t fsize = TRY(readReg(REG_RX_FSIZE)) &amp;amp; 0x7FF; if (fsize 1518) return {}; uint32_t spi_padded_size = (fsize + 3) &amp;amp; ~3; size_t total_spi_bytes = spi_padded_size + 3; memset(dma_tx_buf_, 0, total_spi_bytes); memset(dma_rx_buf_, 0, total_spi_bytes); dma_tx_buf_[0] = 0x80 | ((REG_RX &amp;gt;&amp;gt; 8) &amp;amp; 0x1F); dma_tx_buf_[1] = REG_RX &amp;amp; 0xFF; spi_transaction_t t{}; t.length = total_spi_bytes * 8; t.tx_buffer = dma_tx_buf_; t.rx_buffer = dma_rx_buf_; esp_err_t err = spi_device_transmit(spi_, &amp;amp;t); if (err != ESP_OK) return corestone::Error{err, &amp;quot;SPI RX transmit failed&amp;quot;}; uint16_t ethType = (dma_rx_buf_[17] &amp;gt;::Type; MsgType dummy; if (dummy.id == received_id &amp;amp;&amp;amp; cb) { const MsgType* msg = reinterpret_cast (payload_ptr); cb(*msg); } }(), ...); }, callbacks_); return {}; } protected: void periodic_run() override { bool should_process = (gpio_get_level(int_pin_) == 0); if (!should_process) { ++poll_divider_; if (poll_divider_ &amp;gt;= RX_FALLBACK_DIVIDER) { poll_divider_ = 0; should_process = true; } } else { poll_divider_ = 0; } if (!should_process) { return; } auto res = process_rx(); if (!res) { ESP_LOGE(TAG, &amp;quot;RX Processing failed: %s&amp;quot;, ERRSTR(res)); } } public: ADIN1110(spi_device_handle_t spi, int rst_pin, int int_pin, const uint8_t* src_mac, const uint8_t* dest_mac) : PeriodicTask(&amp;quot;ADIN1110&amp;quot;, 10, 4096, 5), spi_(spi), rst_pin_(static_cast (rst_pin)), int_pin_(static_cast (int_pin)) { memcpy(src_mac_, src_mac, 6); memcpy(dest_mac_, dest_mac, 6); dma_tx_buf_ = (uint8_t*)heap_caps_malloc(DMA_BUF_SIZE, MALLOC_CAP_DMA); dma_rx_buf_ = (uint8_t*)heap_caps_malloc(DMA_BUF_SIZE, MALLOC_CAP_DMA); } ~ADIN1110() { stop(); if (dma_tx_buf_) heap_caps_free(dma_tx_buf_); if (dma_rx_buf_) heap_caps_free(dma_rx_buf_); } corestone::Result init() { if (!dma_tx_buf_ || !dma_rx_buf_) { return corestone::Error{ESP_ERR_NO_MEM, &amp;quot;Failed to alloc DMA buffers&amp;quot;}; } gpio_set_level(rst_pin_, 0); vTaskDelay(pdMS_TO_TICKS(1)); gpio_set_level(rst_pin_, 1); vTaskDelay(pdMS_TO_TICKS(75)); uint32_t status0 = TRY(readReg(REG_STATUS0)); TRY(writeReg(REG_STATUS0, status0)); uint32_t config2 = TRY(readReg(REG_CONFIG2)); // P1_FWD_UNK2HOST (bit 2) + CRC_APPEND (bit 5) TRY(writeReg(REG_CONFIG2, config2 | 0x00000024)); uint32_t config0 = TRY(readReg(REG_CONFIG0)); TRY(writeReg(REG_CONFIG0, config0 | 0x00008000)); ESP_LOGI(TAG, &amp;quot;ADIN1110 initialized&amp;quot;); return {}; } template void onReceive(std::function cb) { std::get &amp;gt;(callbacks_) = cb; } template corestone::Result send(const T&amp;amp; payload) { uint32_t eth_size = 14 + sizeof(T); if (eth_size &amp;gt; 8) &amp;amp; 0x1F); dma_tx_buf_[1] = REG_TX &amp;amp; 0xFF; dma_tx_buf_[2] = 0x00; dma_tx_buf_[3] = 0x00; memcpy(&amp;amp;dma_tx_buf_[4], dest_mac_, 6); memcpy(&amp;amp;dma_tx_buf_[10], src_mac_, 6); dma_tx_buf_[16] = (ETHER_TYPE &amp;gt;&amp;gt; 8) &amp;amp; 0xFF; dma_tx_buf_[17] = ETHER_TYPE &amp;amp; 0xFF; memcpy(&amp;amp;dma_tx_buf_[18], &amp;amp;payload, sizeof(T)); spi_transaction_t t{}; t.length = total_spi_bytes * 8; t.tx_buffer = dma_tx_buf_; esp_err_t err = spi_device_transmit(spi_, &amp;amp;t); if (err != ESP_OK) return corestone::Error{err, &amp;quot;SPI Transmit failed in send()&amp;quot;}; return {}; } }; Sending and receiving data becomes super simple with this driver. Just define structs used for communication, register a few callbacks, and that&amp;#39;s about it: #include &amp;quot;esp_log.h&amp;quot; #include &amp;quot;esp_system.h&amp;quot; #include &amp;quot;freertos/FreeRTOS.h&amp;quot; #include &amp;quot;freertos/task.h&amp;quot; #include &amp;quot;esp_timer.h&amp;quot; #include &amp;quot;corestone/TypeCore.hpp&amp;quot; #include &amp;quot;Hardware.hpp&amp;quot; #include &amp;quot;ADIN1110.hpp&amp;quot; static const char* TAG = &amp;quot;app&amp;quot;; using namespace corestone; // --------------------------------------------------------- // Custom Payloads // --------------------------------------------------------- struct __attribute__((packed)) TelemetryMessage : public BasePayload { TelemetryMessage() : BasePayload(20) {} uint32_t uptime_ms; float temperature; float humidity; }; struct __attribute__((packed)) CommandMessage : public BasePayload { CommandMessage() : BasePayload(21) {} bool turn_on_led; uint8_t target_pwm; }; using MyProtocolRegistry = std::tuple ; // --------------------------------------------------------- // Application Logic // --------------------------------------------------------- Result run_app() { // ---- Hardware Bring-up ---- TRY(hardware::gpio_init()); spi_device_handle_t spi_handle = TRY(hardware::spi_init()); const uint8_t MY_MAC[6] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55}; const uint8_t TARGET_MAC[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; // Broadcast static ADIN1110 adin(spi_handle, ADIN_RST, ADIN_INT, MY_MAC, TARGET_MAC); // ---- Register Callbacks ---- adin.onReceive ([](const TelemetryMessage&amp;amp; msg) { ESP_LOGI(TAG, &amp;quot;[RX] Telemetry | Uptime: %lu ms | Temp: %.1f | Hum: %.1f&amp;quot;, msg.uptime_ms, msg.temperature, msg.humidity); }); adin.onReceive ([](const CommandMessage&amp;amp; msg) { ESP_LOGI(TAG, &amp;quot;[RX] Command | LED: %s | PWM: %u&amp;quot;, msg.turn_on_led ? &amp;quot;ON&amp;quot; : &amp;quot;OFF&amp;quot;, msg.target_pwm); }); // ---- Initialize &amp;amp; Start ADIN1110 ---- TRY(adin.init()); if (!adin.start()) { return Error{ESP_FAIL, &amp;quot;Failed to start ADIN1110 polling task&amp;quot;}; } ESP_LOGI(TAG, &amp;quot;ADIN1110 Background Task Started&amp;quot;); // ---- Main Loop ---- uint32_t loop_counter = 0; while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); loop_counter++; // Send a frame every second TelemetryMessage t; t.uptime_ms = esp_timer_get_time() / 1000; t.temperature = 24.5f; t.humidity = 40.0f; ESP_LOGI(TAG, &amp;quot;[TX] Sending TelemetryMessage...&amp;quot;); auto res = adin.send(t); if (!res) { ESP_LOGE(TAG, &amp;quot;Failed to send packet: %s&amp;quot;, ERRSTR(res)); } // Dump MAC statistics every 10 seconds if (loop_counter % 10 == 0) { adin.dump_statistics(); } } return {}; } extern &amp;quot;C&amp;quot; void app_main(void) { auto result = run_app(); if (!result) { ESP_LOGE(TAG, &amp;quot;Fatal: %s&amp;quot;, ERRSTR(result)); vTaskDelay(pdMS_TO_TICKS(5000)); esp_restart(); } } Battery Controller Firmware The job of this ESP32-S3 is relatively simple at this point, it receives metrics from the charger and SmartShunt, and sends them over Single Pair Ethernet, using the ADIN driver shown above. I made a VE.Direct library that needs some cleaning up, so I won&amp;#39;t show for now, but this is the top level code for this ESP: #include &amp;quot;esp_log.h&amp;quot; #include &amp;quot;esp_system.h&amp;quot; #include &amp;quot;freertos/FreeRTOS.h&amp;quot; #include &amp;quot;freertos/task.h&amp;quot; #include &amp;quot;corestone/TypeCore.hpp&amp;quot; #include &amp;quot;ADIN1110.hpp&amp;quot; #include &amp;quot;Hardware.hpp&amp;quot; #include &amp;quot;Pins.h&amp;quot; #include &amp;quot;VeDirect.hpp&amp;quot; #include &amp;quot;VeTypes.hpp&amp;quot; using namespace vedirect; using namespace corestone; static const char *TAG = &amp;quot;app&amp;quot;; namespace { using VeRegistry = std::tuple ; using VeAdin = ADIN1110 ; VeAdin *g_adin = nullptr; template const char *text_or(const char (&amp;amp;value)[N], const char *fallback) { return value[0] == &amp;#39;\0&amp;#39; ? fallback : value; } void on_orion_data(const OrionXsData &amp;amp;orion_data) { ESP_LOGI( TAG, &amp;quot;[orion_xs] out=%.3fV %.3fA %.1fW in=%.2fV %.1fA %.1fW state=%s(%d) err=%d or=%s&amp;quot;, orion_data.output_v, orion_data.output_a, orion_data.output_w, orion_data.input_v, orion_data.input_a, orion_data.input_w, charge_state_to_string(orion_data.charge_state), orion_data.charge_state, orion_data.error_code, text_or(orion_data.off_reason_hex, &amp;quot;-&amp;quot;)); if (g_adin) { auto tx = g_adin-&amp;gt;send(orion_data); if (!tx) { ESP_LOGW(TAG, &amp;quot;ADIN send Orion failed: %s&amp;quot;, ERRSTR(tx)); } } } void on_smartshunt_data(const SmartShuntData &amp;amp;shunt_data) { ESP_LOGI( TAG, &amp;quot;[smartshunt] V=%.3fV I=%.3fA P=%.1fW SoC=%.1f%% CE=%.3fAh TTG=%.0fmin E_dis=%.1fWh E_chg=%.1fWh err=%d&amp;quot;, shunt_data.battery_v, shunt_data.current_a, shunt_data.power_w, shunt_data.soc_percent, shunt_data.consumed_ah, shunt_data.time_to_go_min, shunt_data.energy_discharged_wh, shunt_data.energy_charged_wh, shunt_data.error_code); if (g_adin) { auto tx = g_adin-&amp;gt;send(shunt_data); if (!tx) { ESP_LOGW(TAG, &amp;quot;ADIN send SmartShunt failed: %s&amp;quot;, ERRSTR(tx)); } } } Result run_app() { TRY(hardware::gpio_init()); spi_device_handle_t spi_handle = TRY(hardware::spi_init()); const uint8_t MY_MAC[6] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55}; const uint8_t TARGET_MAC[6] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x66}; static VeAdin adin(spi_handle, ADIN_RST, ADIN_INT, MY_MAC, TARGET_MAC); TRY(adin.init()); g_adin = &amp;amp;adin; if (!adin.start()) { return Error{ESP_FAIL, &amp;quot;Failed to start ADIN1110 task&amp;quot;}; } static VeDirect orion( { .name = &amp;quot;OrionVE&amp;quot;, .uart = UART_NUM_1, .tx_pin = VE_ORION_TX, .rx_pin = VE_ORION_RX, .stack_size = 6144, }, on_orion_data); static VeDirect smartshunt( { .name = &amp;quot;SmartShuntVE&amp;quot;, .uart = UART_NUM_2, .tx_pin = VE_SMARTSHUNT_TX, .rx_pin = VE_SMARTSHUNT_RX, .stack_size = 6144, }, on_smartshunt_data); TRY(orion.init()); TRY(smartshunt.init()); if (!orion.start()) { return Error{ESP_FAIL, &amp;quot;Failed to start Orion VE.Direct task&amp;quot;}; } if (!smartshunt.start()) { return Error{ESP_FAIL, &amp;quot;Failed to start SmartShunt VE.Direct task&amp;quot;}; } while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); } return {}; } } // namespace extern &amp;quot;C&amp;quot; void app_main(void) { auto result = run_app(); if (!result) { ESP_LOGE(TAG, &amp;quot;Fatal: %s&amp;quot;, ERRSTR(result)); vTaskDelay(pdMS_TO_TICKS(5000)); esp_restart(); } } In short, all of the battery related data is packaged into these two structs, and sent over SPE: struct __attribute__((packed)) OrionXsData : public BasePayload { OrionXsData() : BasePayload(0) {} char pid[8]{}; char serial[24]{}; char firmware_ext[16]{}; char off_reason_hex[16]{}; float output_v{std::numeric_limits ::quiet_NaN()}; float output_a{std::numeric_limits ::quiet_NaN()}; float output_w{std::numeric_limits ::quiet_NaN()}; float input_v{std::numeric_limits ::quiet_NaN()}; float input_a{std::numeric_limits ::quiet_NaN()}; float input_w{std::numeric_limits ::quiet_NaN()}; int charge_state{std::numeric_limits ::min()}; int error_code{std::numeric_limits ::min()}; }; struct __attribute__((packed)) SmartShuntData : public BasePayload { SmartShuntData() : BasePayload(1) {} char pid[8]{}; char fw[8]{}; char model[24]{}; float battery_v{std::numeric_limits ::quiet_NaN()}; float current_a{std::numeric_limits ::quiet_NaN()}; float power_w{std::numeric_limits ::quiet_NaN()}; float soc_percent{std::numeric_limits ::quiet_NaN()}; float consumed_ah{std::numeric_limits ::quiet_NaN()}; float time_to_go_min{std::numeric_limits ::quiet_NaN()}; float energy_discharged_wh{std::numeric_limits ::quiet_NaN()}; float energy_charged_wh{std::numeric_limits ::quiet_NaN()}; int alarm_reason{std::numeric_limits ::min()}; int error_code{std::numeric_limits ::min()}; }; I did not implement the heater control code yet, as I won&amp;#39;t need it for over half a year, and I had a bunch of other things to focus on. Compute Unit ESP Firmware This ESP32 has to receive data over SPE from the Battery Controller, read the INA226 power monitor IC, control the DC-DC converters for the Compute Unit, and package up all data into JSON to send to Linux. Here&amp;#39;s main: #include &amp;quot;esp_log.h&amp;quot; #include &amp;quot;esp_system.h&amp;quot; #include &amp;quot;freertos/FreeRTOS.h&amp;quot; #include &amp;quot;freertos/task.h&amp;quot; #include #include #include #include #include #include #include #include #include &amp;quot;corestone/TypeCore.hpp&amp;quot; #include &amp;quot;ADIN1110.hpp&amp;quot; #include &amp;quot;ChargerData.hpp&amp;quot; #include &amp;quot;Hardware.hpp&amp;quot; #include &amp;quot;PowerMonitor.hpp&amp;quot; static const char* TAG = &amp;quot;app&amp;quot;; using namespace corestone; namespace { using ChargerRegistry = std::tuple ; using ChargerAdin = ADIN1110 ; bool is_valid_float(float value) { return !std::isnan(value); } bool is_valid_int(int value) { return value != std::numeric_limits ::min(); } template bool is_valid_text(const char (&amp;amp;value)[N]) { return value[0] != &amp;#39;\0&amp;#39;; } const char* json_float(float value, char* out, size_t out_size) { if (!is_valid_float(value)) { return &amp;quot;null&amp;quot;; } std::snprintf(out, out_size, &amp;quot;%.4f&amp;quot;, static_cast (value)); return out; } const char* json_int(int value, char* out, size_t out_size) { if (!is_valid_int(value)) { return &amp;quot;null&amp;quot;; } std::snprintf(out, out_size, &amp;quot;%d&amp;quot;, value); return out; } template const char* json_text(const char (&amp;amp;value)[N], char* out, size_t out_size) { if (!is_valid_text(value)) { return &amp;quot;null&amp;quot;; } std::snprintf(out, out_size, &amp;quot;\&amp;quot;%s\&amp;quot;&amp;quot;, value); return out; } void print_orion_json(const OrionData&amp;amp; data) { char pid[24], serial[40], firmware_ext[32], off_reason_hex[32]; char output_v[24], output_a[24], output_w[24]; char input_v[24], input_a[24], input_w[24]; char charge_state[24], error_code[24]; printf( &amp;quot;{\&amp;quot;type\&amp;quot;:\&amp;quot;charger_orion\&amp;quot;,\&amp;quot;pid\&amp;quot;:%s,\&amp;quot;serial\&amp;quot;:%s,\&amp;quot;firmware_ext\&amp;quot;:%s,\&amp;quot;off_reason_hex\&amp;quot;:%s,&amp;quot; &amp;quot;\&amp;quot;output_v\&amp;quot;:%s,\&amp;quot;output_a\&amp;quot;:%s,\&amp;quot;output_w\&amp;quot;:%s,\&amp;quot;input_v\&amp;quot;:%s,\&amp;quot;input_a\&amp;quot;:%s,\&amp;quot;input_w\&amp;quot;:%s,&amp;quot; &amp;quot;\&amp;quot;charge_state\&amp;quot;:%s,\&amp;quot;error_code\&amp;quot;:%s}\n&amp;quot;, json_text(data.pid, pid, sizeof(pid)), json_text(data.serial, serial, sizeof(serial)), json_text(data.firmware_ext, firmware_ext, sizeof(firmware_ext)), json_text(data.off_reason_hex, off_reason_hex, sizeof(off_reason_hex)), json_float(data.output_v, output_v, sizeof(output_v)), json_float(data.output_a, output_a, sizeof(output_a)), json_float(data.output_w, output_w, sizeof(output_w)), json_float(data.input_v, input_v, sizeof(input_v)), json_float(data.input_a, input_a, sizeof(input_a)), json_float(data.input_w, input_w, sizeof(input_w)), json_int(data.charge_state, charge_state, sizeof(charge_state)), json_int(data.error_code, error_code, sizeof(error_code))); } void print_smartshunt_json(const SmartShuntData&amp;amp; data) { char pid[24], fw[24], model[40]; char battery_v[24], current_a[24], power_w[24], soc_percent[24]; char consumed_ah[24], time_to_go_min[24]; char energy_discharged_wh[24], energy_charged_wh[24]; char error_code[24], alarm_reason[24]; printf( &amp;quot;{\&amp;quot;type\&amp;quot;:\&amp;quot;charger_smartshunt\&amp;quot;,\&amp;quot;pid\&amp;quot;:%s,\&amp;quot;fw\&amp;quot;:%s,\&amp;quot;model\&amp;quot;:%s,&amp;quot; &amp;quot;\&amp;quot;battery_v\&amp;quot;:%s,\&amp;quot;current_a\&amp;quot;:%s,\&amp;quot;power_w\&amp;quot;:%s,\&amp;quot;soc_percent\&amp;quot;:%s,&amp;quot; &amp;quot;\&amp;quot;consumed_ah\&amp;quot;:%s,\&amp;quot;time_to_go_min\&amp;quot;:%s,\&amp;quot;energy_discharged_wh\&amp;quot;:%s,\&amp;quot;energy_charged_wh\&amp;quot;:%s,&amp;quot; &amp;quot;\&amp;quot;error_code\&amp;quot;:%s,\&amp;quot;alarm_reason\&amp;quot;:%s}\n&amp;quot;, json_text(data.pid, pid, sizeof(pid)), json_text(data.fw, fw, sizeof(fw)), json_text(data.model, model, sizeof(model)), json_float(data.battery_v, battery_v, sizeof(battery_v)), json_float(data.current_a, current_a, sizeof(current_a)), json_float(data.power_w, power_w, sizeof(power_w)), json_float(data.soc_percent, soc_percent, sizeof(soc_percent)), json_float(data.consumed_ah, consumed_ah, sizeof(consumed_ah)), json_float(data.time_to_go_min, time_to_go_min, sizeof(time_to_go_min)), json_float(data.energy_discharged_wh, energy_discharged_wh, sizeof(energy_discharged_wh)), json_float(data.energy_charged_wh, energy_charged_wh, sizeof(energy_charged_wh)), json_int(data.error_code, error_code, sizeof(error_code)), json_int(data.alarm_reason, alarm_reason, sizeof(alarm_reason))); } class ConsoleSerialReceive final : public corestone::Task { public: explicit ConsoleSerialReceive( std::function line_callback, const char* name = &amp;quot;ConsoleCmdRx&amp;quot;, uint32_t stack_size = corestone::Task::DEFAULT_STACK_SIZE, UBaseType_t priority = corestone::Task::DEFAULT_PRIORITY, BaseType_t core_id = corestone::Task::DEFAULT_CORE_ID) : corestone::Task(name, stack_size, priority, core_id), line_callback_(std::move(line_callback)) { } protected: void run(corestone::StopToken run_token) override { while (run_token) { int ch = std::fgetc(stdin); if (ch == EOF) { clearerr(stdin); vTaskDelay(pdMS_TO_TICKS(10)); continue; } process_byte(static_cast (ch)); } } private: void process_byte(char byte) { if (dropping_oversized_line_) { if (byte == &amp;#39;\n&amp;#39;) { dropping_oversized_line_ = false; line_length_ = 0; } return; } if (byte == &amp;#39;\n&amp;#39;) { emit_line(); line_length_ = 0; return; } if (byte == &amp;#39;\r&amp;#39;) { return; } if (line_length_ line_callback_; std::array line_buffer_{}; size_t line_length_ = 0; bool dropping_oversized_line_ = false; }; void handle_serial_command(const std::string&amp;amp; line) { constexpr const char* prefix = &amp;quot;setFan&amp;quot;; if (line.rfind(prefix, 0) != 0) { return; } const char* cursor = line.c_str() + 6; while (*cursor == &amp;#39; &amp;#39; || *cursor == &amp;#39;\t&amp;#39;) { ++cursor; } if (*cursor == &amp;#39;\0&amp;#39;) { ESP_LOGW(TAG, &amp;quot;Usage: setFan &amp;quot;); return; } errno = 0; char* end_ptr = nullptr; long value = std::strtol(cursor, &amp;amp;end_ptr, 10); if (errno != 0 || end_ptr == cursor) { ESP_LOGW(TAG, &amp;quot;Invalid fan value in command: &amp;#39;%s&amp;#39;&amp;quot;, line.c_str()); return; } while (*end_ptr == &amp;#39; &amp;#39; || *end_ptr == &amp;#39;\t&amp;#39;) { ++end_ptr; } if (*end_ptr != &amp;#39;\0&amp;#39;) { ESP_LOGW(TAG, &amp;quot;Unexpected trailing data in command: &amp;#39;%s&amp;#39;&amp;quot;, line.c_str()); return; } if (value != 0 &amp;amp;&amp;amp; value != 1) { ESP_LOGW(TAG, &amp;quot;Fan state out of range (%ld). Expected 0 or 1&amp;quot;, value); return; } auto fan1_result = hardware::fan1_set_onoff(value == 1); if (!fan1_result) { ESP_LOGE(TAG, &amp;quot;setFan FAN1 failed: %s&amp;quot;, ERRSTR(fan1_result)); return; } printf(&amp;quot;{\&amp;quot;type\&amp;quot;:\&amp;quot;fan_ack\&amp;quot;,\&amp;quot;state\&amp;quot;:%ld}\n&amp;quot;, value); ESP_LOGI(TAG, &amp;quot;Fan state set to %ld on GPIO%d&amp;quot;, value, FAN1_PWM); } Result run_app() { TRY(hardware::gpio_init()); TRY(hardware::fan1_set_onoff(false)); TRY(hardware::i2c0_init()); spi_device_handle_t spi_handle = TRY(hardware::spi_init()); const uint8_t MY_MAC[6] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x66}; const uint8_t TARGET_MAC[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; static ChargerAdin adin(spi_handle, ADIN_RST, ADIN_INT, MY_MAC, TARGET_MAC); TRY(adin.init()); adin.onReceive ([](const OrionData&amp;amp; data) { print_orion_json(data); }); adin.onReceive ([](const SmartShuntData&amp;amp; data) { print_smartshunt_json(data); }); if (!adin.start()) { return Error{ESP_FAIL, &amp;quot;Failed to start ADIN1110 task&amp;quot;}; } gpio_set_level(static_cast (SOC_PWR_EN), 1); gpio_set_level(static_cast (WWAN_PWR_EN), 1); PowerMonitor power_monitor_task; if (!power_monitor_task.start()) { return Error{ESP_FAIL, &amp;quot;Failed to start power monitor task&amp;quot;}; } ESP_LOGI(TAG, &amp;quot;Power monitor task started&amp;quot;); ConsoleSerialReceive serial_receive_task( [](std::string line) { handle_serial_command(line); }, &amp;quot;ConsoleCmdRx&amp;quot;, 4096, corestone::Task::DEFAULT_PRIORITY, corestone::Task::DEFAULT_CORE_ID); if (!serial_receive_task.start()) { return Error{ESP_FAIL, &amp;quot;Failed to start console serial command receiver task&amp;quot;}; } ESP_LOGI(TAG, &amp;quot;Console serial command receiver started&amp;quot;); while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); } return {}; } } // namespace extern &amp;quot;C&amp;quot; void app_main(void) { auto result = run_app(); if (!result) { ESP_LOGE(TAG, &amp;quot;Fatal: %s&amp;quot;, ERRSTR(result)); vTaskDelay(pdMS_TO_TICKS(5000)); esp_restart(); } } The data logged by this code is received by the Communications Hub Python code shown earlier, and is logged to InfluxDB by the logger script, also shown earlier. Grafana Data All of the data logged to InfluxDB is neatly shown in a Grafana dashboard. Here&amp;#39;s the data received from the Battery Controller through Single Pair Ethernet. I have the charger configured for 30A maximum input and output current, and also to maintain at least 13.1V on the input. This can be seen in action on the Charger Input Voltage and Charger Input Current graphs, when my car&amp;#39;s generator decided to drop the voltage, the Charger Input Current also dropped to 22A to maintain 13.1V on the input. These are all of the metrics logged from the 4G modem: And here are video streaming statistics, along with GPS accuracy and speed data: (I had some trouble with the rear camera, probably related to the 4 meter USB2 extension cable used, so that camera is inoperative at the moment) GPS positioning data is also logged and visualized: Video Stream Example And finally, here&amp;#39;s a short segment from one of the front camera streams. The filtering and encoding parameters were not tuned particularly well, so the video looks a bit crunchy, but some work will greatly improve the quality later. community.element14.com/.../cut.mp4 Conclusion I&amp;#39;m very happy with how this project turned out, the battery lasts for about 5 days, and the always-on camera system is a lifesaver when parking on the street. Interestingly, while I was working on this project, someone backed into my parked car, then left. Three of the four cameras recorded the offending car though, so this project has already saved me, even before fully completing it. Future Improvements I&amp;#39;ve already been thinking about how to further improve this system, and there&amp;#39;s one last area to work on: video transport, ingest and filtering. The elegant solution would be a massive money pit though, along with taking forever to do, and I&amp;#39;m not sure I&amp;#39;m ready to tackle it anytime soon. The &amp;quot;proper&amp;quot; way to do things would be to get FPDLink or GMSL serializers on the cameras, and get an AMD Kria K26 FPGA along with FPDLink/GMSL deserializers to handle video filtering and encoding in the FPGA. Designing a PCB for the Kria is a massive step up in complexity compared to what I&amp;#39;ve done so far though. But even in its current state, the system is working great, and all future upgrades are just incremental improvements. Special thanks to E14Alice for helping me with a bunch of shipping issues, and my friend David for helping with sourcing the Orange Pi 5 Plus and dealing with my rants at 3AM about things not working. Also, thanks to Molex for the lovely SPE hardware, which will definitely find its way into some future projects. On that note, I just had to take apart one of the M12 SPE connectors, to see what it looks like inside: {gallery}Molex SPE Connector Teardown M12 SPE Connector: removed from outer shell M12 SPE Connector : inner connector M12 SPE Connector : outer shell M12 SPE Connector : outer shell M12 SPE Connector : shield connection bent out of the way M12 SPE Connector : inner shield removed M12 SPE Connector : inner shield M12 SPE Connector : inner connector without shield M12 SPE Connector : crimped inner connectors</description><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/single%2bpair%2bethernet">single pair ethernet</category><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/experimenting%2bwith%2bsingle%2bpair%2bethernet">experimenting with single pair ethernet</category><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/spe">spe</category></item><item><title>File: 6558.ceiling</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/m/managed-videos/151165</link><pubDate>Fri, 03 Apr 2026 01:01:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:44c66f04-4f53-4b23-a266-996850ac5751</guid><dc:creator>JWx</dc:creator><description /></item><item><title>Blog Post: Line powered SPE IP camera</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/b/projects/posts/subject-placeholder</link><pubDate>Fri, 03 Apr 2026 00:59:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:8abfb85d-321e-43aa-82ab-8133e8f6e2c6</guid><dc:creator>JWx</dc:creator><description>Intro Experimenting with Single Pair Ethernet Design Challenge is a Molex sponsored contest involving conducting research of Single Pair Ethernet - one of newer physical layers of 802.3 Ethernet. This layer, which is utilizing single twisted conductor pair as a medium, further divides into two groups: short range (with a suffix of T1S) and long range (T1L) - and this one is a subject of current challenge Short range version is currently specified only as 10BASE-T1S (10Mb/s), but long range can be 10/100/1000Mb/s (10BASE-T1L, 100BASE-T1L, 1000BASE-T1L). Another mayor difference is the fact that T1L has point-to-point topology, where T1S can connect several devices using one cable run (similar to - for example - RS485). Single Pair Ethernet can also be seen as a mean of unifying the communication protocols across the installation - by means of simple media conversion, the same protocol stack can be used in places where traditionally protocol conversion was needed (for example - serial to Ethernet bridge utilizing protocols capable for - for example - address translation). Design kit Design kit consist of two parts - a set of Molex connectors and cables and some additional components: Analog Devices evaluation boards, Raspberry Pi SBC and a sensor module: Item no. count Description 1 1 1m of IP20 SPE cable with IEC63171-6 connectors 2 1 5m of IP20 SPE cable with IEC63171-6 connectors 3 4 IEC63171-6 fully shielded PCB connector 4 4 IP67 IEC63171-6 PCB connector 5 1 1m of SPE IEC63171-6 IP65/67 cable 6 1 IEC63171-6 IP67 enclosure connector mount - back mounted 7 1 IEC63171-6 IP67 enclosure connector mount - front mounted 8 1 EVAL-ADIN1100EBZ SPE PHY evaluation board 9 1 EVAL-ADIN1110EBZ SPE MAC-PHY evaluation board 10 1 EVAL-CN0575-RPIZ SPE Raspberry Pi shield 11 2 RPI4-MODBP-4GB Raspberry Pi 4 SBC 12 1 PmodTMP2 temperature sensor Unboxing and initial tests Components were delivered neatly packaged in the big cardboard box, allowing us to take closer look at their features. IEC63171-6 IP20 patchcords (1m and 5m) and sockets Those patchcords, built with stranded, shielded AWG26 twisted pair cable have detailed part specification printed on the cable insulation and their connectors sport metal latches for increased reliability in the presence of vibrations. Along with the cables, four shielded, right angle, PCB mounted sockets were delivered. {gallery}ip20 cables IEC63171-6 IP65/67 patchcord and sockets For more demanding environments, 1m of IP65/67 patchcord was also included, along with four matching PCB sockets - this time straight, and two enclosure mounts - one screwed from the inside and one from the outside. Patchord itself is constructed using thicker wire - this time AWG22 shielded twisted pair. {gallery}ip67 cable assemblies Provided PCB connector is IP67 rated only when connected, which can be verified using simple light test - one can see the light through the connector, indicating lack of internal sealings and confirmed in the documentation so - given that this connector is of M12 screwed type, several plastic M12 protector caps were also bought to protect the socket when unconnected As an active part of the experimenting setup, several other components were provided: three evaluation boards from Analog Devices: EVAL-ADIN1100EBZ This board, featured on the photo below, includes ADIN1100 PHY in media converter configuration, connected with ADIN1200 10BASE-T/100BASE-TX industrial grade PHY. There is also on-board MCU, allowing for on-line configuration and ADIN1100 register access using virtual COM on USB port. EVAL-ADIN1110EBZ This board features more integrated 10BASE-T1L chip from Analog Devices: ADIN1110 MAC-PHY, connected with STM32 MCU using SPI bus. CN-0575 CN-0575 is another board with ADIN1110 MAC-PHY, this time in the form of Raspberry Pi shield and with additional Power over Data Line circuits Two Raspberry Pi 4 SBC First connectivity check As I have accidently received two EVAL-ADIN1100EBZ boards instead of one and Element14 staff generously allowed me to keep the additional one, my first test will be to connect a laptop to the Internet using two EVAL-ADIN1100EBZ in media converter configurations. When the board is configured this way, two Ethernet PHYs (one for 10BASE-T1L and one for 10BASE-T) are connected back-to-back, which also proves that SPE is simply a variant of well-known Ethernet, when the same frames can be copied from one medium to another without modification. The converter can be powered using USB or external power supply with voltage in 5-32V range. To test if communication can be carried on typical low-cost medium, I have prepared 48m of CAT5E CCE (copper clad aluminum - very inexpensive one) AWG 24 cable, from which one pair will be used for communication, And the whole setup will look like below And? Autonegotiation was successful and link speed of 10Mb/s correctly set ICMP Echo (ping) test was successful And bandwidth test (using bandwidth metering service from the Internet) was showing full bandwidth was available: Long distance tests Next test will involve the question: can longer link be used? Following the suggestion of wolfgangfriedrich I have connected in series all four pairs of 48m UTC cable from the last test, forming 192m cable with additional inter-pair crosstalk as a bonus. This time, I have used two server-grade Intel 82576 1000Base-T adapters installed in Dell servers, connected using 192m of single pair cable with two EVAL-ADIN1100EBZ as media converters. First test - ICMP echo was successful: PING 172.19.0.11 (172.19.0.11) 56(84) bytes of data. 64 bytes from 172.19.0.11: icmp_seq=1 ttl=64 time=0.561 ms 64 bytes from 172.19.0.11: icmp_seq=2 ttl=64 time=0.523 ms 64 bytes from 172.19.0.11: icmp_seq=3 ttl=64 time=0.533 ms 64 bytes from 172.19.0.11: icmp_seq=4 ttl=64 time=0.523 ms 64 bytes from 172.19.0.11: icmp_seq=5 ttl=64 time=0.545 ms 64 bytes from 172.19.0.11: icmp_seq=6 ttl=64 time=0.539 ms 64 bytes from 172.19.0.11: icmp_seq=7 ttl=64 time=0.544 ms 64 bytes from 172.19.0.11: icmp_seq=8 ttl=64 time=0.554 ms Then, iperf3 test indicated good performance and no data loss client side iperf3 -c 172.19.0.11 -b 10M Connecting to host 172.19.0.11, port 5201 [ 5] local 172.19.0.10 port 35668 connected to 172.19.0.11 port 5201 [ ID] Interval Transfer Bitrate Retr Cwnd [ 5] 0.00-1.00 sec 1.30 MBytes 10.9 Mbits/sec 0 50.9 KBytes [ 5] 1.00-2.00 sec 1.12 MBytes 9.44 Mbits/sec 0 50.9 KBytes [ 5] 2.00-3.00 sec 1.22 MBytes 10.3 Mbits/sec 0 50.9 KBytes [ 5] 3.00-4.00 sec 1.12 MBytes 9.38 Mbits/sec 0 50.9 KBytes [ 5] 4.00-5.00 sec 1.12 MBytes 9.38 Mbits/sec 0 50.9 KBytes [ 5] 5.00-6.00 sec 1.12 MBytes 9.38 Mbits/sec 0 50.9 KBytes [ 5] 6.00-7.00 sec 1.12 MBytes 9.38 Mbits/sec 0 50.9 KBytes [ 5] 7.00-8.00 sec 1.12 MBytes 9.38 Mbits/sec 0 50.9 KBytes [ 5] 8.00-9.00 sec 1.12 MBytes 9.38 Mbits/sec 0 50.9 KBytes [ 5] 9.00-10.00 sec 1.12 MBytes 9.38 Mbits/sec 0 50.9 KBytes - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate Retr [ 5] 0.00-10.00 sec 11.5 MBytes 9.63 Mbits/sec 0 sender [ 5] 0.00-10.08 sec 11.3 MBytes 9.37 Mbits/sec receiver iperf Done. server side: iperf3 -s ----------------------------------------------------------- Server listening on 5201 ----------------------------------------------------------- Accepted connection from 172.19.0.10, port 35656 [ 5] local 172.19.0.11 port 5201 connected to 172.19.0.10 port 35668 [ ID] Interval Transfer Bitrate [ 5] 0.00-1.00 sec 1.07 MBytes 9.01 Mbits/sec [ 5] 1.00-2.00 sec 1.12 MBytes 9.42 Mbits/sec [ 5] 2.00-3.00 sec 1.12 MBytes 9.41 Mbits/sec [ 5] 3.00-4.00 sec 1.12 MBytes 9.42 Mbits/sec [ 5] 4.00-5.00 sec 1.12 MBytes 9.42 Mbits/sec [ 5] 5.00-6.00 sec 1.12 MBytes 9.42 Mbits/sec [ 5] 6.00-7.00 sec 1.12 MBytes 9.42 Mbits/sec [ 5] 7.00-8.00 sec 1.12 MBytes 9.41 Mbits/sec [ 5] 8.00-9.00 sec 1.12 MBytes 9.42 Mbits/sec [ 5] 9.00-10.00 sec 1.12 MBytes 9.42 Mbits/sec [ 5] 10.00-10.08 sec 90.5 KBytes 9.32 Mbits/sec - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate [ 5] 0.00-10.08 sec 11.3 MBytes 9.37 Mbits/sec receiver And iperf3 UDP client Connecting to host 172.19.0.11, port 5201 [ 5] local 172.19.0.10 port 42566 connected to 172.19.0.11 port 5201 [ ID] Interval Transfer Bitrate Total Datagrams [ 5] 0.00-1.00 sec 1.19 MBytes 10.0 Mbits/sec 863 [ 5] 1.00-2.00 sec 1.19 MBytes 9.97 Mbits/sec 861 [ 5] 2.00-3.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 3.00-4.00 sec 1.14 MBytes 9.56 Mbits/sec 825 [ 5] 4.00-5.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 5.00-6.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 6.00-7.00 sec 1.14 MBytes 9.56 Mbits/sec 825 [ 5] 7.00-8.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 8.00-9.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 9.00-10.00 sec 1.14 MBytes 9.56 Mbits/sec 825 - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams [ 5] 0.00-10.00 sec 11.5 MBytes 9.65 Mbits/sec 0.000 ms 0/8329 (0%) sender [ 5] 0.00-10.08 sec 11.4 MBytes 9.52 Mbits/sec 0.014 ms 0/8285 (0%) receiver server Accepted connection from 172.19.0.10, port 48540 [ 5] local 172.19.0.11 port 5201 connected to 172.19.0.10 port 42566 [ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams [ 5] 0.00-1.00 sec 1.09 MBytes 9.13 Mbits/sec 0.299 ms 0/788 (0%) [ 5] 1.00-2.00 sec 1.14 MBytes 9.57 Mbits/sec 0.307 ms 0/826 (0%) [ 5] 2.00-3.00 sec 1.14 MBytes 9.57 Mbits/sec 0.021 ms 0/826 (0%) [ 5] 3.00-4.00 sec 1.14 MBytes 9.56 Mbits/sec 0.019 ms 0/825 (0%) [ 5] 4.00-5.00 sec 1.14 MBytes 9.57 Mbits/sec 0.024 ms 0/826 (0%) [ 5] 5.00-6.00 sec 1.14 MBytes 9.56 Mbits/sec 0.024 ms 0/825 (0%) [ 5] 6.00-7.00 sec 1.14 MBytes 9.57 Mbits/sec 0.018 ms 0/826 (0%) [ 5] 7.00-8.00 sec 1.14 MBytes 9.57 Mbits/sec 0.019 ms 0/826 (0%) [ 5] 8.00-9.00 sec 1.14 MBytes 9.56 Mbits/sec 0.015 ms 0/825 (0%) [ 5] 9.00-10.00 sec 1.14 MBytes 9.57 Mbits/sec 0.022 ms 0/826 (0%) [ 5] 10.00-10.08 sec 93.3 KBytes 9.64 Mbits/sec 0.014 ms 0/66 (0%) - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams [ 5] 0.00-10.08 sec 11.4 MBytes 9.52 Mbits/sec 0.014 ms 0/8285 (0%) receiver this observation was also verified using error counters of interfaces: inet 172.19.0.11 netmask 255.255.0.0 broadcast 172.19.255.255 inet6 fe80::225:90ff:fe3a:1730 prefixlen 64 scopeid 0x20 ether 00:25:90:3a:17:30 txqueuelen 1000 (Ethernet) RX packets 4010912 bytes 5725615220 (5.3 GiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 1865313 bytes 565506418 (539.3 MiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 device memory 0xc15a0000-c15bffff inet 172.19.0.10 netmask 255.255.0.0 broadcast 172.19.255.255 inet6 fe80::225:90ff:fe39:8fb0 prefixlen 64 scopeid 0x20 ether 00:25:90:39:8f:b0 txqueuelen 1000 (Ethernet) RX packets 1865234 bytes 565502452 (539.3 MiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 4011112 bytes 5725623976 (5.3 GiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 device memory 0xc15a0000-c15bffff no errors after transferring 5GB and 500MB back (after bulk file transfer test). Then, Netpipe bandwidth test was conducted for different frame sizes, giving plots as below: Again - very good, no packet loss symptoms - observed slight drop of transfer near 1kB frame size was identified before as a behavior of the Intel network card used. Reverse polarity test As Analog Devices evaluation cards are using screwed terminal blocks to connect the SPE cable, I have seen an opinion that it can be lead to installation errors in form of swapped conductors. Although IEEE 802.3-2022 states: 146.3.4.4 PCS Receive automatic polarity detection An automatic polarity detection and correction shall be implemented on the receive side of both master and slave PHY. we can test it ourselves. After reversing pair conductors, PING still goes through: 64 bytes from 172.19.0.11: icmp_seq=1 ttl=64 time=0.543 ms 64 bytes from 172.19.0.11: icmp_seq=2 ttl=64 time=0.551 ms 64 bytes from 172.19.0.11: icmp_seq=3 ttl=64 time=0.570 ms 64 bytes from 172.19.0.11: icmp_seq=4 ttl=64 time=0.522 ms Iperf3 performance is unaffected: client : Connecting to host 172.19.0.11, port 5201 [ 5] local 172.19.0.10 port 38580 connected to 172.19.0.11 port 5201 [ ID] Interval Transfer Bitrate Total Datagrams [ 5] 0.00-1.00 sec 1.19 MBytes 10.0 Mbits/sec 863 [ 5] 1.00-2.00 sec 1.19 MBytes 9.97 Mbits/sec 861 [ 5] 2.00-3.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 3.00-4.00 sec 1.14 MBytes 9.56 Mbits/sec 825 [ 5] 4.00-5.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 5.00-6.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 6.00-7.00 sec 1.14 MBytes 9.56 Mbits/sec 825 [ 5] 7.00-8.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 8.00-9.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 9.00-10.00 sec 1.14 MBytes 9.56 Mbits/sec 825 - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams [ 5] 0.00-10.00 sec 11.5 MBytes 9.65 Mbits/sec 0.000 ms 0/8329 (0%) sender [ 5] 0.00-10.08 sec 11.4 MBytes 9.52 Mbits/sec 0.022 ms 0/8284 (0%) receiver server: Accepted connection from 172.19.0.10, port 39240 [ 5] local 172.19.0.11 port 5201 connected to 172.19.0.10 port 38580 [ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams [ 5] 0.00-1.00 sec 1.09 MBytes 9.13 Mbits/sec 0.303 ms 0/788 (0%) [ 5] 1.00-2.00 sec 1.14 MBytes 9.57 Mbits/sec 0.306 ms 0/826 (0%) [ 5] 2.00-3.00 sec 1.14 MBytes 9.57 Mbits/sec 0.028 ms 0/826 (0%) [ 5] 3.00-4.00 sec 1.14 MBytes 9.56 Mbits/sec 0.019 ms 0/825 (0%) [ 5] 4.00-5.00 sec 1.14 MBytes 9.57 Mbits/sec 0.010 ms 0/826 (0%) [ 5] 5.00-6.00 sec 1.14 MBytes 9.57 Mbits/sec 0.020 ms 0/826 (0%) [ 5] 6.00-7.00 sec 1.14 MBytes 9.56 Mbits/sec 0.021 ms 0/825 (0%) [ 5] 7.00-8.00 sec 1.14 MBytes 9.57 Mbits/sec 0.020 ms 0/826 (0%) [ 5] 8.00-9.00 sec 1.14 MBytes 9.57 Mbits/sec 0.020 ms 0/826 (0%) [ 5] 9.00-10.00 sec 1.14 MBytes 9.56 Mbits/sec 0.014 ms 0/825 (0%) [ 5] 10.00-10.08 sec 91.9 KBytes 9.50 Mbits/sec 0.022 ms 0/65 (0%) - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams [ 5] 0.00-10.08 sec 11.4 MBytes 9.52 Mbits/sec 0.022 ms 0/8284 (0%) receiver And Netpipe graph is as below (comparing direct and reversed connection) - no observable difference ADIN1110 tests As we have discovered previously, ADIN1110 differs from ADIN1100 PHYs we have used so far that it includes complete Ethernet adapter (both PHY and MAC functions). In this Challenge, we were provided with evaluation boards for both ADIN1100 and ADIN1110. Our next test would be connecting ADIN1110 evaluation board (that has on-board MCU and can serve a web page) with Raspberry PI - but instead of using CN-0575 shield (yet), another interface will be used: USB 10BASE-T1L adapter that is included in AD-SWIOT1L-SL : AD-T1LUSB2.0, that is basically USB Ethernet adapter with included ADIN1100 PHY. It&amp;#39;s block diagram is below: Our test setup consists of Raspberry PI 10BASE-T1L USB adapter short run of MODBUS cable (included in AD-SWIOT1L-SL evaluation platform), later replaced with 48 meters of CAT5E UTP USB powered ADIN1110 evaluation board in a default configuration and is (after configuring DHCP server on Raspberry PI, because default configuration of ADIN1110 evaluation board uses dynamic address) working, serving page as below: From this page, some interesting data can be gathered: As we can see, it has autonegotiated as slave/follower (which is consistent with default jumper setting of &amp;quot;prefer follower&amp;quot;), some quality metric is displayed and DHCP configuration is also present. Then, 48 meters of UTP CAT5E cable was connected instead of MODBUS cable with no observable degradation - even MSE metric stays at -37.2dB Unfortunately, on Raspberry PI side ever increasing counter of receive errors was observed: The frame loss is not affecting ICMP, but TCP segments sent from ADIN1110 get lost and are being retransmitted: As this behavior is not present when using ADIN1100 media converters and is independent to the cable length (happens both on 30cm and on 192m cable) it could be some firmware or USB adapter issue. Technology evaluation and project directions As we have tested, 10BASE-T1 technology can be utilized on typical Ethernet cables (at least in low noise/lab environment) and is immune to reverse polarization. So - what can be benefits of using good quality cables and connectors? First - noise suppression. Molex cables and connectors are shielded which could be needed in the presence of higher noise levels. In fact, even in lab/office environment, mains noise (second image) has similar amplitude to the signal transmitted (first image): and given imperfections of common-mode filtering components, it could have negative impact on the communication if high enough. Second - temporary/detachable connections. In such a situation connectors that can be quickly dis/connected while being vibration resistant are a must. Below we can see metal latch of Molex IP20 connector, which secures it in place and is more damage-resistant that a plastic one we are familiar from RJ-45 plugs. Third - water and dust protection. IP rating specifies protection level against dust (first digit) and water ingress (second digit). IP6x describe equipment that is &amp;quot;dust-tight&amp;quot;, which means that even limited amounts of dust are prevented from entering, IPx5 inform that equipment is protected against &amp;quot;water jets&amp;quot; - water projected in low-pressure jets from any direction will not have harmful effects (but limited ingress may happen), IPx7 specifies that equipment is protected against temporary immersion in water (up to 1m for 30min), Molex offers connectors and cables with IP rating of 65 or even 67. And the last one - power over data line. As we know from &amp;quot;traditional&amp;quot; Ethernet, possibility to power equipment using only data line could simplify installations of low-power, remote components - like WiFi Access Points or cameras. 10BASE-T1 standard allows for powering equipment using data line, but the cables should meet higher standards than for data-only. In this situation also good quality cables are important. Waterproof enclosure and connectors To demonstrate the usage of SPE Molex connectors in more demanding environment, we will try to build water-resistant, line powered IP camera. To do this, we will need appropriate enclosure. I have selected Kradex Z74 I have used as an additional one in E14 Extreme Environment Challenges. It is slightly larger than a Raspberry Pi 4 SBC, so there is a room for additional components but has mounting points only very near the walls - this fact will be important later Enclosure itself - rated for IP65 (so it is protected from dust ingress and from water jets from any direction, but not for water immersion), has foam-roll gasket between the main part and the lid that should be trimmed and self-installed from the provided length Raspberry Pi itself was screwed into section of PCB cut-out to the shape of enclosure floor, boards connected using 2.5mm spacers and screws - RPI has mounting holes of 2.7mm diameter, so more common 3mm spacers cannot be used. Then, first immersion test was performed: with empty enclosure filled with paper towel to quickly identify water ingress community.element14.com/.../0880.enc_5F00_initial_5F00_2.mp4 No excessive bubbling was observer and no water identifiable on the paper towel - success! This enclosure survived short-term water immersion despite being IP65 only. Next goal - install SPE connector. First dilemma - where? Traditionally, it should be installed on one of the walls but there is one problem: connector is PCB mounted and should be slided into a mount screwed to the enclosure wall. So, either PCB should be vertically moved into a mount, then fastened, or a mount should be installed onto a connector. As we see below, connector protrudes out of enclosure, creating mechanical challenge And - as my enclosure doesn&amp;#39;t provide easy way of moving PCB vertically (board profiled in such a way that it can be mounted using mounting points in the corners cannot be moved vertically) I have decided to mount it into the lid - this way, the lid can be positioned on the connector. {gallery}connector mount Next test - is it still waterproof, is the connector really IP67 only when connected and if we can use connector cap to protect it when not connected Test setup is as before: enclosure with paper towel inserted (to quickly identify water ingress), this time with the connector socket installed (we can see inner gasket that is protecting the connection with plug installed) community.element14.com/.../4118.unconn_5F00_test_5F00_out.mp4 as we can see, water can enter through the connector, leaving enclosure after test in the state like below: What can we do about this? Fortunately our Molex M12 connector mounts are of threaded variety, so they have inner thread and a gasket so we can use standard M12 threaded connector caps. Connector filled with such a cap is shown on the photo below: This time test result is quite different community.element14.com/.../8206.cap_5F00_test_5F00_out1.mp4 with no observable water ingress at the end Connector adapters To allow easy interfacing with ADI evaluation boards (that usually terminate Single Pair Ethernet using terminal blocks), two adapters were prepared: one for IP20 PCB connector and another one for IP67 PCB connector RPI with CN-0575 To provide our camera with 10BASE-T1L interface, we will install CN-0575 shield, that not only includes SPI connected ADIN1110 MAC-PHY, but a complete Power over Data Line Powered Device (PD) circuit with galvanic isolation. There is a small problem configuring it through - although ADIN1110 driver is included in the mainline kernel, Raspberry Pi OS (previously known as Raspbian) is not building it - even as loadable module. Additionally, Raspberry Pi OS lacks an overlay file (a form of configuration file for hardware components for CN-0575. Those two obstacles can be solved - driver can be built &amp;quot;by hand&amp;quot; and overlay file can be downloaded, but there is another approach: ADI offers Kuiper Linux - Raspberry Pi OS clone with support included for various ADI development boards - CN-0575 included. After flashing it into SD card (in the standard way), enabling dtoverlay=rpi-cn0575 in the config.txt and rebooting, additional Ethernet interface (usually eth1) appears that can be configured using typical methods. Camera interface To provide main function of the device (and consume somewhat more power to show off power over the data line capabilities), a camera and RGB LED ring were installed on the upper PCB. Camera was connected in the usual way with dedicated cable to the dedicated port of Raspberry Pi SBC, and WS2812B LED ring was also connected in the typical way - to the GPIO18 of the SBC. Then, libraries were installed: #pip3 install rpi_ws281x #pip3 install adafruit-circuitpython-neopixel And modified example was run. Modification involved leaving only solid color set and including sleep in the loop to prevent it from spinning too fast: colorWipe(strip, Color(255, 255, 255)) #white time.sleep(10) Resulting in the ring of white light with device consuming about 600mA. CN-0575 performance test For CN-0575 performance test, setup was modified by including (in addition to 48m of UTP) Molex IP20 connector adapter and Molex IP20 1m cable Raspberry Pi is USB powered in this setup. Iperf3 test results are somewhat surprising - there is some packet loss observable when using datagram (UDP) mode iperf3 -c 172.19.0.20 -u -b 10M Connecting to host 172.19.0.20, port 5201 [ 5] local 172.19.0.10 port 57318 connected to 172.19.0.20 port 5201 [ ID] Interval Transfer Bitrate Total Datagrams [ 5] 0.00-1.00 sec 1.19 MBytes 10.0 Mbits/sec 863 [ 5] 1.00-2.00 sec 1.19 MBytes 9.97 Mbits/sec 861 [ 5] 2.00-3.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 3.00-4.00 sec 1.14 MBytes 9.56 Mbits/sec 825 [ 5] 4.00-5.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 5.00-6.00 sec 1.14 MBytes 9.56 Mbits/sec 825 [ 5] 6.00-7.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 7.00-8.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 8.00-9.00 sec 1.14 MBytes 9.56 Mbits/sec 825 [ 5] 9.00-10.00 sec 1.14 MBytes 9.57 Mbits/sec 826 - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams [ 5] 0.00-10.00 sec 11.5 MBytes 9.65 Mbits/sec 0.000 ms 0/8329 (0%) sender [ 5] 0.00-10.09 sec 11.1 MBytes 9.21 Mbits/sec 0.191 ms 263/8284 (3.2%) receiver Which can be further explored using Netpipe graph Not only performance for small block sizes is significantly lower than when using media converters (which can be attributed to the limited transactional performance of SPI interface) but there are throughput dips observed for largest block sizes, which can be connected with packet loss and subsequent TCP restransmission. In the search of mysterious power coupler Documentation available is really scarce when one wants to know how exactly power should be delivered to the PoDL link. For example, until the recent times, ADI documentation usually limited itself to simply mention such a device, without information how to get/build it (in recent times there is also a comment that power coupling board are in development). TDK - manufacturer of the inductor families dedicated for PoDL applications (and PoDL daughter boards for ADI evaluation kits), includes a slide like below in their product presentation : So - could it be such a simple schematics? Differential mode choke for PS HF isolation, common mode choke on the line and isolation transformer in the PHY direction? Better - considering that for high power delivery power supply can be connected after CMC and both CN575 and EVAL-ADIN1100EBZ include both (at least capacitive) isolation and CNC, could power injector be reduced to something like below? Let&amp;#39;s see - as chokes are needed to separate high frequency signal on the data line from output capacitors of the power supply (which would filter it otherwise) , connecting the coupler with power input shorted by - for example - 1uF capacitor between two EVAL-ADIN1100 boards and doing transfer test would give us an answer about possible interference. As can be seen on the results graph below, no observable difference to the reference signal - good! Next thing - will the network adapter survive DC voltage on the line? According to the IEEE 802.3-2022 146.8.5 MDI DC power voltage tolerance The DTE shall withstand without damage the application of any voltages between 0 V dc and 60 V dc with the source current limited to 2000 mA, applied across BI_DA+ and BI_DA–, in either polarity, under all operating conditions, for an indefinite period of time. This requirement ensures that all devices tolerate DC powering voltages, such as those in Clause 104, even if the device does not require power. so this issue is also resolved. LTC9111 PD But what will happen if one end is equipped with managed power delivery circuit, like LTC9111 PD that could try to negotiate power delivery parameters? LTC9111 datasheet specifies state machine as below - so there is a path to power up without negotiation with Power Sourcing Equipment, but with impossible condition: classification occurs on low voltage and limited current (communication is established using one wire protocol when either end pulls down the line) and the graph states that a power-up sequence can continue without negotiation when voltage is low enough Fortunately - normative reference specifies this condition in the opposite way (VPD &amp;gt; Vsig_disable) so I have decided to find out the truth LTC9111 proof of concept To test if LTC9111 can be powered in the unmanaged way, I have decided to build the simplest application of this chip possible. SPOILER: there is a paragraph in the LTC9111 datasheet that states what will happen in this case: The LTC9111 is designed primarily for connection to an 802.3cg complaint, classifying PSE. When a PD is powered directly by an auxiliary power supply, the state will advance to the MDI_POWER2 state and enable the application, provided the supply voltage exceeds VON for the configured class. Note that the LTC9111 does not implement current limit or circuit breaker functionality. but I didn&amp;#39;t find it until after the test and even ADI technical support didn&amp;#39;t cite it when asked about possible error is state machine graph, so I consider it well hidden. So let&amp;#39;s start - LTC9111 datasheet specifies typical schematics as below: and usual startup sequence involves PSE-PD discovery process, allowing for proper PD classification and management. During the discovery, PSE raises line voltage (with current limit enforced) to about 5V, then initiates communication. When voltage is about 5V, classification can begin. It is done using one-wire SCCP protocol, which involves pulling down the power line at the transmitter side for predefined time. LTC9111 is powered using Cstby capacitor during power down pulses and is using M2/M3 MOSFET pair to pull down power line by it&amp;#39;s own (as the system is designed to be polarity-agnostic, symmetric design is used). Communication is started by the PSE which sends reset pulse and PD answers with presence pulse. Then, PD transmits it&amp;#39;s power class and, when accepted, PSE raises line voltage to the negotiated value. Then, PD opens M1 MOSFET and raises ENABLE signal, starting providing power to the load. When voltage on the line drops, ENABLE signal is lowered and M1 closed. So - good information - PD waits for PSE pulse, so it will not short our unmanaged power supply trying to communicate. Six power classes are defined (and can be configured using two three-state pins of LTC9111), three for supply voltage up to 30V and three for voltage up to 58V Class max power [W] PD voltage [V] 10 1.23 14-30 11 3.2 14-30 12 8.4 14-30 13 7.7 35-58 14 20 35-58 15 52 35-58 As the solution is expected to work over long data lines, which may involve significant voltage drop - and, as we have previously discovered and confirmed in the requirements - signal polarity needs to be corrected if needed, low-loss rectifier is built using D1 and D2 Schottky diodes and M4 and M5 MOSFETs (from which one is activated when voltage polarity is sensed using SNS1 and SNS2 inputs to further reduce voltage drop). Before M4/M5 activation, current flows through their reverse parasitic diodes, effectively forming traditional Graetz bridge. This solution is very advanced, but for the basic usage can be simplified: M4/M5 can be replaced with another set of Schottky diodes and some snubbing circuits can be (at least initially) omitted, leading to the circuit like below: Complete proof of concept looks like below: and the test setup (involving two prizes I have got from E14: MP710079 power supply and 72-7730A multimeter ) looks like below And? LTC9111 turns on without classification at 17.4V and turn off when input voltage drops below 12V. {gallery}LTC9111 measurements So we are all set for the final test PoDL powered Raspnerry PI Final test looks like below and is working happily filming the ceiling - using 24V power supply connected through passive power injector between Raspberry Pi with CN-0575 shield and EVAL-ADIN1100EBZ, with Molex IP67 connectors, 1m of Molex IP67 cable and 48m of UTP at the EVAL-ADIN1100EBZ side. community.element14.com/.../6558.ceiling.mp4 What is more interesting - performance is better than when using external power supply for Raspberry PI - packet loss is gone (maybe power supply used was underpowered - and as SPI clock is directly derived from CPU clock, maybe some throttling was happening?) $ iperf3 -c 172.19.0.20 -u -b 10M Connecting to host 172.19.0.20, port 5201 [ 5] local 172.19.0.10 port 48954 connected to 172.19.0.20 port 5201 [ ID] Interval Transfer Bitrate Total Datagrams [ 5] 0.00-1.00 sec 1.19 MBytes 10.0 Mbits/sec 863 [ 5] 1.00-2.00 sec 1.19 MBytes 9.97 Mbits/sec 861 [ 5] 2.00-3.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 3.00-4.00 sec 1.14 MBytes 9.56 Mbits/sec 825 [ 5] 4.00-5.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 5.00-6.00 sec 1.14 MBytes 9.56 Mbits/sec 825 [ 5] 6.00-7.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 7.00-8.00 sec 1.14 MBytes 9.56 Mbits/sec 825 [ 5] 8.00-9.00 sec 1.14 MBytes 9.57 Mbits/sec 826 [ 5] 9.00-10.00 sec 1.14 MBytes 9.56 Mbits/sec 825 - - - - - - - - - - - - - - - - - - - - - - - - - [ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams [ 5] 0.00-10.00 sec 11.5 MBytes 9.65 Mbits/sec 0.000 ms 0/8328 (0%) sender [ 5] 0.00-10.13 sec 11.5 MBytes 9.52 Mbits/sec 0.020 ms 0/8328 (0%) receiver iperf Done. Summary Just as in &amp;quot;traditional&amp;quot; Ethernet there is managed Power over Ethernet (PoE) with advanced (and expensive) switches but many devices can be powered using passive PoE (with power delivered unconditionally on - for example - two unused pairs of 100BASE-TX cable), we have proved that for Single Pair Ethernet we can also use unmanaged power delivery. That approach can be inexpensive and more accessible for simple devices but at the cost of some safety: managed setup can refuse to provide power to unconnected or shorted outputs, while unmanaged can rely on (poly)fuses or protections built in power supply to deal with short-circuits..</description><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/molex">molex</category><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/single%2bpair%2bethernet">single pair ethernet</category><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/spe">spe</category></item><item><title>Forum Post: [Part 5] Advanced Dashcam and Monitoring System – Single Pair Ethernet Integration, Battery Controller, Car Wiring</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/f/forum/56817/part-5-advanced-dashcam-and-monitoring-system-single-pair-ethernet-integration-battery-controller-car-wiring</link><pubDate>Thu, 02 Apr 2026 21:51:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:0d5aa0d6-794f-4463-a80e-54c9c242c051</guid><dc:creator>vmate</dc:creator><description>Overview It’s finally time for Single Pair Ethernet, along with a custom battery management and charger control PCB. The Single Pair Ethernet link will be used to connect the subjects of Part 2 and Part 3, aka. the glovebox mounted Compute Unit, and the battery and charging system in the trunk. Charger Controller Board This PCB will be housed in the big grey box mounted on top of the battery. Its responsibilities are the following: Communicate over VE.Direct with the Orion XS (50A charger) and SmartShunt Control the heating elements in the battery Control and monitor fans based on temperature of various components Communicate with the Compute Unit over Single Pair Ethernet Communicate with the Bluetooth BMS inside the LiFePO4 battery VE.Direct communication This is Victron’s custom protocol for communicating with their devices, but electrically, it is just a simple UART, at either 3.3v or 5v. Hooking this up to a microcontroller is trivial, except for potential level shifting and isolation. I decided to use ADUM1201 dual channel isolators, as they also provide level shifting, and prevent any dangerous oopsies related to ground paths. Heating element control As shown in Part 2, the battery contains two resistive heaters and DS18B20 temperature sensors, needed for preheating in cold weather. Driving the heating elements is quite simple: just a low side FET per heating element. However, I didn’t want to waste a bunch of IO on my ESP32-S3, so I used an EMC2305 fan controller IC. This has 5 PWM outputs and 5 tach inputs, all controlled and monitored over I2C. I hooked up a fifth low side FET to control a two wire fan, in case I need to use one of those. I also added a few OneWire headers to hook up the DS18B20 temperature sensors. Fan Control The board supports two 12V, 4-pin fans, using an EMC2302 for control and monitoring. This chip is essentially identical to the EMC2305, except for channel count. Interfaces For debugging, I wanted to add USB, but to be safe, I made it isolated as well. My choice ended up being the ADUM3160, which is a USB1.1 isolator IC. Having a CAN bus interface is always useful, so I added an isolated one for good measure. The choice for this was the ADM3053, which I also used on the Compute Unit, to later hook up to the car’s CAN network. DC-DC Converters I found a really nice looking buck converter IC some time ago from TI, which I wanted to try out. The TPS543021 is a 4.5V to 28V input, 3A buck converter IC, which is very cheap (around 60 cents per piece in quantities of 25) and simple to use, and is reasonably easy to solder (although it is quite small with its SOT-563 package). The BOM cost for a single buck converter, including the inductor and passives, ends up being around 1 USD, so if it works well, this will definitely become my go-to buck converter IC for general purpose applications. Microcontroller As mentioned earlier, I went with an ESP32-S3 here too. I needed Bluetooth capability, and it makes sense to use the same MCU as in the Compute Unit, to make code reuse easier, and be able to focus on mastering a single device. The PCB I sprinkled on a generous amount of TVS diodes, and designed this board. The fan and heater control is in the bottom, power supplies in the middle, MCU at the top. The Single Pair Ethernet transceiver is not on this board, as I didn’t have enough space for it, so I just added an 8 pin JST-PH connector, and a separate PCB will have the ADIN1110 and various other SPE related components. Single Pair Ethernet PCB While the supplied kit includes an ADIN1110 development board, the ADI devkit is massive physically, and is full of features I do not need. Since I was ordering a custom PCB anyways, I tried my luck designing a minimal and tiny Single Pair Ethernet module, that I can use in future projects as well. This is the schematic I ended up with: And here’s the PCB design: The finished module: I hooked up my newly built module to the ADIN1100 devkit suppled for the challenge, and thankfully got the Link light lit up on both boards. This is a great sign, and almost guarantees that everything is working perfectly. The size difference between the two boards is incredibly obvious (although this is the ADIN1100 dev board, and not the ADIN1110, but they have the same dimensions). I had a Pi Pico on hand, so I used that for some quick testing. Testing the boards I hooked up the ADIN1110 module to the Charger Controller PCB, and connected the SPE jack to the ADIN1100 dev board. The ADIN1100 board itself was connected to my PC through ‘regular’ ethernet, so I could use Wireshark to see what data was being sent over SPE. I wrote some minimal testing code for the ESP32-S3, that sent a few frames of data through SPE. I forgot to save either the testing code, or images from Wireshark, I apologize for that, but I’ll have some cleaned up code in the next post. Installing the PCBs in the Charger Box This part was quite straight forward, I just had to make a few JST-PH and JST-XH cables to connect everything, and drill a hole in my box for the Single Pair Ethernet jack. Adding SPE to the Compute Unit I made a second ADIN1110 board, and printed a top half for the Compute Unit. Wiring the car The next step was wiring up everything in the car for the charger. This involved routing the Single Pair Ethernet cable from the trunk to the glovebox in the front, and also installing a robust, 40A capable 12V source for the charger. To do this properly, I added another grey box, housing a relay, and some fuses. The purpose of the relay is to only supply 12V to the charger when the engine is running. Technically, this is not required, as the Orion XS and our Charging Controller board both implement a low voltage cutoff, but better safe than sorry. This also provides a simple way to hook up other loads later, like a high power laptop charger or cabin heater in the winter, if I ever decide to add one. The thick blue cable exiting at the top is the 12V input to the Orion XS. It is not fused in this box, to prevent unnecessary voltage drop. This is safe, because the entire setup got a 40A fuse, right at the battery terminals. The smaller blue cable exiting at the top is for my subwoofer, which got a second, 20A fuse. Next up, mounting the cameras. I had quite a lot of trouble with USB3 interference for the front camera, mainly regarding the GPS setup. The solution ended up being a very fancy, USB3.2 Gen2 cable, with USB-C connectors on both ends. The rear and side cameras are only USB2, so I didn’t have any issues regarding those. I added a 2J Antennas 2J4950PGF antenna to the windshield, which includes one 4G antenna, one 2.4/5GHz antenna, and an active GNSS antenna. The cabling for the antennas and front camera were routed down the A pillar, to the glovebox. With everything wired, this is what I got in the glovebox: I temporarily added a USB connection to the Charger Control PCB, so I can debug the ESP32. This will be removed once the firmware is up and running, and all communication and firmware flashing will happen over SPE. Conclusion After confirming that everything was wired properly, and running some test code for the SPE link, the hardware is essentially finalized. All that’s left is to write a lot of code to make everything work, which will be shown in the next, final blog post, along with a summary of what happened so far. The TPS543021 performed great in my testing, so it is definitely becoming my go-to buck converter for custom PCBs.</description><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/single%2bpair%2bethernet">single pair ethernet</category><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/experimenting%2bwith%2bsingle%2bpair%2bethernet">experimenting with single pair ethernet</category><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/spe">spe</category></item><item><title>File: 8206.cap_test_out1</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/m/managed-videos/151137</link><pubDate>Wed, 01 Apr 2026 13:35:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:e88a4035-842f-4e63-8373-8dc3826bce98</guid><dc:creator>JWx</dc:creator><description /></item><item><title>File: 4118.unconn_test_out</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/m/managed-videos/151136</link><pubDate>Wed, 01 Apr 2026 13:35:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:73629739-57f6-40f3-91f1-d8163cf229e1</guid><dc:creator>JWx</dc:creator><description /></item><item><title>File: 0880.enc_initial_2</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/m/managed-videos/151135</link><pubDate>Wed, 01 Apr 2026 13:35:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:ab538940-49dc-4085-be78-c966a34d0b8e</guid><dc:creator>JWx</dc:creator><description /></item><item><title>Forum Post: RE: [Part 4] Advanced Dashcam and Monitoring System - Cameras</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/f/forum/56802/part-4-advanced-dashcam-and-monitoring-system---cameras/234709</link><pubDate>Tue, 31 Mar 2026 18:49:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:39736102-e795-4f05-ab19-8ada33ce187f</guid><dc:creator>DAB</dc:creator><description>Cool, I look forward to seeing how well it works.</description></item><item><title>Forum Post: [Part 4] Advanced Dashcam and Monitoring System - Cameras</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/f/forum/56802/part-4-advanced-dashcam-and-monitoring-system---cameras</link><pubDate>Sun, 29 Mar 2026 23:36:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:28d77f82-6edf-4399-85b5-828199bd8d63</guid><dc:creator>vmate</dc:creator><description>Overview Next up is planning and selecting cameras. This is quite an interesting topic, but there’s relatively little to show, so this post will be a bit shorter, with less images. Picking the sensor The most straight forward starting point is to pick out the sensor we want, and then build around it, so let’s get into the important specs. When talking about sensors, there are a few common specs everyone is familiar with. Namely, these are resolution and framerate. Interestingly, we don’t really care about either. The dashcam setup will be severely video bandwidth limited, so we actually want the least number of pixels and framerate, to minimize how much the video encoder will chew up details. In my quick experiments, roughly 1080p-ish resolution, at 15FPS would be a decent target to work with. That’s about 2MP. Pretty much every single modern sensor can do that, so this is not much to narrow our search by. Now, let’s talk about the more interesting specifications. There are two that we care about a lot: low light performance, and dynamic range. Low light performance is straightforward, the car will be parked at night, the camera needs to be able to see in darkness. Dynamic range is more interesting. Imagine trying to take a photo of a dark object with the sun in the background. This is close to impossible with a regular camera. The extreme brightness in the background requires the sensor to turn the exposure down, so that nothing is blown out. However, that also makes the dark object completely disappear. Manually exposing the dark object will result in the background being so incredibly bright, that it completely clips and just looks like a white blob. The issue here is dynamic range. The camera cannot simultaneously capture the tiny differences in light on the dark object, while there’s a massive light source in the background. To combat this issue, several methods exist. The sensor can be made just really really good, and have inherently great dynamic range, but that gets very challenging very quickly, so trickery is needed. The most common method is multiple exposure HDR. The idea is to take a bunch of separate images with different exposure levels. One image will have the bright object perfectly exposed but the dark object too dark, one image will have the dark object perfectly exposed and the bright object overexposed, etc. Then, a fancy algorithm can be used to merge all of these images into a single one, where all of the varying brightness objects are properly exposed. This method works fairly well, and is very widespread. However, there’s one massive issue: as a single frame is constructed from multiple exposures, motion blur or ghosting can happen. If any movement happens between the individual exposures, the final image will be messed up. There are algorithms that try and correct for this, with varying results. Ultimately, this is a no-go for my dashcam. There are quite a few other methods to achieve high dynamic range, with various tradeoffs, but let’s jump to the one that fits my use case the best: subpixel HDR. The idea is simple: instead of the sensor being made up of a bunch of equal pixels, let’s make multiple different sizes. The small pixels will be less sensitive to light, and the large pixels will be more sensitive. This means the sensor can take both an “underexposed” and “overexposed” image at the exact same time, preventing the motion artifacts. There is a great article about this on e-con Systems’ website, with actual images and comparisons to multiple exposure HDR. https://www.e-consystems.com/blog/camera/technology/everything-you-need-to-know-about-split-pixel-hdr-technology/ Going for subpixel HDR narrows down our sensor options from tens of thousands to a handful, mostly from Sony. My choice ended up being the Sony ISX031. This is a 1920x1536 30/60FPS sensor, but most importantly, it has a built-in ISP. Image sensors don’t just magically output an image. The data needs to be processed in several ways, autoexposure and white balance need to be continuously adjusted, etc. This is an incredibly complex task, handled by an Image Signal Processor. Developing and tuning an ISP is basically black magic, and it needs to be fine tuned to a specific sensor or use case. Typical camera modules also tend to have very bad ISPs that don’t just lack the magic, but are actively horrible. The integrated ISP in the ISX031 solves this issue: it’s specifically made and tuned for the sensor it’s contained within. The sensor’s output is a preprocessed, beautiful, high dynamic range image, with no adjustments to worry about. It also has an LED Flicker Mitigation feature, which uses magic to make flickering LEDs not flicker on video. The ISX031 also happens to have amazing low light performance, so it is the perfect choice for my dashcam. The camera interface Next up is picking what interface to use for connecting the sensor to the Compute Unit, which the previous blog post was about. The native output interface of the sensor is MIPI. It works great, as long as you have short wires, which I don’t. Let’s see what options we have. Stick with MIPI all the way through, and get some heavily shielded cable to make a 3 meter run work: this is the simplest solution in some ways, but the most painful in some others. MIPI-CSI requires at least 3 high speed twisted pairs for this setup, plus a handful of low speed communication signals. Signal integrity, and interference caused are a major issue with this setup. Use a USB camera module: these modules use a MIPI to USB converter IC (or probably an entire cheapo ISP, that has most of its features turned off) to immediately convert MIPI to USB before any signal integrity issues can pop up. There are two big problems with this: the terrible ISP scenario is still a pain, as we will lose even the small amount of control we have over the ISX031, because the cheapo ISPs don’t properly expose controls over USB. USB is problematic in itself: the ISX031, in its full resolution output mode, generates over 350 megabytes per second of image data. That is around 8 times faster than what USB2.0 can do. USB3.0 can handle it just fine, but it brings its own signal integrity and noise issues, so we’re back to square one. (USB3.0 actually uses higher signaling speeds than MIPI, for this specific sensor) Go the proper route: GMSL or FPDLink. These are the actual, reliable, proper solutions for my problem. Put a small GMSL/FPDLink serializer IC near the sensor, pipe MIPI into it, attach a coax or a single twisted pair cable, run that for as long as you need, and put a deserializer at the end to get back MIPI. The problem? Money. In single digit quantities, those two ICs cost well over 50usd. I’d also need custom PCBs, and probably spend a bunch of time debugging things. This would definitely be the proper solution, but it requires both time and money, neither of which I have at the moment. I went searching for ISX031 camera modules, and I found the Arducam B0476. It’s a USB3.0 module with a 118 degree horizontal FOV lens. That settles our interface question, because I couldn’t find any other reasonably priced modules. However, this camera was already quite expensive, and I needed 3 more. So, back to looking at sensors, to find a cheaper option for the rear and side cameras. I settled on the IMX662 for this task. No fancy HDR, but good low light performance, and cheap. No need for USB3.0 either, as a lower framerate, or MJPEG encoded video output is fine from these cameras. The cheap modules, like the one I got, do suffer from a handful of cheapo ISP related issues though. The autoexposure algorithm is quite bad, there’s no denoising before MJPEG compression(which makes the video quality terrible), and the ISP only exposes a select few resolution/framerate combinations, which doesn’t include the ideal ones I’d want. I bought 3 Arducam B0576 modules, which don’t come with an enclosure, so I designed and 3D printed some. Conclusion With that, all four of the cameras are ready to install. In the next post, I’ll talk about finally using Single Pair Ethernet, design and make the controller PCB for the charger box, and write some ADIN1110 code. The final blog post will include the installation of all the cameras and other hardware, along with a demonstration of the system’s full functionality.</description><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/single%2bpair%2bethernet">single pair ethernet</category><category domain="https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/tags/experimenting%2bwith%2bsingle%2bpair%2bethernet">experimenting with single pair ethernet</category></item><item><title>Forum Post: RE: [Part 3] Advanced Dashcam and Monitoring System - Compute Unit</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/f/forum/56786/part-3-advanced-dashcam-and-monitoring-system---compute-unit/234594</link><pubDate>Tue, 24 Mar 2026 19:58:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:667bcf36-402f-4d5d-af06-f0a87a6a2278</guid><dc:creator>DAB</dc:creator><description>Nice build.</description></item><item><title>Forum Post: RE: Deadline Looming</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/f/forum/56780/deadline-looming/234582</link><pubDate>Mon, 23 Mar 2026 16:57:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:94c555d0-906c-4aeb-91bb-dbeada86cb71</guid><dc:creator>JoRatcliffe</dc:creator><description>Hi all, I am happy to extend the deadline by a week so the new deadline will be midnight (23:59 UK time) Sunday 5th April . I will update the dates and terms and conditions tomorrow. You can now post and edit drafts in Projects so - when you are ready - you can submit your final, full project write-up.</description></item><item><title>Forum Post: RE: Deadline Looming</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/f/forum/56780/deadline-looming/234579</link><pubDate>Mon, 23 Mar 2026 14:09:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:5df69dfd-ebde-41d2-a62d-baf359db32a9</guid><dc:creator>JWx</dc:creator><description>confirmed - draft edition possibility is restored</description></item><item><title>Forum Post: RE: Deadline Looming</title><link>https://community.element14.com/challenges-projects/design-challenges/experimenting-with-single-pair-ethernet/f/forum/56780/deadline-looming/234578</link><pubDate>Mon, 23 Mar 2026 14:00:00 GMT</pubDate><guid isPermaLink="false">93d5dcb4-84c2-446f-b2cb-99731719e767:9586d131-ff57-4de4-80d7-c2bf85c42b5e</guid><dc:creator>cstanton</dc:creator><description>Challengers should now be able to post (and edit drafts).</description></item></channel></rss>