<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michael Jordan</title>
    <description>The latest articles on DEV Community by Michael Jordan (@michael_jordan_87eaf96f24).</description>
    <link>https://dev.to/michael_jordan_87eaf96f24</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2760772%2Fc5bbe993-f5f7-480d-b9d7-9b6c4976965b.jpg</url>
      <title>DEV Community: Michael Jordan</title>
      <link>https://dev.to/michael_jordan_87eaf96f24</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/michael_jordan_87eaf96f24"/>
    <language>en</language>
    <item>
      <title>It Started with Fonts: The Nearly Three-Month Road to Selling My Software</title>
      <dc:creator>Michael Jordan</dc:creator>
      <pubDate>Mon, 12 Oct 2026 17:09:25 +0000</pubDate>
      <link>https://dev.to/michael_jordan_87eaf96f24/it-started-with-fonts-the-nearly-three-month-road-to-selling-my-software-48ai</link>
      <guid>https://dev.to/michael_jordan_87eaf96f24/it-started-with-fonts-the-nearly-three-month-road-to-selling-my-software-48ai</guid>
      <description>&lt;p&gt;I've been writing code of one sort or another, as a hobbyist or professionally, for over half a century.&lt;/p&gt;

&lt;p&gt;Some of my projects have involved years of work. The one I'm now trying to take to market started with fonts.&lt;/p&gt;

&lt;p&gt;Fonts are one of those things we take for granted every day. My studies of them eventually grew into a .NET NuGet package anyone can download and use: &lt;a href="https://www.nuget.org/packages/Lxman.PdfLibrary" rel="noopener noreferrer"&gt;Lxman.PdfLibrary&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I started it well before AI coding assistants were a thing. Towards the end, though, I certainly used AI to help me through the maze of transforms, matrix manipulation, and other technicalities involved in faithful document reproduction.&lt;/p&gt;

&lt;p&gt;I've always had it in the back of my mind to release some of my work for others to use. After developing the library, I decided to build a closed-source product based on it. That became Pellucid, a PDF editor, which I've recently set up on my store.&lt;/p&gt;

&lt;p&gt;I naively thought bringing a product to market meant building something good, putting together a little website, adding a way to take payments, and going to market.&lt;/p&gt;

&lt;p&gt;This is the story of what that actually involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  July: making it a company
&lt;/h2&gt;

&lt;p&gt;On July 10, 2026, I submitted the paperwork to form an LLC in North Carolina. Pellucid launched on October 6.&lt;/p&gt;

&lt;p&gt;Nearly three months between those two events, on top of the years of development that came before them.&lt;/p&gt;

&lt;p&gt;That wasn't three months spent waiting for the state, or the bank, or a payment provider. There was development and testing going on too. But there was also a collection of business tasks that I'd badly underestimated, and several points where progress depended on someone else's approval.&lt;/p&gt;

&lt;p&gt;The state formally filed the company on July 22, twelve days after I submitted the paperwork. By August 2, I had the stamped articles in my records.&lt;/p&gt;

&lt;p&gt;Even here, there was a distinction to get right. The day I submitted the application wasn't the day the company legally came into existence. I'd initially treated the submission date as the formation date. The stamped paperwork corrected that.&lt;/p&gt;

&lt;p&gt;Meanwhile, I was sorting out the product name. I went through seven candidates before settling on Pellucid. I'd already bought a domain for an earlier choice, then dropped that name after looking more closely at potential trademark conflicts.&lt;/p&gt;

&lt;p&gt;These were preliminary checks, not a guarantee that a name was legally clear. They were enough to make me reconsider the first choice.&lt;/p&gt;

&lt;p&gt;So there I was, dealing with formation dates, an operating agreement, domain names, and trademark research for something that had started with fonts.&lt;/p&gt;

&lt;h2&gt;
  
  
  August: a tax ID and a business checking account
&lt;/h2&gt;

&lt;p&gt;On August 10, I obtained the company's EIN through the IRS online system and opened a business checking account.&lt;/p&gt;

&lt;p&gt;Both happened that day. The bank approved the application the same day I applied, so I can't blame the bank for dragging that part out.&lt;/p&gt;

&lt;p&gt;But a month had already passed since I'd submitted the state paperwork. The business now had its formation documents, a federal tax ID, and somewhere to keep its money separate from mine.&lt;/p&gt;

&lt;p&gt;It also needed books. There were expenses to record, personal money put into the business to account for, and records to keep. Buying something for a project and recording it properly as a business transaction are separate tasks.&lt;/p&gt;

&lt;p&gt;None of this added a feature to the PDF editor. It was still work that came with selling it.&lt;/p&gt;

&lt;h2&gt;
  
  
  There was another identity to sort out
&lt;/h2&gt;

&lt;p&gt;Distributing a desktop application under a company name brought another set of requirements.&lt;/p&gt;

&lt;p&gt;For Apple's organization enrollment, I needed a D-U-N-S number. On August 21, I located an existing record for the company. I didn't have to request a new one, but it was another piece of business identity to find and check.&lt;/p&gt;

&lt;p&gt;Having an individual developer account and having an account under the company weren't the same thing. Later, on September 24, I requested the conversion of my personal Apple developer account to the company.&lt;/p&gt;

&lt;p&gt;At that point, the conversion was in progress with no ETA. It was confirmed complete on September 29.&lt;/p&gt;

&lt;p&gt;That settled the account conversion. Certificates, signing, notarization, and testing the actual customer packages were still separate work. An approved company account didn't turn a local build into a finished Mac release by itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  September: adding a way to take payments
&lt;/h2&gt;

&lt;p&gt;This was the part I'd mentally reduced to a small item on the list.&lt;/p&gt;

&lt;p&gt;On September 21, I set up the company domain, business email, and company website. I also created the Lemon Squeezy store and completed its onboarding application.&lt;/p&gt;

&lt;p&gt;Even the email setup had its own details: DNS records, email authentication, and checking that messages actually arrived and went out under the right address.&lt;/p&gt;

&lt;p&gt;I chose Lemon Squeezy as the merchant of record. It handles customer payments and sales tax, then remits payouts to the business. That takes a substantial piece of the selling process off my hands. It doesn't remove the onboarding process.&lt;/p&gt;

&lt;p&gt;After submitting the application, the store remained in test mode while it was reviewed.&lt;/p&gt;

&lt;p&gt;I could work on the integration and test purchases, activation, and refunds. Being able to run those tests didn't mean I was approved to take real payments.&lt;/p&gt;

&lt;p&gt;Lemon Squeezy requested a demonstration and more information. I submitted those on September 25. By September 28, merchant approval was confirmed.&lt;/p&gt;

&lt;p&gt;A week from onboarding to confirmed approval isn't an extraordinary delay on its own. By then, though, it was late September, and I'd started the company paperwork in July. Each individual step looks fairly small when you describe it separately. Together they occupied a much larger stretch of the calendar than I'd pictured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approval wasn't the end of the list
&lt;/h2&gt;

&lt;p&gt;Once the merchant account was approved, there was still a live product to configure and a customer purchase to fulfill.&lt;/p&gt;

&lt;p&gt;A payment had to result in a usable license. A customer had to receive the right download, install it, and activate it. Refunds had to behave properly. There needed to be a way to deal with someone replacing a computer or losing access to a device.&lt;/p&gt;

&lt;p&gt;There were decisions about refund terms, license coverage, and support. Those decisions then had to agree with what the website said and what the software actually did.&lt;/p&gt;

&lt;p&gt;Test mode was useful, but a successful test purchase wasn't the same as verifying the live customer path. The packages being delivered needed to be the ones that had actually been checked, signed where appropriate, and accepted for release.&lt;/p&gt;

&lt;p&gt;This is where the business work came back around to software engineering. There was more code and testing, but now it was about the machinery around the application as well as the application itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  October: finally available
&lt;/h2&gt;

&lt;p&gt;Pellucid launched on October 6 for Windows, macOS, and Linux.&lt;/p&gt;

&lt;p&gt;From July 10 to October 6 was 88 days.&lt;/p&gt;

&lt;p&gt;That number describes my journey, not a required waiting period for anyone else's business. Some tasks could overlap. Some happened quickly. Some required review. Software work continued alongside all of it.&lt;/p&gt;

&lt;p&gt;What I'd underestimated was the total. Form the company. Check the name. Get the tax ID. Open the account. Keep the books. Establish the business email and website. Apply to the payment provider. Supply the demonstration. Sort out the developer account. Sign the releases. Verify that a purchase actually gives the customer something they can use.&lt;/p&gt;

&lt;p&gt;I'd thought about most of those things, if at all, as small jobs to take care of once the software was ready.&lt;/p&gt;

&lt;p&gt;They took nearly three months of calendar time to come together.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell another developer
&lt;/h2&gt;

&lt;p&gt;If you're planning to sell something you've built, investigate the business and distribution requirements while you're still developing it. Find out which accounts require a legal entity, what evidence the providers want, and which steps have a review process.&lt;/p&gt;

&lt;p&gt;Keep the dates and confirmations. In my case, even the formation date needed correcting once the stamped document arrived.&lt;/p&gt;

&lt;p&gt;And give the work between approvals a place in your schedule. Getting approved to accept payments still leaves you responsible for delivering the software and supporting the person who bought it.&lt;/p&gt;

&lt;p&gt;After more than half a century of writing code, I still had plenty to learn about getting my own product to market.&lt;/p&gt;

&lt;p&gt;It started with fonts. I hadn't expected the journey to include quite so much paperwork.&lt;/p&gt;




&lt;p&gt;I'm the developer of &lt;a href="https://pellucid-pdf.com/" rel="noopener noreferrer"&gt;Pellucid&lt;/a&gt; and the open-source .NET library &lt;a href="https://www.nuget.org/packages/Lxman.PdfLibrary" rel="noopener noreferrer"&gt;Lxman.PdfLibrary&lt;/a&gt; that it builds on.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>career</category>
      <category>sideprojects</category>
      <category>startup</category>
    </item>
    <item>
      <title>Reverse-engineering the OBSBOT Tail 2: HTTP, USB, and a Linux kernel fix</title>
      <dc:creator>Michael Jordan</dc:creator>
      <pubDate>Sat, 03 Oct 2026 01:35:51 +0000</pubDate>
      <link>https://dev.to/michael_jordan_87eaf96f24/reverse-engineering-the-obsbot-tail-2-http-usb-and-a-linux-kernel-fix-12em</link>
      <guid>https://dev.to/michael_jordan_87eaf96f24/reverse-engineering-the-obsbot-tail-2-http-usb-and-a-linux-kernel-fix-12em</guid>
      <description>&lt;p&gt;The first OBSBOT camera I reverse-engineered was a Tiny 2: a webcam on a motorized gimbal. I built &lt;a href="https://github.com/lxman/obsbot-mcp" rel="noopener noreferrer"&gt;obsbot-mcp&lt;/a&gt; so an AI assistant could take a picture, inspect it, and control the camera through the Model Context Protocol.&lt;/p&gt;

&lt;p&gt;Then I started on the Tail 2.&lt;/p&gt;

&lt;p&gt;Same manufacturer. Similar controls. Even the same USB Extension Unit GUID. Plenty of reasons to expect that supporting it would mean adding another device ID and adjusting some ranges.&lt;/p&gt;

&lt;p&gt;The Tail 2 has a different network protocol, a different vendor USB protocol, and the same firmware capability-reporting problem that had sent me into the Linux camera driver. The useful part of the investigation was working out which similarities were real.&lt;/p&gt;

&lt;p&gt;The measurements below were made on a physical Tail 2 in September 2026, with firmware &lt;strong&gt;7.2.13.1&lt;/strong&gt;. The &lt;a href="https://github.com/lxman/obsbot-mcp/blob/main/TAIL2-PROTOCOL.md" rel="noopener noreferrer"&gt;protocol notebook&lt;/a&gt; distinguishes hardware measurements from things inferred by reading the camera's software.&lt;/p&gt;

&lt;h2&gt;
  
  
  The camera ships its own API client
&lt;/h2&gt;

&lt;p&gt;The Tail 2 is a network PTZ camera with Ethernet, Wi-Fi, streaming outputs, and a USB-C webcam mode. Point a browser at its network address and it serves a web application.&lt;/p&gt;

&lt;p&gt;That application was the best starting point. Its minified Vue/Vite bundle contained the endpoint names, request methods, and payload construction for an HTTP API under &lt;code&gt;/camera/sdk/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For example, these are useful read-only probes. Set &lt;code&gt;CAMERA&lt;/code&gt; to your camera's address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CAMERA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.1.50

curl &lt;span class="s2"&gt;"http://&lt;/span&gt;&lt;span class="nv"&gt;$CAMERA&lt;/span&gt;&lt;span class="s2"&gt;/camera/sdk/device_info"&lt;/span&gt;
curl &lt;span class="s2"&gt;"http://&lt;/span&gt;&lt;span class="nv"&gt;$CAMERA&lt;/span&gt;&lt;span class="s2"&gt;/camera/sdk/range"&lt;/span&gt;
curl &lt;span class="s2"&gt;"http://&lt;/span&gt;&lt;span class="nv"&gt;$CAMERA&lt;/span&gt;&lt;span class="s2"&gt;/camera/sdk/ptz/zoom"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The range endpoint reports the camera's own zoom-ratio scale, &lt;strong&gt;1.0–12.0&lt;/strong&gt;. Setting zoom requires both a ratio and a speed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;PUT /camera/sdk/ptz/zoom
Content-Type: application/json

{"ratio": 2.0, "speed": 5}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave either field out and the camera returns 400. That is a small detail, but exactly the kind that makes an undocumented API frustrating when all you have is an endpoint name.&lt;/p&gt;

&lt;p&gt;The bundle gave me a census of 47 endpoints. That does not mean 47 fully decoded, hardware-verified operations: some payloads remain unprobed. It gave me a map of where to look.&lt;/p&gt;

&lt;p&gt;On the tested configuration, HTTP reads and writes worked without authentication. That is an observed property of this unit and firmware, not something I would generalize to every configuration or future release.&lt;/p&gt;

&lt;h2&gt;
  
  
  A successful request can change nothing
&lt;/h2&gt;

&lt;p&gt;The camera also pushes a JSON status block over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ws://&amp;lt;camera-address&amp;gt;/ws/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connect, and a full status message arrives without a subscription request. Further updates arrive at roughly one per second. The block includes zoom, tracking mode, orientation, presets, streaming flags, battery state, and subsystem health.&lt;/p&gt;

&lt;p&gt;That feedback matters because the write response is weaker than it looks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"err_idx"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During testing, a roll-trim write received that response while the portrait-rotation motor was moving. The requested value never landed. Reading it back still showed the old value.&lt;/p&gt;

&lt;p&gt;So the client separates acknowledgement from observed state. Where feedback exists, it sends the command, polls a bounded number of times, and returns the value it actually read plus a &lt;code&gt;settled&lt;/code&gt; flag.&lt;/p&gt;

&lt;p&gt;The zoom implementation, for example, compares the reported ratio with the target within a small tolerance. Portrait rotation checks the orientation field in the WebSocket status. If the polling budget runs out, &lt;code&gt;settled:false&lt;/code&gt; says exactly what happened: the command was sent, but the expected state was not observed in time.&lt;/p&gt;

&lt;p&gt;There are limits to this verification. The network status block has &lt;strong&gt;no live yaw or pitch&lt;/strong&gt;. Recenter therefore returns on acknowledgement; it cannot prove physical arrival. Preset recall can verify zoom, but that does not prove that both gimbal axes have arrived.&lt;/p&gt;

&lt;p&gt;That distinction matters when the caller is an AI assistant. A tool result saying “done” becomes the premise for its next action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Presets: the request schema was in the component
&lt;/h2&gt;

&lt;p&gt;My first attempts at the preset endpoint failed because I guessed the discriminator wrong. &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, and &lt;code&gt;operate&lt;/code&gt; were plausible. The firmware wanted &lt;code&gt;operation&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reading the web application's preset component gave me this grammar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PUT /camera/sdk/ptz/preset

{"operation":"set",    "id":0, "name":"RGVzaw=="}
{"operation":"call",   "id":0}
{"operation":"rename", "id":0, "name":"V2lkZQ=="}
{"operation":"delete", "id":0}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Names are base64-encoded UTF-8. IDs are zero-based, with three slots. All four operations were exercised on hardware.&lt;/p&gt;

&lt;p&gt;The semantic detail is more important than the spelling: &lt;strong&gt;&lt;code&gt;set&lt;/code&gt; saves the camera's current pose&lt;/strong&gt;. It also overwrites an occupied slot. There is no decoded request here that accepts an arbitrary yaw/pitch pose to save.&lt;/p&gt;

&lt;p&gt;Reading a preset returns angles and a zoom ratio, but a rich response schema does not imply a symmetric write schema. You cannot take the returned object, edit its angles, and assume that writing it back will position the camera.&lt;/p&gt;

&lt;p&gt;The server exposes slots as 1–3 and handles the wire encoding internally. Its save verification checks that the slot exists afterward; it cannot independently verify the captured pose, and an existing slot alone does not prove an overwrite succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a picture took a different protocol
&lt;/h2&gt;

&lt;p&gt;Controlling a camera and getting video from it turned out to be separate jobs.&lt;/p&gt;

&lt;p&gt;The device advertised RTSP, and its status could report RTSP enabled. Nevertheless, the paths I tested returned &lt;code&gt;400 Bad Request&lt;/code&gt;, including after a reboot with the enable flag set. I did not establish a working RTSP stream on this firmware.&lt;/p&gt;

&lt;p&gt;SRT listener mode worked. With that output enabled in OBSBOT Center, an SRT-enabled ffmpeg build could pull a frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"srt://&lt;/span&gt;&lt;span class="nv"&gt;$CAMERA&lt;/span&gt;&lt;span class="s2"&gt;:5000?mode=caller&amp;amp;latency=120&amp;amp;streamid=mainstream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-frames&lt;/span&gt;:v 1 tail2.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That uses the measured port and default stream ID. The tested stream was H.264 at 1920×1080, 30 fps, with AAC audio.&lt;/p&gt;

&lt;p&gt;Two practical constraints remain: enabling SRT disables NDI on this configuration, and the SRT enable state does not survive a camera reboot. Center also requires the output to be off while changing its streaming settings. An early apparent startup delay was actually a probe landing during that reconfiguration window; once enabled, the listener served immediately.&lt;/p&gt;

&lt;p&gt;The snapshot tool uses this SRT path. The camera-control module is pure TypeScript, but image capture still needs ffmpeg.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same USB GUID did not mean the same protocol
&lt;/h2&gt;

&lt;p&gt;The USB side supplied the most misleading resemblance.&lt;/p&gt;

&lt;p&gt;In UVC webcam mode, the Tail 2 exposes the same vendor Extension Unit GUID as the Tiny 2. Its selector 6 also returns a 60-byte status block. Running the Tiny 2 decoder over those bytes produces plausible-looking values.&lt;/p&gt;

&lt;p&gt;They are wrong. Some byte positions happen to line up; the layouts are different.&lt;/p&gt;

&lt;p&gt;The useful method was to change one setting at the camera, read the selectors again, and compare. Then write values the camera had actually been observed to report and check the result.&lt;/p&gt;

&lt;p&gt;The Tail 2 controls I decoded use flat, zero-padded 60-byte payloads. They do not need the Tiny 2's framed V3 command, sequence number, or CRC.&lt;/p&gt;

&lt;p&gt;Selector 9 controls human tracking:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;First byte&lt;/th&gt;
&lt;th&gt;Observed meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tracking off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Human tracking, single&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;02&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Human tracking, group&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those values were written and read back on Linux. Activation also depended on a matching subject being in frame: an accepted write did not necessarily make tracking engage.&lt;/p&gt;

&lt;p&gt;Selector 10 contains another easy trap. Its &lt;strong&gt;read&lt;/strong&gt; layout is a settings block, but its &lt;strong&gt;write&lt;/strong&gt; layout is an index/value pair:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read:
  byte 0 = tracking speed
  byte 2 = Auto Zoom level

Write, with the remaining bytes zero-padded:
  00 04 = set tracking-speed index 0 to value 4
  02 07 = set Auto Zoom index 2 to value 7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing a read block back verbatim changes the wrong thing. A payload beginning &lt;code&gt;02 01 07&lt;/code&gt; means “set index 2 to value 1,” regardless of what its third byte meant in the read response.&lt;/p&gt;

&lt;p&gt;The camera even keeps these settings separately for landscape and portrait orientation. Switching orientation and seeing different values was not evidence that the preceding write had disappeared.&lt;/p&gt;

&lt;p&gt;These USB findings are documented, but the current Tail 2 MCP tools use the network transport. A combined USB/network implementation is still future work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Linux problem really was shared
&lt;/h2&gt;

&lt;p&gt;Standard UVC pan, tilt, and zoom controls also work on the Tail 2. On Linux, however, the affected controls had the same capability-reporting defect as the Tiny 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET_INFO -&amp;gt; 0x03
            GET and SET supported
            AUTOUPDATE not advertised
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The camera can return changing PTZ values through &lt;code&gt;GET_CUR&lt;/code&gt;, including motion driven by its own tracking. But its capability reply causes &lt;code&gt;uvcvideo&lt;/code&gt; to clear the auto-update flag and cache those readings.&lt;/p&gt;

&lt;p&gt;This is also where I need to correct &lt;a href="https://dev.to/michael_jordan_87eaf96f24/i-reverse-engineered-a-webcam-so-claude-could-point-it-at-things-2h3"&gt;my earlier Tiny 2 article&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I had argued that even a compliant camera would yield only one live sample per write. &lt;strong&gt;That explanation was wrong.&lt;/strong&gt; Ricardo Ribalda pointed out the read-side rollback path I had missed. At the end of a read transaction, the driver invalidates an &lt;code&gt;AUTO_UPDATE&lt;/code&gt; control's cache, allowing the next read to fetch from the device. It does not need a control-change interrupt between every pair of polls.&lt;/p&gt;

&lt;p&gt;Restoring the existing flag is sufficient. A separate live-read buffer was unnecessary. A wider probe also disproved my earlier claim that the firmware returned a uniform &lt;code&gt;0x03&lt;/code&gt; stub for every control: the response varies; the affected PTZ capability bits are wrong.&lt;/p&gt;

&lt;p&gt;The userspace write lesson survived review: &lt;strong&gt;set pan and tilt together in one &lt;code&gt;VIDIOC_S_EXT_CTRLS&lt;/code&gt; call&lt;/strong&gt;. Both axes share one UVC payload. If separate writes merge against live position while the first axis is still moving, the second write can replace the first axis's target with an intermediate position.&lt;/p&gt;

&lt;p&gt;On September 29, Hans de Goede &lt;a href="https://lore.kernel.org/linux-media/dabfa389-6bb1-4884-aede-bc7b7969bad8@kernel.org/" rel="noopener noreferrer"&gt;merged the fixup infrastructure and both camera entries into the uvcvideo maintainer's &lt;code&gt;for-next&lt;/code&gt; branch&lt;/a&gt;. The Tail 2 entry is commit &lt;code&gt;7a7c915d6bae&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is a maintainer-tree merge. It does not establish availability in a released or distribution kernel. At the September 29 checkpoint, the separate v5 patch exposing volatile-control flags with corrected event handling was still under review. The fixups already provide live reads without that separate patch.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/lxman/obsbot-mcp/blob/main/UVCVIDEO-LINUX-POSITION-2026-07-21.md" rel="noopener noreferrer"&gt;updated kernel write-up&lt;/a&gt; records the mechanism, review corrections, and hardware results.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is usable now
&lt;/h2&gt;

&lt;p&gt;The Tail 2 module in &lt;a href="https://github.com/lxman/obsbot-mcp" rel="noopener noreferrer"&gt;obsbot-mcp&lt;/a&gt; exposes 16 tools covering discovery, status, device information, zoom, recentering, portrait rotation, roll trim, tracking mode and speed, five preset operations, and snapshots.&lt;/p&gt;

&lt;p&gt;For network discovery, call &lt;code&gt;obsbot_tail2_scan&lt;/code&gt;, configure &lt;code&gt;OBSBOT_TAIL2_HOSTS&lt;/code&gt;, or pass the camera's IP as the &lt;code&gt;camera&lt;/code&gt; parameter. Registered cameras can also be selected by MAC address or device name.&lt;/p&gt;

&lt;p&gt;The HTTP/WebSocket control implementation is shared across Windows, Linux, and macOS, with no platform-specific native helper. The USB kernel fixups are a separate result of the investigation; those network tools do not require them.&lt;/p&gt;

&lt;p&gt;Arbitrary network yaw/pitch commands, live network pose feedback, and a shipped Tail 2 USB transport remain gaps. The Tiny 2's “point at this pixel” workflow should not be assumed to work on the Tail 2 just because both cameras appear in the same project.&lt;/p&gt;

&lt;p&gt;The recurring lesson was that decoding a payload is only the beginning. A response can acknowledge a write that never happened. A status block can omit the one measurement needed to verify arrival. Two devices can share a GUID while speaking different protocols. And a convincing explanation of a driver can still be missing the function that changes the conclusion.&lt;/p&gt;

&lt;p&gt;The useful API is the one that preserves those distinctions all the way up to the caller.&lt;/p&gt;

</description>
      <category>hardware</category>
      <category>linux</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Micro-compaction: amortizing context compression in agent loops</title>
      <dc:creator>Michael Jordan</dc:creator>
      <pubDate>Wed, 05 Aug 2026 16:26:59 +0000</pubDate>
      <link>https://dev.to/michael_jordan_87eaf96f24/micro-compaction-amortizing-context-compression-in-agent-loops-3f73</link>
      <guid>https://dev.to/michael_jordan_87eaf96f24/micro-compaction-amortizing-context-compression-in-agent-loops-3f73</guid>
      <description>&lt;p&gt;If you've run a long agent session in any framework, you've hit the wall: context fills up, the framework decides it's time to compact, and everything stops while a summarizer chews through the whole transcript. In hermes-agent that batch compaction fires around 80% of the context threshold, and on a long session the pause is measured in minutes. The agent isn't stuck, it's doing necessary work, but from the outside it looks dead. And the longer you wait to compact, the bigger the lump you eventually pay.&lt;/p&gt;

&lt;p&gt;Micro-compaction is a different way to pay the same bill. Instead of one big batch pass when you're nearly full, you fold the oldest un-absorbed exchange into a rolling summary after every turn. The total summarization work is roughly the same. What changes is the shape of the cost: small increments spread across the session instead of one long stall at the worst possible moment.&lt;/p&gt;

&lt;p&gt;Origin note, since it's unusual: the first draft of this feature was written by Hermes itself, for its own codebase. What follows, the measurement, the bugs, and the upstreaming, is what it took to get that draft into a state worth merging.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;After each turn, the finalizer looks for the oldest agent turn that hasn't been absorbed yet, summarizes it into a cumulative rolling summary, and splices a single summary marker into the transcript where that material used to be. There's exactly one marker at any time. Each pass replaces it rather than adding another. A protected head (the system prompt and early setup) and a protected tail (the most recent turns, 16% of the window by default) never get touched, so the model always has verbatim access to what it's currently working on.&lt;/p&gt;

&lt;p&gt;One design decision worth calling out: user turns are never compacted. Assistant output compresses well because it boils down to "it did it this way," and the details can usually be reconstructed from the work that followed. User intent can't. If you summarize away what the user actually asked for, no amount of downstream context recovers it. The cost is a floor on how much the middle of a conversation can shrink, since user messages accumulate verbatim. I think that's the right trade. If a user is dropping 20K-token prompts, that's on them.&lt;/p&gt;

&lt;p&gt;The feature is opt-in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;compression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;micro_compact&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also &lt;code&gt;micro_compact_every_n_turns&lt;/code&gt; if you want to thin out how often it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers say
&lt;/h2&gt;

&lt;p&gt;The claim isn't "saves tokens." Early on I framed it that way and it was the wrong headline. The real value is two things: the long pause gets amortized away, and context lasts longer before you hit the threshold at all.&lt;/p&gt;

&lt;p&gt;The validation run was a real 3.5-hour session, a whole-project code review, with telemetry on every pass. Two results:&lt;/p&gt;

&lt;p&gt;Zero batch compactions across roughly 75K tokens of transcript. The multi-minute pause never fired once.&lt;/p&gt;

&lt;p&gt;Occupancy (context used as a share of the compaction threshold) went 8.7% → 15.0% → 17.5% → 21.8% → 22.0%, then flat. In the last stretch the conversation added 4,841 tokens and micro-compaction reclaimed 4,395. That's equilibrium at 22% instead of a steady climb toward 80%. The session could have run much longer than it did.&lt;/p&gt;

&lt;p&gt;One gotcha if you instrument this yourself: the first pass costs tokens. The summary marker carries about 411 tokens of fixed scaffolding, and on pass one that's charged against a single absorbed exchange, so the delta is typically positive, around +330 in my measurements. From pass two the marker is replaced instead of added and each exchange is near-pure saving. Break-even lands around pass two or three. Judge the trajectory, never a single telemetry line.&lt;/p&gt;

&lt;p&gt;Getting to trustworthy numbers was most of the work, honestly. The original draft had a bug where every pass appended a new marker and left the old ones in place. The rolling summary is cumulative, so each stale marker was a near-duplicate prefix of the next, and the transcript grew every turn. Measured over six turns: 4,104 tokens ballooned to 4,797. With the supersede fix, the same conversation went 4,104 to 2,572. A second bug had the resume cursor pointing at pre-splice indices, so on tool-heavy conversations it silently skipped about half the exchanges it should have absorbed. Neither bug is visible in a single-pass unit test. Both fell out of a randomized stress harness that runs 480 conversation shapes through 25 passes each and asserts invariants after every pass: one marker max, transcript stays API-valid, message count never grows, user turns intact. If you're building anything in this class, compaction bugs are accumulating drift, and simulating many turns is the only test shape that finds them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest costs
&lt;/h2&gt;

&lt;p&gt;Micro-compaction rewrites already-sent history every time it commits a pass, and that breaks the provider's prompt-cache prefix. Batch compaction breaks it too, but once, not every turn. hermes-agent's own config treats per-conversation caching as close to sacred, and its proactive-prune logic exists specifically to avoid "a tiny break every tool iteration." Micro-compaction as originally shipped was doing exactly that, which is why review flipped it to default-off. If your provider bills cache reads at a steep discount, per-turn invalidation is real money, and my 3.5-hour validation measured occupancy and pause counts but never priced the cache side. That tradeoff is not settled by my own numbers, and I'd rather say so than pretend the headline result covers it.&lt;/p&gt;

&lt;p&gt;The passes themselves aren't free either. Median pass duration in the validation run was about 31 seconds, synchronous in turn finalization. The response has already streamed by then, so the user isn't staring at a frozen answer, but the turn doesn't close until the pass does. Total was around two minutes of summarization spread over 3.5 hours, which still beats one multi-minute lump, but 31-second increments strain the "small increments" framing. In my setup the bottleneck is the summarizer model, a 4-bit Qwen 7B on a shared box, not the input size, so a faster auxiliary model would probably shrink this a lot. Probably.&lt;/p&gt;

&lt;p&gt;There's also no reclaim-size gate yet. A pass commits whatever one exchange saved, even if that's a few hundred tokens, and each commit is a cache break. The obvious follow-up is buffering absorbed exchanges and only splicing once the reclaim crosses a threshold, mirroring what the prune logic already does. That doesn't exist today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;The feature merged into &lt;a href="https://github.com/NousResearch/hermes-agent" rel="noopener noreferrer"&gt;hermes-agent&lt;/a&gt; and ships in v0.19.1, opt-in via the config flag above. The original PR was &lt;a href="https://github.com/NousResearch/hermes-agent/pull/74522" rel="noopener noreferrer"&gt;#74522&lt;/a&gt;; it landed through a maintainer salvage PR (&lt;a href="https://github.com/NousResearch/hermes-agent/pull/75345" rel="noopener noreferrer"&gt;#75345&lt;/a&gt;) that kept the commits and authorship and then reworked some internals with a design I'd call better than mine in places. The marker is now an assistant-role message, an exchange is a full agent turn bounded by user messages, and markers are only ever superseded when their content is provably contained in the rolling summary, which makes stale-marker bugs structurally hard to reintroduce. The docs in the repo cover the mechanics in more depth.&lt;/p&gt;

&lt;p&gt;If you maintain an agent loop and batch compaction pauses are hurting you, the shape of this idea transfers even if the code doesn't: pay the summarization bill per-turn, protect the head and tail, never touch user intent, and instrument it before you trust it. Especially that last one. Every real bug in this feature was found by measurement, not by reading the code.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>agents</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I reverse-engineered a webcam so Claude could point it at things</title>
      <dc:creator>Michael Jordan</dc:creator>
      <pubDate>Tue, 04 Aug 2026 20:39:26 +0000</pubDate>
      <link>https://dev.to/michael_jordan_87eaf96f24/i-reverse-engineered-a-webcam-so-claude-could-point-it-at-things-2h3</link>
      <guid>https://dev.to/michael_jordan_87eaf96f24/i-reverse-engineered-a-webcam-so-claude-could-point-it-at-things-2h3</guid>
      <description>&lt;p&gt;The OBSBOT Tiny 2 is a webcam bolted to a motorized gimbal. It pans, it tilts, it zooms, and it has an on-device AI that follows you around the room. It's a nice piece of hardware and OBSBOT ships a perfectly good GUI for it.&lt;/p&gt;

&lt;p&gt;What I wanted was different. I wanted an &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; server, so a model could take a snapshot, look at the picture, find the thing it cared about, and then &lt;em&gt;point the camera at it&lt;/em&gt;. Close the loop: see, aim, see again.&lt;/p&gt;

&lt;p&gt;That needs a protocol. There isn't a published one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ghidra, headless
&lt;/h2&gt;

&lt;p&gt;The vendor app knows how to talk to the camera, so the protocol is in the vendor app. I ran Ghidra headless over the binaries and went looking for whatever assembles the USB control transfers.&lt;/p&gt;

&lt;p&gt;It's a class called &lt;code&gt;FrmPacketV3&lt;/code&gt;, and once you find it the frame is almost tidy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;off 0    : 0xAA              magic
off 1    : 0x25              flags (bit 0x60 =&amp;gt; there's a nested segment)
off 2-3  : seq    u16 LE
off 4-5  : len    u16 LE     bytes covered by the checksum (12)
off 6-7  : token  u16 LE     the checksum itself
off 8    : sender   0x0A
off 9    : receiver 0x04
off 10-11: cmd    u16 LE     the command id
off 12+  : payload           float32/int32, little-endian
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixty bytes, zero-padded, handed to a UVC Extension Unit as a &lt;code&gt;SET_CUR&lt;/code&gt; on selector 2. The &lt;code&gt;token&lt;/code&gt; is CRC-16/USB — poly &lt;code&gt;0xA001&lt;/code&gt;, init &lt;code&gt;0xFFFF&lt;/code&gt;, reflected, xorout &lt;code&gt;0xFFFF&lt;/code&gt; — computed over the first twelve bytes with the token field itself zeroed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0xFFFF&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="mh"&gt;0xA001&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;crc&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="mh"&gt;0xFFFF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gimbal moves don't fit in twelve bytes, so they go in a nested segment starting at offset 12 with its own length and its own CRC over its own bytes. Roll, pitch, yaw as three float32s. A move-to-angle is &lt;code&gt;cmd = 0x6444&lt;/code&gt;, a speed move is &lt;code&gt;0x6484&lt;/code&gt;, wake and sleep are both &lt;code&gt;0xA0C2&lt;/code&gt; with an inverted flag byte (wake is 0, sleep is 1, which I got backwards the first time).&lt;/p&gt;

&lt;p&gt;There turned out to be four separate ways into the device, and the vendor app uses all of them depending on what it's doing: framed V3 packets on XU selector 2, flat byte reads on XU selector 6, and then plain &lt;code&gt;IAMCameraControl&lt;/code&gt; and &lt;code&gt;IAMVideoProcAmp&lt;/code&gt; for the boring stuff. Knowing which channel a given feature lives on is most of the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that taught me not to trust my own opcode table
&lt;/h2&gt;

&lt;p&gt;Track speed didn't work. I'd send the command, the camera would acknowledge nothing in particular, and the tracking speed would stay exactly where it was. For weeks I assumed I had the payload encoding wrong, because that's the kind of thing that's usually wrong.&lt;/p&gt;

&lt;p&gt;I had &lt;code&gt;0x0944&lt;/code&gt;. The real opcode is &lt;code&gt;0x0cc4&lt;/code&gt;, &lt;code&gt;AI_SET_TRACK_MODE&lt;/code&gt;. I only found it by capturing USB traffic while OBSBOT's own app changed the setting, and diffing that frame against mine.&lt;/p&gt;

&lt;p&gt;This is the thing about an undocumented binary protocol that I hadn't internalized: a wrong opcode doesn't fail. There's no error, no stall, no NAK. The firmware looks at a command id it doesn't recognize and drops it on the floor, and from the host side that's indistinguishable from a command that worked and had no visible effect. Every silent no-op I chased after that, I went to the wire first instead of re-reading my decoder.&lt;/p&gt;

&lt;p&gt;Static analysis tells you what the code &lt;em&gt;could&lt;/em&gt; send. A capture tells you what it &lt;em&gt;does&lt;/em&gt; send. Those are different documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then half of it turned out not to be vendor protocol at all
&lt;/h2&gt;

&lt;p&gt;Somewhere in the middle of this I checked whether the gimbal answers &lt;code&gt;CT_PANTILT_ABSOLUTE&lt;/code&gt; — control selector &lt;code&gt;0x0D&lt;/code&gt;, a bog-standard UVC Camera Terminal control that every OS already knows how to talk to.&lt;/p&gt;

&lt;p&gt;It does. And it's not echoing back what I told it. I polled it every 40 ms while a 0° to 90° pan was in flight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t+0ms     pan=0°
t+709ms   pan=3°
t+832ms   pan=10°
t+1913ms  pan=51°
t+3259ms  pan=90°   &amp;lt;- physical arrival
t+3300ms  pan=90°   (steady)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's an encoder tracking a physical slew. Tilt stayed at zero the whole time, having never been commanded. It even reports motion the host never commanded through that control at all — vendor speed moves, recenters, the AI panning itself around after a face.&lt;/p&gt;

&lt;p&gt;That reframed the project. My end goal was cross-platform, and vendor XU access means writing and maintaining a native helper for every OS — DirectShow on Windows, IOKit on macOS, V4L2 and libusb on Linux — and then keeping three implementations of the same protocol honest against each other. Standard UVC controls cost none of that. The OS already has the code.&lt;/p&gt;

&lt;p&gt;So the rule became: use standard UVC wherever it's sufficient, drop to the vendor extension unit only where it isn't. Zoom is UVC. Focus, exposure, white balance are UVC. Pan and tilt are UVC in both directions, read and write. What's genuinely vendor-only is the AI tracking, the presets, sleep/wake, and speed-mode gimbal moves. That's maybe a third of the surface, and everything else stopped being three implementations.&lt;/p&gt;

&lt;h2&gt;
  
  
  And it worked everywhere except Linux
&lt;/h2&gt;

&lt;p&gt;Same control, same camera, same USB transfers. On Linux, &lt;code&gt;pan_absolute&lt;/code&gt; reads back whatever value I last wrote, forever, no matter where the gimbal actually is.&lt;/p&gt;

&lt;p&gt;That's &lt;code&gt;uvcvideo&lt;/code&gt; caching. &lt;code&gt;__uvc_ctrl_load_cur()&lt;/code&gt; short-circuits on a &lt;code&gt;ctrl-&amp;gt;loaded&lt;/code&gt; flag, and that flag gets cleared in exactly two places: the driver's own commit path, and on receipt of a Control Change interrupt from the device. The Tiny 2 never sends one. I dumped &lt;code&gt;GET_INFO&lt;/code&gt; across five Camera Terminal controls and got a constant &lt;code&gt;0x03&lt;/code&gt; for every single one — the firmware isn't computing the response per-control, it's returning a stub. Bits D3 (Autoupdate) and D4 (Asynchronous) are clear on a gimbal that takes seconds to complete a move and repositions itself autonomously as a headline feature. Both should be set. That's a real spec violation, and I've now reported it to OBSBOT.&lt;/p&gt;

&lt;p&gt;But "the vendor is non-compliant, work around it" is a weak argument to bring to a kernel mailing list, and it's also not what's actually going on. Trace the cache on a &lt;em&gt;fully compliant&lt;/em&gt; PTZ camera, one that does set D3 and does send its interrupts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;S_CTRL(pan=90)&lt;/code&gt; — commit clears &lt;code&gt;loaded&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;first &lt;code&gt;G_CTRL&lt;/code&gt; — live &lt;code&gt;GET_CUR&lt;/code&gt;, sets &lt;code&gt;loaded = 1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;every read after that — cache hit, same value, until the move ends and the interrupt finally arrives&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One live sample per write. And if userspace reads promptly after commanding the move, which is the natural thing to do, that one sample is taken before the actuator has appreciably moved. It's the least useful sample available. Polling is useless on any UVC PTZ camera, compliant or not.&lt;/p&gt;

&lt;p&gt;The spec more or less concedes this. §4.2.2.1.15: a relative-control write "shall result in a Control Change interrupt for the Absolute Control &lt;strong&gt;at the end of the movement&lt;/strong&gt;." The notification mechanism is event-shaped, and it's specified for the end of the move and nothing during it. That's fine for "auto-exposure changed mode." It's the wrong shape for a quantity that varies continuously for three seconds. &lt;code&gt;V4L2_CTRL_FLAG_VOLATILE&lt;/code&gt; already exists for exactly this concept and &lt;code&gt;uvcvideo&lt;/code&gt; just never applies it here.&lt;/p&gt;

&lt;p&gt;So: patch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first patch was wrong in an interesting way
&lt;/h2&gt;

&lt;p&gt;Four lines. Add a &lt;code&gt;UVC_CTRL_FLAG_VOLATILE&lt;/code&gt; bit, set it on the pan/tilt entry, bypass the cache, surface the flag to userspace. Clean under &lt;code&gt;checkpatch.pl --strict&lt;/code&gt;. I never sent it, and I'm glad.&lt;/p&gt;

&lt;p&gt;One UVC control holds both axes — &lt;code&gt;PANTILT_ABSOLUTE&lt;/code&gt; is 8 bytes, and PAN and TILT are 32-bit mappings at offsets 0 and 32. Because the mapping doesn't span the whole control, &lt;code&gt;uvc_ctrl_set()&lt;/code&gt; does a read-modify-write on every single pan or tilt write. Make that read live and:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;S_CTRL(pan=90)&lt;/code&gt; commits, gimbal starts moving&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;S_CTRL(tilt=20)&lt;/code&gt; a millisecond later re-reads live position. The gimbal hasn't physically moved yet, so it reads &lt;code&gt;pan=0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;commits &lt;code&gt;SET_CUR(pan=0, tilt=20)&lt;/code&gt; — the pan command is cancelled&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The write path only works today &lt;em&gt;because&lt;/em&gt; the cache holds the commanded setpoint rather than reality. My own server issues pan and tilt as two parallel single-axis writes, so I'd have shipped a patch that broke the tool I'd hardware-verified, the moment it merged.&lt;/p&gt;

&lt;p&gt;The commit message had a second problem: I'd quoted the UVC spec asserting the control "indicates the pan/tilt actuator's current position." Correct section number. The quote does not exist. I'd reconstructed it from memory and presented it as verbatim, and the real text uses setpoint language throughout. The citation that actually supports the argument was two subsections away the whole time.&lt;/p&gt;

&lt;p&gt;The version I sent inverts the fix. Instead of adding a setpoint shadow so the write path can keep working — which means modifying the write path, which is exactly where the regression lived — it adds a separate &lt;em&gt;read&lt;/em&gt; buffer. &lt;code&gt;uvc_ctrl_set()&lt;/code&gt; comes out byte-for-byte identical to mainline. The regression isn't avoided, it's impossible by construction, and that's a much better thing to be able to tell a reviewer.&lt;/p&gt;

&lt;p&gt;55 insertions, 4 deletions, two files. Hardware results on a self-built 7.2.0-rc4: the two-axis write passes with the hazard window genuinely exercised (pan still read 0 at the second write, and it still landed); live position steps 0→5→17→24→36→47→55→66→78→90 during a slew; 100 frames at 30.5 fps while polling position at 5 Hz with no frame loss; &lt;code&gt;v4l2-compliance&lt;/code&gt; identical patched versus pristine.&lt;/p&gt;

&lt;p&gt;It went to linux-media on July 25th, and Ricardo Ribalda picked it up six days later, opening with &lt;em&gt;"I am not against the idea proposed by this patch"&lt;/em&gt; — then three questions and a counter-proposal. He suggested V4L2 control events instead, which fails three ways here: uvcvideo's only device-originated event is fed by the Control Change interrupt, the spec promises that interrupt only at the end of a move, and this camera never sends it at all. He also found a real hazard on the write side — once the AI has tracked the gimbal away from the last commanded pose, a single-axis write merges from the setpoint and yanks the other axis back. Real, but pre-existing: the patch leaves the write path byte-identical to mainline.&lt;/p&gt;

&lt;p&gt;He came back the same evening, added Hans Verkuil to the thread, and said he was curious what HansV thought. So it's waiting on a second maintainer now, not on silence.&lt;/p&gt;

&lt;p&gt;Two of his points I conceded outright. I'd overstated the accuracy requirement — nothing here correlates a position with a particular frame, and I'd answered a question as though it did. And he pointed out that &lt;code&gt;VIDIOC_S_EXT_CTRLS&lt;/code&gt; sets both axes in a single commit, which sidesteps the write-path hazard entirely from userspace. I'd missed it completely. That one became a shipped fix in my own server the next day, which is the better outcome anyway: the patch is about the read side, and the write side had a userspace answer the whole time.&lt;/p&gt;

&lt;p&gt;There's a nice accident buried in the retest, too. One run failed with the exact v1 signature — pan cancelled back to zero — and the cause wasn't my patch at all. The module reload had re-probed the camera while it was asleep, and asleep it doesn't answer &lt;code&gt;GET_INFO&lt;/code&gt;, so the driver couldn't override the static table and pan/tilt kept &lt;code&gt;AUTO_UPDATE&lt;/code&gt;. With that flag set, mainline clears &lt;code&gt;loaded&lt;/code&gt; after every commit and does the live read-modify-write &lt;em&gt;by itself&lt;/em&gt;. It's a stock-kernel bug on this camera. It's also not going in this patch — it's orthogonal, and bundling a second bug story into a commit message whose only real virtue is narrowness is how you lose a reviewer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/lxman/obsbot-mcp" rel="noopener noreferrer"&gt;obsbot-mcp&lt;/a&gt; is on npm and does the thing I wanted: snapshot, find something in the frame, aim at that pixel, snapshot again to confirm. The aiming math reads the camera's own magnification out of its status block, so it works at any zoom without being told what the zoom is, and it refuses rather than guessing when it can't — AI tracking is fighting it for the gimbal, the zoom is still ramping, the camera had to be woken (waking moves the gimbal, so the frame you measured is stale).&lt;/p&gt;

&lt;p&gt;35 tools on Windows and macOS, 34 on Linux, where speed-mode gimbal moves are hidden — a speed times duration burst can't be bounded without live position feedback, and until that patch lands, Linux doesn't have any.&lt;/p&gt;

</description>
      <category>reverseengineering</category>
      <category>linux</category>
      <category>usb</category>
      <category>mcp</category>
    </item>
    <item>
      <title>A Pure-C# PDF Toolkit for .NET: Read, Create, Edit, Optimize, Render, Fill Forms — and No Native Dependencies</title>
      <dc:creator>Michael Jordan</dc:creator>
      <pubDate>Wed, 24 Jun 2026 19:51:33 +0000</pubDate>
      <link>https://dev.to/michael_jordan_87eaf96f24/render-a-pdf-to-an-image-in-net-with-a-pure-c-pdf-engine-a7o</link>
      <guid>https://dev.to/michael_jordan_87eaf96f24/render-a-pdf-to-an-image-in-net-with-a-pure-c-pdf-engine-a7o</guid>
      <description>&lt;p&gt;Most PDF libraries in .NET are wrappers. PDFium, MuPDF, Ghostscript — solid engines, but they ship native binaries you P/Invoke into. That buys you per-RID packages, Native AOT friction, the "works on my box, throws &lt;code&gt;DllNotFoundException&lt;/code&gt; in the Alpine container" dance, and a deployment story that's never quite boring.&lt;/p&gt;

&lt;p&gt;PdfLibrary takes the other road: the entire PDF engine — parsing, content streams, fonts, and &lt;strong&gt;every image codec&lt;/strong&gt; (baseline and progressive JPEG, JPEG 2000, JBIG2, CCITT G3/G4 fax, LZW, Flate) — is pure managed C#. No vendored native PDF library, no native image decoders.&lt;/p&gt;

&lt;p&gt;The first version of this post had an honest asterisk: rendering a page to pixels went through SkiaSharp, which carries a native component, and I wasn't going to pretend otherwise. &lt;strong&gt;2.0 removed the asterisk.&lt;/strong&gt; Rendering is now a small geometry-only interface the core hands flattened paths to — bring your own target, or use the in-box WPF one, which is managed WPF and ships no native code. The packages you install now have zero native dependencies. More on that below.&lt;/p&gt;

&lt;p&gt;And rendering was always the flashy demo, not the point. So let me actually show you the toolkit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;One core package — &lt;code&gt;Lxman.PdfLibrary&lt;/code&gt; — covers the whole document lifecycle, all managed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read &amp;amp; extract&lt;/strong&gt; — parse any PDF 1.x or 2.0; pull text, with positions if you want them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create&lt;/strong&gt; — build documents from scratch with a fluent builder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edit&lt;/strong&gt; — add / remove / reorder / rotate pages, stamp, watermark, annotate, set metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimize&lt;/strong&gt; — losslessly shrink: compression, object/xref streams, font subsetting, optional image recompression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fill forms&lt;/strong&gt; — read and fill AcroForm fields, regenerate appearances, flatten.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pixels are the one thing that lives in a separate package, because most of the above never needs them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extract text
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;PdfLibrary.Structure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PdfDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"input.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ExtractText&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Edit a document
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;PdfLibrary.Structure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PdfDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"input.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;edit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Edit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RemoveAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// delete the 3rd page&lt;/span&gt;
&lt;span class="n"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;90&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// rotate the 1st page 90°&lt;/span&gt;
&lt;span class="n"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// move the 6th page to the front&lt;/span&gt;

&lt;span class="n"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"edited.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Optimize / shrink
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;PdfLibrary.Optimization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PdfDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"input.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"optimized.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;PdfOptimizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Optimize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// lossless by default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fill a form
&lt;/h2&gt;

&lt;p&gt;New and front-and-center in 2.0. Set field values, optionally flatten, save:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;PdfLibrary.Structure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;PdfLibrary.Editing.Forms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PdfDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"form.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;edit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Edit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;PdfTextField&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Forms&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"applicant.name"&lt;/span&gt;&lt;span class="p"&gt;]!).&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Ada Lovelace"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;PdfButtonField&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Forms&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"subscribe"&lt;/span&gt;&lt;span class="p"&gt;]!).&lt;/span&gt;&lt;span class="nf"&gt;Check&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Flatten&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// optional: bake the values into the page&lt;/span&gt;
&lt;span class="n"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"filled.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting a value regenerates the field's appearance stream, so it renders correctly in any viewer afterward. (There's also a pure-WPF viewer in the repo that overlays &lt;em&gt;native&lt;/em&gt; controls on the form fields so a person can fill them by hand and hit Save — but that's a post of its own.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Render — and the "no native dependencies" story
&lt;/h2&gt;

&lt;p&gt;Here's the 2.0 change worth understanding. The core flattens everything — including text, down to glyph outlines — and calls a small interface, &lt;code&gt;IRenderTarget&lt;/code&gt;, with nothing but geometry: &lt;em&gt;fill this path, stroke this path, draw this image, set this clip.&lt;/em&gt; About seventeen methods, none of which require any PDF knowledge to implement. The hard part — operators, fonts, color, clipping — stays in the core.&lt;/p&gt;

&lt;p&gt;That makes rendering bring-your-own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On Windows&lt;/strong&gt;, install &lt;code&gt;Lxman.PdfLibrary.Rendering.Wpf&lt;/code&gt; and render a page to a retained WPF &lt;code&gt;DrawingGroup&lt;/code&gt; — vector, so it's crisp at any zoom — then host it or rasterize it. Managed WPF, no native code.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;PdfLibrary.Rendering.Wpf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PdfDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"input.pdf"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;DrawingGroup&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RenderToDrawing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// host `page` in an Image/DrawingImage, or render it into a&lt;/span&gt;
  &lt;span class="c1"&gt;// RenderTargetBitmap and PngBitmapEncoder it to a file.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anywhere else&lt;/strong&gt;, implement &lt;code&gt;IRenderTarget&lt;/code&gt; against a canvas you already have — SkiaSharp, ImageSharp, System.Drawing, a printer, an SVG writer, a game engine. The repo ships an SVG target as a worked example; it's small.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the honest breakdown, 2.0 edition:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parse, extract, create, edit, optimize, fill forms → &lt;strong&gt;fully managed, ship no native code.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Render to a vector visual or a bitmap on Windows → &lt;code&gt;Lxman.PdfLibrary.Rendering.Wpf&lt;/code&gt;, &lt;strong&gt;still no native code.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Render to a raster image cross-platform → &lt;strong&gt;you bring the canvas.&lt;/strong&gt; WPF won't help you on Linux; that's the one line to be precise about. But "implement ~17 geometry methods" is a very different proposition from bundling and P/Invoking a native PDF engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No P/Invoke, no &lt;code&gt;SetDllDirectory&lt;/code&gt;, no &lt;code&gt;DllNotFoundException&lt;/code&gt; at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threading
&lt;/h2&gt;

&lt;p&gt;PdfLibrary is built for the &lt;strong&gt;one-document-per-request&lt;/strong&gt; model — the standard ASP.NET Core pattern. Load a &lt;code&gt;PdfDocument&lt;/code&gt; per request, work with it, dispose it. Under that model it's thread-safe, and the process-wide caches (glyph paths, font resolution, ICC profiles) are synchronized. The one thing you must not do is share a single &lt;code&gt;PdfDocument&lt;/code&gt; across threads. (WPF rendering additionally wants an STA thread, as WPF always does.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Lxman.PdfLibrary
&lt;span class="c"&gt;# rendering on Windows (optional):&lt;/span&gt;
dotnet add package Lxman.PdfLibrary.Rendering.Wpf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NuGet: &lt;strong&gt;Lxman.PdfLibrary&lt;/strong&gt; · &lt;strong&gt;Lxman.PdfLibrary.Rendering.Wpf&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Source (MIT): &lt;strong&gt;github.com/lxman/PdfLibrary&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Targets .NET 8, 9, and 10. Current release: &lt;strong&gt;2.0.0&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try it and something doesn't behave the way you expect, open an issue with the PDF attached — edge cases in the wild are how this kind of library gets better.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>pdf</category>
      <category>opensource</category>
    </item>
    <item>
      <title>MongoDB-backed ASP.NET Core Identity, without the EF Core detour</title>
      <dc:creator>Michael Jordan</dc:creator>
      <pubDate>Wed, 24 Jun 2026 19:42:41 +0000</pubDate>
      <link>https://dev.to/michael_jordan_87eaf96f24/mongodb-backed-aspnet-core-identity-without-the-ef-core-detour-848</link>
      <guid>https://dev.to/michael_jordan_87eaf96f24/mongodb-backed-aspnet-core-identity-without-the-ef-core-detour-848</guid>
      <description>&lt;p&gt;If you're already running MongoDB and you reach for ASP.NET Core Identity, the&lt;br&gt;
official story points you at Entity Framework Core. That's a fine answer if you&lt;br&gt;
have a relational database. If you don't, you end up bolting a second data&lt;br&gt;
access stack onto an app that has exactly one store. You don't need it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AspNetCoreIdentity.MongoDriver&lt;/code&gt; is a store provider that talks to Mongo through&lt;br&gt;
the official &lt;code&gt;MongoDB.Driver&lt;/code&gt; directly — no EF Core, no SQL, no second&lt;br&gt;
persistence model. It implements the Identity store interfaces, ships a&lt;br&gt;
&lt;code&gt;UserStore&lt;/code&gt; and &lt;code&gt;RoleStore&lt;/code&gt;, and wires up &lt;code&gt;UserManager&lt;/code&gt;/&lt;code&gt;RoleManager&lt;/code&gt; the way&lt;br&gt;
you already expect.&lt;/p&gt;
&lt;h2&gt;
  
  
  The 15-line version
&lt;/h2&gt;

&lt;p&gt;Register the provider in &lt;code&gt;Program.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddIdentityMongoDbProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MongoUser&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;MongoRole&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireUniqueEmail&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;mongo&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;mongo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionString&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"MongoDb"&lt;/span&gt;&lt;span class="p"&gt;)!;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A connection string like &lt;code&gt;mongodb://localhost:27017/Identity&lt;/code&gt; creates an&lt;br&gt;
&lt;code&gt;Identity&lt;/code&gt; database and stores the collections there. That's the whole setup.&lt;br&gt;
Now inject the managers wherever you need them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AccountController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserManager&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MongoUser&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;userManager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Controller&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They're registered as scoped services — let the container manage their&lt;br&gt;
lifetime. Don't call &lt;code&gt;BuildServiceProvider()&lt;/code&gt; yourself, and don't cache the&lt;br&gt;
managers in long-lived objects.&lt;/p&gt;
&lt;h2&gt;
  
  
  Your key type is yours
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;MongoUser&lt;/code&gt; and &lt;code&gt;MongoRole&lt;/code&gt; classes are generic, so the primary key isn't&lt;br&gt;
forced to be a &lt;code&gt;Guid&lt;/code&gt;. Want &lt;code&gt;string&lt;/code&gt; keys? Use &lt;code&gt;MongoUser&amp;lt;string&amp;gt;&lt;/code&gt;. If you use&lt;br&gt;
&lt;code&gt;string&lt;/code&gt; keys and don't assign an &lt;code&gt;Id&lt;/code&gt; on create, the store generates an&lt;br&gt;
ObjectId-style string for you.&lt;/p&gt;

&lt;p&gt;If you do go with &lt;code&gt;Guid&lt;/code&gt;, register the serializer once before the code above so&lt;br&gt;
Mongo stores them in the standard representation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;BsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RegisterSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;GuidSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GuidRepresentation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Standard&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Migrations that survive a rolling deploy
&lt;/h2&gt;

&lt;p&gt;Here's the part that usually bites people who hand-roll a Mongo store. On the&lt;br&gt;
first store operation — not during service registration — the library:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;applies any pending schema migrations, &lt;strong&gt;guarded by a distributed lock&lt;/strong&gt; so
that if several app instances boot at once, the migrations apply exactly
once; and&lt;/li&gt;
&lt;li&gt;creates its indexes: a unique index on &lt;code&gt;NormalizedUserName&lt;/code&gt;, an index on
&lt;code&gt;NormalizedEmail&lt;/code&gt;, a compound index on &lt;code&gt;Logins.LoginProvider&lt;/code&gt; /
&lt;code&gt;Logins.ProviderKey&lt;/code&gt;, and a unique index on the role &lt;code&gt;NormalizedName&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That distributed lock matters the moment you run more than one instance. Two&lt;br&gt;
pods starting simultaneously won't race each other into a half-applied schema.&lt;/p&gt;

&lt;p&gt;Because the &lt;code&gt;NormalizedUserName&lt;/code&gt; index enforces real uniqueness, index creation&lt;br&gt;
will fail if your existing data already contains duplicate user names — clean&lt;br&gt;
those up before you upgrade. If you'd rather own these concerns yourself, set&lt;br&gt;
&lt;code&gt;mongo.DisableIndexCreation = true&lt;/code&gt; and/or &lt;code&gt;mongo.DisableAutoMigrations = true&lt;/code&gt;.&lt;br&gt;
And if you want the work done at startup instead of on first request, resolve&lt;br&gt;
&lt;code&gt;MongoIdentityInitializer&lt;/code&gt; and await &lt;code&gt;EnsureInitializedAsync()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  It won't silently clobber concurrent writes
&lt;/h2&gt;

&lt;p&gt;Updates and deletes use optimistic concurrency through Identity's&lt;br&gt;
&lt;code&gt;ConcurrencyStamp&lt;/code&gt;. If the document changed since you loaded your copy, the&lt;br&gt;
operation returns a &lt;code&gt;ConcurrencyFailure&lt;/code&gt; instead of overwriting the other&lt;br&gt;
write. Reload, reapply, retry — the normal Identity dance.&lt;/p&gt;

&lt;h2&gt;
  
  
  One honest limitation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;options.Stores.ProtectPersonalData&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; supported. The store doesn't&lt;br&gt;
encrypt personal data at rest, and rather than letting you flip that switch and&lt;br&gt;
quietly store unprotected data anyway, enabling it throws at runtime. If you&lt;br&gt;
need encryption-at-rest for PII, handle it at a different layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package AspNetCoreIdentity.MongoDriver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Package: &lt;a href="https://www.nuget.org/packages/AspNetCoreIdentity.MongoDriver" rel="noopener noreferrer"&gt;https://www.nuget.org/packages/AspNetCoreIdentity.MongoDriver&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/lxman/AspNetCoreIdentity.MongoDriver" rel="noopener noreferrer"&gt;https://github.com/lxman/AspNetCoreIdentity.MongoDriver&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're on Mongo and Identity, this is the short path. Issues and PRs welcome.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>mongodb</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
