finished steam controller blogpost
This commit is contained in:
parent
99d1d58553
commit
ba905e5deb
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
|
|
@ -494,14 +494,14 @@
|
|||
</div>
|
||||
<h1 class="article-title">Steam Controller Driver for Actual Motors</h1>
|
||||
<p class="article-subtitle">
|
||||
lorem ipsum
|
||||
Reverse engineering a Steam Controller to control actual motors via USB and Bluetooth
|
||||
</p>
|
||||
<div class="tech-stack">
|
||||
<div class="tech-item">React</div>
|
||||
<div class="tech-item">Node.js</div>
|
||||
<div class="tech-item">WebSocket</div>
|
||||
<div class="tech-item">MongoDB</div>
|
||||
<div class="tech-item">Redis</div>
|
||||
<div class="tech-item">C/C++</div>
|
||||
<div class="tech-item">ESP32</div>
|
||||
<div class="tech-item">Bluetooth LE</div>
|
||||
<div class="tech-item">USB HID</div>
|
||||
<div class="tech-item">PWM</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -518,33 +518,13 @@
|
|||
</p>
|
||||
|
||||
<p>
|
||||
The haptic feedback touchpads made Games like Civ and XCOM playable on the couch. But I had my eyes on something much bigger target that could use a controller with fourteen buttons, six directional axes, two analog triggers, and a gryoscope: Flight Simulator. With traditional controllers, you had to have a plethora of different button configurations to make it work. Joysticks make it hard to sense and control your aircraft. Touchpads allow for refined movement with feedback to help you ramp up and down with ease.
|
||||
The unfortunate part is the Steam Controller was depricated in 2019, which I think is a shame. There's a lot of great ideas packed into it. Maybe the world wasn't ready for it. Regardless, I see potential in it. I think it has a range of applications that aren't gaming. Famously, the US Navy spent countless research hours and dollars on an advanced submarine control scheme, only to replace it with <a href="https://bigthink.com/politics-current-affairs/us-navy-xbox-why-use-a-38k-joystick-when-you-can-use-a-30-controller/">game controller</a>. Which really shows just how intuitive and familar it is. Why not take this well-developed and tested product and put it to use? I believe that I can do just such a thing.
|
||||
</p>
|
||||
|
||||
<div class="deco-divider">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
|
||||
<h2>Flight Simulator</h2>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum eget tortor massa. Sed sed nulla vitae enim accumsan pulvinar fermentum nec nunc. Phasellus scelerisque sem vel massa tempus, id lobortis arcu mattis. Donec sed lectus cursus libero auctor facilisis. Donec mattis luctus congue. Maecenas et nunc odio. Sed eu diam lectus. Phasellus sit amet mi vitae lacus rhoncus elementum. Nullam dictum justo sed dignissim placerat. Pellentesque porta velit vel luctus efficitur.
|
||||
</p>
|
||||
|
||||
<h3>WebSocket Server</h3>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum eget tortor massa. Sed sed nulla vitae enim accumsan pulvinar fermentum nec nunc. Phasellus scelerisque sem vel massa tempus, id lobortis arcu mattis. Donec sed lectus cursus libero auctor facilisis. Donec mattis luctus congue. Maecenas et nunc odio. Sed eu diam lectus. Phasellus sit amet mi vitae lacus rhoncus elementum. Nullam dictum justo sed dignissim placerat. Pellentesque porta velit vel luctus efficitur.
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>Handles client connections and disconnections gracefully</li>
|
||||
<li>Broadcasts operations to all connected clients in a room</li>
|
||||
<li>Implements heartbeat mechanisms to detect stale connections</li>
|
||||
<li>Scales horizontally using Redis pub/sub for multi-server deployments</li>
|
||||
</ul>
|
||||
|
||||
<div class="deco-divider">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
<h2>Physical Applications</h2>
|
||||
<h3>Parsing the Byte Stream</h3>
|
||||
<p>
|
||||
|
|
@ -597,203 +577,197 @@ Descriptor (first 32 bytes): 06 00 FF 09 01 A1 01 15 00 26 FF 00 75 08 95 40 09
|
|||
<p>
|
||||
I found a tool called <i>RDD! HID Report Descriptor Decoder</i> on github by <a href="https://github.com/abend0c1/hidrdd">abend0c1</a>. It takes the data from usbhid-dump and performs lookups on all the devices given. It's a very comprehensive tool that gave much more output than I listed.
|
||||
</p>
|
||||
<h3>Operational Transformation</h3>
|
||||
<p>
|
||||
The core algorithm that makes collaboration possible is operational transformation (OT). When two users
|
||||
edit different parts of the document, their operations must be transformed relative to each other to
|
||||
maintain consistency.
|
||||
</p>
|
||||
<p>
|
||||
</p>
|
||||
<p>
|
||||
Now that I have a clear picture of what I'm looking at, it was as simple as reading the HIDRAW.
|
||||
</p>
|
||||
<p>
|
||||
Remember when I said inputs were a little more complicated? Unfortunately, it's not as simple as just input = output. They actually work in byte streams: long, fixed-length data packets, much like a TCP connection. This has several benefits. You can hit multiple buttons at the same time and don't have to worry about them overriding each other. When you aren't pressing anything at all, no data is streamed, which means it only has to check if it received any data rather than checking which data is has or hasn't received. It can also do some error checking. If an "a" input got corrupted, it might put out a "b", which would be an awful experience when typing. With byte streams, there are some parity bytes at the end. The computer takes all the data bytes, does some math, checks them against the parity bytes. This ensures your data comes out the other side safely. This is standard over the USB protocol and is done at such a low level that we wouldn't even see it in the byte stream, but it is still very much there and important. We wouldn't want any <a href="https://beza1e1.tuxen.de/lore/crash_cows.html">cows</a> flipping the bits. While sending these byte stream packets is indeed slower, it's still done at breakneck speed for real applications and the fixed-length nature of it provides plenty of benefits that remove future headaches.
|
||||
</p>
|
||||
<p>
|
||||
This actually makes it much easier, in some respects, to figure out the mappings of the controller. The data will be in the same place each time. If a bit should flip or a byte fail to read, the USB protocol won't allow it to pass through to the application. I did a small amount of testing on this by reading the clock byte and never found an instance where the next byte didn't follow the previous. From here is the fun part: byte mapping.
|
||||
</p>
|
||||
<p>
|
||||
|
||||
<blockquote>
|
||||
"Operational transformation is the mathematical foundation that allows distributed systems to converge
|
||||
to the same state despite concurrent modifications."
|
||||
</blockquote>
|
||||
</p>
|
||||
<img src="device-data.png" alt='long strings of device data'/>
|
||||
<p>
|
||||
As you can see here, I have a long stream of data; 64 bytes to be precise. By pressing buttons on the controller, I can see which bytes change, which allows me to make a list of which bytes map to controls. This is the fun part, but also the hard part. I have to manually write down the placement for each control with the occasional funky behavior. For instance: byte 10 will show a 20 for when a button is pressed, but 21 for when the right grip is pressed. Furthermore, why is A to 80, X to 40, B to 20, and Y to 10? This seems like strange and errant behavior at first, but you have to remember how bits are counted.
|
||||
</p>
|
||||
<p>
|
||||
20 is 00010100 in binary<br>
|
||||
21 is 00010101 in binary.
|
||||
</p>
|
||||
<p>
|
||||
The right grip is the first bit in the byte, and it's added to the 20 for the B button. Now we have to think about why 10, 20, 40, 80 for Y, B, X, A respectively. The first bit on the right is 1, to the second is 2, then 4, 8, 16, 32, 64, 128. We make our byte (which, consequently, is 8 bits) from the addition of all these bits. So when we get a 20 from our byte 10, that's only bits 5 and 3: 00010100. With byte streams, I don't have to store 14 different bytes for all the different buttons. I can do some bit checking and squish them all up together to fit inside 2 or 3 bytes. This keeps the byte stream down in size to 64 bytes, which is still pretty large when all a button needs is a single bit. Other things like joysticks and touchpads would take up much more space.
|
||||
</p>
|
||||
<p>
|
||||
As a fun note, I tracked down another user who mapped out all the bytes. His were slightly off from mine because he did it in 2015 and mine was from 2025. They had a firmware update over those years that changed some functionalities in the byte stream. Finding those discrepancies helped me know which bytes meant what and helped me double check to make sure I had the right one.
|
||||
</p>
|
||||
<p>
|
||||
This brings me to my next issue: how to use this information. Part 1 of 3 is to actually read the data from a file descriptor. Again, USB devices are character devices just like keyboards and mice, but they have to be opened at the kernel level with file system permissions. It was pretty straightforward:
|
||||
</p>
|
||||
<pre><code>int fd;
|
||||
char *device = "/dev/hidraw8";
|
||||
|
||||
<p>
|
||||
Our implementation uses a three-operation model: insert, delete, and retain. Each operation carries
|
||||
positional information that gets transformed when concurrent operations occur.
|
||||
</p>
|
||||
|
||||
<div class="deco-divider">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
|
||||
<h2>Key Features</h2>
|
||||
<p>
|
||||
The final implementation includes several sophisticated features that enhance the collaborative experience:
|
||||
</p>
|
||||
|
||||
<ol>
|
||||
<li><strong>Cursor Tracking:</strong> Real-time display of where other users are typing</li>
|
||||
<li><strong>Presence Indicators:</strong> Shows who's currently viewing the document</li>
|
||||
<li><strong>Conflict-Free Resolution:</strong> Automatic handling of concurrent edits</li>
|
||||
<li><strong>Undo/Redo:</strong> Full history management that works across collaborative sessions</li>
|
||||
<li><strong>Rich Text Formatting:</strong> Support for bold, italic, lists, and more</li>
|
||||
</ol>
|
||||
|
||||
<h3>Performance Optimizations</h3>
|
||||
<p>
|
||||
To ensure smooth performance even with large documents and many concurrent users, several optimizations
|
||||
were implemented:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>Operation batching to reduce network overhead</li>
|
||||
<li>Delta compression for efficient data transmission</li>
|
||||
<li>Lazy loading of document history</li>
|
||||
<li>Client-side caching with service workers</li>
|
||||
</ul>
|
||||
|
||||
<div class="deco-divider">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
|
||||
<h2>Code Implementation</h2>
|
||||
<p>
|
||||
The operational transformation logic is implemented in JavaScript. Here's a simplified example of how
|
||||
operations are transformed when they conflict:
|
||||
</p>
|
||||
|
||||
<pre><code>function transformOperation(op1, op2) {
|
||||
// If operations affect different positions, no transformation needed
|
||||
if (op1.position < op2.position) {
|
||||
return op1;
|
||||
}
|
||||
|
||||
// Transform op1 based on op2s changes
|
||||
if (op2.type === 'insert') {
|
||||
return {
|
||||
...op1,
|
||||
position: op1.position + op2.length
|
||||
};
|
||||
}
|
||||
|
||||
if (op2.type === 'delete') {
|
||||
return {
|
||||
...op1,
|
||||
position: Math.max(op1.position - op2.length, op2.position)
|
||||
};
|
||||
}
|
||||
|
||||
return op1;
|
||||
fd = open(device, O_RDONLY);
|
||||
if (fd < 0) {
|
||||
perror("Can't open device!");
|
||||
return 1;
|
||||
}</code></pre>
|
||||
<p>
|
||||
The open function returns a file descriptor. This is a non-negative integer, which means I can set it to an int type. I also have to open it in O_RDONLY flag, which is read only. I want to open it from the kernel level. We're not reading a text file here, we're reading from a data stream, so we need to "be careful" (the kernel wants us to be explicit and aware of what we're doing). From there, I have to make a simple error check to ensure we're actually able to. Open can fail for any number of reasons: the device doesn't exist, the user doesn't have permission, or the file is locked. This is good practice and ensures that my code doesn't run into issues later.
|
||||
</p>
|
||||
<p>
|
||||
That's the easy part. Part 2 of 3 is turning the byte stream into something I can use as an input. Since I know where these input positions are within each byte, I can check those specifically. I have a parser function that takes the 64 bytes out of the buffer and into a struct with inputs as keys and their values. Here are just a few examples.
|
||||
</p>
|
||||
<pre><code>state->a = buffer[10] & 0x80;
|
||||
state->b = buffer[10] & 0x20;
|
||||
state->x = buffer[10] & 0x40;
|
||||
state->y = buffer[10] & 0x10;</code></pre>
|
||||
<p>
|
||||
Do you see what I'm doing here? The ampersand isn't for a pointer this time (maybe if it were double ampersands, but not singular). The ampersand is the binary AND operator, which does logical comparisons on binary representations of numbers. So a 1 AND 1 is 1 and 1 AND 0 is 0 and 0 AND 1 is 0 and 0 AND 0 is 0. Any combination that isn't 1 AND 1 is 0. Buffer 10 had the A, B, X, Y buttons all stored in its 8 bits. By using a bitmask I can check if the byte is storing 0 or 1 for each position for each button.
|
||||
</p>
|
||||
<p>
|
||||
Let's take 0x20, which is 00100000 in binary. Let's check if it's an A button press. I bitwise AND the 0x20 with 0x80 (10000000).
|
||||
</p>
|
||||
<pre><code> 00100000
|
||||
& 10000000
|
||||
----------
|
||||
00000000 = 0 = false</code></pre>
|
||||
<p>
|
||||
It isn't an A button press, but let's check if it's a B button press. 0x20 with 0x20.
|
||||
</p>
|
||||
<pre><code> 00100000
|
||||
& 00100000
|
||||
----------
|
||||
00100000 = 0x20 != 0 = true</code></pre>
|
||||
<p>
|
||||
It's a B button. Maybe it was incredibly obvious from the start given that 0x20 is a B button, but I had to make sure you understood why. Now I can check each byte individually and turn them into human readable named inputs!
|
||||
</p>
|
||||
<p>
|
||||
The analog inputs weren't as straightforward. Let's say you have an analog input like your horizontal position on the joystick with a base value of 128 or 0x8000 in hex. You can move it all the way left and get 0 or move it all the way right and get 255 or 0xFF00. Okay sure you can do some math like "x_pos - 128" and get a number from -128 to 127 and make a differential like "+80", but these are values going into a struct that should be between 0 and 255. The easy thing you can do here is subtraction with a signed short. This is a data type that takes 2 bytes of memory and stores both a negative sign and a value. By setting it to a signed short, when you subtract 128 from it, you get a value between -128 and 127.
|
||||
</p>
|
||||
<pre><code>state->joystick_x = (int16_t)buffer[16] - 128;</code></pre>
|
||||
<p>
|
||||
A note here, int16_t is a 16-bit integer. It can store values from -32,768 to 32,767. This is a much larger range than what I need, but I can get away with it. I could also use an int8_t, which is a signed 8-bit and goes from -128 to 127. This is just enough for what I need. And int32_t is a 32-bit which is 2,147,483,647 and way too much, but useful if you're multiplying joystick_x by something else. It's just better practice to use a variable type that you know is the exact size you need. Just in case. This ensures it stays the same on different devices and architectures. This is also why I can cast buffer[16] to an int16_t before doing the math on it.
|
||||
</p>
|
||||
<p>
|
||||
There are a couple more fancy things: like the touchpad (which actually has two axis: x_pos and y_pos), the triggers have an initial pressed binary button and an analog axis which determines by how much, and there is a clock byte that increases over time by 1 every couple milliseconds which loops back around at 256. But you get the general idea from what's been said. Part 3 of 3 is using the inputs. If I were making a driver, I would be writing to a device like a FIFO pipe or sending network packets over UDP. But since I don't quite have my Pilots License and I don't want any No-Fly-Zone violations, I'm going to actually control some motors with it (well just 1 right now). For that, I'm going to write some Arduino-style code and control a PWM on an ESP32 board.
|
||||
</p>
|
||||
<pre><code>void setup() {
|
||||
setupController();
|
||||
}
|
||||
|
||||
<h3>WebSocket Event Handler</h3>
|
||||
<p>
|
||||
The server-side WebSocket handler manages incoming operations and broadcasts them to all connected clients:
|
||||
</p>
|
||||
void loop() {
|
||||
uint8_t buffer[64];
|
||||
SteamControllerState state = {0};
|
||||
|
||||
<pre><code>io.on('connection', (socket) => {
|
||||
console.log('Client connected:', socket.id);
|
||||
read(fd, buffer, sizeof(buffer))
|
||||
|
||||
socket.on('join-document', (docId) => {
|
||||
socket.join(docId);
|
||||
socket.to(docId).emit('user-joined', {
|
||||
userId: socket.id,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
parse_controller_state(buffer, &state);
|
||||
|
||||
socket.on('operation', (data) => {
|
||||
const { docId, operation, version } = data;
|
||||
if (state.grip_right){
|
||||
update_pwm(1);
|
||||
} else {
|
||||
update_pwm(0);
|
||||
}
|
||||
|
||||
// Transform and broadcast operation
|
||||
const transformed = applyTransformation(operation, version);
|
||||
socket.to(docId).emit('operation', transformed);
|
||||
delay(100); //0.1 seconds
|
||||
}</code></pre>
|
||||
<p>
|
||||
The actual code has much more error checking, I promise.
|
||||
</p>
|
||||
<p>
|
||||
We setup the controller (part 1) in the setup function. In the loop function we create a buffer of 64 bytes, which is the exact size of the byte stream. We instantiate an empty struct for the steam controller to store it's data. We take our empty buffer and pass it into a read function call that takes data from the file descriptor (our device) and plants it into our empty buffer. From there we take our data and and a pointer to the empty struct and send it to a parsing function (part 2). By passing a pointer to it, we can modify the struct that was already instantiated.
|
||||
</p>
|
||||
<p>
|
||||
Once the parsing is done, we can perform some checks. If the right_grip is a value other than 0, it will update the pwm value. An important note here: all the values from the controller are sent from the buffer to the SteamController struct. If I only read the non-zero inputs, then I could accidentally be reading "ghost data" from memory whenever I wasn't using the grip. This can cause a lot of issues that are incredibly hard to track down.
|
||||
</p>
|
||||
<p>
|
||||
The update_pwm function will raise the PWM above the minimum threshold if it receives a positive signal, up to the maximum threshold. If it receives a negative signal, it will lower it to the minimum threshold. These values exist to keep the motor operating and prevent <i>stalling conditions</i>. Anecdotally, this is the same reason why many household fans will go from off to high, then medium, then low. The update_pwm function is called every time the loop function runs. If the button isn't pressed, then it automatically starts to lower the PWM.
|
||||
</p>
|
||||
|
||||
// Save to database
|
||||
saveOperation(docId, transformed);
|
||||
});
|
||||
});</code></pre>
|
||||
|
||||
<h3>React Component Example</h3>
|
||||
<p>
|
||||
On the client side, React components manage the editor state and handle user input:
|
||||
</p>
|
||||
|
||||
<pre><code>const CollaborativeEditor = () => {
|
||||
const [content, setContent] = useState('');
|
||||
const [cursors, setCursors] = useState({});
|
||||
const wsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Connect to WebSocket server
|
||||
wsRef.current = io('wss://api.example.com');
|
||||
|
||||
wsRef.current.on('operation', (op) => {
|
||||
setContent(prev => applyOperation(prev, op));
|
||||
});
|
||||
|
||||
wsRef.current.on('cursor-move', (data) => {
|
||||
setCursors(prev => ({
|
||||
...prev,
|
||||
[data.userId]: data.position
|
||||
}));
|
||||
});
|
||||
|
||||
return () => wsRef.current.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="editor">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
onSelect={handleCursorMove}
|
||||
/>
|
||||
{renderCursors(cursors)}
|
||||
</div>
|
||||
);
|
||||
};</code></pre>
|
||||
|
||||
<div class="deco-divider">
|
||||
<div class="deco-divider">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
|
||||
<h2>Lessons Learned</h2>
|
||||
<p>
|
||||
Throughout this project, several important insights emerged about building real-time collaborative systems:
|
||||
</p>
|
||||
<h2>Bluetooth</h2>
|
||||
<p>
|
||||
One small hitch in my plan. For several different reasons, I couldn't just plug my controller into my ESP32 and expect it to work. I needed to switch to the Bluetooth protocol for my actual input, which nullifies some of my earlier code, but it was still relevant for reverse engineering the byte stream. As it turned out, Bluetooth wasn't initially available in Steam Controllers and had to be enabled at a later date through a firmware update. Part of the challenge of this project was tracking down that Bluetooth firmware that was no longer available through official means. I bricked my controller a couple times trying to get it to work, but eventually I was successful.
|
||||
</p>
|
||||
<p>
|
||||
I won't go into too much detail as this blog-post is already stretching on. I scanned for Bluetooth devices using bluetoothctl. I was able to connect and copy the service UUID and character UUID of the controller. It is a BLE (Bluetooth Low Energy) device. Instead of laying out the code, I will simply lay out what the different requirements for implementing Bluetooth.
|
||||
</p>
|
||||
<p>
|
||||
The ESP32 scans for a Bluetooth device with the associated with the Steam Controller's MAC address and connects. They connect and form a handshake. From there, they both need to send data to each other.
|
||||
</p>
|
||||
<p>
|
||||
The ESP32 scans for a Bluetooth device associated with the Steam Controller's MAC address and connects. They connect and form a handshake. From there, they both need to send data to each other.
|
||||
</p>
|
||||
<p>
|
||||
The first hurdle was actually finding the controller. Bluetooth Low Energy devices use randomized MAC addresses for privacy (your phone does the same thing with connecting to wifi), so I had to scan for nearby devices and match them by their service UUIDs instead of a hardcoded address.
|
||||
</p>
|
||||
<p>
|
||||
Once connected, I needed to understand how BLE (Bluetooth Low Energy) organizes data. Unlike USB where you just read from a file descriptor, BLE uses a hierarchy: Services contain Characteristics, and Characteristics are where the actual data lives. The Steam Controller has a standard HID service, but it also uses a proprietary Valve service (<code>100F6C32-1735-4313-B402-38567131E5F3</code>) which contains the actual controller data in its Input Report characteristic.
|
||||
</p>
|
||||
<p>
|
||||
This is where BLE's "notify" system comes in. Instead of constantly polling the controller asking over and over like I did with USB reads, BLE lets the controller push data to me automatically when something changes. I wrote a callback function, and whenever the controller has new input data, it fires off a notification and my callback runs. It's like getting one of those restaurant buzzers - when it goes off, your food is ready. You don't have to keep walking up to the host and asking if it's done yet.
|
||||
</p>
|
||||
<p>
|
||||
To enable notifications, I write to a special descriptor (UUID <code>0x2902</code>) on the characteristic, basically flipping a switch that says "notify me when data changes." Once enabled, data started flooding in at hundreds of packets per second. This is a fundamental aspect of the Bluetooth protocol. One devices changes the behavior of another and they agree to it. This is the "handshake".
|
||||
</p>
|
||||
<p>
|
||||
One last note: there are several modes on this controller. Remember back to the other subdevices in the HID report? There's a "lizard mode" (<a href='https://steen.run/blog/SteamController/P8Q4WHD.png'>named for the amygdala </a>being the lizard part of the brain), which is fallback keyboard mouse behavior for maximum compatibility. Lizard mode only has 19 bytes per bytestream instead of 64. Since I was no longer using the earlier method, it required a different approach. They require inputs from the connecting device. This also enables gyroscope and some other features, but this blogpost is getting too long already. Let's just revel in the results.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Network Reliability:</strong> Never assume the network is stable. Implementing robust reconnection
|
||||
logic and conflict resolution for offline edits proved essential for a production-ready system.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>State Management:</strong> Keeping client and server state synchronized requires careful attention
|
||||
to edge cases. The operational transformation algorithm must handle not just simple concurrent edits, but
|
||||
also complex scenarios involving multiple clients making rapid changes.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>User Experience:</strong> The technical implementation, no matter how elegant, means nothing if
|
||||
users experience lag or data loss. Optimizing for perceived performance was as important as actual performance.
|
||||
</p>
|
||||
|
||||
<div class="deco-divider">
|
||||
<div class="deco-divider">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
|
||||
<h2>Future Enhancements</h2>
|
||||
<p>
|
||||
While the current implementation is robust, several exciting enhancements are planned for future iterations:
|
||||
</p>
|
||||
<h2>Results</h2>
|
||||
<p>
|
||||
<video width='720' height='1280' controls>
|
||||
<source src='20251104_153705.mp4' type='video/mp4'>
|
||||
(actual video of the motor spinning)
|
||||
</video>
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>End-to-end encryption for sensitive documents</li>
|
||||
<li>Voice and video integration for enhanced collaboration</li>
|
||||
<li>AI-powered suggestions and auto-completion</li>
|
||||
<li>Mobile app support with native performance</li>
|
||||
<li>Advanced permission systems for enterprise deployments</li>
|
||||
</ul>
|
||||
<div class="deco-divider">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
This project demonstrates that building truly collaborative software requires deep understanding of
|
||||
distributed systems, careful attention to user experience, and thoughtful architecture decisions. The
|
||||
result is a system that feels magical to users while being built on solid engineering principles.
|
||||
</p>
|
||||
</article>
|
||||
<h2>Future prospects</h2>
|
||||
<p>
|
||||
I have no intentions of making a 2x4 fly, which I don't quite think it would, but it would be interesting to see. Nonetheless, I would need some sort of battery and Power Management System. Currently I have it hooked up to a bench power supply that isn't very mobile. To create a custom, bespoke frame would be ideal. For this, I would need a 3d printer of some sort.
|
||||
</p>
|
||||
<p>
|
||||
It would be interesting to see a 2 propeller design in action. I can't say I know a lot about aeronautics, but it would be neat to try. Most commercial drones have 4 propellers, which gives them more mobility, safety, and finer control of things like attitude. But the complexity of just two motors and calibrating the controls to allow for movement would be enough for me at this moment. Even with my limited knowledge of flight, I know that the direction is a function of differentials between the propellers. Between two, I can comprehend that. Between more, calculating the thrust differentials between motors would start to get more complicated.
|
||||
</p>
|
||||
<p>
|
||||
It would also be nice to add more sensors to it. Gyroscopes to allow for stabilization and calculate acceleration. Lidar or Sonar sensors to detect height and avoid any airspace violations. MAVLink modules to communicate over cell towers. Maybe even a camera for a flight path visuals or obstruction detection and avoidance.
|
||||
</p>
|
||||
<p>
|
||||
This could also be used for building some sort of RC car. All of this could very easily carry over. It would require more mechanical knowledge of actual gears and some material design so when I eventually ram the car into something, it doesn't shatter my whole project.
|
||||
</p>
|
||||
|
||||
<div class="deco-divider">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
|
||||
<h2>Update</h2>
|
||||
<p>
|
||||
Just announced the other day: Valve has released a new Steam Controller. Unfortunately, it looks like they will be calling it just the Steam Controller and not the Steam Controller 2, which will make it hard to track down any documentation for mine. I'll have to make an archive of everything I can now so I don't lose it.
|
||||
</p>
|
||||
<p>
|
||||
But on the brighter side, the new controller looks great. They seem to be copying the design of the Steam Deck and using much more concise touchpads, and 2 joysticks, so you can always have the default option. Instead of just two grip inputs, there's now 4 with some captivate touch features. It seems like this might be similar to the finger sensors found in their VR controllers that allow for independent movement of 4 fingers.
|
||||
</p>
|
||||
<p>
|
||||
I don't actually have any desire to buy one, but I would jump at the opportunity to write another blog post where I reverse engineer all the features, especially the ones I didn't get to touch on in this one. It looks like a solid piece of hardware pushing controller technology into the future.
|
||||
</p>
|
||||
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,620 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Article - Tech Blog</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--deep-navy: #0A1128;
|
||||
--rich-gold: #D4AF37;
|
||||
--warm-gold: #F4E5C2;
|
||||
--cream: #FFF8E7;
|
||||
--charcoal: #2C2C2C;
|
||||
--silver: #C0C0C0;
|
||||
--burgundy: #7C2D37;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Didot', 'Bodoni MT', 'Playfair Display', Georgia, serif;
|
||||
line-height: 1.8;
|
||||
color: var(--charcoal);
|
||||
background: linear-gradient(180deg, var(--deep-navy) 0%, #1a2847 100%);
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Art Deco geometric background pattern */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
repeating-linear-gradient(45deg, transparent, transparent 35px, rgba(212, 175, 55, 0.03) 35px, rgba(212, 175, 55, 0.03) 70px),
|
||||
repeating-linear-gradient(-45deg, transparent, transparent 35px, rgba(212, 175, 55, 0.03) 35px, rgba(212, 175, 55, 0.03) 70px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
nav {
|
||||
background: rgba(10, 17, 40, 0.95);
|
||||
border-bottom: 2px solid var(--rich-gold);
|
||||
padding: 1.5rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
color: var(--warm-gold);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
color: var(--silver);
|
||||
text-decoration: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.nav-back:hover {
|
||||
color: var(--rich-gold);
|
||||
}
|
||||
|
||||
.nav-back::before {
|
||||
content: '←';
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Article Container */
|
||||
.article-container {
|
||||
max-width: 1000px;
|
||||
margin: 4rem auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
/* Article Header */
|
||||
.article-header {
|
||||
background: var(--cream);
|
||||
padding: 4rem 4rem 3rem;
|
||||
border: 2px solid var(--rich-gold);
|
||||
position: relative;
|
||||
margin-bottom: 3rem;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Corner decorations */
|
||||
.article-header::before,
|
||||
.article-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid var(--rich-gold);
|
||||
}
|
||||
|
||||
.article-header::before {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.article-header::after {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.article-number {
|
||||
font-size: 4rem;
|
||||
font-weight: 300;
|
||||
color: var(--rich-gold);
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.article-category {
|
||||
color: var(--burgundy);
|
||||
font-weight: 700;
|
||||
position: relative;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.article-category::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--rich-gold);
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--deep-navy);
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.article-subtitle {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.6;
|
||||
color: var(--charcoal);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Tech Stack */
|
||||
.tech-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid var(--rich-gold);
|
||||
}
|
||||
|
||||
.tech-item {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--deep-navy);
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, rgba(212, 175, 55, 0.15), rgba(212, 175, 55, 0.05));
|
||||
border-left: 3px solid var(--rich-gold);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Featured Image */
|
||||
.featured-image {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
background: linear-gradient(135deg, var(--deep-navy) 0%, var(--burgundy) 100%);
|
||||
border: 2px solid var(--rich-gold);
|
||||
margin-bottom: 3rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Sunburst pattern on image */
|
||||
.featured-image::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
rgba(212, 175, 55, 0.1) 0deg 10deg,
|
||||
transparent 10deg 20deg
|
||||
);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 6rem;
|
||||
filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
/* Article Content */
|
||||
.article-content {
|
||||
background: var(--cream);
|
||||
padding: 4rem;
|
||||
border: 2px solid var(--rich-gold);
|
||||
position: relative;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Corner decorations for content */
|
||||
.article-content::before,
|
||||
.article-content::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid var(--rich-gold);
|
||||
}
|
||||
|
||||
.article-content::before {
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.article-content::after {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.article-content h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 400;
|
||||
color: var(--deep-navy);
|
||||
margin: 3rem 0 1.5rem 0;
|
||||
letter-spacing: 0.02em;
|
||||
position: relative;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.article-content h2::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 80px;
|
||||
height: 2px;
|
||||
background: var(--rich-gold);
|
||||
}
|
||||
|
||||
.article-content h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.article-content h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--burgundy);
|
||||
margin: 2.5rem 0 1rem 0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.article-content p {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.9;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--charcoal);
|
||||
}
|
||||
|
||||
.article-content ul,
|
||||
.article-content ol {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.9;
|
||||
margin: 1.5rem 0 1.5rem 2rem;
|
||||
color: var(--charcoal);
|
||||
}
|
||||
|
||||
.article-content li {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.article-content ul li::marker {
|
||||
color: var(--rich-gold);
|
||||
}
|
||||
|
||||
.article-content code {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--burgundy);
|
||||
}
|
||||
|
||||
.article-content pre {
|
||||
background: var(--charcoal);
|
||||
color: var(--warm-gold);
|
||||
padding: 1.5rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 2rem 0;
|
||||
border-left: 4px solid var(--rich-gold);
|
||||
}
|
||||
|
||||
.article-content pre code {
|
||||
background: none;
|
||||
color: var(--warm-gold);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.article-content blockquote {
|
||||
border-left: 4px solid var(--rich-gold);
|
||||
padding-left: 2rem;
|
||||
margin: 2rem 0;
|
||||
font-style: italic;
|
||||
color: var(--burgundy);
|
||||
background: linear-gradient(90deg, rgba(212, 175, 55, 0.05), transparent);
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
/* Inline images */
|
||||
.article-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 2rem 0;
|
||||
border: 2px solid var(--rich-gold);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Decorative divider */
|
||||
.deco-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 3rem 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.deco-divider::before,
|
||||
.deco-divider::after {
|
||||
content: '';
|
||||
width: 100px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--rich-gold), transparent);
|
||||
}
|
||||
|
||||
.deco-divider .diamond {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--rich-gold);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
color: var(--silver);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.footer-ornament {
|
||||
width: 200px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--rich-gold), transparent);
|
||||
margin: 0 auto 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.footer-ornament::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--rich-gold);
|
||||
transform: rotate(45deg);
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.article-header,
|
||||
.article-content {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.article-number {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.featured-image {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.article-container {
|
||||
padding: 0 1rem;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav>
|
||||
<div class="nav-content">
|
||||
<a href="#" class="nav-logo">Steam Controller Driver</a>
|
||||
<a href="https://steen.run/blog" class="nav-back">Back to Projects</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Article Container -->
|
||||
<div class="article-container">
|
||||
|
||||
<!-- Article Header -->
|
||||
<header class="article-header">
|
||||
<div class="article-number">04</div>
|
||||
<div class="article-meta">
|
||||
<span class="article-category">Hardware</span>
|
||||
<span>November 2025</span>
|
||||
</div>
|
||||
<h1 class="article-title">Steam Controller Driver for Actual Motors</h1>
|
||||
<p class="article-subtitle">
|
||||
lorem ipsum
|
||||
</p>
|
||||
<div class="tech-stack">
|
||||
<div class="tech-item">React</div>
|
||||
<div class="tech-item">Node.js</div>
|
||||
<div class="tech-item">WebSocket</div>
|
||||
<div class="tech-item">MongoDB</div>
|
||||
<div class="tech-item">Redis</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Featured Image -->
|
||||
<div class="featured-image">
|
||||
<div class="image-placeholder">⚡</div>
|
||||
</div>
|
||||
|
||||
<!-- Article Content -->
|
||||
<article class="article-content">
|
||||
<h2>Backstory</h2>
|
||||
<p>
|
||||
I was cleaning out a closet and I discovered an old Steam Controller I bought about a decade ago. To my surprise, it still turned on. Way to go, Duracell. I remembered it being a pretty niche thing that had very little adoption and was quickly deprecated by Valve. Boy was I wrong. I turned it on and immediately remembered how amazing the haptic feedback was on the touchpads. How could I forget the grip inputs? Even a gyroscope! The design and hardware were top notch, but adoption was the problem.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The haptic feedback touchpads made Games like Civ and XCOM playable on the couch. But I had my eyes on something much bigger target that could use a controller with fourteen buttons, six directional axes, two analog triggers, and a gryoscope: Flight Simulator. With traditional controllers, you had to have a plethora of different button configurations to make it work. Joysticks make it hard to sense and control your aircraft. Touchpads allow for refined movement with feedback to help you ramp up and down with ease.
|
||||
</p>
|
||||
|
||||
<div class="deco-divider">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
|
||||
<h2>Flight Simulator</h2>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum eget tortor massa. Sed sed nulla vitae enim accumsan pulvinar fermentum nec nunc. Phasellus scelerisque sem vel massa tempus, id lobortis arcu mattis. Donec sed lectus cursus libero auctor facilisis. Donec mattis luctus congue. Maecenas et nunc odio. Sed eu diam lectus. Phasellus sit amet mi vitae lacus rhoncus elementum. Nullam dictum justo sed dignissim placerat. Pellentesque porta velit vel luctus efficitur.
|
||||
</p>
|
||||
|
||||
<h3>WebSocket Server</h3>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum eget tortor massa. Sed sed nulla vitae enim accumsan pulvinar fermentum nec nunc. Phasellus scelerisque sem vel massa tempus, id lobortis arcu mattis. Donec sed lectus cursus libero auctor facilisis. Donec mattis luctus congue. Maecenas et nunc odio. Sed eu diam lectus. Phasellus sit amet mi vitae lacus rhoncus elementum. Nullam dictum justo sed dignissim placerat. Pellentesque porta velit vel luctus efficitur.
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>Handles client connections and disconnections gracefully</li>
|
||||
<li>Broadcasts operations to all connected clients in a room</li>
|
||||
<li>Implements heartbeat mechanisms to detect stale connections</li>
|
||||
<li>Scales horizontally using Redis pub/sub for multi-server deployments</li>
|
||||
</ul>
|
||||
|
||||
<div class="deco-divider">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
<h2>Physical Applications</h2>
|
||||
<h3>Parsing the Byte Stream</h3>
|
||||
<p>
|
||||
If I want to make a driver for this controller, I need to see what the output is. Controllers are just like a keyboard: character devices. When you type an "a" character into your keyboard, the keyboard sends data for an "a" key to the computer. When you press an "a" button on your controller, it does just about the same thing. It sends data, which in this case is 41 in hex or 01000010 in binary, to the computer and it is handled by the OS, which monitors for any special inputs, before sending it to the application you're using. This happens so quickly, we don't even think about it. It's a little more complicated than that, but I'll explain later. What I need to do is reverse engineer what data the inputs on my controller output and write a driver to turn those inputs into actions.
|
||||
</p>
|
||||
<p>
|
||||
Knowing my controller is a character device, I can "read" it just like a file, but I have to jump through a couple hoops first. I don't know what that actual file is yet. I'm going to have to check around. When you plug a device into a computer running an OS, it has to decide what to do with it. Devices that already have their firmware configured will tell the OS what to do with it, usually through the Human Interface Device (HID) standard. So I need to ask the OS where it put the file.
|
||||
</p>
|
||||
<pre><code>$ lsusb
|
||||
Bus 003 Device 022: ID 28de:1102 Valve Software Wired Controller</code></pre>
|
||||
<p>
|
||||
This gives us the Bus number, Device number, and both the Device ID and Vendor ID. The bus and device number are abstractions for userspace created by udev. In this instance they seem very redundant and abstract away from what I'm trying to accomplish. What I am trying to do is take the raw input from the device at a kernel level, so I need to ask udev where it's getting this device from.
|
||||
<pre><code>$ udevadm info -q path -n /dev/bus/usb/003/022
|
||||
/sys/devices/pci0000:00/0000:00:14.0/usb3/3-7</pre></code>
|
||||
This shows us where the kernel has put the device. We can go into this series of directories and find it. Problem solved! Almost.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
In this case, with the steam controller, it actually has 3 subdevices, each with their own hidraw file. As it turns out, the controller can also be used in place of a mouse and keyboard. One could poke around the files some more to differentiate them, but I found a better solution.
|
||||
<pre><code>sudo usbhid-dump -m 28de:1102 -ed > descriptor.txt
|
||||
grep -A 10 "DESCRIPTOR" descriptor.txt | grep -v "DESCRIPTOR" | grep -v "STREAM" | tr -d ' \n' > descriptor_hex.txt
|
||||
rexx rd.rex -d --hex "$(cat descriptor_hex.txt)"</pre></code>
|
||||
<pre><code>=== /dev/hidraw6 ===
|
||||
Name: Valve Software Wired Controller
|
||||
Vendor:Product: 0x28de:0x1102
|
||||
Bus type: 3
|
||||
Physical: usb-0000:00:14.0-7/input0
|
||||
Descriptor size: 65 bytes
|
||||
Descriptor (first 32 bytes): 05 01 09 06 95 01 A1 01 05 07 19 E0 29 E7 15 00 25 01 75 01 95 08 81 02 95 01 75 08 81 01 95 05
|
||||
Type hints: [KEYBOARD]
|
||||
|
||||
=== /dev/hidraw7 ===
|
||||
Name: Valve Software Wired Controller
|
||||
Vendor:Product: 0x28de:0x1102
|
||||
Bus type: 3
|
||||
Physical: usb-0000:00:14.0-7/input1
|
||||
Descriptor size: 52 bytes
|
||||
Descriptor (first 32 bytes): 05 01 09 02 A1 01 09 01 A1 00 05 09 19 01 29 05 15 00 25 01 95 05 75 01 81 02 95 01 75 03 81 01
|
||||
Type hints: [MOUSE]
|
||||
|
||||
=== /dev/hidraw8 ===
|
||||
Name: Valve Software Wired Controller
|
||||
Vendor:Product: 0x28de:0x1102
|
||||
Bus type: 3
|
||||
Physical: usb-0000:00:14.0-7/input2
|
||||
Descriptor size: 33 bytes
|
||||
Descriptor (first 32 bytes): 06 00 FF 09 01 A1 01 15 00 26 FF 00 75 08 95 40 09 01 81 02 95 40 09 01 91 02 95 40 09 01 B1 02 </pre></code>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I found a tool called <i>RDD! HID Report Descriptor Decoder</i> on github by <a href="https://github.com/abend0c1/hidrdd">abend0c1</a>. It takes the data from usbhid-dump and performs lookups on all the devices given. It's a very comprehensive tool that gave much more output than I listed.
|
||||
</p>
|
||||
<p>
|
||||
|
||||
</p>
|
||||
<p>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<div class="footer-ornament"></div>
|
||||
<p>Crafted with Precision & Elegance © 2025</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue