nuxx.net
Making, baking, and (un-)breaking things in Southeast Michigan.

Category making things

Updating the HDMI-CEC and Onkyo RI to Home Assistant Bridge

Chip, laying on Kristen’s lap, in the living room.

A couple years after putting together the HDMI-CEC to Onkyo RI Bridge I decided to give it a bit of an update and subtle name change. There were a couple problems that I wanted to fix:

  • The PR for adding Onkyo RI support to ESPHome (#7117) worked, but it didn’t get much attention, fell behind, and it’d been rejected when it aged out. So, a new ESPHome build couldn’t use it.
  • Occasionally, usually in the middle of the night, the Home Assistant automation keying off of the Apple TV waking up would turn on the lights in the living room. The Apple TV seemed to wake a little bit for a firmware check, and a false positive in my HDMI-CEC watcher would trigger on this, turning on the lights and receiver.
  • Onkyo receivers also send data out the RI port, but I hadn’t tested receiving data from a device.

The name was also changed from “HDMI-CEC to Onkyo RI” to “HDMI-CEC and Onkyo RI” to better reflect that this bridges these two separate things into Home Assistant (HA). Originally I had the device itself turning the receiver on (via RI) after hearing a certain CEC command (no HA needed), but for flexibility I moved this logic into HA. The name change reflects that it doesn’t do the bridging itself and is really two separate functions that just happen to run on the same ESPHome device.

To handle this I broke it down into a few pieces:

  1. Fix/update/resubmit the Onkyo RI support in ESPHome.
  2. Refine what HDMI-CEC commands are considered to be the Apple TV waking up.
  3. Rewrite/improve the ESPHome YAML.
  4. Update Home Assistant (HA) to use these new features.

Again, leaning hard on an AI coding assistant† I started with building an HDMI-CEC sniffer in ESPHome, collecting a few days of traffic on the CEC bus, and built a good understanding of what the communication actually looks like.

The HDMI-CEC sniffer is here: sniffer.yaml (Note: Completely AI generated test code.)

Next I worked with the agent to modernize my previous ESPHome Onkyo RI code for the current version of the platform, tested it in send and receive configurations, and submitted a new PR (#18585). This PR is much more robust than the last one, even including a hand-drawn schematic showing my hardware test setup. I intentionally did this using AI as a programming partner, leaning on it for code logic and consistency and testing, but doing the architecting and validation and and git/GitHub stuff by hand.

Test Transmit and Receive hardware for Onkyo RI integration in ESPHome.

Along the way I took a detour to inspect all commands that came out of the RI port when pressing the buttons on the RC-830S remote that came with the A-9050 receiver. Unfortunately, what I realized is that RI is more complicated than just a remote control command. I thought the RI port might re-transmit what the remote sends, but I was very wrong.

As (in hindsight is obvious), RI is an inter-device protocol for managing a home theater system. The remote is meant to talk to individual device. Supported (in function and expectation) by the RI mode switches in the back, parts of the remote might only trigger RI signals when certain inputs are selected, inputs might vary (or not cause) RI output, or surprisingly some buttons don’t cause anything to be sent. Or others work the same all the time.

Having set out to only update my current hardware to work better, I opted to not add any read-RI-into-HA stuff for now. There’d be a lot of complexity to unwind before it’d be usable, and I don’t have good use for it, so I dropped the idea. But at least the software support is there and tested.

(You can download the Onkyo RI transmit and receive tests for ESPHome here: ri-transmit-test.yaml / ri-receive-test.yaml. Disclaimer: These are both completely AI generated development test harnesses.)

With these two pieces — new understanding of HDMI-CEC and a freshened-up Onkyo RI component — I was then able to build a new config for the Seeed Studio XIAO ESP32S3-based hardware I already have in place. And along the way a previous bug I’d encountered with Over-The-Air (OTA) updates to ESPHome failing on this module got fixed, which is a nice side effect. Now it’ll be much easier to try out changes.

I’m pretty happy with this. The result is an expanded/modernized device that nicely communicates with modern (HDMI-CEC) and old (Onkyo RI serial port) home entertainment stuff, logically coupled together with ESPHome and Home Assistant. There’s modern code for RI in HA, and while the PR is still waiting to be merged, I’m hopeful. Going off the test results and AI feedback on the code quality I’m much more hopeful this time.

The updated ESPHome code is here: seeed-xiao-esp32s3_onkyo-a-9050_v2.1.0.yaml

Home Assistant turning on the Onkyo RI receiver and living room light grouping after receiving an HDMI-CEC event.

Currently the typical usage flow is:

  • Push button on Apple TV to wake it.
  • Apple TV wakes Vizio TV.
  • ESPHome sees this happen via HDMI-CEC and informs Home Assistant.
  • Home Assistant turns on the receiver via Onkyo RI and some lights.
  • Then later, when the Apple TV goes to sleep (automatic or manual), ESPHome sees this, notifies HA, and an inverse flow happens.

In the future I might begin looking at waking or sleeping the system remotely by sending HDMI-CEC commands. This eluded me before, but with some automated analysis of traffic on the CEC bus, I might be able to make it happen. Maybe it’d be nice to have the system wake up remotely, perhaps as part of a lets-watch-TV automation? Even if I don’t do this in the end, it would at least be nice to know how…

I might also look at ingesting commands sent out by RI, but this goes beyond the receive-only test and will both require new hardware on the ESP32 side (to handle send and receive on the same pair of wires) and digging into the RI complexity, so… probably less likely.

Using This in Home Assistant

With the HDMI-CEC triggers for the logic being in Home Assistant, and as part of handling the random wakes (receiver/lights turning on), I needed tighter triggers in Home Assistant. Here’s what’s now used used, specific CEC frames:

triggers:
  - trigger: event
    event_type: esphome.hdmi_cec_frame
    event_data:
      source: '4'
      opcode: '82'
      payload: '50:00'
    id: system_on
  - trigger: event
    event_type: esphome.hdmi_cec_frame
    event_data:
      source: '4'
      opcode: '90'
      payload: '01'
    id: system_off

Specifically, this looks for CEC frames that are from source 4 (the Apple TV, a playback device).

The system_on event is the Active Source opcode (82), from physical address 5.0.0.0 (50:00) or the Apple TV’s HDMI port. Or, otherwise stated, a playback device set itself as the active source of content, and is connected to port 5.

The system_off event is the Report Power Status opcode (90), with Standby power status (01): the same playback device went into standby.

This works for my setup, but be aware that it might not work for you.

With things plugged in differently, or possibly with different devices, different addresses are likely needed. For example, if I added another playback device and added it the source could change. If plugged into a different port, the physical address (payload) of the Apple TV would change from 5.0.0.0.

To use this yourself you’ll want to inspect the HDMI-CEC frames (look at turning logger level: to DEBUG) and using esphome logs) to see what’s on your bus before writing the automation. LLMs (AI) are really good at helping with this kind of protocol analysis.


† Claude Opus 5. Even though it’s the results that matter, part of me still feels compelled to mention every time I use AI tools to develop something. Yet I don’t mention using search engines, IDEs, compilers, etc… And yes, this entire blog post, comments in the main ESPHome YAML, and the PR was written by hand, just by me.

around the housecomputerselectronicsmaking things

A Bicycle Troubleshooting Process Example

Slightly bent tooth on my Pivot Trail 429.

I like troubleshooting things. It’s basically what I do at my day job, and I’m kinda good at it. Sometimes I’m asked for illustrations of how I troubleshoot stuff, so I’m sharing this example of figuring out a noise on my Pivot Trail 429‘s cassette that cropped up yesterday when riding at Poto with some friends.

I visualize this sort of troubleshooting as being shaped like a funnel. At first everything’s really broad and big and open, but with each step I (hope to) narrow things further and further until the potential is super narrow and pointing at the actual problem.

When riding last night the noise started out sounding like a shifting problem. I didn’t feel it in the pedals (so it wasn’t ghost shifting), but it was an irritating click noise that seemed to only happen in my most frequently used gear. So, I kept riding, gathering a bit of data, so I could solve it later. Here’s the whole process broken down by question and finding.

Q: What gear does this happen in?
A: Only the fourth from the largest cog.
Clues: Something might be wrong with this cog. Since it doesn’t affect other gears, it’s probably not cable tension (this affects shifting across all cogs). It could be a bent derailleur hanger, but this is a plastic UDH, and the problem didn’t happen when I last rode the bike in June, so it’s probably not that.

The next day I put the bike in the work stand in the basement and got to looking at the problem.

Q: Where is the noise coming from?
A: Cassette/chain.
Clue: This means it’s probably not a jockey wheel / derailleur issue.

Q: Does the problem cog have an even or odd number of teeth?
A: Even.
Note: With an even-toothed cog, teeth always align with inner/outer plates on each revolution. With odd-toothed cogs, they alternate. Therefore on an even-toothed cog you may need to shift around the cassette and back before the issue will reproduce.

Q: Pedaling gently — forward and backward — and looking at the chain, does it jump or skip?
A: No.
Clue: This means it’s probably not a chain / stuck link problem. (Note that this chain is waxed, and it is a mountain bike, so after damp rides stuck links and corrosion can set in quickly.)

Q: When slowly pedaling backwards, does the noise happen?
A: No.
Clue: Must be pedaled forward to repro. This points further to a tooth issue.

Q: Slowly pedal forward, watching the chain links as they mesh with the cassette. Did this happen at a particular tooth?
A: Yes. Mark it with a sharpie.
Clue: Great! Now we know exactly which tooth. This could be the problem…

Q: Does it happen only when that tooth is meshing with an inner or outer plate?
A: Yes, inner.
Clue: This tells me the tooth might be catching on the narrower plate.

Q: Shift a few rings down the cassette to get the chain out of the way, then inspect and feel tooth for barbs/damage.
A: None found.
Clue: Could be a bent/misaligned tooth. If barb/burr/damage was found, that could cause the catching/noise.

Q: Using a straight-edge and your eyes, see if the overall cog is bent at this point.
A: Doesn’t seem to be. But tip of tooth does look a little bit out of line with the others.
Clue: Tooth could have become slightly bent… This can happen with mis-shifts, debris, etc.

Action: Use a flat blade screwdriver braced against the inner part of the smaller cog to gently nudge the tip of this tooth inboard. (Not even enough to be visible.)

Q: Shifting back to this cog, then shifting around a bunch, can the issue be reproduced anymore?
A: Nope! Seems like this sorted it out.

And… that’s it. Of course the issue shouldn’t be considered completely solved until after a test ride, but a simple change eliminating a noise is promising.

cyclingmaking things

nuxx-spoiler: The WordPress Plugin

The idea of spoilers, and trying to avoid them, for everything from fiction to time-delayed sports matches, is nothing new. Discord and Reddit have special features allowing text and images to be hidden with a spoiler tag so users must click to show the content. I’m planning a post about the things I learned while healing my scraped leg (resulting from pushing a worn out bike tire too hard), and I realized that it’d be good to both show pictures of the wound and healing progress and that a spoiler-like tag would be useful for obscuring some of the images.

After looking around for a WordPress Plugin I couldn’t find any good options for this. There’s some old and abandoned plugins, there are others which obscure text, some parts of larger plugins, but no stand-alone “spoiler” plugin. Knowing what I wanted I fired up my currently-preferred AI coding assistant (Claude) and got it working on what I wanted.

After a day or so of back-and-forth, this is what I came up with: c0nsumer/nuxx-spoiler

This WordPress plugin allows a spoiler tag to be added to any image, text, or group of blocks, hiding the content with a configurable warning about what it contains. Clicking the warning or overlay then shows what’s behind it; very very similar to Discord’s Spoiler Tags. Below the fold I’ve a number of examples (this also serves as a test post) showing hidden text, hidden individual images, hidden blocks (such as a gallery of images) with the spoiler type (configurable or canned) listed over images. And it’s all nicely integrated with the WordPress editor (as compared to some older schemes that required using code for tagging.)

I’m considering if I’ll submit this to the standard WordPress Plugin repository, but for now I think that keeping it over in GitHub and used here will be easier/best. This way others can grab and use it if they want, but I don’t need to deal with the overhead of supporting a plugin that I don’t have a deep care for about and put minimal (AI) effort into. (If I do this I’ll need a better name, slug, etc as well. I don’t really like the nuxx-spoiler name, but it meets the needs.)

Continue reading: Spoiler Examples
making thingsnuxx.net

KICKR CORE 2 Tennis Ball Feet

Tennis ball rocker feet on my KICKR CORE 2.

Last winter, after getting a Wahoo KICKR CORE 2 trainer and designing some variable height feet to level the bike I kept thinking about tennis ball feet. These provide a bit of give, allowing the trainer to rock and pitch a bit, reportedly making it more comfortable to ride. (And yes, for some reason I sat on this post for half a year before finishing it up.)

I’ve never really noticed an issue with the static positioning of any of my trainers, but Kristen purchased a KOM Cycling Indoor Rocker Plate RPV1 while getting ready for AIDS/LifeCycle in 2021/2022 and now finds she can’t really ride indoors without it. Her experience, and hearing nice things about tennis ball feet from others, has made me wonder how something similar might be for me.

There’s a whole bunch of tennis ball feet available for trainers. From the well-engineered ones that my friend Jake / Cycl3dCo makes and sells to a bunch of free designs on Printables and MakerWorld, there’s… a lot of options. Mostly because I couldn’t find one that was low enough profile to not require a riser block for the front tire (the inverse of the problem which prompted my trainer foot design), a bit of late-December poor weather boredom, wanting to get better at drawing stuff, and perhaps a bit of NIH, I decided to try designing my own.

Detail of four of my KICKR CORE 2 low profile tennis ball rocker feet.

Here’s what I came up with. These are yet-another-set-of tennis ball feet, but they are designed so that with a 700c x 32mm front tire on the bike the axles are level during riding, and they replace the stock rubber feet and are held in place with the original retention screws.

I restarted this design numerous times over a few days and learned a lot about constraints and what not to do. I was originally going to make this a parametric design like the variable height feet, but realized it’s not as useful and stuck to a single height. This also allowed me to take some shortcuts in constraining drawings… Not the best practice, but not horrible for what I was doing.

The final items were printed using the same filament and profile as the Riser Feet (Overture PETG in black, 6 walls, 25% gyroid infill, printed hot and very slow), and they came out nicely. Because of the quantity of filament needed (estimated ~529g) I started on one spool and used the AMS to auto-swap to the second when the first ran out. I was a bit nervous about how this would go, but it ended up working out wonderfully. While the second spool, purchased about two month after the first, had a more matte look to it, but it looked good once melted and deposited. My concerns about print imperfections during the swap ended up being for naught.

A very slow print job. This took ~34 hours.

I’m fairly certain I both designed and printed these more robustly (and slowly) than is needed, but as a beginner I’d rather err on the side of overbuilt (and spending a bit more on plastic) than have something which breaks and interrupts a ride.

I’ve made this design available under the CC BY-SA-NC 4.0 license and posted it here at Printables.

Included are .f3d, .step, .stl, and Bambu Studio .3mf files. The .3mf is as I printed it, including the print profile and imported .step files. (The .stl from Fusion, even exported at the High setting, end up lower resolution than I prefer, but was included for completeness.)

So how do they it feel to ride? Well, to be honest, they did what I thought they would and added some flex and give. But I’m not completely sure I like it. Just before spring I went back to my previous self-designed riser feet. Next winter I’ll give these another go, though.

A big, big thanks to Jake for providing a bunch of help and suggestions. He has a ton of experience designing parts professionally and printing them for both prototype work and production and he shared a bunch of tips, tricks, and information that got me pointed in the right direction for this and many other designs.

If you are looking to buy some tennis ball feet or other Michigan-made accessories for your trainer, give Jake your business over at Cycle3dCo.

(As I was finishing up my design and waiting on a test print, I came across Kickr Core 2 Rocker Feet for tennisballs by punkti over at MakerWorld. A quick measure showed they have basically the same vertical as my feet, are physically narrower, but thicker. These likely would have sufficed and use roughly the same amount of filament, but I was nearly ready to do a final print when I found these, wanted to give my own design a try, didn’t really care for the appearance. These do look like a nice option, though.)

cyclingmaking things

Iterating…

Six iterations, from initial to final, of slip-fit stove knob lockouts.

Even for a few-hour project, I find it interesting to look back at the various iterations of developing something. In this case it’s six different versions of a slip-fit shim that latches on to our stove’s knob shafts, preventing them from being pressed in turning the gas on or igniting. Kristen wanted these to help avoid accidental knob turns (from people, dogs, etc) and being stuck inside by extremely poor quality air from wildfires, it was today’s after-work pass-time.

I’m happy with where the idea ended up. My original idea was for something that pushed in from the bottom and clipped in place, but because of the D shaped knob shaft I ended up with something that’s slipped in horizontally, and as it falls/is turned to a natural position with the tab down, it sticks in place. Along the way I screwed up the thickness, tried making things smaller, then smaller still, then had to nudge the thickness, and then finally cut out a bunch of the inside to use less plastic.

The first five were prototyped in PLA, and for the final items I printed them in PETG. Although I think the plastic might have been a bit damp as it was stringy… Still, it was easy enough to clean up and fine for this.

The models can be found here on Printables, if you’re interested. Or I’ve got a simple demonstration video of it here.

around the housemaking things

trailmaps.app + Map Generator

trailmaps.app Website on 2026-Jun-25

I’ve been using it for a while now, so I guess it’s a good time to announce the revamp / relaunch / whatever of trailmaps.app. This is a personal project website which started one frosty January morning as I sat in an Ishpeming rental waiting for temps to rise before heading out on a fatbike ride and is now a hub for hosting maps I’ve generated of various trail systems.

The site started out as a hand-written HTML landing page, hand-made Leaflet maps, and directory indexes of my trail map PDFs where I learned a bunch about showing OpenStreetMap (OSM) data via the web, generating tiles, etc (for example, see Making an Online RAMBA Trails Map from 2021). Now it’s now hosting feature-rich web-based maps that help people find their way around mountain bike trails.

The root of the idea was cooked up on a long drive; I wanted something akin to the subway-map-style’d official RAMBA print map (parallel lines over a single trail to illustrate the trail’s membership in multiple routes), but web based. And maybe usable on a phone. And after the early learning then quite a bit of work this spring… Here it is.

Instead of just doing a single map (one-map-at-a-time coding / tile generation / etc) as I had in the past, I now have a full-on map generator that takes data from OSM and other online/open/free sources, combines it all, and generates static content that’s easy to host and cachable so it works if a device loses cell service. It’s basically as close to an app as one can get while still staying web-based.

By being hand-curated (that is, not just auto-generated off of all OSM data) these maps also fill a long-standing gap with other online maps (eg: Trailforks, MTB Project, Strava, RideWithGPS) in that they don’t style (color) the routes the way official park maps and signage do, making what a rider sees on their phone challenging to align with what they see on a signpost next to a trail.

For example, compare these maps of the Shelden Trails at Stony Creek Metropark to the PDF of the official park map which is at the trailhead, and whose color-matched signs are along all loops.

  • Trailforks: Only difficulty colored.
  • MTB Project: Only difficulty colored.
  • Strava: Only dashed lines or a heatmap, unless someone’s created a route.
  • RideWithGPS: Just dashed green lines, unless someone’s created a route.

Then look at the trailmaps.app map of the Shelden Trails at Stony Creek Metropark, for example, the Beach trail.

There’s a bunch more features that this brought about, which I won’t dive into as much depth, but which I’m still quite proud / happy with. The end results are what I want in maps, and it’s nicely reusable:

  • Reusable map generation engine; I write a YAML description of the map (title, OSM references, info that can’t be found in OSM) and it makes the map. If the engine or data gets updated, re-run the map and/or website generation tools.
  • PWA (Progressive Web Apps), so they are installable app-ish, but without the app store overhead.
  • OSM data is not fetched live — it’s a snapshot taken map-generation time — meaning errant edits don’t break the map.
  • Zero user/usage tracking, including having all assets loaded from trailmaps.app. (I’m trying to support less and less online tracking while still providing a good tool.)
  • Ability to generate maps custom from non-OSM data, such as race or group ride routes.
  • Thorough, proper (read: non-shady) SEO such as OpenGraph previews and metadata, making link embedding, sharing, and site discovery by search engines work well.
  • Hostable for cheap since the only server requirements are TLS and RANGE requests. A $5/mo Nanode from Akamai (formerly Linode) easily does it all.
  • Stand-alone maps (each map is a self-contained site) makes it possible for them to be hosted elsewhere, such as if one was made for a trail club/org, etc.

And yes, I heavily used AI-assisted development for this. It was quite educational as since I knew the inputs and outputs, use the maps myself, and was able to do quite a bit of QA, the result is great. At my day job in IT there is (as typical) a huge emphasis in using AI tooling to assist us with our work. This served as a nights-and-weekends project that was quite educational and will benefit me in day-job stuff while achieving a personal goal of making something I wanted and useful for others. It also illustrated the interesting balance between what AI-generation is good at (code, bug finding) and what it’s not (wording, stylistic choices that aren’t simple clones, avoiding feature bloat).

I’m subsequently making the map generator itself available under the MIT license so others can use it. I do have an extensive toolchain for generating the website (takes a definition file and generates the maps, creates preview images, updates the index, rsync’s it to the server) but that part is staying closed / non-released because it’s very my-setup-specific.

cyclingmaking thingsmapping

local-heatmap-tile-server v1

local-heatmap-tile-server v1 showing Northern Michigan in Warm style and Light appearance.

During a long drive to (and from) Florida, and a lot of thinking about maps, I realized something that I really wanted, and something that I could use AI-assisted development to experiment with: generating a heatmap from all my personal, archived activity files. Specifically, generating XYZ tiles, making them available via TMS (so they can be used as an imagery layer in JOSM), and also displaying them on a slippy map.

For years I’ve been using the Strava heatmap as a layer in JOSM for OpenStreetMap (OSM) editing and this works great, but I’m finding myself disconnecting from online social networks, including Strava, more and more. And while the Strava Global heatmap does work as a data layer with a free account, I began thinking about other options to use it, and other cloud providers, less and less. And yes, there’s similar offerings from RideWithGPS and whatnot, but I really wanted to generate my own since it’d give me a lot more flexibility.

So, for my next project working with Claude, I decided to try building a personal heatmap generation tool. And it worked.

Cool heatmap of my ride and hike data, used as a layer in JOSM. (Ishpeming/Negaunee area.)

Using AI tools to develop software is nothing new, but I’ve never really been one to jump right on brand-new things, instead waiting for them to bake and show their utility before I dig in and use/learn them. I also find it very difficult to learn any tool or system unless I have a way to apply it. But when I do, getting my head around it comes pretty quickly.

In making this I’ve learned / found / finally-realized that with a known set of inputs, a desired output, an ability to identify/recognize bugs, and a task that’s known-possible, AI-assisted development saves can save incredible amount of time. Within reason it makes it possible for me to be more of a product manager than developer. Since I’m not really a developer (my career is in systems management and troubleshooting), that work for me is slow… and I’m not good at it.

Using Claude on the desktop to write the code, VS Code to read and make a few manual edits, and Docker Desktop so I could keep an eye on things, after about a week of free-time iterating, this is what I came up with, and I’m quite pleased:

c0nsumer/local-heatmap-tile-server

This is a single Docker container that uses a bunch of Python to import GPS data files (.FIT, .GPX, .TCX), imports, deduplicates, and renders a complete set of XYZ tiles. It then makes them available via HTTP (for display in a slippy map or something like JOSM) or exports them to a PMTiles file for simple hosting. And it has a built-in slippy map viewer/data manager and a couple bundled viewers for completely static hosting (example).

Dashboard for importing new files, stats, and exporting the heatmap for static use.

The Python webserver, uvicorn, isn’t the fastest nor great at caching, so the XYZ tiles are fronted with nginx to very quickly serve them from disk, only passing the request back to uvicorn and the Python stack for rendering if the tile isn’t present. Once the tiles are rendered they are cached very quickly served up solely by nginx, to the point where panning and zooming freely is seamless. (And yes, you can pre-render all tiles for optimal performance.)

It’s been tested on ~4000 track single-GPX files (exported from rubiTrack), ~4000 .FIT files directly from Garmin devices, and a bunch of different types of single GPX files. And… it seems to work!

The file inputs (FIT, TCX, GPX) aren’t special and parsers have existed for a long time. Nothing about heatmaps is new. Tile rendering isn’t new. Tile serving isn’t new. Nor are web-based heatmaps from fitness tracker data. But it needed to be glued together to get something that works this way, and this type of development made it possible. And I learned something new about AI-assisted software development along the way. It’s sure an interesting new world with these tools.

And yes, beyond thinking about the features I had to do a lot of nudging along the way.

Some major bugs that were encountered were getting cross-tile heatmap brightness correct, missing cross-tile data, tiles not rendering properly when called via different ways, moving to a faster web server so panning the map felt smooth, and a whole lot of tweaking of brightness and line thickness and blur and such at different zoom levels so it’d feel nice to use, noticing and dealing with malformed XML in GPXs…

But this was nudging via prompts and having a bit of an idea what it was doing, not coding. Which is what’s so weird and new to me. It’s like directing a team of pretty-decent junior devs.

And the end result is something I’ve wanted for a while. And now it exists. (And no, none of this post was written by any AI tool.)

computersmaking thingsmapping

Updated MTB Trail Mapping Workflow: Thanks, Claude!

River Bends Park OSM data, with DEM hillshade layer, ready for Adobe Illustrator

After a bunch of years I’ve updated my MTB trail mapping workflow with a much-improved tool for getting OpenStreetMap (OSM) data into Adobe Illustrator. I’ve been experimenting with AI development tools, and I’ve been looking for projects, and this one fit the bill.

My old workflow used osm2ai.pl, a rather crude script (which I found years ago) that’d take an OSM file and turn it into vectors that I’d then group and style in Illustrator. While the tool claimed to filter objects into layers, I never got this working right, so there was a lot of manual work before I could begin styling the map. Each map took a couple of hours solely selecting, joining, grouping, and deleting stuff.

With some time on my hands during a mountain biking trip I began prompting Claude Code, via Visual Studio Code, for something similar: a tool which would take OSM data and make it usable in Illustrator. After a bit of back and forth I ended up with this: c0nsumer/osm_to_ai.

I very intentionally had Claude write everything on this, from the script to the readme, and it seems to have been a success. A bit of experience was gained, and I now have a tool that’ll read in OSM data (either exported from tools or queried live) and produce an Illustrator-compatible SVG that has trails grouped by OSM tag, colored, etc. All ready to finish up in Illustrator. It even has an extra layer of USGS 3DEP hillshade data, something I’ve wanted for years after seeing it on the Noquemanon Trails Network maps (but didn’t know how to do in Illustrator).

This was both a good exercise in using AI tools to assist in simple software development and helped streamline my mapping process. While I have a general discomfort with AI-developed code ending up with potentially uncertain output, this output is immediately validated visually, so it’s fine.

Unplanned, but on quick check this seems to have the side-effect of being compatible with Affinity Designer. While this wasn’t (yet) an intention, I’ve been looking for a good way to move away from Illustrator due to software cost (this is volunteer stuff, after all) but the old osm2ai.pl needed replacing before I could do that. This will make that possible.

So what next? Maybe I’ll see if I can make the PDF maps geospatial. This has long been a goal of mine, as it’d allow my maps to be opened in something like Avenza Maps and they’d show one’s actual location on the trail. But for now, I’ll start here.

As an overview, here’s the --help output to show what it can do:

(venv) PS C:\Users\svigneau\Desktop\OSMtoAI> python .\osm_to_ai.py --help
usage: osm_to_ai.py [-h] (--file PATH | --bbox BBOX | --overpass FILE) --output PATH [--width PX] [--dem PATH] [--fetch-dem] [--dem-resolution METERS] [--sun-azimuth DEGREES]
                    [--sun-altitude DEGREES] [--save-osm PATH]

Convert OSM data to an Adobe Illustrator-compatible layered SVG.

options:
  -h, --help            show this help message and exit
  --file PATH           .osm file to read
  --bbox BBOX           Bounding box: min_lon,min_lat,max_lon,max_lat
  --overpass FILE       File containing an Overpass QL query
  --output PATH         Output .svg file
  --width PX            SVG width in pixels (height is auto-calculated, default: 800)
  --dem PATH            GeoTIFF DEM file to generate a hillshade layer (any CRS)
  --fetch-dem           Download a USGS 3DEP DEM automatically and use it for hillshade. Saves a sidecar .tif next to --output for reuse.
  --dem-resolution METERS
                        Target DEM pixel size in metres for --fetch-dem (default: 3). Use 1 for lidar-quality where available, 3 for 1/9 arc-second, 10 for 1/3 arc-second.
  --sun-azimuth DEGREES
                        Sun azimuth in degrees clockwise from north (default: 315 = NW)
  --sun-altitude DEGREES
                        Sun altitude above horizon in degrees (default: 45)
  --save-osm PATH       Save the downloaded OSM XML to a file for later reuse with --file

Examples:
  python osm_to_ai.py --file mypark.osm --output mypark.svg
  python osm_to_ai.py --bbox "-71.12,42.36,-71.10,42.38" --output mypark.svg
  python osm_to_ai.py --overpass query.overpassql --output mypark.svg
  python osm_to_ai.py --file mypark.osm --dem elevation.tif --output mypark.svg
  python osm_to_ai.py --file mypark.osm --fetch-dem --output mypark.svg
  python osm_to_ai.py --file mypark.osm --fetch-dem --sun-azimuth 270 --sun-altitude 35 --output mypark.svg

(venv) PS C:\Users\svigneau\Desktop\OSMtoAI>
making thingsmapping

Shimano Y0GX01500 (Adhesive Ring) Replacement

UHMW PE replacement ring applied to a CS-M8100-12 cassette.
Stock Y0GX01500 on a CS-M8100-12 cassette.

Many Shimano cassettes, such as the CS-M8100 (XT, 12 speed) have a thin adhesive ring (part number Y0GX01500) on the back side, where it sits against the Microspline freehub body.

Unfortunately, these can easily be lost as they tend to stay on the freehub body when removing the cassette. Which is exactly what happened when I sent the NOBL wheels from my Mach 4 SL‘s in for a warranty rim replacement. Some folks advocate for removing them, believing them to cause cassette wobble, but the main purpose seems to be eliminating noise and fretting between the cassette and freehub bodies.

Since I don’t like bike noises, I wanted another. They can be bought online for something like $9/ea before shipping, but that seems like a lot… So a better solution? Make one!

37mm x 33mm ring cut from UHMW PE on a Cricut.

Measuring a new ring on a spare cassette showed it to be 37mm OD x 33m ID, roughly 0.2mm thick. I have some 0.0115″ / ~0.29mm (Ultra High Molecular Weight Polyethylene (UHMW PE) tape from McMaster-Carr (part 76445A764) that I use for rub on bike frames, so that seems perfect. Kristen cut a ring out with her Cricut (with a Deep Point Blade, set to “thin cardboard”), I stuck it to the cassette, and that was that. Much better than spending $9 and waiting for it to arrive.

I had originally tried to print one with PETG filament, but when the first of two broke coming off the build plate I figured it probably wasn’t the right material and would come apart under load, leading to a loose cassette, noise, etc. UHMW PE tape is very malleable and often used to stop noise between rubbing parts, so it seemed like the better choice.

cyclingmaking things

OpenSCAD Is Kinda Neat

Designing a simple battery holder in OpenSCAD.

Earlier this year I designed a very basic box/organizer for AA and AAA batteries in Autodesk Fusion, making it parameterized so that by changing a few variables one could adjust the battery type/size, rows/columns, etc. This worked well, and after uploading it to Printables earlier today I realized that reimplementing it would probably be a good way to learn the basics of OpenSCAD.

OpenSCAD is a rather different type of CAD tool, one in which you write code to generate objects. Because my battery holder is very simple (just a box with a pattern of cutouts) and uses input parameters, I figured it’d be a good intro to a new language / tool. And in the future might even be better than firing up Fusion for such simple designs.

After going through part of the tutorial and an hour or so of poking, here’s the result: battery_holder_generator.scad

Slicer showing the Fusion model on top and OpenSCAD on bottom.

By changing just a few variables — numRows and numColumns and batteryType — one can render a customized battery holder which can then be plopped into a slicer and printed. No heavy/expensive CAD software needed and the output is effectively the same.

Without comments or informative output, this is the meat of the code:

AA = 15;
AAA = 11;
heightCompartment = 19;
thicknessWall = 1;
numRows = 4;
numColumns = 10;
batteryType = AA;

widthBox = (numRows * batteryType) + ((numRows + 1) * thicknessWall);
lengthBox = (numColumns * batteryType) + ((numColumns + 1) * thicknessWall);
depthBox = heightCompartment + thicknessWall;

difference() {
    cube([lengthBox, widthBox, depthBox]);
    for (c = [ 1 : numColumns ])
        for (r = [ 1 : numRows ])
            let (
                startColumn = ((c * thicknessWall) + ((c - 1) * batteryType)),
                startRow = ((r * thicknessWall) + ((r - 1) * batteryType))
            )
            {
                translate([startColumn, startRow, thicknessWall])
                cube([batteryType, batteryType, heightCompartment + 1]);
            }
};

Simply, it draws a box and cuts out the holes. (The first cube() draws the main box, then difference() subtracts the battery holes via the second cube() as their quantity and location (via translate()) is iterated.

That’s it. Pretty neat, eh?

(One part that confused me is how I needed to use let() to define startColumn and startRow inside the loop. I don’t understand this…)

While this probably won’t be very helpful for more complicated designs, I can see this being super useful for bearing drifts, spacers, and other similar simple (yet incredibly useful in real life) geometric shapes.

computersmaking things