# Matthew Brunelle's Blog > I write about a variety of projects, events and thoughts, but they all share one aspect: I learned something and want to share my experience with others. Public Ghost content for AI and LLM tooling. This file includes a bounded export of public pages first, then recent public posts. Append `.md` to any post or page URL to get the content in Markdown (for example, `/example-post.md`). ## Pages ### About URL: https://blog.matthewbrunelle.com/about/ Last updated: 2025-09-29T14:46:29.000Z ![4845061864_f88f3de88a_o_edited.jpg](https://blog.matthewbrunelle.com/content/images/2025/09/4845061864_f88f3de88a_o_edited-1.jpg) I've been blogging on and off since 2015\. Right now seems to be an "on" time because I want to practice writing. I graduated from UMass Amherst where I studied CS and I'm a software engineer. A LinkedIn exists, but I don't really want to support them. I don't use social media, but I think the Fediverse is interesting so you can actually follow this blog though @index@blog.matthewbrunelle.com. On GitHub I am [@ciferkey](https://github.com/ciferkey?ref=blog.matthewbrunelle.com). ### Blog URL: https://blog.matthewbrunelle.com/blog/ Last updated: 2025-07-02T13:25:53.000Z _No content available._ ### Tags URL: https://blog.matthewbrunelle.com/tags/ Last updated: 2025-09-29T14:53:24.000Z _No content available._ ### Please, No (Physical) Gifts URL: https://blog.matthewbrunelle.com/please-no-physical-gifts/ Last updated: 2025-10-26T15:15:27.000Z As a chronic stuff haver, and thing buyer, I'm trying to change my ways. Instead of getting me a physical gift, consider doing one of the following: One time donation in my name: - [Housing Corporation of Arlington](https://www.housingcorparlington.org/support/?ref=blog.matthewbrunelle.com) - [Food Link MA](https://www.foodlinkma.org/donation-form?ref=blog.matthewbrunelle.com) - [Arlington EATS](https://www.arlingtoneats.org/?ref=blog.matthewbrunelle.com) - [ACLU](https://action.aclu.org/give/now?ref=blog.matthewbrunelle.com) - [WGBH](https://wgbh.plus.npr.org/gifts?ref=blog.matthewbrunelle.com) [\[1\]](#fn1) - [EFF](https://supporters.eff.org/donate/tbc--sm?ref=blog.matthewbrunelle.com) - [Internet Archive](https://archive.org/donate/) - [Wikipedia](https://donate.wikimedia.org/w/index.php?title=Special:LandingPage&country=US&uselang=en&wmf%5Fmedium=spontaneous&wmf%5Fsource=fr-redir&wmf%5Fcampaign=spontaneous&ref=blog.matthewbrunelle.com) - [Mozilla Foundation](https://www.mozillafoundation.org/en/donate/?ref=blog.matthewbrunelle.com) Sate my dark passenger that craves reading, by helping me pay for digital subscriptions and books: - [New Yorker](https://subscriptions.newyorker.com/pubs/N3/NYR/Gift%5FAll.jsp?cds%5Fpage%5Fid=279670&cds%5Fmag%5Fcode=NYR&id=1761482505210&lsid=52990741452046777&vid=1&cds%5Fresponse%5Fkey=84XHAAAA1&ref=blog.matthewbrunelle.com) - [NYT](https://www.nytimes.com/subscription/gift?ref=blog.matthewbrunelle.com) - [The Economist](https://subscribenow.economist.com/gift?ref=blog.matthewbrunelle.com) [\[2\]](#fn2) - [Libro.fm](https://libro.fm/gift?ref=blog.matthewbrunelle.com) - [Bookshop.org](https://bookshop.org/gift%5Fcards?ref=blog.matthewbrunelle.com) Help me learn to do other things good too with classes: - [Grubstreet](https://grubstreet.org/gift-card?ref=blog.matthewbrunelle.com) - [Artisans asylum](https://www.artisansasylum.com/class-vouchers?ref=blog.matthewbrunelle.com) - [RISD](https://cereg.risd.edu/faq/faq.jsp?ref=blog.matthewbrunelle.com#:~:text=Are%20gift%20cards%20available%3F) Podcasts: - [Gastropod](https://gastropod.com/support/?ref=blog.matthewbrunelle.com) Performing arts: - [Boston Symphony Orchestra](https://www.bso.org/gift-certificates?ref=blog.matthewbrunelle.com) - [Boston Ballet](https://www.bostonballet.org/home/tickets-performances/?ref=blog.matthewbrunelle.com) Restaurants in Boston: - [Comfort Kitchen](https://www.comfortkitchenbos.com/merch?ref=blog.matthewbrunelle.com) - [Tryst](https://www.trystrestaurant.com/gift-cards?ref=blog.matthewbrunelle.com) - [TABLE](https://tableboston.com/gift-cards/?ref=blog.matthewbrunelle.com) - [Sarma](https://www.sarmarestaurant.com/gift-cards?ref=blog.matthewbrunelle.com) Help me hoard hard drives for my NAS: - [Microcenter](https://community.microcenter.com/kb/articles/109-can-i-purchase-or-use-a-micro-center-gift-card-online?ref=blog.matthewbrunelle.com) --- 1. Make sure to do the gift option since I already have an NPR+ subscription. [↩︎](#fnref1) 2. Their main site is really expensive. I can usually resubscribe at a discount by telling them I'm leaving. Only order this if you can find it discounted. [↩︎](#fnref2) ### Now URL: https://blog.matthewbrunelle.com/now/ Last updated: 2026-01-15T14:32:13.000Z _No content available._ ## Posts ### Building a Better Dog Camera Part 1 - Raspberry Pi with NixOS URL: https://blog.matthewbrunelle.com/building-a-better-dog-camera-part-1-raspberry-pi-with-nixos/ Last updated: 2026-09-15T11:38:25.000Z I'll say up top that this part was unfinished before I moved on to my second attempt. So think of this more as a collection of notes rather than complete instructions. #### On this page Last year I wrote about [self hosting dog cameras using Frigate](https://blog.matthewbrunelle.com/self-hosting-a-dog-camera-for-away-from-home-monitoring/). At the time I was using a TP-Link Tapo camera and had to write a whole section on securing the cameras by firewalling them off from the internet. I've been longing for a solution where that wasn't needed. Then Benn Jordan had a video on the [security risk of cameras](https://www.youtube.com/watch?v=UMIwNiwQewQ&ref=blog.matthewbrunelle.com): > Maybe you should just have a realistic discussion with your family members about the risks you’ve just heard about versus the odds that cameras would actually be useful to you... but if you ask me my opinion, with the way that we seem to be plunging into authoritarianism and considering the sad state of cybersecurity, I’d say that having a cloud-connected camera from a tech conglomerate makes you significantly less safe. He also suggests if you still feel you need a camera to feel safe you can get an offline model that writes encrypted files to an SD card. I think that's a well balanced approach. My use case is a little different though, since I don't want cameras outside, and I am not trying to protect my house. I want dog cameras inside to check on my elderly dog when I am away. Like that one time we caught her taking a poop inside. So I unfortunately need something that can be accessed remotely. What I would love is a camera you do not have to firewall, put on a dedicated VLAN, block DNS, etc. --- ## A first attempt: Hand rolling a camera with a RPi When you boil it down, a dog camera is just a small computer with a camera attached. If I build one myself, then I can avoid the camera phoning home. In my hubris I thought, "How hard could that be?" I wanted to put something together using commodity parts. Even after the price increases Raspberry Pis are still pretty affordable. I am also fortunate to have a Micro Center near me and they sell them at MSRP if you're buying just one. The price goes up fast when you buy multiple to deter scalpers. I could just plug a USB webcam in and set up some remote access. I already have one running [BirdNet Go](https://blog.matthewbrunelle.com/birdnet-go-but-we-have-bird-monitoring-at-home/). There are even the various Pi Camera modules that are specced so that the Pi can handle them. I wanted something small so I went to buy a Raspberry Pi Zero 2w and a Pi Camera 3 NoIR Wide. However, they were actually out of the 2w, so I had to go with a 1GB RPi 4\. The 2gb was also out of stock and who wants to pay $100 for a 4gb. Plus, if I could get everything working on 1gb that would be a big win for others that might want to try. Oh, and there are also printable [camera mounts](https://www.printables.com/model/368788-raspberry-pi-camera-mount-camera-module-3-version?ref=blog.matthewbrunelle.com) available. --- ## Building a custom image with NixOS The NixOS wiki has a general [page on the Raspberry Pi](https://nixos.wiki/wiki/NixOS%5Fon%5FARM/Raspberry%5FPi?ref=blog.matthewbrunelle.com), plus a page specifically for the [Pi 4](https://nixos.wiki/wiki/NixOS%5Fon%5FARM/Raspberry%5FPi%5F4?ref=blog.matthewbrunelle.com). You can write the configuration on your desktop and generate an image you can flash. The examples they provide use nix channels and I wanted to use flakes instead: ```nix { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; nixos-hardware.url = "github:NixOS/nixos-hardware"; }; outputs = { self, nixpkgs, nixos-hardware, ... }: { nixosConfigurations.camera = nixpkgs.lib.nixosSystem { system = "aarch64-linux"; modules = [ "${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix" nixos-hardware.nixosModules.raspberry-pi-4 ./configuration.nix ]; }; }; } ``` Most of the configuration is pretty standard: - Enable Tailscale. - Enable ssh - Disable bluetooth - Enable swap However, there are some specific changes for this project. Rather than use the [camera section](https://nixos.wiki/wiki/NixOS%5Fon%5FARM/Raspberry%5FPi?ref=blog.matthewbrunelle.com#Camera) on the wiki I used the nixos-hardware overlay: ```nix hardware.raspberry-pi."4" = { apply-overlays-dtmerge.enable = lib.mkDefault true; }; hardware.deviceTree.overlays = [ { name = "imx708"; dtboFile = "${pkgs.raspberrypifw}/share/raspberrypi/boot/overlays/imx708.dtbo"; } ]; boot.kernelPackages = pkgs.linuxPackages_rpi4; ``` Then I set up the user in the `video` group: ```nix users.users.camera = { isNormalUser = true; extraGroups = [ "video" "wheel" ]; }; ``` Finally, I generate the image to flash: ```nix sdImage.compressImage = true; sdImage.imageName = "rpi4-camera.img"; ``` The last piece is to set up go2rtc for streaming for the camera, but I got distracted by another option. --- ## Then deciding to try out Secluso I'll talk about this in more detail in part 2 (TBD), but I came across a project called [Secluso](https://secluso.com/?ref=blog.matthewbrunelle.com) that was aiming to provide a secure and private camera using Pi parts. I decided to can this work and see if I could contribute my time to that project instead. That would mean dropping NixOS, but I would rather work with a group of people on something like this. ### Boston Athletic Association 10k Post Race Report URL: https://blog.matthewbrunelle.com/boston-athletic-association-10k-post-race-report/ Last updated: 2026-09-10T18:15:39.000Z Part of the reason I'm pushing this post out now is because I am doing my first sprint triathlon next week! I wanted to get this post out since it covers a change in my training philosophy that I carried into my current training block. I've slowly been working my way down from marathons to shorter distances, so this year I ran the [BAA 10k](https://www.baa.org/races/boston-10k/info-for-athletes/?ref=blog.matthewbrunelle.com) on Jun 21st. One thing I changed up that had a huge impact was choosing to take more days off to improve my recovery. #### On this page --- ## The Course A friend recommended this race when I mentioned I wanted to run a 10k near Boston. The race starts right between the Public Garden and Boston Common, takes you over the Longfellow, past MIT on Memorial Dr, back over the Charles, through Back Bay and finally back to the Common. I couldn't get a GPX file from the event staff, so I transcribed the course map on my own into Garmin: ![bcaa_2026_course_map.png](https://blog.matthewbrunelle.com/content/images/2026/09/bcaa_2026_course_map.png) As you can see, it was a very flat race: ![elevation.png](https://blog.matthewbrunelle.com/content/images/2026/09/elevation.png) The course overlapped a little with the [Boston Run to Remember Half Marathon](https://blog.matthewbrunelle.com/post-race-report-boston-run-to-remember/) I ran last year, but had a lot of new ground. The first portion of that race was in Seaport and it did not go near Commonwealth. --- ## Training I like to buy structured training plans that I can anchor onto my race day. Just like my last couple of races, I used a Matt Fitzgerald 80/20 running plan. Some upsides to using a structured training plan: - Handles 3 week cycles and the taper for me. - Enforces rest days. - Helps me pull back on foundation runs, knowing I have hard workouts scheduled on other days. This time I went with the level 0 version of the 10k plan because I wanted my training to better fit into my life. More concretely, here's how the two plans differ: | | Level 0 10k plan | Level 1 Half-marathon plan | | -------------------------- | ---------------- | -------------------------- | | **Duration** | 12 Weeks | 15 Weeks | | **Weekly time commitment** | 3–5 hrs/wk | 4–6 hrs/wk | | **Longest Run** | 1:15h | 2:00h | | **Number of Rest Days** | 2 | 1 | I adopted a dog and even though she's 13 years old she *loves* to go for walks. Capped around twenty minutes, of course, as recommended by the vet. We walk probably one and a half to two and a half miles a day and unsurprisingly that has an impact on my recovery needs. I also explicitly decided to prioritize completing my strength workouts twice a week. I gave up a lot of my strength days when I was in the thick of training over the last couple of years. This time I ran the 2 day Tactical Barbell Fighter template. It's specifically designed to be flexible for your recovery needs and pairs nicely with race training. I was able to get both workouts in most weeks. When training for my past races I missed some social opportunities to cram more miles in. This time I gave myself the grace to miss more training days and that was a good thing. Outside of vacations, I only skipped 5 runs when training for my last race. This time I missed 19 workouts, but had more green recovery days on average. So I felt more prepared and hit my targets on the days when I ran. Plus, I got to spend more time with the people in my life. The plan started April 1st and finished the day of the race. Before the block I only did some light indoor biking over winter. Honestly, it was weird to do *less* training this time and yet feel better prepared for the race. --- ## Planning the Race Pacing I usually set up my pacing to have a slightly negative split with an easier uphill effort: ![pace pro.png](https://blog.matthewbrunelle.com/content/images/2026/09/pace-pro.png) There was minimal elevation impact this time. About a week before the race Garmin predicted a pace around 8:05 minutes / mile. I know the predictions are not that accurate for most people, but they are pretty on point for me. I run with a chest strap and I wonder if that helps. For my marathon and half marathon I was intentionally very conservative with my pacing. At longer distances the risk of injury is pretty high. Especially for runs over three hours. This time though, I wanted to just let loose and have fun since the race was a shorter distance. So I targeted 50 minutes or a 7:52 pace. --- ## Results The race started just after 8am. The early morning was chilly, but got up to 68 at race start and over 70 by the end of the race. You never know what the wind will be like in Boston, but it was light that day. Honestly it was perfect weather for running a race. 50 minutes sounded like a stretch to me, but I ended up finishing in 49:04, which I was pretty happy about. Here are my kilometer splits: | Splits | Time | Distance | Avg Pace | Goal Pace | Avg Power | Avg GAP | | ------ | ------ | -------- | -------- | --------- | --------- | ------- | | 1 | 5:01 | 0.61 | 8:11 | 8:08 | 366 | 8:00 | | 2 | 5:04.0 | 0.61 | 8:15 | 8:11 | 337 | 8:24 | | 3 | 4:58.0 | 0.63 | 7:56 | 8:03 | 359 | 7:57 | | 4 | 4:33 | 0.56 | 8:05 | 8:01 | 358 | 8:01 | | 5 | 5:00 | 0.63 | 7:59 | 7:55 | 326 | 8:01 | | 6 | 4:53.0 | 0.61 | 8:02 | 7:50 | 340 | 7:59 | | 7 | 4:49.0 | 0.61 | 7:54 | 7:47 | 361 | 7:56 | | 8 | 4:44 | 0.61 | 7:47 | 7:44 | 345 | 7:48 | | 9 | 4:41.0 | 0.62 | 7:36 | 7:38 | 364 | 7:32 | | 10 | 4:37.0 | 0.63 | 7:18 | 7:33 | 367 | 7:21 | | 11 | 0:40.3 | 0.13 | 5:21 | 7:31 | 466 | 5:59 | Based on my target pace I was in the second wave of runners. You can see that I went out a little slow for the first two km while I worked my way through the crowd. Then I started hitting the target paces. For the last half I opened up and did a nice negative split. I finished with a 5:21 push through the final stretch. I felt good and probably had a little extra in the tank! Thats encouraging and I think I'll set an even more aggressive goal vs my estimate for future races. BAA took photos of me during the race, but they were offered through MarathonFoto, and it's *$69* to get all of them. So alas, I have no photos to share. I was able to get some photos of cones left over from our Glaswegian visitors. I studied in Glasgow for a semester so I looked fondly at these as I remembered the [Duke of Wellington statue](https://en.wikipedia.org/wiki/Equestrian%5Fstatue%5Fof%5Fthe%5FDuke%5Fof%5FWellington,%5FGlasgow?ref=blog.matthewbrunelle.com). ![cones2.jpg](https://blog.matthewbrunelle.com/content/images/2026/09/cones2.jpg) ![cones1.jpg](https://blog.matthewbrunelle.com/content/images/2026/09/cones1.jpg) --- ## My recommendation I really enjoyed this race. If you're looking for a 10k in the Boston area, I think it's a really nice option for an early season event. It could make a great B race if you want to run a second training cycle and race in Fall. That's exactly what I did in preparation for my upcoming sprint triathlon. I'll try to get the post race report out sooner this time. ### Google Decided to Index My Blog Again URL: https://blog.matthewbrunelle.com/google-decided-to-index-my-blog-again/ Last updated: 2026-09-08T11:35:52.000Z #### On this page In June last year I [remade my blog using Ghost after some time off](https://blog.matthewbrunelle.com/all-personal-blogs-die-but-mine-came-back-as-a-ghost/). I also [celebrated 15 years of blogging by bringing back some old posts](https://blog.matthewbrunelle.com/blog-paleontology-15-years-of-blogging/). However, I realized the way I did that was a [big mistake for Google indexing](https://blog.matthewbrunelle.com/i-dont-want-to-play-the-seo-game/). Since the old site's hosting had been broken for a bit, relaunching my site and changing the blog engine at the same time made it look like [expired domain abuse](https://developers.google.com/search/docs/essentials/spam-policies?ref=blog.matthewbrunelle.com#expired-domains). Since May of last year the search console shows all of my pages as "Crawled - currently not indexed." I had a couple of posts that ended up on the Hacker News front page and they were briefly indexed. I got clicks during that window, but the pages were de-indexed a week later. After triple checking all the common reasons why I might not be indexed, I decided to try screaming into the gaping void that is Google support. When Google mysteriously disowns you, there is no way to contact them. I [posted](https://support.google.com/webmasters/thread/386714012?hl=en&sjid=13193020450334658785-NA&ref=blog.matthewbrunelle.com) on the support forums and was told it didn't look like there were any penalties. They said "Once Google re-evaluates your site quality, indexing should resume naturally." The suggested resolution was to just wait and stare out into the far distance. The thread was locked. Indeed, it seems I just had to wait another *9 months*: ![google_impressions.png](https://blog.matthewbrunelle.com/content/images/2026/09/google_impressions.png) The clicks and page views are slowly starting to trickle in. At this point getting indexed by Google is nice, but not as important as I thought it was before. --- # The numbers At first I self hosted Umami analytics. Then when [Ghost 6.0 launched](https://blog.matthewbrunelle.com/ghost-6-0-activitypub-is-a-go/) I got access to the built-in analytics. So I have web traffic dating back to November. Ignoring spikes like landing on a weird AI-generated mailing list, most of my traffic is marked as "direct". Those are probably people like me that block trackers. Honestly, they are the kind of people I most want to read my site. After that, the next biggest source of the traffic is Hacker News when other people happen to submit my posts. (Thank you!) Traditional social, the non-aggregators, is only 8% of traffic. I don't use social media, so that's not a source I push. If you remove those top sources, half of my traffic comes from search. It's much smaller than the spikes, but a stable source of visitors: | Engine | % of search | | ------------ | ----------- | | DuckDuckGo | 59.1% | | Google | 15.3% | | Kagi | 9.2% | | Bing | 5.6% | | Brave Search | 5.0% | | Ecosia | 2.5% | | Yandex | 1.7% | | Qwant | 1.7% | Google is only 15% of those views! Instead, the privacy focused search engines are \~75% of the traffic. That's because after Google wouldn't index me, I set myself up on sites like Kagi's smallweb, personalsit.es, bubbles.town, blogroll.org, etc. The result is that I can get wide coverage in terms of search engines. It's nice not to be under the thumb of Google. --- # I'll keep on writing I have no clue why Google decided to index me again. All I can say is that practicing how to write quality content and actually writing regularly is probably the best way to get indexed by any search engine. If you're reading this, regardless of how you got here, thank you for taking the time. ### Gboard's Rambler Rewrites Your Intent URL: https://blog.matthewbrunelle.com/gboards-rambler-rewrites-more-than-your-filler-words/ Last updated: 2026-09-02T12:02:33.000Z #### On this page When I go for dog walks, I like to voice dictate raw thoughts into Obsidian to collect fodder for my writing. They will be scattered and possibly incoherent, but they are enough for me to remember my intent later on. Eventually I will sit down and clean them all up. When I communicate with another person, be that over text or in an article, I want to be thoughtful about what I am saying. Just like when I had [accidentally opted out of smart notifications](https://blog.matthewbrunelle.com/ive-inadvertantly-opted-out-of-smart-notifications-thanks-to-grapheneos/), I missed Google's [announcement in May](https://blog.google/products-and-platforms/platforms/android/gemini-intelligence/?ref=blog.matthewbrunelle.com) about Rambler. The feature is designed to help clean up speech by removing repetition and filler words. [\[1\]](#fn1) I wanted to see if it could more accurately transcribe my quick brain dumps. [\[2\]](#fn2) I ended a paragraph in my notes with "Provide citations." I spoke a complete sentence with no repetition or filler words, but Rambler still decided to change the tone: > Please provide citations for this information. Even more humorously, in a second attempt the text was rewritten to: > (Smith, 2023) [\[3\]](#fn3) I said "Calculate and mention how long I've been running GrapheneOS" and Rambler completely made up a duration of time: > I've been running GrapheneOS for about two years now. [\[4\]](#fn4) It rewrote "Expand on the lack of a live preview to help you catch it" to have an entire argument I never stated: > This is especially risky because the lack of a live preview means you cannot catch errors in real-time, forcing you to rely entirely on the tool's initial output without verification. All three of those were notes to myself, but Rambler decided to act on them as commands. The quotes are egregious examples of Rambler misunderstanding my intent and are easy to spot. Things get harder when the changes are subtler. With a non-language model based tool, the blast radius is relatively small. Just like how a spell checker only changes a single word. In a language model based tool, that's not the case. The problem is Rambler doesn't just attempt to correct spelling mistakes. The [support page](https://support.google.com/pixelphone/answer/17468539?hl=en&ref=blog.matthewbrunelle.com) says you can make requests like "make this sound more professional" and Rambler will do what it decides is best. If you have existing text before you start dictation, it may even rewrite that. Rambler is running your raw transcription and past text through a language model with a system prompt you cannot inspect. You do not get a chance to catch any mistakes with a preview or comparison. You can easily write messages with unclear intent on your own without AI.Google specifically designed the tool to help with language barriers where this problem comes up often. I think that is a meaningful use case to address. However, there is a difference between improving clarity and changing what you are saying. This is part of a much larger issue with language model based tools reinterpreting you. It's the continued slopification of everything you use. When you write messages with Rambler you are not just asking for help with clarity, you are giving up agency in how you communicate. Google tells us: > As Android transitions from an operating system into an intelligence system, your devices are becoming even more helpful with upgrades that will save you time. I don't know if anyone asked for Android to become an "intelligence system." An alternative that I [have been using for a while](https://blog.matthewbrunelle.com/the-apps-that-i-kept-on-grapheneos-2/) is [FUTO keyboard's voice typing](https://voiceinput.futo.tech/?ref=blog.matthewbrunelle.com). You can select a larger model like `English-244` to improve accuracy. I just need to slow down my speech a bit when ranting notes into my phone on walks. --- 1. I once did Toastmasters and they would have a person count all the filler words used during presentations. That really made me notice how many we use when speaking. [↩︎](#fnref1) 2. A headphone mic and wind do not make for great audio reception. [↩︎](#fnref2) 3. Who is Smith and what knowledge do they hold? [↩︎](#fnref3) 4. The actual answer is 14 months now since I [first installed](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/)! [↩︎](#fnref4) ### I've Inadvertently Opted Out of Smart Notifications Thanks to GrapheneOS. URL: https://blog.matthewbrunelle.com/ive-inadvertantly-opted-out-of-smart-notifications-thanks-to-grapheneos/ Last updated: 2026-08-16T13:53:51.000Z #### On this page I recently read Jacques Corby-Tuech's post on [what Apple and Google are doing to your push notifications](https://www.jacquescorbytuech.com/writing/what-apple-and-google-are-doing-your-push-notifications?ref=blog.matthewbrunelle.com). The piece is written from the perspective of marketing and how the changes impact their industry. So we get an interesting look at how the sausage is made: > Push exists to bring users back into the product; once they're there, the owned surfaces take over. Corby-Tuech focuses on how the new AI based notifications on Android and iOS are impacting advertising channels. I didn't realize how much push notifications had changed in the last year, because I've been [using GrapheneOS for a little over a year now](https://blog.matthewbrunelle.com/8-4-months-of-daily-driving-grapheneos/) and do not have access to the Smart Notifications that rolled out on Pixels. \[^ On GrapheneOS [push notifications still go through Google Play Services](https://grapheneos.org/faq?ref=blog.matthewbrunelle.com#notifications), but there is at least the sandbox.\] That hasn't been a problem for me though. Rather than look at the heap of notifications you receive and relinquish control to AI, you can instead cut the notifications off at the source. That's why I am making deliberate choices rather than delegating that power. Some people [view the notification organizer as a lifesaver](https://www.androidauthority.com/how-to-use-pixel-notification-organizer-3624003/?ref=blog.matthewbrunelle.com), a way to deal with all the distractions apps bombard you with. Google's documentation on how to [use AI to manage your notifications](https://support.google.com/pixelphone/answer/16691280?hl=en-Get%3D&ref=blog.matthewbrunelle.com) lists four categories that notifications are bundled into: promotions, news, social, suggested. Everything else is not bundled, implicitly marking it as more important. As a thought experiment, how many of those categories do you really want to receive notifications from? I did the experiment last year. The notification history feature is helpful for this. \[^ Its opt in on GrapheneOS.\] I could see what apps were sending lots of notifications and toggle off categories that were noise. If an app didn't break out an annoying notification into its own category then I removed notification access altogether. Often I realized I was just better off [removing many apps](https://blog.matthewbrunelle.com/the-apps-that-i-got-rid-of-when-trying-out-grapheneos/). The [apps I kept](https://blog.matthewbrunelle.com/the-apps-that-i-kept-on-grapheneos-2/) are the ones that actually provided value instead of extracting value from me. Here's how I applied that thought process to the four categories the AI filter on Pixels handles. The notifications in the promotions category are literal ads that you opt into. The piece by Jacques even references a paper on how users form a hierarchy of notification importance and consistently rank promotions as the least important. Likewise Suggested is the drip feed of content that providers use to pull you in. Remember, "push exists to bring users back into the product". With the Smart Notifications both Promotions and Suggested are silenced by default. That should be a good indication that you should reevaluate whether the apps actually are important to you. Google News pushes breaking news as if it is important. Yet Google also added a News category to the Smart Notifications. The constant bombardment of "breaking news" causes people to [disengage from journalism due to fatigue](https://news.umich.edu/more-feelings-of-misinformation-more-news-avoidance-u-m-study-shows/?ref=blog.matthewbrunelle.com). I've been trying to embrace slow journalism. When publications have time to investigate news pieces you will be better informed on the event rather than just superficially informed. I've moved my news apps to an eink device and have a specific news reading time. That way I can still be engaged, but without the rush. Finally there is Social. The book *Superbloom* does a great job describing social media as a priming mechanism that "keeps us in a permanent state of anticipation, awaiting the next stimulus, craving the next glance at the screen". Notifications are a core part of this. There is FOMO and that is by design. I felt this myself, but I am better off after reducing my usage over time. I'm now fully off of social media. That's a deep plunge that you don't have to fully replicate, but reducing your time just a bit is a start. A [review from 2023 concluded that](https://pmc.ncbi.nlm.nih.gov/articles/PMC10476631/?ref=blog.matthewbrunelle.com) "There is now a great deal of evidence that social media is a substantial *cause*, not just a tiny *correlate*, of depression and anxiety". Since then this debate hasn't been settled, but continues to amass supporting evidence. Carr frames this nicely in Superbloom: "spreading doubts about the causative role of social media in mental distress may have been warranted a decade ago, it’s hard to justify now. It comes off as a head-in-the-sand response, and, given the numbers, an irresponsible one." For me, not many notifications were left after removing those categories. Essentially all that remained was timely calendar/todo reminders and messages from people. To be sure, if one refuses to reduce their app usage, then Smart Notifications feels like the only option we are left with. However, I would argue this is by design. The apps that push the most notifications do not want us to think we can reduce engagement. I did not drop everything all at once. I think that would be unpalatable to most. If you have a hard time stomaching that you can start to pare down your apps and use Smart Notifications to cover the remaining notifications. You can even keep apps, but deny notification access. You can make great progress if you go a little bit at a time. You might even be surprised to find that as you use those "essential" apps less you start to feel comfortable enough to remove them. The most important part is to be more intentional about how you interact with your phone. That is why I don't like the Smart Notification approach. It lacks intentionality. At I/O this year Google announced a harm reduction feature called [Pause Point](https://www.androidauthority.com/pause-point-for-android-3664886/?ref=blog.matthewbrunelle.com) which lets you mark "distracting" apps. This then adds friction by prompting you when you open an app to consider if you need to use it. Adding an app to this feature is a sign that it is designed to bypass self control. That is also a good indication that it's an app worth reevaluating. You can undo the inversion of control and be in charge of deciding which apps are truly important to use. I like Cal Newport's advice: don't keep your phone on you when you are at home. Pick a phone spot and keep it there. Go to the phone spot when you need to use your phone. Like many Pixel features, Smart Notifications is region locked. On the plus side the use-less-apps approach is available globally and absolutely free. Plus, the feature uses AICore which [reserve 3.5 GB of ram for models](https://www.androidauthority.com/pixel-10-ai-ram-use-3591327/?ref=blog.matthewbrunelle.com) on the stock Pixel 10\. So that's more ram in my pocket \[^ Well technically on the kitchen table since that's the Phone Spot.\], which is especially important now that [Google has reduced the ram from 16 GB to 12 GB](https://www.engadget.com/2234960/pixel-11-series-cost-more-but-less-ram/?ref=blog.matthewbrunelle.com) on some Pixel 11 models. ### Agentic Engineering Is Just Everything We Haven't Been Doing URL: https://blog.matthewbrunelle.com/agentic-engineering-is-just-everything-we-havent-been-doing/ Last updated: 2026-08-12T17:25:10.000Z As always, the views here are expressly my own. In this post I'm not advocating for adopting agentic engineering. I'm assuming you chose to try it or someone decided for you. --- Matt Levine often writes about how crypto is [rediscovering modern finance from first principles](https://www.bloomberg.com/opinion/articles/2024-01-03/coal-plants-for-green-investors?ref=blog.matthewbrunelle.com).[\[1\]](#fn1) In a similar vein, when people get excited about approaches to improve agentic engineering, I can't help but think they are just talking about the practices we should already have in place as software engineers. The kind of work we typically cut because of time pressure. A classic example is using an issue tracker to organize details about the change and describe the requirements so you can hand off the work to an agent. That way the agent can comment on the issue with questions and update the status as they work. *This is just normal software engineering.* Some more examples: - Documentation strings on methods. - Descriptive pull requests. - Keeping documentation up to date or even writing it in the first place. - Writing meaningful tests or even just having tests. - Automated linting to ensure consistency or even just having agreed upon code conventions. - Planning, architecting, and receiving feedback on designs before implementing. - Capturing notes from meetings, and confirming that everyone agrees on what was decided. - Having conversations in open, searchable channels rather than only sending DMs. Once again, these are all normal software engineering practices. I am relieved to see that people are excited to do them now. I'm just bummed it took agentic engineering to encourage the change. Why are we holding the bots to higher standards than ourselves? A coworker recently said: > I reviewed a PR where I noticed some missing context on guiding principles. I'm now making changes to the documentation to encode those principles so other engineers' agents will respect them in the future. We could have updated the documentation in cases like this before, but realistically it wouldn't have much effect. It would have required having a talk with the whole team to be sure they memorized it in their heads. Now, with proper documentation structure, all engineers' agents will read and adhere to the documentation. Software engineers have automated their work since time immemorial. I am not upset that we have code formatting checks in CI. If I wanted to draw a line in the sand on when to automate, I would look to James C. Scott's mētis from *Seeing Like a State*: > "mētis is better understood as the kind of knowledge that can be acquired only by long practice at similar but rarely identical tasks, which requires constant adaptation to changing circumstances... Knowing how and when to apply the rules of thumb *in a concrete situation* is the essence of mētis." Mētis is always local to a particular situation. Code syntax is a well described human construct [\[2\]](#fn2). Other engineering principles are free form and thus harder to prescribe and make legible. A written form of these rules will always be less flexible when dealing with the nuance of a particular issue. A written rule is not a judgment. To be sure, a frozen legible system is better than nothing at all. Remember, [programming sucks](https://www.stilldrinking.org/programming-sucks?ref=blog.matthewbrunelle.com)[\[3\]](#fn3) in general. A friend at a company struggling to applying AI tools told me he just had to throw out a bunch of agent output: > I had to do a small refactor because my team decided to make changes on one of my dependencies, mid sprint, without communicating. If "**agentic engineering is just normal engineering with robots**" then I have a corollary as well: **Anything that makes engineering harder makes agentic engineering much harder.** Agents don't have the mētis to navigate the situation the way a person would. If you are having a hard time with agentic engineering, stop, and think about whether normal software engineering is also tough for your company. Slapping AI on a dysfunctional system, be it people or software, will not fix your problems. Since 1975 we've known that adding more people to late projects makes them later. Adding a fake person is no better. --- 1. As well as repeating all the same mistakes. [↩︎](#fnref1) 2. Though I admit this varies by language. The important thing is as an organization you will hopefully agree on one set of style conventions. [↩︎](#fnref2) 3. I remember reading this piece my junior year of university and thinking "Well this is what the rest of my life will feel like I guess". 12 years later and it's still accurate. [↩︎](#fnref3) ### Converting a PS4 Persona 5 Royal NG+ Save to Steam with Adversarial Interoperability URL: https://blog.matthewbrunelle.com/converting-a-ps4-persona-5-royal-ng-save-to-steam-with-adversarial-interoperability/ Last updated: 2026-08-09T16:19:07.000Z #### On this page Why do Persona games keep getting remakes and remasters? It's me. I'm the problem. I find the surveys Atlus sends and say "I will pay money for more remakes": ![persona_library.png](https://blog.matthewbrunelle.com/content/images/2026/08/persona_library.png) While I wait for Persona 4 Revival to release, I want to replay 5\. Specifically, I wanted to do a New Game+, since it goes much quicker than the normal [100+ hours to beat](https://howlongtobeat.com/game/66630?ref=blog.matthewbrunelle.com). I played the initial release when it launched in 2017 on the PS4\. [\[1\]](#fn1) When I heard Royal was getting a release in 2020, I decided to wait and NG+ that. Only problem is that Royal changed the game so much that you couldn't port your save over, so I had to start fresh again. This time I actually *can* NG+ on my PS5, but I am now [past my second year of Linux gaming](https://blog.matthewbrunelle.com/my-second-year-of-the-linux-desktop-for-gaming/) and figured out I could [use my desktop as a Steam Machine thanks to a 50ft HDMI cable](https://blog.matthewbrunelle.com/my-steam-machine-is-a-50ft-hdmi-cable/). Plus, Sony hasn't been behaving favorably lately. Persona 5 Royal was 80% off on Steam recently and as I already established, I will repeatedly pay Atlus for their games. Only problem is I don't want to start a full play through *yet again*. Thankfully we have [adversarial interoperability](https://www.eff.org/deeplinks/2019/10/adversarial-interoperability?ref=blog.matthewbrunelle.com). --- # Decrypting the save PS4 saves need to be decrypted first, because of course there is always DRM. I tried to do this a couple years ago and saw the only solution was a program called Save Wizard which costs $50. Thankfully the FOSS community stepped up and there is now [Garlic Saves](https://www.garlicsaves.com/?ref=blog.matthewbrunelle.com). It's a 100% free site that uses [garlic-savemgr](https://git.etawen.dev/earthonion/garlic-savemgr?ref=blog.matthewbrunelle.com) running on a pool of jailbroken consoles. You upload your save, they decrypt it, and you download the decrypted save. Ironically, I didn't want to pay $50 for Save Wizard, but I donated that amount to Garlic Saves. Anyways, the process is pretty simple: - Garlic Saves converts one save slot at a time. Open up the game and check the save slot with your completion data. Note the slot number. - Put a usb drive into your console. - [Copy the save to the drive](https://web.archive.org/web/20260722025557/https://garlicsaves.com/guides/ps4-to-ps5-transfer) - Plug the drive into your computer and look for `/PS4/SAVEDATA/<16-character hex name>` - Make an account on Garlic Saves and add the hex name. - The folder will have a bunch of files like `DATA0` and `DATA0.bin`. Make a zip containing both files for the slot you want to convert. - Upload the zip and wait ![decrypt.png](https://blog.matthewbrunelle.com/content/images/2026/08/decrypt.png) - Download the descrypted save. --- # Converting the save Once decrypted, you can convert the save to the Steam format using a small Python script [fiber-saveutil](https://github.com/zarroboogs/fiber-saveutil?ref=blog.matthewbrunelle.com). The instructions use pip, but I used uv instead and had no issues: [\[2\]](#fn2) ``` uv init uv add -r requirements.txt uv run fiber-saveutil.py convert Persona_5_Royal_CUSA17416_dec/ Persona_5_Royal_CUSA17416_dec/ ``` When I unzipped the decrypted save, I got the directory `Persona_5_Royal_CUSA17416_dec`. I'm using that for both the input and output, which will overwrite the original files. Also note from the readme: > Equipping any one of these will result in a soft-lock since the *Raidou Kuzunoha Costume & BGM Special Set* was cut from the PC version - you'll need to use a [mod](https://cdn.discordapp.com/attachments/546718581572894730/1032708752538882171/Fiber%5FRaidou%5FRestore.7z?ref=blog.matthewbrunelle.com) to restore them. All that's left is to: - Disable cloud sync for the game in Steam. - Start the game once to create the save directory. - Your save data on Linux will likely be in `~/.steam/steam/steamapps/compatdata/1687950/pfx/drive_c/users/steamuser/AppData/Roaming/SEGA/P5R/Steam//savedata/`. Make a folder matching your slot number. In my case that was `DATA04`. - Copy `DATA.DAT` from the script's output directory into the new DATA directory. - Re-enable your cloud sync. That's it! Start your NG+ and *take your time*. --- When the Steam version of the game was released, Atlus also released a PS5 upgrade. I could have bought that, but you can't import your PS4 save into the PS5 version. Plus, Sony is [no longer producing discs](https://blog.playstation.com/2026/07/01/physical-disc-production-ending-in-january-2028-for-new-games-releasing-on-playstation-consoles/?ref=blog.matthewbrunelle.com)[\[3\]](#fn3), [removing videos people paid for](https://www.playstation.com/en-gb/legal/psvideocontent/?ref=blog.matthewbrunelle.com), [introducing 30 day online DRM](https://www.tomshardware.com/video-games/playstation/sony-rolls-out-30-day-online-drm-check-in-for-playstation-digital-games-players-could-temporarily-lose-access-if-they-dont-keep-their-consoles-online?ref=blog.matthewbrunelle.com), [hiking prices](https://blog.playstation.com/2026/03/27/new-price-changes-for-ps5-ps5-pro-and-playstation-portal-remote-player/?ref=blog.matthewbrunelle.com), [testing out dynamic pricing](https://www.polygon.com/playstation-store-dynamic-pricing-testing/?ref=blog.matthewbrunelle.com) and [no longer bringing games to PC](https://www.bloomberg.com/news/articles/2026-03-04/sony-pulls-back-from-playstation-games-on-pc?ref=blog.matthewbrunelle.com). Five different countries are now [suing PlayStation](https://kotaku.com/playstation-is-being-sued-in-five-different-countries-over-anti-competitive-concerns-and-the-end-of-discs-isnt-helping-2000722141?ref=blog.matthewbrunelle.com). More and more third party titles are now releasing on PC without any kind of delayed release. Atlus is simultaneously releasing Persona 4 Revival next year on PS5, Steam and Xbox. Oh also, if you had bought the PS5 version of the game, started a new save and then decided you wanted to convert the save to Steam you would be out of luck. You can't export the PS5 saves. If anyone wants to NG+ and can't get their old save, I've uploaded my converted save [here](https://github.com/ciferkey/fiber-saveutil?ref=blog.matthewbrunelle.com) in a fork of fiber-saveutil that includes the uv changes. --- 1. At the time I lived with someone and their room was right next to the living room, so they were subjected to the battle theme *Last Surprise* over and over again. [↩︎](#fnref1) 2. Here's the [docs on converting to UV](https://docs.astral.sh/uv/guides/migration/pip-to-project/?ref=blog.matthewbrunelle.com#the-uv-lockfile) if you're interested. [↩︎](#fnref2) 3. Though you should have a look at [DoesItPlay](https://www.doesitplay.org/?ref=blog.matthewbrunelle.com) since many discs aren't even full games anymore. [↩︎](#fnref3) ### YouTube's New Programs to Encourage Binge Watching URL: https://blog.matthewbrunelle.com/youtubes-new-programs-to-encourage-binge-watching/ Last updated: 2026-08-07T15:52:51.000Z #### On this page I was watching a [video](https://youtu.be/w14sPMjtp48?t=405&ref=blog.matthewbrunelle.com) from Tasting History where Max said: > I wanted to let you know about the second channel I recently started. Its in conjunction with YouTube. Essentially, it's marathons of old Tasting History videos. It's a way to give old dead videos a second life. If you go to the [channel,](https://www.youtube.com/@TastingHistorywithMaxTopVideos?ref=blog.matthewbrunelle.com) you will see that the videos are all around 2 hours long. The channel setup and video naming doesn't feel like something Max did himself. Yet I can understand why Max would agree to this. He often makes several videos with a shared theme, so bundling them together makes sense. The "in conjunction with YouTube" part is what set off the red flag for me. YouTube does this kind of special relationship with big channels all the time, but they are tight lipped about the process. Getting to see behind the curtain is important because it helps us understand how YouTube tries to suck viewers in and how to defend against it. --- ## Linear TV Outside of that quote from Max there isn't anything else to go on, but we can make some assumptions. Earlier this year YouTube started [rolling out Stations](https://streampush.co/blog/youtube-stations-explained/?ref=blog.matthewbrunelle.com) [\[1\]](#fn1) which turns a playlist into a continuously running stream. At the last Made on YouTube event they noted how live content is a big focus for them. Stations allow them to create more live content without actually needing a *live* stream. Then last month they made it so you can [turn playlists into Shows](https://www.techtimes.com/articles/321284/20260722/youtube-shows-upgrades-playlists-netflix-style-series-ypp-creators.htm?ref=blog.matthewbrunelle.com). These are on-demand rather than live and have a season and episode structure. They will appear in the "Continue watching" and "Recommended shows" shelves. So the play here seems pretty simple. Normally you can't repost old videos, since YouTube will demonitize them. However, YouTube wants content in more formats that keeps people glued to the screen. So they go to big channels and offer to do the work to turn their backlog into long-form bingeable videos. Those videos then can be converted into Stations and Shows. This is working really well for them too. YouTube [had the #1 spot on Nielsen's report](https://www.nielsen.com/news-center/2026/sports-and-dramas-drive-april-viewing-patterns-in-nielsens-latest-gauge-reports/?ref=blog.matthewbrunelle.com) with 13.4% of TV viewing in April. This is a conflict of interest problem. The recommendation system makes choices for YouTube's benefit, such as surfacing bingeable compilations, while making it look like it's a favor to you. The economics of YouTube[\[2\]](#fn2) requires viewers to consume ever more videos. More and more it looks like YouTube designs around power users who watch it as their main form of content. The rest of us all get caught up in those choices. --- ## Defensive YouTube Viewing So if YouTube is trying their hardest to make us watch more videos, we're going to need some help avoiding all those recommendation shelves. Thankfully the FOSS community has our back. For the browser, [Unhook](https://unhook.app/?ref=blog.matthewbrunelle.com) allows you to turn off various sections. I removed the entire front page except for the search bar. Also all comments and the recommended video sidebar on video pages. There is also [DeArrow](https://dearrow.ajay.app/?ref=blog.matthewbrunelle.com), which replaces clickbait titles and thumbnails with crowdsourced alternatives. [\[3\]](#fn3) Instead of the browser, you could use apps that give you back the ability to choose how to consume videos. On desktop, [FreeTube](https://freetubeapp.io/?ref=blog.matthewbrunelle.com) integrates both DeArrow and SponsorBlock and has lots of distraction reduction settings: ![freetube_distraction_free_settings.png](https://blog.matthewbrunelle.com/content/images/2026/08/freetube_distraction_free_settings.png) On Android there are many options. [\[4\]](#fn4) I've been using [LibreTube](https://libretube.dev/?ref=blog.matthewbrunelle.com) most recently: [\[5\]](#fn5) ![libre_tube_screenshot](https://blog.matthewbrunelle.com/content/images/2026/08/3331.png) I also have an Android TV device. I used to use [SmartTube](https://smarttubeapp.github.io/?ref=blog.matthewbrunelle.com) but their builds were compromised. What I really need to do is finally set up automation like [TubeArchivist](https://github.com/tubearchivist/tubearchivist?ref=blog.matthewbrunelle.com) to download my subscribed channels so I can just watch them in Jellyfin. Unfortunately, all these tools break *constantly* because YouTube doesn't want you to be able to consume content this way. --- ## Be Intentional With What You Watch This situation can only be described as an adversarial relationship with YouTube. I want to limit how much YouTube I watch. My main goal is to avoid the grazing behavior where you would like to watch *something*, but not something specific. That is when the autoplay and suggestions are primed to get you. I don't subscribe to any channels through YouTube directly and I make sure not to have any form of automatic notifications. You can actually subscribe to any YouTube channel by RSS! The url is `https://www.youtube.com/feeds/videos.xml?channel_id=`. So over the years I've been able to pare down to only handful of channels I actually want to watch. Then, if I'm looking to watch a video I can open up my RSS reader and see what is new from those specific creators. If there isn't anything to watch then I choose to do something else. I'm never exposed to the automatic recommendations that make you click *just one more video*. I could take that as a sign to just stop using YouTube altogether. In fact at some point I will, considering I can no longer view videos with my VPN. For now though, I'm still getting some value from the couple of channels I watch. With all the shelves hidden, I won't see these repackaged marathon channels of content I've already watched from Max. --- 1. Right now it's invite only. [↩︎](#fnref1) 2. This is a fun [podcast episode](https://www.complexsystemspodcast.com/episodes/justin-kuiper-youtube/?ref=blog.matthewbrunelle.com) on the idea. [↩︎](#fnref2) 3. [uBlock Origin](https://ublockorigin.com/?ref=blog.matthewbrunelle.com) removes the ads that YouTube inserts into the videos and [SponsorBlock](https://sponsor.ajay.app/?ref=blog.matthewbrunelle.com) skips sponsored segments in the videos themselves if you want that too. [↩︎](#fnref3) 4. In my post on the [apps I kept when I switched to GrapheneOS](https://blog.matthewbrunelle.com/the-apps-that-i-kept-on-grapheneos-2/) I mentioned I used a NewPipe fork called Tubular to watch YouTube videos, but the app is no longer developed. [PipePipe](https://github.com/InfinityLoop1308/PipePipe?ref=blog.matthewbrunelle.com) is a good replacement. [↩︎](#fnref4) 5. You also can patch the official app with [Morphe](https://morphe.software/?ref=blog.matthewbrunelle.com), but I prefer a FOSS option. [↩︎](#fnref5) ### Thundermail, Tuta, and remembering Lavabit URL: https://blog.matthewbrunelle.com/thundermail-tuta-and-remembering-lavabit/ Last updated: 2026-07-10T16:41:32.000Z Let's do a thought experiment: do you care if a state actor tries to access your mail provider? I think most people would respond "when would that happen?" Well, as the [EFF reports](https://www.eff.org/deeplinks/2026/02/open-letter-tech-companies-protect-your-users-lawless-dhs-subpoenas?ref=blog.matthewbrunelle.com) the DHS has issued unlawful subpoenas to Google for emails of people engaged in First Amendment activity. Similarly, Proton Mail's [transparency report](https://proton.me/legal/transparency?ref=blog.matthewbrunelle.com) details all the court orders they followed. Proton thankfully encrypts all their emails and can't share them, but they do have to share metadata. Plus, after a case in 2021 they [clarified that](https://proton.me/blog/climate-activist-arrest?ref=blog.matthewbrunelle.com) the Swiss court can force them to collect information on specific accounts. On a more personal front, I posted this final message on my Facebook before I dumped my data and deleted my account: > Friday, August 9, 2013 at 10:06am EDT > Matthew Brunelle updated his status. > I'm reactivating my Facebook temporarily to say this: to all the people that believe the recent discussion of surveillance and privacy in the US isn't relevant to them or that they have nothing to hide, it does have a lasting impact on our freedom. My email provider Lavabit.com shutdown yesterday after it was rumored last week that it was the email provider Snowden was using. The owner Ladar Levison never stated the shutdown was due to Snowden, but he is in fact unable to talk about anything that happened as he has implied he is under an NSL (a court order preventing him from discussing the investigation). Recently the owner announced that after a decade of work he is shutting down the service rather than becoming "complicit in crimes against the American people". Levison warned "this experience has taught me one very important lesson: without congressional action or a strong judicial precedent, I would *strongly* recommend against anyone trusting their private data to a company with physical ties to the United States." After I grab all my photo from my facebook account I am deleting it permanently. If you're tagged in any of my photos and you wish to keep them, please download them now. [\[1\]](#fn1) Lavabit was a small operation, but a darling of the privacy community. I remember emailing with Pete, the one other employee aside from Ladar, for support issues. When the FBI requested they turn over their SSL private keys Ladar decided to shut the service down and protect the users instead of comply. The closure of Lavabit disheartened me and I went back to gmail. Then last year I decided to finally move off gmail around the time when Mozilla announced [Thunderbird Pro](https://blog.thunderbird.net/2025/04/thundermail-and-thunderbird-pro-services/?ref=blog.matthewbrunelle.com). It wouldn't release for a year, so I signed up for Tuta as a stopgap. I now run [both Tuta and Thunderbird on GrapheneOS](https://blog.matthewbrunelle.com/8-4-months-of-daily-driving-grapheneos/) as I port over my accounts to new catch-all emails. The process of moving accounts is long and slow. Thus, the gmail account remains active in Thunderbird. After [much waiting for my Thundermail invite](https://blog.matthewbrunelle.com/trying-out-thunderbird-appointment-while-i-patiently-await-for-an-invite/) it arrived last week. Just in time to try out the new FOSS web mail they launched, [stormbox](https://github.com/thunderbird/stormbox?ref=blog.matthewbrunelle.com). So far, I've enjoyed the experience, but something feels missing. Using Tuta for a year made me realize I miss Lavabit. If I want one email provider, I need to choose between staying on Tuta, which has strong end-to-end encryption, or switching to Thundermail, which has better ergonomics. For a decision like this take an honest at your [threat model](https://www.privacyguides.org/en/basics/threat-modeling/?ref=blog.matthewbrunelle.com): who do you want protection from, and how much friction will you accept to get it? So then, how do Tuta and Thundermail compare? You should think about them as two totally different offerings. Tuta has full end-to-end encryption including metadata and a convenience trade-off that comes with this. [\[2\]](#fn2) You can only access the mail through their web mail, or their official clients. Contacts can sync with your phone, but not your calendar. They also have a [transparency report](https://tuta.com/blog/transparency-report?ref=blog.matthewbrunelle.com) and like Proton they can't hand over old encrypted messages, but a court real-time monitoring order can compel them to collect plaintext emails going forwards. [\[3\]](#fn3) In comparison, Thundermail builds on the awesome [Stalwart](https://stalw.art/?ref=blog.matthewbrunelle.com) project. However, it does not offer not zero-knowledge, nor end-to-end encryption. You should think of it as gmail that doesn't mine your personal information. Since Thundermail took the opposite side of the trade-off, it can build on open standards that bolster integration. [\[4\]](#fn4) Now, do *I* need protection from state actors? No, not at all. However, I would like my email to reside with a company whose philosophy doesn't allow that. Certainly it's easier to not worry about privacy at all. People say "I have nothing to hide". My friend feels like they have lost all sense of privacy given the powerful tooling that exists. So why try with that asymmetry? However, as [they say](https://old.reddit.com/r/IAmA/comments/36ru89/just%5Fdays%5Fleft%5Fto%5Fkill%5Fmass%5Fsurveillance%5Funder/crglgh2/?ref=blog.matthewbrunelle.com): > Arguing that you don't care about the right to privacy because you have nothing to hide is no different than saying you don't care about free speech because you have nothing to say. A free press benefits more than just those who read the paper. If you'd like to help others better understand problems with the nothing to hide argument, share the digestible piece [The Eternal Value of Privacy](https://www.wired.com/2006/05/the-eternal-value-of-privacy/?%5F%5FreadwiseLocation=&ref=blog.matthewbrunelle.com). Additionally, Daniel J. Solove has written much about the topic, and [this piece](https://archive.ph/1MQfH?ref=blog.matthewbrunelle.com) condenses his counterargument. One final thought, when you send an email it goes to the recipient's provider. They probably do not use a private option which exposes your emails. Related, some people view using privacy-focused tools as an admission of guilt. See Catalan police [assuming all Pixels are owned by drug dealers](https://www.xatakandroid.com/sociedad/cada-vez-que-vemos-google-pixel-pensamos-que-puede-ser-narcotraficante-movil-perfecto-para-crimen-sencilla-razon?ref=blog.matthewbrunelle.com). These are both network effect problems, which means we can resolve them the same way: The more people that switch to privacy-focused options, the better privacy gets for everyone. So where does this leave us? My advice: if you want to degoogle and need an alternative to gmail, Thundermail will be a great option when released. Just getting off a service like gmail is a huge step. Plus, if you set up a catch-all domain moving between email providers becomes much easier. Then, if you can accept more friction in order to have full E2E encryption, try out Tuta. You don't have to be perfect, just strive for incremental improvements. Doing nothing is the worst option for yourself and others. For now I'm not really ready to give up the privacy of Tuta, nor do I want to lose the convenience of Thundermail. So for a bit I'm going to use both side by side since I actually have *multiple* custom domains. Even if that means short term pain juggling multiple accounts and email clients. Then I will reevaluate after another year. Oh, and now that I'm finally in on Thundermail, I guess it's time to start waiting for a [Thunderbolt](https://github.com/thunderbird/thunderbolt?ref=blog.matthewbrunelle.com) invite --- 1. Yes, back then I wrote single giant blobs of text. [↩︎](#fnref1) 2. Reminiscent of the things you lose when switching to GrapheneOS [↩︎](#fnref2) 3. They also have a strong [stance on chat control](https://tuta.com/blog/chat-control-criticism?ref=blog.matthewbrunelle.com). [↩︎](#fnref3) 4. Including the latest email protocol JMAP. [↩︎](#fnref4) ### My Steam Machine is a 50ft HDMI Cable URL: https://blog.matthewbrunelle.com/my-steam-machine-is-a-50ft-hdmi-cable/ Last updated: 2026-08-09T17:47:35.000Z #### On this page You can now [pre-order Valve's Steam Machine](https://store.steampowered.com/news/group/45479024/view/685257114654870245?ref=blog.matthewbrunelle.com)! Fortuitous timing as I drafted this as a follow up to my post from December where I wrote [about using Linux for PC gaming](https://blog.matthewbrunelle.com/my-second-year-of-the-linux-desktop-for-gaming/). At that time Steam ran well on my desktop and I chipped away at lighter games on the Deck. I ended that post by saying: > I don't think I'll buy a [Steam Machine](https://store.steampowered.com/sale/steammachine?ref=blog.matthewbrunelle.com), but I'm very happy it will exist. I've been testing out dual booting Bazzite on my desktop and I'd love to replace that with SteamOS proper. If I can pick up a controller and play a game on Steam with my TV as easily as the PS5, then I won't need to boot that up as much. I underestimated that last sentence. --- ## The Desktop to TV experiment Initially I ran a 20ft HDMI cable across the floor to my TV so I could play games from my NixOS install. The setup was fiddly though. Each time I wanted to play a game I had to hook the cable up, change sound outputs/display, etc. I installed my Steam games on their own NVME drive separate from my NixOS install. So I added a third NVME drive [\[1\]](#fn1) and put [Bazzite](https://bazzite.gg/?ref=blog.matthewbrunelle.com) on that. In Big Picture mode Bazzite figures out the correct display to use and remembers to use the HDMI out for sound. That meant I could reboot and play games without any setup. Yet I stopped using that setup and went back to docking my Steam Deck. As silly as it sounds, two additional changes have made the new setup stick for me: - The release of the Steam Controller 2 - A 50ft fiber optic HDMI cable --- ## The Controller I want a controller that has: - Good compatibility with Linux and Steam - Symmetric joysticks - A touchpad for when I need to do something mouse-like - Back buttons I used to use a DualSense Edge with steam and it checked all those boxes. However, I prefer the controls on the Steam Deck even more. [\[2\]](#fn2): - Split symmetric touchpads - First class support with Steam and Steam Input - More comfortable for my hands thanks to the larger vertical height When the Steam Controller 2 dropped I managed to snag one by spamming the checkout button in the Steam Store for *18 continuous minutes* before they went out of stock. I also no longer need to use a USB extender with a UB500 bluetooth adapter. The range on the DualSense leaves a lot to be desired. Even with perfect line of sight, my *arm* will often interfere with its reception. The Steam Controller 2 puck works great and has not set anything on fire yet. --- ## The HDMI Cable I doubted long HDMI cables would work well, so I never bought one. Turns out we've had active fiber optics cables for a good while now! I snagged a 50ft cable that supports HDMI 2.1 for about \~$75 and have had no issues. I now run the cable along the molding and keep it in place. I do not need HDMI 2.1 at the moment since my aging TCL Roku TV maxes out at 60hz. Regardless, I plan to get an LG C5 as they go on sale since [Fox bought Roku](https://www.reuters.com/business/media-telecom/fox-buy-roku-22-billion-deal-2026-06-15/?ref=blog.matthewbrunelle.com) [\[3\]](#fn3) and that will require 2.1. Unfortunately, HDMI 2.1 does not work with AMD graphics on Linux due to shenanigans from the HDMI Forum. As far as I can tell: - In 2024 AMD tried to release official patches, but [the forum rejected them](https://www.phoronix.com/news/HDMI-2.1-OSS-Rejected?ref=blog.matthewbrunelle.com) saying it would expose IP. - A couple months ago independent users [shared patches](https://www.phoronix.com/news/AMDGPU-HDMI-2.1-FRL?ref=blog.matthewbrunelle.com) that added support though reverse engineering efforts. - In the last month AMD started [releasing their official patches](https://www.phoronix.com/news/HDMI-FRL-2.1-Submitted-DRM?ref=blog.matthewbrunelle.com). The HDMI forum made no official announcement on why they allowed this. Sounds like [Valve played a role in persuading the forum](https://www.techpowerup.com/348723/amd-readies-full-open-source-hdmi-2-1-support-for-linux?ref=blog.matthewbrunelle.com)! I also like to hope that the threat of a reverse engineered implementation that they could not control convinced them to change their tune. [\[4\]](#fn4) --- ## Why not streaming? Finally, why not use Steam's in-home streaming [\[5\]](#fn5) to connect to my desktop from my Steam Deck? Try as I might I couldn't get a good stable setup. No amount of triaging worked for me in the end to get a simple and reliable setup. I expect streaming would work better with Bazzite than NixOS, but at that point, why not just run the cable? For me, a 50ft HDMI cable was the lowest friction option. I grew up playing games on desktops with a keyboard and mouse. I never expected to want a console-like experience. However, as life gets busier I've come to appreciate the ability to pick up and put down a game as needed. The Steam Deck handled that well and new setup gets even closer. Plus, I still get to keep control over my system unlike a console. I have one last problem to settle: Right now I can sleep Bazzite and resume without issue. If I want to boot into NixOS, I need to quit my game and restart. It looks like you can [enable swap and hibernate to disk](https://docs.bazzite.gg/Advanced/swapfile/?ref=blog.matthewbrunelle.com) with Bazzite. That would allow me to save my state and reboot into NixOS. I can't share the game SSD anymore in that scenario though. Even mounting read only while hibernated could cause file system corruptions. If I like using Bazzite long term, I might start booting into desktop mode for games I would play at my desk. Then I can detach the third SSD from my NixOS install. --- The Steam Machine costs about as much as a comparable self-built PC. So if you want a gaming computer at that spec tier, it's a great option. Especially if you primarily use Steam or prefer Linux. I almost ordered a Steam Machine just to support Valve and all the work they do to support gaming on Linux, but I have a feeling the Machine will sell just fine without me. Instead, a 50ft HDMI cable will do. --- 1. I had an old NVME SSD that Windows was installed on many years ago. With the [End of 10](https://endof10.org/?ref=blog.matthewbrunelle.com) there is no use keeping it and I had an extra M.2 slot. [↩︎](#fnref1) 2. I have a Steam Controller 1, but it collects dust in a drawer now [↩︎](#fnref2) 3. Even though I always keep the TCL TV offline. [↩︎](#fnref3) 4. A great example of why we need the right to inspect hardware to interoperate with it. [↩︎](#fnref4) 5. or Sunshine [↩︎](#fnref5) ### Trying Out OpenCode To Avoid Claude Code Lock-in URL: https://blog.matthewbrunelle.com/trying-out-opencode-to-avoid-claude-code-lock-in/ Last updated: 2026-04-30T16:00:14.000Z #### On this page I don't want the blog to be flooded with articles about AI tooling, but as I alluded to in my previous post about how [the experience with Claude Code is getting worse](https://blog.matthewbrunelle.com/the-claude-coding-vibes-are-getting-worse/), I've wanted to try out OpenCode. Since then in the last couple of days the vibes around Claude continue to sour: - It looked like [Anthropic might be pulling the plug on Claude Code in the Pro tier of the plan](https://news.ycombinator.com/item?id=47854477&ref=blog.matthewbrunelle.com), only for the Head of Growth to claim it was a "small test" on 2% of users. - Claude Code [usage might be allowed again](https://news.ycombinator.com/item?id=47844269&ref=blog.matthewbrunelle.com) in a limited use case for certain agent workflows, but the messaging is very murky. - There are open issues on [unexpectedly high token consumption rate](https://github.com/anthropics/claude-code/issues/52434?ref=blog.matthewbrunelle.com), [a bug around auto-compaction](https://github.com/dan-online/opencode-nix?ref=blog.matthewbrunelle.com), and a [regression on the malware reminder on every read](https://github.com/anthropics/claude-code/issues/49363?ref=blog.matthewbrunelle.com) Then Anthropic finally broke the silence with an [official response on the drop in quality of Claude Code](https://www.anthropic.com/engineering/april-23-postmortem?ref=blog.matthewbrunelle.com). The users [are still skeptical](https://news.ycombinator.com/item?id=47878905&ref=blog.matthewbrunelle.com). As long as you are given a black box, you have no control and the value of the service can be changed at any time. So of course I want to see if OpenCode can help give a little control back. After all, I'm a [big fan of FOSS](https://blog.matthewbrunelle.com/tag/open-source/). To help me with the evaluation, I put together a post on an experiment I did last month [testing out Claude Code by implementing a project I never finished](https://blog.matthewbrunelle.com/its-ok-to-use-coding-assistance-tools-to-revive-the-projects-you-never-were-going-to-finish/). That means I can run that same experiment again, but with a different harness. Though the question of what model to use needs to be answered first. --- ## On the feasibility of running your own models Before I can run OpenCode I need to select a model to use. My first thought was to self-host a model, because that is the only way to prevent a service making the model you are using worse. However, it [has been a while since I looked](https://blog.matthewbrunelle.com/using-self-hosting-language-models-so-you-can-evaluate-claude-code/) at how well open-weight models were doing. [\[1\]](#fn1) The last time I had tried was Gemma 3 early last year. I'm not using Opus 4.7 with all the issues currently going on, so I'm mostly curious about how the open models line up with Opus 4.6\. Luckily, in the last couple of weeks Kimi K2.6, GLM-5, Qwen 3.6 and Gemma 4 and Deepseek v4 all dropped and have open-weight versions. A quick comparison: | Model | Released | Parameters | Active Params | Context Size | FP16 Size | INT4 Size | | | ----------- | -------- | ---------- | ------------- | ------------ | --------- | --------- | | | Kimi K2.6 | April 20 | 1T | 32B | 256K | 2TB | 594GB | | | GLM-5.1 | April 7 | 754B | 40B | 200K | 1.65TB | | | | Qwen 3.6 | April 16 | 35B | 3B | 263K | 72GB | | | | Deepseek v4 | April 24 | 1.6T | 49B | 1M | 3.2TB | 800GB | | | Gemma 4 | April 2 | 30B | 30B | 256K | | | | The main detail that stood out to me is just how much memory the models need now. Even the quantized version of Kimi is giant. Yes, there are other, smaller open-weight models like Gemma 4, but not ones that perform on the level of Opus 4.6\. [\[2\]](#fn2) So for the purpose of testing OpenCode vs Claude Code with Opus 4.6, I decided to shelve self-hosting for now. Especially considering how high memory and storage prices are currently. So for this experiment I went with OpenCode's Go subscription. Not because I liked it over other options, but because I could try it for $5 the first month and because they are running a 3x usage limit promo for Kimi K2.6 right now. [\[3\]](#fn3) --- ## Running the experiment In my previous post I detailed [creating a shim to listen to YouTube Music through OpenSubsonic](https://blog.matthewbrunelle.com/its-ok-to-use-coding-assistance-tools-to-revive-the-projects-you-never-were-going-to-finish/). Take a look at the post if you want to know all the details, but the important parts are: - I wrote a proof of concept by hand and knew it could be done. - There was a well defined OpenAPI spec to implement. - I could use real SubSonic clients to test the implementation as I went. In terms of actual code produced at the end, I would say OpenCode with K2.6 worked roughly as good as Claude Code with Opus 4.6! With Claude Code I was able to get a working version within a short evening. Though I bumped into a 4-hour window limit once and had to wait \~30 minutes during the process. In both experiments after generating the stubbed endpoints I cleared the context and asked the tool to compare the generated code to the spec. Both Claude Code and OpenCode made mistakes in their initial generation, but then caught them during that review. [\[4\]](#fn4) There were also some small implementation details that tripped up the harnesses: - Both wanted to use yt-dlp by calling it through the command line. I had to refine the plan in both experiments to programmatically use the yt-dlp internals. - Some clients append `.view` to the endpoints. Feishin did this while I was testing with it. A quick paste of the server logs was enough for both harnesses to resolve the issue. Two other aspects that OpenCode handled just as well as Claude Code: - When the harness doesn't have enough context I'll often paste a link to documentation and direct the tool to read review it. For example, the spec didn't cover the details of what a conformant implementation of `getOpenSubsonicExtensions` should include, but [the docs](https://opensubsonic.netlify.app/docs/endpoints/getopensubsonicextensions/?ref=blog.matthewbrunelle.com) did. - When there are multiple options for an implementation and I don't have a strong opinion I will sometimes ask the harness to search the web for what is idiomatic. For example, when I added caching there are a couple different libraries I could use. --- ## OpenCode UX vs Claude Code The main difference between the two harnesses was not the performance, but the UX. The main differences I want to highlight are: - No proper plan mode yet - Asking vs Doing - Getting OpenCode to use specific tools - No built in memory yet ### No proper plan mode yet One huge problem for me is the lack of a tightly integrated plan mode. I really like the process of iterating on a written plan with the agent, and then changing modes to implement the plan. It feels more like the "centaur" scenario where automation helps you, rather than the ["reverse centaur"](https://pluralistic.net/2024/08/02/despotism-on-demand/?ref=blog.matthewbrunelle.com) scenario, where you are helping the machine. Instead, in OpenCode you have [two built-in agents](https://opencode.ai/docs/modes/?ref=blog.matthewbrunelle.com) by default: Plan and Build. Plan mode disables system tools for writing and provides a system prompt for planning. You can still shift+ctrl to cycle between modes, but the workflow is not as well developed. One distinction is that Claude Code drafts the plan in a markdown file. OpenCode has a `.opencode/plans/` directory that it can use for the same purpose, but I found that OpenCode never actually wrote out the plan and iterated on it. The written plan is especially important to enable the "Accept plan and clear context" functionality that Claude Code has. Context size is limited and clearing the context while keeping the written plan is a nice way to avoid context poisoning. There is an [open issue](https://github.com/anomalyco/opencode/issues/13271?ref=blog.matthewbrunelle.com) with an in progress PR for adding the "Accept plan and clear context" functionality so I imagine OpenCode will get there one day. ### Asking vs Doing By default, Claude code assumes it cannot use tools and will ask you to use a tool the first time it tries to. You are then given the choice to allow it once, or always for that project. Claude scopes write permissions more granularly at the folder level. On the other hand, for OpenCode's [tools usage](https://opencode.ai/docs/tools/?ref=blog.matthewbrunelle.com): > By default, all tools are **enabled** and don’t need permission to run. You can control tool behavior through [permissions](https://opencode.ai/docs/permissions?ref=blog.matthewbrunelle.com). This is more of a philosophical difference than a technical one, but I find Claude Code better aligns with what I want. One of the reasons I like Claude Code more than tools like Cursor is the amount of control I retain. Now I should say, part of this difference is because of the tight coupling between Anthropic's models and Claude code. It is much harder to constrain tool use when you want to support any open model. Some specific examples: - OpenCode assumed we would stub the endpoints with no auth, Claude Code asked if should implement auth. - Claude Code asked what should be done about IDs for artists, albums, and songs. OpenCode assumed I would just want to re-use the youtube music IDs. - After the MVP I had to manually prod Claude Code to do some curl tests against the server. OpenCode tested with curl on its own. In these specific examples, OpenCode happened to default to the choice I would have picked, but I would have preferred I was asked. Thankfully, [runtime permission models were added](https://opencode.ai/docs/tools/?ref=blog.matthewbrunelle.com) in Jan, though they are not currently persisted. There is an [open issue](https://github.com/anomalyco/opencode/issues/20066?ref=blog.matthewbrunelle.com) to add persistence, which is very important to avoid approval fatigue. I am hopeful that OpenCode will continue to improve on this front, but it will take time. ### Getting OpenCode to use Specific Tools On the flip side, I had trouble getting OpenCode to consistently use tools. I really like using the `AskUserQuestion` in Claude Code when iterating on a design. OpenCode has a similar [Question tool](https://github.com/anomalyco/opencode/issues/5960?ref=blog.matthewbrunelle.com), but rarely used it. Even when I added a note in the agents file, or asked in a session to make sure to use the tool it often would not. I'm not sure if that was K2.6 specific. Similarly, the OpenAPI spec in this project is big and I have `jq` installed to handle large JSON files. Claude Code will use that more or less automatically, but I had to prompt OpenCode to remember to use jq rather than writing a bespoke Python script every time. ### No memory yet All of those preceding issues wouldn't be a huge problem if I could use [memory files or automemory like in Claude Code](https://code.claude.com/docs/en/memory?ref=blog.matthewbrunelle.com) to iteratively guide OpenCodes behavior. However, OpenCode does not have built in support for memory yet. There is a plugin [opencode-mem](https://github.com/tickernelz/opencode-mem?ref=blog.matthewbrunelle.com) that may help, but this is essential enough functionality it would be nice to have by default. There is an [open ticket that is assigned](https://github.com/anomalyco/opencode/issues/20322?ref=blog.matthewbrunelle.com) so hopefully we won't have to wait too long for this. ### Other minor thoughts: - I like that I can see all the reasoning prompts, Claude Code has started hiding reasoning unless you explicitly turn it back on. - I like that by default I can see the context usage without having to install anything. In Claude Code I have to use [ccstatusline](https://github.com/sirmalloc/ccstatusline?ref=blog.matthewbrunelle.com). - There is no double-escape to rewind like Claude Code, but I didn't need that for this test in particular. Looks like rewind was just [merged this week](https://github.com/anomalyco/opencode/issues/7794?ref=blog.matthewbrunelle.com) though. --- ## Closing thoughts Bringing all these issues together, the gap between the two means that I feel like I have more agency when using Claude Code as it is right now than I do in OpenCode. Ironic, considering that I have less control on the model side with Claude Code. That's the whole reason I wanted to see if I could move off to OpenCode. The gap is not insurmountable though. So, for a little while at least I've canceled my Anthropic subscription to give OpenCode more time. The best part is that when my discounted subscription ends, I can try out some more discounted models from other providers. Ultimately, the demand problem for model providers does not seem to be going away any time soon. They will continue to over provision to acquire users, and then cut back when they scale up. I should note that during the experiment I got a number of: ![Tool execution aborted. Network connection lost.](https://blog.matthewbrunelle.com/content/images/2026/04/Pasted-image-20260422111211.png) So I imagine OpenCode's hosted service is under the same scaling pressure if more people jump ship from Anthropic. I will keep a careful eye on how OpenCode handles this. The good news is that tools which supports interoperability are more guarded against service degradation. You can always choose to pick a different service provider. [\[5\]](#fn5) Or better yet, if we're lucky the self-hostable models will get good enough that we will not need to run a hosted model for most tasks. We can instead use a hybrid approach where we default to local models first, and only pay for models on harder tasks. ![Pasted image 20260422134949.png](https://blog.matthewbrunelle.com/content/images/2026/04/Pasted-image-20260422134949.png) --- 1. Still a bummer that we can only get the weights rather than a truly open model. [↩︎](#fnref1) 2. See [this benchmark](https://www.verdent.ai/guides/kimi-k2-6-vs-claude-opus-4-6-vs-gpt-5-4?ref=blog.matthewbrunelle.com). [↩︎](#fnref2) 3. I will say, I'm not a fan that I can only sign up with Google or GitHub oauth. [↩︎](#fnref3) 4. Weirdly enough OpenCode wanted to use datamodel-code-generator to make the stubs, but eventually gave up on that. [↩︎](#fnref4) 5. I know I will probably try a different provider once my one month discount ends for OpenCode Go. [↩︎](#fnref5) ### It's OK to Use Coding Assistance Tools To Revive The Projects You Never Were Going To Finish URL: https://blog.matthewbrunelle.com/its-ok-to-use-coding-assistance-tools-to-revive-the-projects-you-never-were-going-to-finish/ Last updated: 2026-04-24T00:54:40.000Z #### On this page Note: I initially drafted this before my last post on how [Claude Code is getting worse](https://blog.matthewbrunelle.com/the-claude-coding-vibes-are-getting-worse/). I'm putting it out now so I can reference it in a future post on OpenCode. As you can imagine my opinion on Claude Code has shifted since I wrote this. --- Long ago I attempted a personal project, but never finished due to life being busy. [\[1\]](#fn1) Sort of like the Japanese word Tsundoku, for the pile of books you intend to eventually read one day. We all have these projects and they are good candidates for testing out AI coding assistance. After all, they were never going to get done anyway. The POC I put together was a shim between YouTube Music and the [OpenSubsonic api](https://opensubsonic.netlify.app/?ref=blog.matthewbrunelle.com). Explaining OpenSubsonic could be its own article, but for our purposes it's an API contract for nicely decoupling music streaming clients and servers. You can pick your own options for both. In my case I like [Navidrome](https://www.navidrome.org/?ref=blog.matthewbrunelle.com) for the server, [Feishin](https://github.com/jeffvli/feishin?ref=blog.matthewbrunelle.com) for desktop, and as I mentioned in my [post on GrapheneOS](https://blog.matthewbrunelle.com/the-apps-that-i-kept-on-grapheneos-2/#the-media-apps), [Symfonium](https://www.symfonium.app/?ref=blog.matthewbrunelle.com) for Android. Anyways, the shim made YouTube Music conform to the API so I could add it to any of my clients. Under the hood I used `ytmusicapi` for metadata lookup and programmatically called `yt-dlp` to stream the music. Getting basic streaming working was pretty simple. However, there was a long tail implementing all the endpoints in a conformant way. Then as always, there were new shiny projects that stole my attention away. Like that [embedded rust location project I promise I'll finish at some point](https://blog.matthewbrunelle.com/hello-walter-learning-rust-on-esp32/). Maybe. Luckily, nothing was really novel in that streaming project, and there is a clear spec to implement which is perfect for assisted coding. So a month and a half ago I thought I would test Claude Code with Opus 4.6 and see how it did implementing the project from scratch. After all, they gave me a free $50 in credit, so I might as well. --- ## The setup Since I had already written a proof of concept by hand, I had my own opinions about the implementation and laying all of that out beforehand constrained the tool in a nice way. I did the following: - Created a uv project with `fastapi`, `pydantic`, `ytmusicapi` and `yt-dlp` as dependencies. - Changed main.py to the example FastAPI main file. - Dropped the openapi spec for OpenSubsonic in the folder. - Added a brief description in a readme file: ``` This project acts as a shim, exposing YouTube music as an opensubsonic client. It uses fastapi for its server with pydantic, ytmusicapi for metadata and yt-dlp for streaming." opensubsonic docs are available at: https://example.docsy.dev/docs/reference/ The openapi spec is in openapi.json. ``` - Added an empty TODO file. - Generated a CLAUDE.md file using `/init`. I also often add a section like this to the CLAUDE.md file: ``` ## Conventions - Methods should have type annotations for args and returns as well as docstrings. - Use Pydantic for data modeling. Use modern Pydantic V2 conventions. - Doc strings should use the Google style format with an args and returns sections. - Write unit tests with modern pytest style, eg top level methods using `assert` and fixtures. ``` That's mostly based on past experience for what I have to repeatedly ask Claude Code not to do. I've bundled up this starting point [into a git repository](https://github.com/ciferkey/coding-assist-experiment-starting-point?ref=blog.matthewbrunelle.com) in case anyone else wants to try the experiment. --- ## Implementing the MVP With that setup done, I let Claude kick things off. The workflow I typically use is: - Enter plan mode. - Prompt for the next piece of work. - After getting the initial plan, look for gaps / problems and ask follow up questions until I like the plan. - Provide links to resources when Claude is off. - Ask Claude to use the search tool to figure out what is idiomatic when there are multiple options and it is unclear to me which to take. - Use "Accept and clear context". - Repeat. The first prompt I used was: > Have a look at the openapi.json file. This is a spec for the opensubsonic api. Implement an async fastapi server that stubs out all of the methods. There are both older xml endpoints and newer style json endpoints. You only need to handle the newer json endpoints. For this kind of change I like to clear context after implementing and then ask a follow up question: > I implemented stubbed versions of all the methods specified in openapi.json. Double-check they are correct. Even with a spec, Claude Code makes mistakes the first time, but then will catch them (mostly) the second time through. Also, after implementing larger changes, I like to re-run `/init` to update the CLAUDE.md file to cover the new pieces. The next major prompt was: > The methods for all endpoints are stubbed out now. I want to connect a subsonic client, search for a song, and stream it to the client. What is the minimum amount of functionality needed to implement that? Use ytmusicapi for searching YouTube music and yt-dlp for streaming. I got *an implementation* that looked reasonable pretty quickly, but fell over when trying to actually connect with Feishin. At that point I iterated by testing the client and handing the server request logs to Claude Code. Even with a spec there are details that are not spelled out clearly, like how endpoints may have a `.view` suffix that needs to be stripped. Every time there was an error I generated new unit tests to cover them. I was shocked to hear the audio streaming through feishin after only a couple of iterations. The main issues involved stubbed endpoints returning nothing. They mostly had to be updated to return empty, but correctly structured responses. Just getting an MVP is the easy part though. Not that far beyond what I implemented in my POC. --- ## Working through the long tail. The rest of the work was the less interesting, more drudgery parts to make the project actually usable. From [the docs](https://opensubsonic.netlify.app/docs/opensubsonic-api/?ref=blog.matthewbrunelle.com), OpenSubsonic has \~80 endpoints spread over 15 different categories. For the MVP use case I only had to support: - getLicense, getUser, getGenres and getMusicDirectories with empty, but valid collections. - getSong as a pass through that returned the ID in the query params and default values. - search3 with a very basic ytmusicapi call. - stream with a yt-dlp call wrapped in an `asyncio.to_thread` to extract the URL for the `"bestaudio"` format. - getCoverArt with a call to yt-dlp to extract the cover image URL. To support the full functionality of a subsonic client I: - Added simple in memory caching for ytmusicapi calls to avoid hitting usage limits. - Used sqlite for storing music metadata and implemented all the endpoints in the browsing category. Even getTopSongs by querying for the top songs list. - Saved the song to disk as it streamed to avoid redownloading songs. I had to have additional handling to clean up the incomplete file when a client disconnects from the stream endpoint before the file was fully downloaded. I knew all these things had to be done to make my own POC more usable, and I could have done them, but never did. At the same time, since I never planned to release anything I absolutely skipped the hard bits around authentication. All together I was able to get a working service that I could connect to from a subsonic client in a short evening. In the end I dubbed the project "Sub-standard". --- ## Is this good? I don't want to sound like an AI coding assist booster. I still have fears around deskilling from relying on these tools too much. That's why I still bang my head against the wall [trying to learn Rust](https://blog.matthewbrunelle.com/finally-actually-hopefully-learning-rust/). In my mind there are different buckets for personal projects. One is *things I do to learn and grow* and the other is *things I really wish existed*. [\[2\]](#fn2) This kind of project falls into the second bucket. Using AI coding assist to reify those projects is sort of a form of wish fulfillment. I never would have gotten to it, but now I can have the project. One less metaphorical book sitting unread on bookshelf. In the end I think the important thing is not whether you are doing projects in bucket 2, but whether you are also still doing the stretch projects in bucket 1. --- 1. Or at least that is the excuse I tell myself. [↩︎](#fnref1) 2. Also other buckets, I don't want to imply those are the only two. [↩︎](#fnref2) ### The Claude Coding Vibes Are Getting Worse URL: https://blog.matthewbrunelle.com/the-claude-coding-vibes-are-getting-worse/ Last updated: 2026-04-16T19:29:36.000Z For a while now I've been evaluating Claude Code with my personal projects. [\[1\]](#fn1) See my post on [self-hosting models for Claude Code](https://blog.matthewbrunelle.com/using-self-hosting-language-models-so-you-can-evaluate-claude-code/) [\[2\]](#fn2) The public sentiment for Claude Code has grown quite positive recently. Starting with the release of Opus 4.5 in November last year [\[3\]](#fn3) and continuing to grow with the release of Opus 4.6 in February. Then there was OpenAI's Pentagon deal in late February and Anthropic's [response](https://www.anthropic.com/news/where-stand-department-war?ref=blog.matthewbrunelle.com) in March. With all of that came a shift in usage to Anthropic which is nicely captured in this revenue chart: ![Pasted image 20260416140525.png](https://blog.matthewbrunelle.com/content/images/2026/04/Pasted-image-20260416140525.png) From [Anthropic's press release](https://www.anthropic.com/news/anthropic-raises-30-billion-series-g-funding-380-billion-post-money-valuation?ref=blog.matthewbrunelle.com). So if Anthropic is growing like crazy from positive press, why are people starting to sour now? That revenue growth is nice, but it also means there was a large increase to usage that Anthropic has to handle. Their capacity can't meet the demand, so they are diminishing the product to keep up. Above all, the way they are handling these changes feels adversarial. Claude Code would get an update, something would feel *worse* and I'd have to look into what it was and if i could reverse it. [\[4\]](#fn4) In late March the [clear context and execute option in plan mode was removed](https://github.com/anthropics/claude-code/issues/38071?ref=blog.matthewbrunelle.com) in Claude Code release `2.1.81` the option `showClearContextOnPlanAccept` was added and defaulted to false. The justification given by Anthropic was: "This was unshipped because you don't need this anymore with 1M context!". Even with a 1M context the performance degrades as the context percentage goes up and I literally had to stop until I could restore the option. Anthropic eventually relented and changed the back to true. Then in early April Anthropic stopped allowing 3rd party programs to use tokens from users subscriptions to the Pro and Max plans. Now only credits for API tokens are now allowed. There was no official announcement. Instead, they just updated the terms, update to the [usage policy docs](https://code.claude.com/docs/en/legal-and-compliance?ref=blog.matthewbrunelle.com#authentication-and-credential-use). Some staff also [confirmed](https://www.threads.com/@boris%5Fcherny/post/DWsAWeND5nm/?ref=blog.matthewbrunelle.com) the change on social media. Also in early April an update changed the [cache TTL from 1 hour to 5 minutes](https://github.com/anthropics/claude-code/issues/46829?ref=blog.matthewbrunelle.com). The [response](https://github.com/anthropics/claude-code/issues/46829?ref=blog.matthewbrunelle.com#issuecomment-4231266649) said this was a bug, corrected it and noted that it was implemented as a part of "ongoing optimization work - [\[5\]](#fn5) it wasn't a regression, on balance it lowers total cost for users across the request mix." Finally there is this somewhat vague issue that mostly captures the feeling about how ["Claude Code is unusable for complex engineering tasks with the Feb updates"](https://github.com/anthropics/claude-code/issues/42796?ref=blog.matthewbrunelle.com#issuecomment-4215081503). All these issues on the Claude Code project on GitHub were shared around the internet and now are flooded with complaints. They are marked as closed or not planned, but the underlying concerns still remain. All of this leads up to today when Opus 4.7 dropped and in the release notes was this [section](https://platform.claude.com/docs/en/about-claude/models/whats-new-claude-4-7?ref=blog.matthewbrunelle.com#extended-thinking-budgets-removed) on how extended thinking budgets were removed: > Extended thinking budgets are removed in Claude Opus 4.7\. Setting `thinking: {"type": "enabled", "budget_tokens": N}` will return a 400 error. [Adaptive thinking](https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking?ref=blog.matthewbrunelle.com) is the only thinking-on mode, and in our internal evaluations it reliably outperforms extended thinking. Yet another degradation that I cannot remove or reverse. As for the statement that "our internal evaluations it reliably outperforms extended thinking", this is a graphic from the issue thread: [\[6\]](#fn6) ![Pasted image 20260416135609.png](https://blog.matthewbrunelle.com/content/images/2026/04/Pasted-image-20260416135609.png) To be sure, these changes are designed to mitigate the impact of oversubscribing users while Anthropic goes through this growth period. [\[7\]](#fn7) However, the most frustrating part is these changes impact the quality of life for *all* users of Claude Code. Even when you are paying for tokens through the API. I know I [use the word too much](https://blog.matthewbrunelle.com/braunshittification/), but this does feel like we are moving through the enshittification curve. Cresting over the "good to their users" phase. So while I am making a reference to Cory, I might as well quote this thought [from him](https://pluralistic.net/2026/02/19/now-we-are-six/?ref=blog.matthewbrunelle.com): > Doubtless some of you are affronted by my modest use of an LLM. You think that LLMs are "fruits of the poisoned tree" and must be eschewed because they are saturated with the sin of their origins. I think this is a very bad take, the kind of rathole that purity culture always ends up in. > Refusing to use a technology because the people who developed it were indefensible creeps is a self-owning dead-end. You know what's better than refusing to use a technology because you hate its creators? Seizing that technology and making it your own. Don't like the fact that a convicted monopolist has a death-grip on networking? Steal its protocol, release a free software version of it, and leave it in your dust > The AI bubble sucks. AI itself is a *normal technology* I can't use Anthropic's models with [OpenCode](https://github.com/anomalyco/opencode?ref=blog.matthewbrunelle.com), but I think I will give it a try with other models. [\[8\]](#fn8) Things move so fast, I can't imagine Anthropic will have the lead with their models forever. The open models and harnesses will catch up and when that happens Anthropic can't degrade those. --- 1. At my employer we are also really trying to test out the tooling for work tasks to decide if we want to use it more. [↩︎](#fnref1) 2. Though things move fast so this is now out of date. [↩︎](#fnref2) 3. Also with developers taking vacation in December and giving it a try for the first time. [↩︎](#fnref3) 4. Also tucking away links as I fixed things knowing I would inevitably have to write this. [↩︎](#fnref4) 5. This was an em dash, and even through it was a quote, I strive to purge those from my site. [↩︎](#fnref5) 6. And originally from Reddit. [↩︎](#fnref6) 7. Yes, I just finished a class on writing politically engaged essays, so of course I'm going to literally include the "to be sure" paragraph. [↩︎](#fnref7) 8. After all, qwen3.6-35b-a3b [dropped](https://qwen.ai/blog?id=qwen3.6-35b-a3b&ref=blog.matthewbrunelle.com) today. [↩︎](#fnref8) ### Griffin McElroy On Writing His CYOA Book The Stowaway URL: https://blog.matthewbrunelle.com/griffin-mcelroy-on-writing-his-cyoa-book-the-stowaway/ Last updated: 2026-03-15T14:19:43.000Z #### On this page This post is a little different than usual. A friend reached out asking me to go an [event](https://artsatthearmory.org/events/griffin-mcelroy-the-stowaway/?ref=blog.matthewbrunelle.com) at Arts At The Armory in Somerville. At that time the page had no description and only the title "Griffin McElroy", but that was enough for us to buy tickets. In my post on [what podcasts I'm listening to](https://blog.matthewbrunelle.com/what-podcasts-am-i-listening-to-2025/#now-the-fun-stuff) there is a whole section on McElroy family podcasts. The event actually was a live reading from *The Stowaway* by Griffin, an interview by fellow author Aaron Mahnke, and audience Q&A. The ticket also including a copy of the book. Weirdly enough, this was the only venue where Griffin talked about his new book. Luckily since I've [been practicing writing](https://blog.matthewbrunelle.com/on-practicing-writing/) I decided to take notes. I thought I'd share them since Griffin gave some really good advice and no one else will get to have this experience. --- ## The interview Aaron did a great job leading the interview portion with Griffin. The two had a great rapport. Also, the questions here are all reconstituted from my hastily written notes so they are not the exact questions, but pretty close. ![griffin_interview.jpg](https://blog.matthewbrunelle.com/content/images/2026/03/griffin_interview.jpg) ### Q: How did Griffin get into choose your own adventure books (CYOA)? Griffin "was never a book guy as a kid", though his two brothers and dad were very much into reading. However he did play text heavy games like FF2 (FF4) at 5 years old. His first real exposure to books was when his family would go on long road trips. His dad Clint would do two things every time: - find an all you can eat crab leg restaurant and "single handedly decimate a crustacean population". - find a used bookstore. This was pre-google maps, but his dad just had friends that would know where to go. He would spend at least two hours there looking for books and Griffin would have to find a way to pass the time. Of course he had his Game Boy, but he discovered choose your own adventure books and started getting one or two on every trip. In his mind CYOA were the most video game like book option out there. The books he really remembers as a kid were the R.A. Montgomery books *The Abominable Snowman* and *Space and Beyond*. *Space and Beyond* was especially mind blowing because the very first choice involved picking between two different planets and that felt like there were two stories in one book. Of course the Goosebumps books were memorable too. ### Q: What was it like making the book? Griffin hadn't read a CYOA book for a while, but visual novels acted as a touchstone for him. He mentioned *The Nonary Games* as an example. Also, as a kid he played text parser games like Zork, Ultima, and Kings Quest. > "I didn't read a lot, but when I did it was on a screen and almost always localized from Japanese." Chooseco was the one to reach out to Griffin, but he was on board from the start. Pedro Rodríguez did the art and when Chooseco sent it over to Griffin to see he knew it was a match. ### Q: Do your kids enjoy CYOA books? His oldest son Henry is 10 now, and Griffin thought he might be able to trick him into reading like his dad did to him. However, Henry got scared by a witch killing him the first book they tried. He hadn't wanted to try another book until the day before the show when he asked Griffin to. That might be because Henry heard there was a dedication to him at the start of the book. ### Q: Was a CYOA book a good match for your writing style? People often talk about how there are two types of writers: those who fly by the seat of their pants (pantsers) and those who carefully plan everything out (plotters). A CYOA is definitely a plotter's type of work. Griffin and his dad did most of the writing on the book adaptations of *The Adventure Zone*. That informed how he wrote this book. Also, for the podcast Griffin will plan a lot out ahead of time, but when GMing in game for the podcast he adjusts things as needed. Whereas the book is *entirely* planning. ### Q: What was the process like for writing the book? The first thing Griffin had to do was figure out how all the pieces of the CYOA fit together. His editor Ben Wilgus suggested using draw.io to map out the flow of the book. Before that he had a pile of index cards on the floor "like the laziest person trying to solve a mystery". He was going to do a crime wall, but thought that if he put them on the floor "it's not like they would go anywhere". It was really important to get the flow right because it would have been tough to make big structural changes after he started working. A change to an individual section impacts everything else so much. So once he figured out the flow, he could work on each part in isolation. This was especially important because he said he has trouble staying on schedules. As a heavy planner this "was the format for him" and made it easier to hit his agreed on deadlines. ### Q: What was it like adapting the Adventure Zone podcast as books? Griffin mentioned that adapting an existing story is hard. To change a work is worse than to make something wholly new. Thankfully for *Balance* there were natural split points to make the seven books. The down side of course is that that also means essentially writing seven different stories. Griffin added: "I am medicated to do this". Also, he mentioned he has a big e-ink tablet and that he only uses for scheduling. [\[1\]](#fn1) ### Q: Would you do it again? "Yes!" Though Griffin noted that for him and his family there is a little guilt any time they do something that is not the main podcasts because that is their "real" job. They sort of have to come to their fans with their hat in their hands. In general it can be tough to find a place for other projects w/ four podcasts a week. ### Q: How did working on the Advanture Zone help you? There was sort of a natural progression from the work plotting out seasons of the Adventure Zone. GMing ended up being great prep for writing this kind of book. However, he cautioned that writing a CYOA book would not be a good prep for GMing. ### Q: Are there any endings that are you favorite? There are 26 endings and yes he has a favorite, but you'll have to read to find it. He said it would be fairly obvious. Also, Ben Wilgus gave some advice: it is a mainstay of the genre to have some wild endings. Ones that even can feel tonally off and that should be honored. --- ## The audience Q&A After Aaron's interview there was a chance for some quick audience questions. ![PXL_20260312_000349239.jpg](https://blog.matthewbrunelle.com/content/images/2026/03/PXL_20260312_000349239.jpg) ### Q: I am also a writer. Does the feeling "what if no one cares?" ever go away? "No. It truly doesn't. I didn't even know if anyone was going to come watch me read it tonight." ## Q: What is an ending you were sad to cut? Thankfully Griffin didn't have to totally cut any endings. He made a schedule so that he would not write more than he needed to. However, "there's a little cyberspace detour" that got a bit long when he realized he just wanted to do a *REBOOT* style story. So he had to can a lot of that down. ### Q: As a multiformat story teller, what is the easiest way to get it all out? "Motivation is not the word... I don't want to sound like a *stick with it* kind of guy". What actually helped him was having a contract with a timeline. Griffins best advice: if you complete one story, don't think of the other 99 unfinished stories in the folder as a failure. Instead think "I wrote that. I made that. I got better from it." Everything is cumulative and if he doesn' think he will get excited enough to finish a story, it wasn't worth doing. ### Q: Did you beta test the book? Griffin knows some authors do that, he did not. Instead he had just a few rounds of edits. Also, some people think CYOA are just for kids so he wasn't sure about asking others. Though Griffin noted that they are the rare opportunity to read a second person story and even adults should try them. ### Q: Why Somerville, MA for your one and only stop? "There is no satisfactory answer". Griffin did not select the venue, but was so thankful that everyone came. His family has been to Boston a lot for other tours, but only the city proper. He said he was charmed by walking around Somerville and it reminded him of his time in Roscoe Village in Chicago. --- These are mostly some raw notes right now. I'm taking a class on [Writing and Publishing Politically Engaged Essays](https://grubstreet.org/workshop/politically-engaged-essays-gilsdorf-w26?ref=blog.matthewbrunelle.com) and I might use this to create my first op-ed style article. I'll post that if I use this material for my class. Also, this show got me excited to read the book with my nieces and nephews. They are just about old enough now for a CYOA. --- 1. I am the same in that regard. [↩︎](#fnref1) ### 8.4 Months of Daily Driving GrapheneOS URL: https://blog.matthewbrunelle.com/8-4-months-of-daily-driving-grapheneos/ Last updated: 2026-03-02T19:35:30.000Z #### On this page Back in June I wrote a [post on trying out GrapheneOS](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/). I was going to wait a year before doing an update, but two things happened recently: First, the big news I've been waiting for: GrapheneOS [announced](https://grapheneos.social/@GrapheneOS/116159602850585685?ref=blog.matthewbrunelle.com) they are [partnering with motorola](https://motorolanews.com/motorola-three-new-b2b-solutions-at-mwc-2026/?ref=blog.matthewbrunelle.com) to offer a non-pixel device! [\[1\]](#fn1) Second, Pixel 9 Pro Fold decided to implode. I've been [battling support](https://blog.matthewbrunelle.com/i-almost-had-a-good-support-experience-with-google/) around the replacement, but was pleasantly surprised by the [GrapheneOS backup experience](https://blog.matthewbrunelle.com/the-backup-and-restore-experience-on-grapheneos/). The upside from all of this is I needed to reinstall GrapheneOS and that gave me a chance to reflect on the last 8.4 months of using it. --- ## The apps When I first transitioned to GrapheneOS I gave in depth write ups on [the apps I kept](https://blog.matthewbrunelle.com/the-apps-that-i-kept-on-grapheneos-2/) and [the apps I got rid of ](https://blog.matthewbrunelle.com/the-apps-that-i-got-rid-of-when-trying-out-grapheneos/). This was my first time trying to be intentional about my phone usage. Back then I broke my apps down into five buckets: - "essential" apps that that I use daily. In quotes because I realized I didn't necessarily need all of them. - Nice to have - I use these regularly, but am not overly attached to them. - Google - their various services. - Work - apps I need for work. - Private space - the "jail" for junk apps I only need on occasion like airline, rideshare, and restaurant. When private space is not on the apps are all frozen. Now my buckets are simply: - Daily Drivers - Google apps I begrudgingly use - Private space / reinstall when needed Work apps are *gone*. I'm in a lucky situations where I can do this. --- ## The Google apps I begrudgingly use Try as I might, there is going to be some amount of Google apps on an Android phone. The game now is to minimize their usage. \[+-\] Google Camera replaced GrapheneOS camera - I reinstalled Google Camera for the occasional photo in difficult lighting. The camera app is really hard to beat. \[-\] Google Chat - gone, the two friends that used it are now on Signal finally. \[ \] Google Maps - still here and will be for a long time. I looked into using [Transportr](https://transportr.app/?ref=blog.matthewbrunelle.com) for when I take public transit but Navitia removed their free API, so Transportr [can no longer use them](https://github.com/grote/Transportr/issues/877?ref=blog.matthewbrunelle.com). \[ \] Google Messages - for most of the last 8 months RCS was broken on GrapheneOS, but it's back now and Google messages is still the only option for messaging with family members on iOS. \[ \] Android Auto - mostly working without issue. --- ## The Daily drivers As I've continued to whittle down the apps I use I don't really feel a need to distinguish between "essential" apps and nice to have apps. The survivors are now just the set that I truly use day to day. What hasn't changed since the old posts: - Firefox for my browser. - The nature and fitness app categories from my old post are unchanged. - Tuta for my mail email and Thunderbird for my gmail account. [Version 17 is launching soon](https://github.com/thunderbird/thunderbird-android/releases/tag/THUNDERBIRD%5F17%5F0b1?ref=blog.matthewbrunelle.com) with foldable support! - I'm still using Futo keyboard but switched the voice to text model to English 244 and the accuracy is considerably better. The main changes are: \[+-\] AntennaPod replaced PocketCasts - After my [post on modifying PocketCasts to remove ads](https://blog.matthewbrunelle.com/podcasts-you-altered-the-deal-so-i-will-alter-your-app/) when they reneged on their grandfathered accounts I started using AntennePod with [gpodder sync](https://apps.nextcloud.com/apps/gpoddersync?ref=blog.matthewbrunelle.com) on nextcloud. I miss some features from PocketCasts [\[2\]](#fn2), but the change was pretty smooth. \[+\] [CameraSync](https://github.com/rock3r/CameraSync?ref=blog.matthewbrunelle.com)/GR World - I'm still [using my GRIII for photos](https://blog.matthewbrunelle.com/ricoh-griii-in-japan/). Ricoh released a new GR World app that is better than their old bad app, but still pretty flakey. CameraSync is a recent FOSS app that so far is very reliable for syncing my location to my camera. \[-\] Discord - added [mandatory age verification](https://www.eff.org/deeplinks/2026/02/discord-voluntarily-pushes-mandatory-age-verification-despite-recent-data-breach?ref=blog.matthewbrunelle.com), and though it hasn't impacted me yet I've wanted to reduce my Discord usage for a long time. The interleaving of memes and important conversation makes it hard for me to follow up on messages. As I mentioned in my [post on Thunderbird Appointment](https://blog.matthewbrunelle.com/trying-out-thunderbird-appointment-while-i-patiently-await-for-an-invite/) I still use it for coordinating TTRPGs. So for now I'm going to deemphasize Discord by uninstalling from my phone and only using it on desktop. [\[3\]](#fn3) \[+\] Kagi Translate - I previously dropped Google Translate and thankfully last month [Kagi released their own translate app](https://blog.kagi.com/kagi-translate-mobile?ref=blog.matthewbrunelle.com). I haven't had a chance to try it out while traveling, but OCR translate is very helpful when abroad. \[+-\] Kvaesitso replaced Niagra for my launcher. The extra customization options were appreciated and it's FOSS. [\[4\]](#fn4) \[+\] TickTick - For self management I relented and switched from DAVx5, ICSx5, Tasks.org, Fossify Calendar and NextCloud Deck back to TickTick. I noticed I was dropping a lot of tasks outside of work and TickTick just works really well for managing my ADHD. \[+\] [SpamBlocker](https://github.com/aj3423/SpamBlocker?ref=blog.matthewbrunelle.com) \- is a FOSS option you can set as your default caller ID app. I love that the repo has a table explaining in detail why each permission is needed. There is also a build that has no internet permission. \[-\] Snapchat - I was essentially only opening this once a week and kept it in an isolated user profile. Now I've just dropped it. Snapchat was the last social media app I had and it's nice to be done with it. As a side note, I'm experimenting with self-host a defederated Pixelfed instance for just family, but mostly I just text photos to people now. --- ## The Private space / install when needed apps I used to keep apps that slurp up lots of data and I only used occasionally in the the Private Space. Now I mostly just keep them uninstalled and only install them temporarily. So if I'm going on a flight I will install the airline's app in the Private Space and then remove it after the trip. Parking apps were another category since there are at least four of them for the greater Boston area. Now, I just walk to the pay terminal and print a ticket. --- ## Updates on how I use GrapheneOS day to day When I first wrote about GrapheneOS I had grand designs around using multiple user profiles to isolate the different parts of my life: personal, work, apps that were trying to steal my attention, etc. I gave up on that pretty quickly though. Partially because juggle multiple users was a little rough, but mostly because I just use my phone differently now. After limiting what I do with my phone I don't need those different profiles. Also as I mentioned in my post on using [xDrip+ with Stelo](https://blog.matthewbrunelle.com/cgms-part-2-using-xdrip-with-stelo/), another major change in my behavior is I now toggle off location using the quick tile unless I specifically need it. This was mostly due to me relenting and giving Google Play services expanded access to location. --- ## Updates in GrapheneOS itself Over the last year I've been collecting notes on GrapheneOS releases with changes that caught my eye. Here are some important highlights for my day to day usage: From [release 2025122500](https://grapheneos.org/releases?ref=blog.matthewbrunelle.com#2025122500): > enable lockscreen widget support From [release 2026020600](https://grapheneos.org/releases?ref=blog.matthewbrunelle.com#2026020600) > add cross-SIM calling support (making calls using a SIM via the mobile data provided by another SIM similarly to Wi-Fi calling); requires Automatic data switching in SIM settings, and existing installs may need to toggle Automatic data switching off and on to enable cross-SIM calling From [release 2026021200](https://grapheneos.org/releases?ref=blog.matthewbrunelle.com#2026021200) rcs fix part 2 > Sandboxed Google Play compatibility layer: add toggle for granting Play services access to ICC auth in order to support RCS with carriers requiring it for RCS in Google Messages including T-Mobile These are incremental, but meaningful additions. Subscribing to the GrapheneOS changelog really puts into perspective how many small improvements add up over time. The issue RCS fix for T-Mobile was especially important for me. --- ## FOSS to table In addition to GrapheneOS changes, there have been some other changes to fill in the missing gaps. Similar to thinking about where the food you eat comes from, it's a useful exercise to learn about where the features you use come from. I talked about this [in a past post](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/#categories-of-features), but the short version is: it's either AOSP, Google's apps, or the Pixel ROM. Since my initial GrapheneOS post, a few more missing features have been filled in. The FOSS [\[5\]](#fn5) app [Flip 2 DND](https://github.com/robinsrk/flip%5F2%5Fdnd?ref=blog.matthewbrunelle.com) implements Flip to Shhh from Digital Well Being. I initially really missed this on GrapheneOS. However, I no longer need it since I just keep my phone on DND all day to reduce distractions, but I wanted to highlight this as a good replacement option. The other big feature I missed from Digital Wellbeing was Wind Down which toggled settings on the phone for bed time. Android 16 AOSP reworked the toggles for DND, work, driving, and sleep into "Modes", so this is mostly fixed now. There is still no option to turn on automatically after a certain time when plugged in like in the "Pixel Modes" version, but the schedule for turning it on is close enough. Finally, a note around TTS. On Android TTS is not build into AOSP. [\[6\]](#fn6) Instead you need to have an application that provides [TextToSpeechService](https://developer.android.com/reference/android/speech/tts/TextToSpeechService?ref=blog.matthewbrunelle.com). On most devices this is usually handled by Google's Speech Recognition & Synthesis app, but that is of course not installed by default on GrapheneOS. There are FOSS apps like [Sherpa TTS](https://f-droid.org/en/packages/org.woheller69.ttsengine/?ref=blog.matthewbrunelle.com), but the models they provide only support one language at a time and the app can only load one model at a time. This limitation means Sherpa cannot easily switch between different languages, which is exactly what you need for a translation app. I think [espeak NG](https://play.google.com/store/apps/details?id=com.redzoc.ramees.tts.espeak&hl=en%5FUS&ref=blog.matthewbrunelle.com) might work, but I haven't given it a try considering the voice quality. --- ## Closing thoughts > You're stuck with using Pixel devices if you want to use GrapheneOS, but I may leave Google devices behind once they find a [possible hardware partner in the future](https://piunikaweb.com/2025/11/19/grapheneos-to-release-non-pixel-flagship-phone-in-q4-2026-or-in-2027/?ref=blog.matthewbrunelle.com). > \~ Me, in the first draft of this post. Well the future is now! Actually the future is still slightly ahead of now, since you can't actually get a motorola fold yet, but the future is closer now than previous was. [\[7\]](#fn7). And the timing for this is excellent with Google trying to close off Android and fdroid's statement on [keeping Android open](https://f-droid.org/2026/02/20/twif.html?ref=blog.matthewbrunelle.com). A FOSS ROM with first party hardware support is a huge step in the right direction. If you're already using GrapheneOS, consider [donating](https://grapheneos.org/donate?ref=blog.matthewbrunelle.com) if you are not already. --- 1. What's more, there is also going to be a [motorola razr fold](https://motorolanews.com/motorola-razr-fold-mwc/?ref=blog.matthewbrunelle.com), so I won't have to give up using a foldable. [↩︎](#fnref1) 2. The audio normalization, voice boost and silence skipping are much better. [↩︎](#fnref2) 3. The upside is less disruptions from my phone which is in line with Cal Newport's suggestions on phone usage. [↩︎](#fnref3) 4. Rest in peace Nova launcher, you will be remembered as a tragic cautionary tale. [↩︎](#fnref4) 5. The app has a paid pro version with more features that is a one time purchase. All of the feature are in the repo though if you wanted to do your own builds, and I'm down with supporting developers. [↩︎](#fnref5) 6. Which makes sense, since you don't want to have to wait for an OS update to get new capabilities. [↩︎](#fnref6) 7. [That's not the subjunctive.](https://www.youtube.com/watch?v=QQnIBF470T8&ref=blog.matthewbrunelle.com) [↩︎](#fnref7) ### The Backup and Restore Experience On GrapheneOS URL: https://blog.matthewbrunelle.com/the-backup-and-restore-experience-on-grapheneos/ Last updated: 2026-03-02T18:42:53.000Z #### On this page I can frequently be critical of Google. What is the expression, the harshest critics are often the ones that care the most? Despite their steady downward slide, I did at one point really appreciate them. Even back in 2015 when my Nexus 6p suffered a common [catastrophic hardware failure](https://drum.lib.umd.edu/bitstreams/29c531a3-6c2c-40c0-8028-b227dcc8d5bb/download?ref=blog.matthewbrunelle.com) (PDF direct link) [\[1\]](#fn1) I still kept replacing the phone (four times) and continued to use the device. However, my Pixel 9 Pro Fold recently started to exhibit a common [catastrophic hardware failure](https://www.androidpolice.com/pixel-fold-battery-question-mark-icon/?ref=blog.matthewbrunelle.com): the touch on my outer screen started to fail \[ ^ The upside of two screens is that when one fails, you still have the other.\] and the battery icon became a question mark. Typically once that starts the devices fail within a week. Mine lasted three more days. Also, I am still dealing with support cases with the Google Store. Since the situation is evolving, I moved that story into [another post](https://blog.matthewbrunelle.com/i-almost-had-a-good-support-experience-with-google/). I was able to find [recent threads](https://discuss.grapheneos.org/d/23677-progress-of-grapheneos-own-backup-solution/42?ref=blog.matthewbrunelle.com) where people were confused about the state of how well backups work on GrapheneOS. This was my first time having to restore on GrapheneOS and I wanted to capture the experience in case anyone else's Pixel device decides to implode. The good news is the process went well for me. --- ## Seedvault - GrapheneOS's built in backup tool Knowing that my hardware was failing imminently, I had to back my device up. On stock Pixels changing phones is pretty easy now a days, even when you do not have the original device. Most everything now is backed up into your Google account storage and reconstituted on the new device. The engineering to [retrofit this into Android](https://developer.android.com/identity/data/autobackup?ref=blog.matthewbrunelle.com) is actually pretty slick. In the Android Developers Backstage podcast, staff from Google [recently talked about](https://adbackstage.libsyn.com/back-it-up-bring-it-back-no-data-left-behind?ref=blog.matthewbrunelle.com) the challenges around backup and the improvements they are making. On GrapheneOS the back up option is [seedvault](https://github.com/seedvault-app/seedvault?ref=blog.matthewbrunelle.com). It was [known for being very flaky](https://github.com/GrapheneOS/os-issue-tracker/issues/6008?ref=blog.matthewbrunelle.com), but [has gotten much better over time](https://github.com/GrapheneOS/os-issue-tracker/issues/4687?ref=blog.matthewbrunelle.com). Especially after [version 15.5](https://github.com/seedvault-app/seedvault/releases/tag/15-5.0?ref=blog.matthewbrunelle.com) where much of it was rewritten. In fact, the renovations are partially built off some of those mainline improvements. The big changes were: - A new format inspired by Restic that uses a new chunker [implementation](https://github.com/seedvault-app/seedvault-chunker?ref=blog.matthewbrunelle.com) based off FastCDC. This enabled compression and deduplication. - Snapshots which allows better handling of interruptions. - Auto-cleanup of older backups. Seedvault also uses a clever trick. Typically on Android if an app sets `android:allowBackup="false"`, then Auto Backup will not backup their data. However, Android 12 changed the behavior for device-to-device (D2D) transfers like when you connect two phones with a cable. D2D now ignores the flag. Seedvault leverages this by pretending to be a D2D transfer so that it can backup all app data. See [PR 562 for more](https://github.com/seedvault-app/seedvault/pull/562?ref=blog.matthewbrunelle.com). This [doc](https://github.com/VoltageOS/packages%5Fapps%5FSeedvault/tree/15-qpr2/doc?ref=blog.matthewbrunelle.com) from the `15-qpr2` branch covers the new design in detail. --- ## Backing up I was able to [backup over WebDAV](https://github.com/seedvault-app/seedvault/issues/317?ref=blog.matthewbrunelle.com) to Nextcloud as well as to a USB 3.0 HDD I connected with an OTG cable. ![seedvault_backup_screenshot.png](https://blog.matthewbrunelle.com/content/images/2026/02/seedvault_backup_screenshot.png) Currently there is no way to configure multiple backup options at once. I had to toggle the backup location when I wanted to change between WebDAV and the HDD, each time reentering the NextCloud information. The HDD backup took maybe a half hour which is pretty fast, all things considered. --- ## Restoring The HDD backup was an order of magnitude faster than the WebDAV backup. [\[2\]](#fn2) So for restoration I used the HDD, figuring there would be a similar performance difference. All of my system settings, WiFi, UI config, etc synced fine, but bluetooth devices all needed to be paired again. I believe this is also the case with a restore on a stock Pixel though. Seedvault backed up my apps in addition to the system configuration. A couple of apps failed to reinstall though: - Readwise Reader - Audiobookshelf - Google Play Store / Google Play Services / Android Auto. The listed Google apps make sense, since they need to be installed through the GrapheneOS app store due to sandboxing, but I'm not sure why the third party apps failed to backup. One interesting caveat is that Google replaced my Pixel 9 Pro Fold with a 10 Pro Fold. Seedvault backed up Google Camera *too well*, and included some native binaries that differed between devices. The logs complained about something related to lens flares. I had to fully uninstall and reinstall the app to fix it. Now that I know how to configure everything I'm going to run backups overnight: ![Screenshot_20260216-202610.png](https://blog.matthewbrunelle.com/content/images/2026/02/Screenshot_20260216-202610.png) --- ## Other device setup Even with Seedvault I still had to log back into some apps. Thankfully there wasn't too much that I had to configure on my device after the restore. For a couple apps I manually exported their configuration just to be safe. Ironically, the exports were all uploaded to Nextcloud as zero byte files. There is an [issue with Nextcloud](https://www.github.com/nextcloud/android/issues/16426?ref=blog.matthewbrunelle.com) where if you select a directory through the Android file picker, it will create empty files. So now for manual exports I write files into a folder on my phone that Nextcloud uploads async. Weirdly enough, this time passkey authentication with Bitwarden did not work when first logging into Google. Luckily I had backup codes to get into my account. Also, I couldn't install my esim until I [enabled esims](https://grapheneos.org/usage?ref=blog.matthewbrunelle.com#esim-management). A useful tip: on T-Mobile you need to contact support to get a new esim setup. If you use T-Force through Facebook or Twitter [\[3\]](#fn3), they are actually very good with support issues. I also received this lovely comment from the rep: > What a great plan you currently have with so many discounts, I love this so much for you. Going forward I'm going to use a [JMP adapter](https://jmp.chat/esim-adapter?ref=blog.matthewbrunelle.com) to store my esim on a physical sim, so I don't have to provision it again. --- ## TL;DR, Seedvault worked well I'm really happy seedvault is usable now, especially since I had to restore from backup *three times* while triaging my issues with the Google Store because I had to go in with the stock OS installed. Backup and restore is finally working now. Fingers crossed I do not have to use it again any time soon. --- 1. Where it would consume power at 200% per hour and die at 60%. [↩︎](#fnref1) 2. I have pretty good APs, but it's hard to beat data locality. [↩︎](#fnref2) 3. I know, I know, I don't use either of those too, but that's the only way. [↩︎](#fnref3) ### Hello Walter! Learning Rust on ESP32 URL: https://blog.matthewbrunelle.com/hello-walter-learning-rust-on-esp32/ Last updated: 2026-02-28T21:31:20.000Z #### On this page ## The story so far I am trying to make an embedded device to scan cell towers, BLE devices, wifi access points and log everything with their GPS positions. The goal is to submit the information to [BeaconDB](https://beacondb.net/?ref=blog.matthewbrunelle.com), and have a fused location provider that works without depending on Google. I don't have a background in hardware or embedded devices though so I've been bootstrapping my knowledge. First I started doing [HackerBoxes's monthly kits](https://blog.matthewbrunelle.com/tag/hackerbox/) to learn the basics. In [HackerBox 0118 More Human](https://blog.matthewbrunelle.com/review-of-hackerbox-0118-more-human/) I messed around with BLE and WiFi scanning using Arduino. Then in [HackerBox 0119 Geopositioning](https://blog.matthewbrunelle.com/review-of-hackerbox-0119-geopositioning/) I tried out using a GNSS with an ESP32 to get location. After that I [set up some BLE beacons around my house](https://blog.matthewbrunelle.com/testing-out-ble-beacons-with-beacondb/) to learn about how they work. All of this came together in a [prototype](https://blog.matthewbrunelle.com/an-esp32-based-beacondb-scanner-prototype/) franken project gluing together a bunch of Arduino example code. The plan was always to rewrite the prototype, but I'm only getting there now after [finally trying to learn Rust](https://blog.matthewbrunelle.com/finally-actually-hopefully-learning-rust/). I've wanted to use the wonderful [Embassy](https://embassy.dev/?ref=blog.matthewbrunelle.com) framework for embedded Rust development, but none of my boards were supported. That is, until now. --- ## About the Walter I recently saw [the Walter devboard](https://www.crowdsupply.com/dptechnics/walter?ref=blog.matthewbrunelle.com) on Crowd Supply and realized the board has essentially everything I need: - An ESP32-S3 - good toolchain support for ESP-IDF and Embassy. Also, has hardware for both bluetooth and wifi. - [Sequans GM02SP](https://www.mouser.com/ProductDetail/Sequans/GM02SP?qs=sGAEpiMZZMu3sxpa5v1qrpTz8AipR5dZiOFp8fdqK3U%3D&ref=blog.matthewbrunelle.com) modem which can handle *both* the cell tower scanning and the GNSS positioning. [\[1\]](#fn1) - Low power. In comparison, I actually bought an ESP32-s3 and a GNSS quite a while ago, but they have been sitting in a box because: - I was still working my way through some Rust practice books. - I didn't yet have a modem I could use for checking cell towers. - The project felt daunting. Now, the Walter is $77 before shipping. This costs more than the ESP32-S3, and bare GNSS chip, but starts to get more competitive when you include the cost of a modem. Plus, the Walter gives me a nice platform to start learning about rust dev on in a low friction way. --- ## The plan Speaking of low friction, this time I'm going to do several smaller posts as I go, to keep the writing overhead down. Roughly the idea is to work on: - Getting Embassy running on the board (this post) - BLE Scanning - WiFi Scanning - Cell Tower Scanning - Location and Time - Saving to SD Card Both BLE and wifi scanning should be pretty easy since there are good examples of the Embassy libraries available. Working with the modem is after that since it involves learning about UART for communication. Finally, saving to an SD card is not hard, but thinking about *how* I want to encode everything will be easier when I have the context from the other pieces. All these little pieces are also why Embassy feels like a good fit for this project. Several kinds of scans will all operate at once and that would be pretty ugly on something like Arduino. [\[2\]](#fn2) While async Rust may not be beloved, this is the exact use case for it and Embassy is an async framework. --- ## Setting up the dev environment on NixOS TL;DR ESP32-S3 dev setup is not turn key yet, but doable. If you go on the NixOS wiki and look at the [rust page](https://nixos.wiki/wiki/Rust?ref=blog.matthewbrunelle.com), you will learn about the [oxalica/rust-overlay](https://github.com/oxalica/rust-overlay?ref=blog.matthewbrunelle.com) which helps create nice clean development flakes. However, the overlay uses the official rust channels, which do not support the Xtensa toolchain needed for the esp32-s3\. It also seems that the [overlay cannot be modified to work](https://github.com/oxalica/rust-overlay/issues/89?ref=blog.matthewbrunelle.com) with the forked toolchain yet. There is an [issue tracking the work to upstream the compiler on LLVM](https://github.com/espressif/llvm-project/issues/4?ref=blog.matthewbrunelle.com). So the only choice left is to use a FHS environment with espup. There are several projects on Github to do this, but I couldn't get any of them to work. This is understandable as the tooling changes so frequently. Have a look at the commit messages on [opeik/rust-overlay-esp](https://github.com/opeik/rust-overlay-esp?ref=blog.matthewbrunelle.com) as an example: ![rust-overlay-esp-commit-messages.png](https://blog.matthewbrunelle.com/content/images/2026/02/rust-overlay-esp-commit-messages.png) After a couple days of trying out various projects, I opted instead to make this absolutely cursed flake with the help of Claude Code: ```nix { description = "ESP32-S3 Rust dev environment"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils = { url = "github:numtide/flake-utils"; inputs.nixpkgs.follows = "nixpkgs"; }; }; outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; config.allowUnfree = true; }; in { devShell = (pkgs.buildFHSEnv { name = "esp-rust-env"; targetPkgs = pkgs: with pkgs; [ # rustup manages the actual Rust + esp toolchain rustup # ESP tooling espup espflash esp-generate # probe-rs for flashing/debugging probe-rs-tools # Build dependencies openssl openssl.dev pkg-config # C compiler and native libs needed for build scripts and espup binaries gcc stdenv.cc.cc.lib # libstdc++ zlib # IDE rust-analyzer jetbrains.rust-rover ]; profile = '' export RUST_SRC_PATH="$HOME/.rustup/toolchains/esp/lib/rustlib/src/rust/library" export DEFMT_LOG="info" if [ -f "$HOME/export-esp.sh" ]; then source "$HOME/export-esp.sh" fi ''; runScript = "bash"; }).env; } ); } ``` I am OK using GenAI for this part. Making a working flake is the kind of friction that often kills my interest in projects. After running `nix develop` I ran the following commands *once* to set up the tool chains: ```bash rustup toolchain install stable espup install ``` This will unfortunately create files in your home directory which is not the nix way. --- ## Booting Embassy on the Walter I mostly followed the [starting a new project section](https://embassy.dev/book/index.html?ref=blog.matthewbrunelle.com#%5Fstarting%5Fa%5Fnew%5Fproject) from the Embassy book and used `esp-generate` to set up the project. In HackerBox 0119 - Geopositioning the Tiny Yeti Locator board was based off the ESP32 Marauder board. That uses a ESP-WROOM-32 which is not supported by the Rust HAL. So esp-generate did not allow me to enable wifi or BLE. Thankfully with the Walter I was able to configure everything I needed. Based on the book's recommendations I used `embassy-trouble` rather than `bleps` for BLE, `defmt` for printing messages and `probe-rs` instead of `espflash` for flashing: ![embassy_generate_esp32_s3.png](https://blog.matthewbrunelle.com/content/images/2026/02/embassy_generate_esp32_s3-1.png) When I tried to flash the project to my board with `cargo run --release` the flash kept failing and I saw the warning `WARN probe_rs::probe::list::linux: The 'plugdev' group does not exist`. I had to add the following to my [NixOS config](https://github.com/ciferkey/NixOS-Config/blob/master/nixos/esp32.nix?ref=blog.matthewbrunelle.com): ```nix users.groups.plugdev = {}; # need to make explicitly users.users.ciferkey = { isNormalUser = true; description = "ciferkey"; extraGroups = [ "dialout" # Allow access to serial device "plugdev" # For esp32 flashing ]; }; services = { udev = { packages = with pkgs; [ probe-rs-tools ]; }; }; ``` Essentially three parts are needed: - I needed to add my user to the plugdev group. - This does not actually create the group, so the group has to be setup explicitly. - I had to enable the udev service for `probe-rs-tools` to access the USB device. With that in place I was able to flash and get *Hello World!* over serial: ![embassy_walter_hello_world.png](https://blog.matthewbrunelle.com/content/images/2026/02/embassy_walter_hello_world-1.png) --- ## Keeping the momentum going Finally getting something running on the board felt so encouraging! As I lamented in [I never thought I could be a maker](https://blog.matthewbrunelle.com/i-never-thought-i-could-be-a-maker/), I'm late to trying out hardware. Thankfully with this project I have a nice little roadmap I can follow to scale up my understanding about embedded Rust development. Who knows, after I get all the software pieces in place I might try my hand at wiring everything up with a battery and learning how to draft a case in FreeCAD. --- 1. There are also some precertified antennas. [↩︎](#fnref1) 2. I will say though, there is good [zephyr support](https://docs.zephyrproject.org/latest/boards/dptechnics/walter/doc/index.html?ref=blog.matthewbrunelle.com), and if you want something like Arduino you might want to learn that instead. [↩︎](#fnref2) ### I Almost Had a Good Support Experience With Google URL: https://blog.matthewbrunelle.com/i-almost-had-a-good-support-experience-with-google/ Last updated: 2026-02-23T22:52:49.000Z #### On this page ## "Your battery will fail within weeks" One day the outer screen on my Pixel 9 Pro Fold stopped responding to touch. Then the battery percentage started showing as "?". From [a reddit thread](https://www.reddit.com/r/PixelFold/comments/1oatwxb/well%5Fthis%5Fis%5Fnew%5Fquestion%5Fmark%5Fnext%5Fto%5Fbattery/?ref=blog.matthewbrunelle.com) I saw that "this is the step 1 to battery failure. They will repair your phone under warranty. Your battery will fail within weeks". And so I prepared myself for the cursed experience of dealing with Google's support. > Hello, darkness, my old friend > I've come to talk with you again --- ## Things started off strong Foldables are quite expensive to repair so I braced myself for a big bill: ![pixel_fold_replacment.png](https://blog.matthewbrunelle.com/content/images/2026/02/pixel_fold_replacment.png) Huzzah, this issue is so common that Google is replacing the devices at no cost and out of their 1 year warranty . As far as repair location options, I am super lucky to have one of the 9 actual Google branded stores near me, rather than a 3rd party repair center. That meant I had this option available: ![google_store_scheduling.png](https://blog.matthewbrunelle.com/content/images/2026/02/google_store_scheduling.png) When scheduling the appointment the 3rd party repair locations did not offer same day replacement. [\[1\]](#fn1) What's more, the Google store *replaced my 9 Pro Fold with a 10 Pro Fold*. I can only assume the device failure rate on the 9 is so high that it wasn't worth replacing with another 9 that might fail. The staff at the Google store was also really wonderful. Such a 180 from the years of dealing with Google's chat support. [\[2\]](#fn2) Thank you Timmy, you were really great to work with and I hope [the Steam Deck shortage](https://store.steampowered.com/steamdeck?ref=blog.matthewbrunelle.com#:~:text=Note,available) ends so your brother-in-law can finally buy one. --- ## Would you like to sign up for device protection? Usually I [don't do device protection for electronics](https://freakonomics.com/2010/01/when-should-you-go-for-the-extended-warranty/?ref=blog.matthewbrunelle.com). [\[3\]](#fn3) The math typically does not work in your favor. However, the cost *of parts* to fix the inner screen on the Pixel 10 Pro Fold is [$800](https://www.ifixit.com/Guide/Google+Pixel+10+Pro+Fold+Inner+Screen+Assembly+Replacement/203253?ref=blog.matthewbrunelle.com). With [Pixel Care+ coverage](https://support.google.com/store/answer/16496166?hl=en&ref=blog.matthewbrunelle.com), the inner screen replacement is not free, but you only have to pay the $99 deductible: ![pixel_10_pro_fold_repair_costs.png](https://blog.matthewbrunelle.com/content/images/2026/02/pixel_10_pro_fold_repair_costs.png) So when Timmy in the store said that as a part of the extended warranty program, you can sign up for the Pixel Care+ warranty [\[4\]](#fn4), I decided I would sign up. However, I have extra authentication enabled on my Google account and couldn't log in in store to do it there. So I said thanks, I'll make sure to do that when I get home and set up my phone. All I had to do was click the offer banner in the My Pixel app. --- ## Google's support, or lack thereof There was no banner in the My Pixel app, but that is OK because you can also [sign up online](https://store.google.com/pixel-care/eligibility?hl=en-US&ref=blog.matthewbrunelle.com): ![pixel_care_plus_device_not_eligible.png](https://blog.matthewbrunelle.com/content/images/2026/02/pixel_care_plus_device_not_eligible.png) Google's site kept saying my IMEI was not eligible. So I reached out to the Google Store support over chat. No matter how I tried to break down the situation to explain this was a special case, they kept insisting that only newly purchased devices were eligible for Pixel Care+. In the followup NPS email I made sure to say that, no my problem was not solved. I'm certain that form is sent directly into a shredder and no humans review them. So I made a second appointment to visit the Google store. My good buddy Timmy was there and with some research found that this is a known issue with their system. Under the special warranty program the new devices are eligible for pixel care+, but sometimes the IMEI does not get properly associated with your account so they have to fill out a form for someone to fix it. He got everything set up for me through a special "gUp" page the staff has. I let him know that I'm still rooting for his brother-in-law to get that Steam Deck and thanked him for the help. The next day I got the email from Google to fix my IMEI. I just needed to get a text code from them to confirm my account. However, no matter how quickly I tried to respond to the email with the code, they kept telling me I had taken too long and the code had expired. They proceeded to tell me to contact the store chat support and then stopped replying to my email messages. --- ## "Please do not worry Matthew" The store chat support told me that I did not need to do a verification text. The store chat support confirmed for me: > When you receive a replacement through an extended warranty claim, the system often fails to automatically "hand off" the eligibility from your old device to the new one. When I told the store chat support that yes, that is the problem I'm here for, and I already had a special ticket to cover that, the store chat support told me: ![please_do_not_worry_matthew.png](https://blog.matthewbrunelle.com/content/images/2026/02/please_do_not_worry_matthew.png) The store chat support told me that: > After reviewing my resources, I would like to inform you that the Pixel Care Plus coverage will reactivate within a few days. Since the device was replaced, the IMEI number will also change. When I told the store chat support that *I never previously had Pixel Care+* and I need to purchase it, the store chat support told me > It typically takes 3 to 5 business days for the following to happen in the background: > IMEI Activation: Your new device’s IMEI must be registered as "Active" in Google’s shipping database. > The system needs to see your old device has been received (or the warehouse has processed the swap) to "unlock" the new IMEI for retail services like insurance. The Google Store chat support lies. I needed someone to follow up on that existing ticket so that the IMEI could be manually fixed. No amount of waiting has fixed the code in their system. --- ## Support chats are designed to act this way In a recent episode of the podcast Complex Systems [about support](https://www.complexsystemspodcast.com/episodes/des-traynor/?ref=blog.matthewbrunelle.com) titled *Your support rep is also trapped in this call*, Des Traynor broke down why support experiences are universally so bad. I now try to reframe all my support experiences in the context of: - Organizations set up incentives for metrics like ticket closure time, rather than actually helping the customer. - Tier 1 support is there to get rid of all the confused people asking bad questions, and is tuned for high false positive rates. - Support staff are underpaid and often use support as a way to get a foot in at a company before moving up. The people that are best at support do not stay in a support role long. Reminding myself of this does not make me feel better about bad support, but it does make me feel a little sympathetic for the support staff. --- ## Ongoing timeline of issues I have not resolved this yet. I do not know if I will ever resolve this. All I know is I will need to book yet another Google Store appointment, drive to Newbury Street, get an additional parking ticket, and talk to my good friend Timmy who actually truly tries to help customers despite the system that surrounds all of us trying its best to prevent that. Feb 9th - Touch started to not function and "?" for battery percentage. Feb 13th - first Google Store visit. Phone was replaced. Feb 14th - contacted Google chat support about no Pixel Care+ offer. Feb 21st - email support thread where they could not verify my device. ??? - Visit the Google Store in Boston again. --- 1. Though I did have to pay a $40 parking ticket for accidentally using a 15 minute spot on Newbury Street. [↩︎](#fnref1) 2. I had a multi year struggle with Fi's support before eventually giving up and porting out. I still miss how their data only sims work. [↩︎](#fnref2) 3. There is a good explanation for this in a Freakonomics episode, but I can't seem to find it. [↩︎](#fnref3) 4. At the Google store they said the system processed the replacement as a purchase which made me eligible for some [new device promos](https://one.google.com/offer/terms-and-conditions/pixel10pro-12month-aipro?g1%5Flanding%5Fpage=0&e=EnableNonLivePromoCodeGeneration&ref=blog.matthewbrunelle.com). [↩︎](#fnref4) ### Braunshittification URL: https://blog.matthewbrunelle.com/braunshittification/ Last updated: 2026-04-20T02:05:23.000Z #### On this page I bought a Braun shaver in 2023 and it has taught me a lot about enshittification. While this post is a way for me to vent some frustration, it's also a case study on how physical products are enshittified. I got to hear Cory [talk about enshittification at PyCon](https://blog.matthewbrunelle.com/pycon-2025-friday-keynote-and-talks/) and I [read the book Enshittification](https://blog.matthewbrunelle.com/i-read-a-couple-of-books-in-2025/#enshitification-by-cory-doctorow-252-highlights) last year. [\[1\]](#fn1) In his strict definition, enshittification is about big tech companies making their products worse from all sides. However, Cory has [talked about](https://pluralistic.net/2024/01/30/go-nuts-meine-kerle/?ref=blog.matthewbrunelle.com) how the word has taken on a larger meaning because (1) it puts a name to the feeling we all share that everything is getting worse and (2) it tries to give an explanation for the processes that led us here. [\[2\]](#fn2) The structure of enshittification as outlined by Cory is: ``` First, platforms are good to their users. Then they abuse their users to make things better for their business customers. Next, they abuse those business customers to claw back all the value for themselves. Finally, they have become a giant pile of shit. This pattern is everywhere. ``` --- ## The Shaver I bought a series 7 Braun electric foil shaver in 2023\. [\[3\]](#fn3) The model was not cheap, but certainly not the most expensive choice in that product category. When you buy a razor or shaver, the foil/blade will wear down over time and need to be replaced. This is a lucrative business model for companies since it provides a source of recurring revenue. So good in fact, that there is a [business model named after it](https://en.wikipedia.org/wiki/Razor%5Fand%5Fblades%5Fmodel?ref=blog.matthewbrunelle.com). This is also a scenario where incentives are misaligned: if you make a blade *too good* people will buy them less. In 2020 Braun [redesigned the 7 Series shavers](https://shavercheck.com/braun-series-7-new-vs-original/?ref=blog.matthewbrunelle.com). The new models are cheaper to buy, but also perform worse. So when I bought my shaver in 2023, I inadvertently bought the redesigned model. I'm usually pretty good at researching products first, but even I did not notice. Likely, this was Braun's intent when using the Series 7 branding on what is essentially a different product line. Reducing the build quality on the shaver heads allowed them to improve the margin of cost on sales for the most frequently sold part. The most frustrating aspect of the situation is that the replaceable piece, the shaver head, is the *worst part* of the product. Every replacement head I have ever bought felt noticeably worse than the one that came with the shaver. This is not uncommon in a world where counterfeits are everywhere. You can find plenty of posts online about how to ID OEM vs counterfeits by sight. [\[4\]](#fn4) Thus, you will see a lot of product review distributions like this listing from Walmart: ![walmart_review_distribution.png](https://blog.matthewbrunelle.com/content/images/2026/02/walmart_review_distribution.png) You will say to yourself, "well if I want to guarantee OEM parts, I'll buy from the manufacturer", to which you will see the following reviews on the Braun website: ![braun_review_distribution.png](https://blog.matthewbrunelle.com/content/images/2026/02/braun_review_distribution.png) When you dig into those reviews and reviews at other vendors online, you will see experiences very similar to my own: The product claims they last 18 months, but I have never been anywhere near that outside of the original head. All subsequent heads I have bought barely make it to 6 months. That is great for Braun who is in the business of selling shaver heads. --- ## "This pattern is everywhere" Here is how the enshittification pattern plays out: "First, platforms are good to their users" Braun used to make products that people enjoyed. Customers didn't feel like they were being fleeced. People literally [go to a Braun collectors fair](https://arslan.io/2024/05/05/braun-borse-2024/?ref=blog.matthewbrunelle.com) to see the wonderful older products, [\[5\]](#fn5) so no wonder the branding still holds sway even after their [Procter & Gamble buyout in 2012](https://news.ycombinator.com/item?id=40277291&ref=blog.matthewbrunelle.com). "Then they abuse their users..." The original usage of enshittification focused on big tech *platforms* where there are both users and business customers. [\[6\]](#fn6) For physical products there isn't this divide, there is just one middle step of misery. However, the same forces are at work here. Lack of competition, vendor lock-in, and product stickiness keep users in an ecosystem as their experience degrades. Notably, the razor blade business model usually involves *selling a cheap handle and then expensive blades*, but in Braun's business model the shaver and accessories are both expensive. This acts as a sunk cost to increase switching costs. "Finally, they have become a giant pile of shit." Eventually, we reach bottom. Cost cutting gets to a point where the official product feels like a counterfeit. Somehow we are still using them. --- ## Enshittification all the way down Even returning bad products has been enshittified. Most recently, I relented and bought a new shaver head off Amazon. How bad could it be? Pretty bad: ![opened_braun_74s.jpg](https://blog.matthewbrunelle.com/content/images/2026/02/opened_braun_74s-1.jpg) The package was opened and then taped shut. What's more, there was particulate on the head in the packaging that looked like skin: ![particulate_on_razer_head.jpg](https://blog.matthewbrunelle.com/content/images/2026/02/particulate_on_razer_head.jpg) This feels like a physical manifestion of enshittification itself. As a consumer when faced with a situation like this, I now have to bear the cost of time to remedy the situation. I opened up the return item flow in Amazon and was presented with only two options: - Go into the middle of Boston to a location that is annoying to reach. - *Pay* UPS for packaging for the return. However, there is a different, secret return flow you can go to through amazon.com/hz/contact-us, rather than going through the order listing directly. If you say the right incantation of choices you will get more return location options. Of course, I still have to take time out of my day to return an item that arrived damaged. --- ## I want out So why am I still using this shaver? I don't really know. I know I would feel bad throwing out the device. I know I could give the device away on TrashNothing to avoid creating waste, but then I'm trapping someone else in the enshittocene. What I wanted to hit on here is that *there previously was a solution that worked well: the legacy 7 series*, and that is no longer available to consumers because the manufacturer wanted to reduce costs and extract as much rent as possible. [\[7\]](#fn7) I know I will find a better alternative to switch to, but that is not what this post is about. This post is a lamentation about how we all ended up here in the first place. --- 1. The book is excellent and I recommend it. [↩︎](#fnref1) 2. In the book a big focus is also *what we can do about it*. Thus the subtitle. [↩︎](#fnref2) 3. Bundle model number 7071cc. [↩︎](#fnref3) 4. Also by weight since they are significantly lighter. [↩︎](#fnref4) 5. That blog uses the same theme on Ghost as I do! [↩︎](#fnref5) 6. Think ad sales for example. [↩︎](#fnref6) 7. Yes, there are other options that work well. I mostly like foil shavers because I don't grow that much facial hair, they are super quick, and I am very greedy with my time. Avarice is my constant companion. [↩︎](#fnref7) ### Trying out Thunderbird Appointment While I Patiently Wait For An Invite URL: https://blog.matthewbrunelle.com/trying-out-thunderbird-appointment-while-i-patiently-await-for-an-invite/ Last updated: 2026-02-09T14:57:07.000Z #### On this page Scheduling TTRPG groups as an adult can be weirdly difficult sometimes. For the PF2e game I run I usually make a poll on Rallly. The problem with that is I need to have my calendar side by side and pick out all the days I can run. I would love a tool that integrates with my calendars and shows the time slots to my players. [Thunderbird Appointment](https://github.com/thunderbird/appointment?ref=blog.matthewbrunelle.com) sort of aims to do that. In April of last year there was an [announcement](https://blog.thunderbird.net/2025/04/thundermail-and-thunderbird-pro-services/?ref=blog.matthewbrunelle.com) that a paid mail service under the Thunderbird branding was coming. The service is going to include an appointment scheduling software, so I put my name on the waitlist. That same month the repo for Thunderbird Appointment went up on GitHub and I tried to set it up locally. However, Appointment depends on another project called [Accounts](https://github.com/thunderbird/thunderbird-accounts?ref=blog.matthewbrunelle.com) and that was not yet ready. I opened an [issue](https://github.com/thunderbird/thunderbird-accounts/issues/111?ref=blog.matthewbrunelle.com) to learn more. Almost a year later and I'm still working my way through the waiting list, so I'm taking another stab at running the project myself. --- ## Setting up Thunderbird Accounts locally Thankfully, for the most part you can just follow the instructions on the readme. You will need uv installed [\[1\]](#fn1) and to update your hosts file. I tried to temporarily modify my hosts through a project specific flake.nix, but that doesn't work well since you also need the host overrides in your browser. I relented and instead added the following to my system configuration temporarily: ```nix services.dnsmasq = { enable = true; settings = { address = [ "/keycloak/127.0.0.1" "/stalwart/127.0.0.1" ]; }; }; users.users..extraGroups = [ "docker" ]; virtualisation = { docker = { enable = true; enableOnBoot = false; }; }; ``` You will also need to have [docker enabled in your configuration file](https://wiki.nixos.org/wiki/Docker?ref=blog.matthewbrunelle.com). `enableOnBoot` isn't strictly necessary, but I like to start it on demand to save resources. Also, during the setup steps the bootstrap script failed for me. I've [opened an issue](https://github.com/thunderbird/thunderbird-accounts/issues/571?ref=blog.matthewbrunelle.com) and was able to workaround the error by creating the missing `mail/etc` directory by hand. --- ## Setting up Thunderbird Appointment locally Appointment is a bit more finicky than Accounts, but you can still mostly follow the readme. The first issue is when the instructions tell you to run: `docker-compose exec accounts uv run manage.py create_client 'Appointment' 'dev contact' 'noreply@example.org' 'https://example.org' --env_type dev --env_redirect_url 'http://localhost:5000/auth/accounts/callback' --env_allowed_hostnames 'localhost:8080,accounts:8087'` that will fail with: > Unknown command: 'create\_client'. Did you mean createcachetable? > Type 'manage.py help' for usage. This is because the `create_client` command was [removed](https://github.com/thunderbird/thunderbird-accounts/issues/160?ref=blog.matthewbrunelle.com). The good news is that it looks like the step is unnecessary now and can be skipped. I've opened an [issue](https://github.com/thunderbird/appointment/issues/1491?ref=blog.matthewbrunelle.com) to report that and confirm you can safely skip the step. Additionally, the Docker compose files for Appointment and Accounts expose several containers on the same ports and will conflict. I had to change postgres (5432), the frontend (8080) and mailpit (1025) ports. That also meant I had to update the corresponding values like `FRONTEND_URL` in the backend/.env file. --- ## A little preview of Thunderbird Appointment On the off chance someone else is salivating over Appointment, here are some screenshots and a little tour. Once all the ports are fixed, you can access Thunderbird Appointment: ![thunderbird_appointment_login.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointment_login.png) However, this leads to a question: what account do we log in with? By default Thunderbird Accounts has an admin account setup. You can go to the Thunderbird Accounts login page: ![thunderbird_accounts_login.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_accounts_login.png) And login as admin. That will take you to this landing page: ![thunder_accounts_landing_page.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunder_accounts_landing_page.png) Sadly, you cannot log into Appointment with that account. I spent a not insignificant amount of time in the Stalwart admin panel trying to figure this out: ![thunderbird_accounts_stalwart.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_accounts_stalwart.png) [Stalwart](https://stalw.art/?ref=blog.matthewbrunelle.com) is a really impressive email server (and now [CalDAV server](https://stalw.art/docs/collaboration/calendar/?ref=blog.matthewbrunelle.com)) project that Thunderbird Mail is built around. [\[2\]](#fn2) Unfortunately, for local testing the answer was to not use Stalwart. Instead, in backend/.env, you can set `APP_ALLOW_FIRST_TIME_REGISTER=True` and whatever credentials you enter at the Appointment login page will be used to create a user. Then you can proceed with the account setup flow: ![thunderbird_appointments_create_profile.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_create_profile.png) I had the choice to connect a Google Calendar or a CalDav calendar: ![thunderbird_appointments_connect_calendar.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_connect_calendar.png) The Google auth will fail: ![thunderbird_appointments_signin_google_failed.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_signin_google_failed.png) That is to be expected because you need to set some configuration information in backend/.env: ``` GOOGLE_AUTH_CLIENT_ID= GOOGLE_AUTH_SECRET= GOOGLE_AUTH_PROJECT_ID= GOOGLE_AUTH_CALLBACK=http://localhost:5000/google/callback ``` I've [messed around with GCP enough](https://blog.matthewbrunelle.com/google-cloud-platform-with-gradle-kotlin-dsl/), that I didn't want to waste time setting up a whole new project just to test the integration. Instead I tried out the CalDav integration: ![thunderbird_appointment_connect_caldav.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointment_connect_caldav.png) Stalwart has CalDav so I figured I could just use that. Then, after another hour of fiddling with the Stalwart admin panel without sucess, I decided to just log into my Nextcloud server since [that too has CalDav support](https://docs.nextcloud.com/server/stable/admin%5Fmanual/groupware/calendar.html?ref=blog.matthewbrunelle.com). Once connected you can configure a booking page and select the calendar to use for appointments: ![thunderbird_appointments_create_booking.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_create_booking.png) And set your availability for bookings: ![thunderbird_appointments_set_availability.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_set_availability.png) As well as video meeting links if you want: ![thunderbird_appointments_zoom_integration.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_zoom_integration.png) Then you can access your dashboard to see your synced calendar events: ![thunderbird_appointments_dashboard.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_dashboard.png) You can create a shareable link for appointment signups. A person with your link would see something like this: ![thunderbird_appointments_book_time.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_book_time.png) Once they pick a time they get a confirmation: ![thunderbird_appointment_request_sent.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointment_request_sent.png) Back on your dashboard you see a list of unconfirmed bookings: ![thunderbird_appointments_booking_confirmations.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_booking_confirmations.png) You can see the details for each of those unconfirmed bookings: ![thunderbird_appointments_meeting_request_info.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_meeting_request_info.png) And confirm them: ![thunderbird_appointments_booking_confirmed.png](https://blog.matthewbrunelle.com/content/images/2026/02/thunderbird_appointments_booking_confirmed.png) *chef's kiss* --- ## The bad news Now here's the thing, after trying it out I realized Thunderbird Appointment doesn't support group based booking. I'm not sure if that's even on the roadmap. So if you're reading this and have the ability to bump me up on that wait list, please do. I promise to provide lots of feedback as I try the service out. I've already responded diligently to the *Thunderbird Pro – Evaluation Questionnaire* email you've sent out. --- 1. At work we have mostly moved off Poetry to uv and I could not be happier. [↩︎](#fnref1) 2. Thunderbird is also [actively contributing](https://blog.thunderbird.net/2025/07/state-of-the-thunder-answering-community-questions/?ref=blog.matthewbrunelle.com#:~:text=Answer,nice) to the project. [↩︎](#fnref2) ### Polygenic Risk Score for Coronary Artery Disease "At Home" URL: https://blog.matthewbrunelle.com/polygenic-risk-score-for-coronary-artery-disease-at-home/ Last updated: 2026-02-02T15:59:23.000Z #### On this page In a recent [episode of the podcast What's Your Problem](https://www.pushkin.fm/podcasts/whats-your-problem/the-new-science-of-preventing-heart-attacks?ref=blog.matthewbrunelle.com) Jacob interviewed the founder of the Scripps Research Translational Institute, Eric Topol. Towards the end of the podcast Eric mentioned: > You know, you can actually now, if you have your AncestryDNA or 23andMe, you can use one of the AI platforms like Claude or OpenAI and can analyze and give you your polygenic risk. Challenge accepted! Except I do not want Claude to do the heavy lifting for me, I do not want to send Anthropic my genetic information. GenAI Warning: In this project I bounced *a lot* of questions off Claude. This is different than a lot of posts where I explicitly try not to use GenAI. See my post on learning rust [where I talk about deliberate practice](https://blog.matthewbrunelle.com/finally-actually-hopefully-learning-rust/#on-mastery-learning-and-stretching-with-deliberate-practice). If you want to skip all the background and primer on genomics, skip to [Running plink and PRSice-2](#running-plink-and-prsice-2). If you only want to read the part where I am grumpy about LLMs, skip to [On Claude](#on-claude). --- ## On sourcing your genetic sequence and 23andMe 23andMe was one of the first companies to offer DNA testing using a saliva test. [\[1\]](#fn1) They launched at $999 in 2007\. I signed up for 23andMe in 2012 when the price was lowered to $99 and the subscription was removed. Originally they were a health focused company and you can imagine why they piqued my interest. Especially since part of the pitch was that you could help research by contributing your genetic information. I loved that sentiment. In 2013 the FDA issued a warning letter that 23andMe needed to stop marketing itself as a health product and the platform became ancestry focused until 2015 when they were able to display carrier risk information again, but at a much reduced capacity. Skip forward to 2023, 23andMe had a data breach of the DNA relatives feature. Thankfully, due to some grandfathering from joining the platform early I never enabled that feature. I was able to export my genetic data from the platform and delete my account. Most importantly, I was able to delete before the bankruptcy and sale that made it harder to do so. [\[2\]](#fn2) The end result of this entire saga is that I have a copy of *something* that claims to contain information about my genetics. --- ## What the 23andMe export is, and is not The export is a .txt file. There is actually a lovely header at the top with some useful comments: > This file contains raw genotype data Let's travel back in time and remember our high school biology. This [site](https://basicbiology.net/micro/genetics/alleles-genotype?ref=blog.matthewbrunelle.com) is a nice reference. Also, I'll provide links to simple.wikipedia.com. [Gene](https://simple.wikipedia.org/wiki/Gene?ref=blog.matthewbrunelle.com) \- parts of DNA, which is the molecule in a cell that has instructions for making proteins. [Allele](https://simple.wikipedia.org/wiki/Allele?ref=blog.matthewbrunelle.com) \- a form of a gene at a specific location on the chromosome. [Genotype](https://simple.wikipedia.org/wiki/Genotype?ref=blog.matthewbrunelle.com) \- the combination of the *two* copies of alleles that are inherited from parents. [Homozygous](https://en.wikipedia.org/wiki/Zygosity?ref=blog.matthewbrunelle.com#Homozygous) \- where the alleles are the same. [Heterozygous](https://en.wikipedia.org/wiki/Zygosity?ref=blog.matthewbrunelle.com#Homozygous) \- when there are two different alleles. [Phenotype](https://simple.wikipedia.org/wiki/Phenotype?ref=blog.matthewbrunelle.com) \- the traits that are expressed based on the Genotype and the environment. [\[3\]](#fn3) So the file should tell us something about the allele pairs. > This data has undergone a general quality review however only a subset of markers have been individually validated for accuracy. As such, this data is suitable only for research, educational, and informational use and not for medical or other use. A nice warding spell against the FDA. > Below is a text version of your data. Fields are TAB-separated. Each line corresponds to a single SNP. SNP or [Single Nucleotide Polymorphism](https://simple.wikipedia.org/wiki/Single%5Fnucleotide%5Fpolymorphism?ref=blog.matthewbrunelle.com) \- a DNA sequence variation in a population. This [20 minute video](https://www.youtube.com/watch?v=Bw0yO4mA488&ref=blog.matthewbrunelle.com) covers SNPs and genomics in more depth. If someone said a SNP is "A/G" that means that at that position the variations contain either an A or a G. [Nucleotide](https://simple.wikipedia.org/wiki/Nucleotide?ref=blog.matthewbrunelle.com) \- the building blocks of DNA (and RNA). In DNA there are 4: guanine, adenine, cytosine, and thymine, which are often abbreviated G, A, C and T. > For each SNP, we provide its identifier (an rsid or an internal id), rsid or [Reference SNP cluster ID](https://customercare.23andme.com/hc/en-us/articles/212196908-What-Are-RS-Numbers-Rsid?ref=blog.matthewbrunelle.com) \- a label to identify a specific SNP. Researchers report new SNPs to dbSNP, who clusters them and generates IDs. > its location on the reference human genome, and the genotype call oriented with respect to the plus strand on the human reference sequence. We are using reference human assembly build 37 (also known as Annotation Release 104). Here be dragons, no more simple wikipedia pages. [Sequence assembly](https://en.wikipedia.org/wiki/Sequence%5Fassembly?ref=blog.matthewbrunelle.com) \- DNA sequencing cannot read a whole genome all at once. So instead smaller fragments are read. They then need to be aligned and merged. [Reference genome](https://en.wikipedia.org/wiki/Reference%5Fgenome?ref=blog.matthewbrunelle.com) \- a complete genetic sequence as a series of nucleotides. Often made from multiple donors. Remember how alleles refer to a specific location? The reference genome acts as a coordinate system for the locations. Reference human assembly build 37 or GRCh37/hg19 - the specific reference genome used. [Plus strand](https://en.wikipedia.org/wiki/Sense%5F%28molecular%5Fbiology%29?ref=blog.matthewbrunelle.com) \- DNA is double sided, so there is a plus and a minus strand that can be used as a reference. [Genotype call](https://www.biostars.org/p/277927/?ref=blog.matthewbrunelle.com) [\[4\]](#fn4) \- a mapping of a reading into the reference. A point to highlight here is that services like 23andme do not sequence your entire genome. They only sequence a large sample of SNPs. So as an example, lets say the export had this row: | rsid | chromosome | position | genotype | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------- | -------- | | rs4477212 | 1 | 82154 | AA | | That means there is a genotype of AA on the plus strand at position 82,154 on chromosome 1\. We can even [look up rs4477212 in dbSNP](https://www.ncbi.nlm.nih.gov/snp/rs4477212?ref=blog.matthewbrunelle.com)! | | | | One other detail, the export filename contains "v3". That is in reference to the chip version used to do the sequencing. The [International Society of Genetic Genealogy Wiki](https://isogg.org/wiki/Wiki%5FWelcome%5FPage?ref=blog.matthewbrunelle.com) has a nice breakdown of the chip versions. My sequencing was done with chip version 3, which was released in 2010\. v4 wasn't offered until 2013, after I signed up. The main difference between the chip versions is the number of total SNPs. Oddly enough, v3 actually has the most SNPs. v4 and v5 have fewer total SNPs and more y-dna and mtdna... which is a whole other can of worms having to do with [haplogroups](https://en.wikipedia.org/wiki/Haplogroup%5FY?ref=blog.matthewbrunelle.com). The short version is that information is useful for ancestry information, which is why 23andme traded off total SNPs for that information due to the pivot to ancestry services in 2013\. Neat! --- ## Polygenic what? Back to the original challenge, using the data from 23andMe to assess my polygenic risk. Many genes can contribute to a particular phenotype along with environmental factors. The [polygenic score](https://en.wikipedia.org/wiki/Polygenic%5Fscore?ref=blog.matthewbrunelle.com) aims to estimate the effect of several genes in the context of an individuals risk for a particular disease. In the podcast episode the focus is on coronary artery disease. The main paper mentioned from the episode was [A tutorial on conducting genome-wide association studies: Quality control and statistical analysis](https://www.ncbi.nlm.nih.gov/pubmed/29484742?ref=blog.matthewbrunelle.com). Since its publication in 2018 there are a number of FOSS tools available: PRSice-2, PRS-CS, and LDpred2\. Of those three options. PRSice-2 looked like the easiest to try out, and quickest to run, though it is also the least accurate of the three. I'd love to try PRS-CS and LDpred2 in the future, but that's for another post. [\[5\]](#fn5) --- ## Running plink and PRSice-2 Instead of walking through the whole learning process I went through, I'm going to start with what worked for me, and then pull back the curtain for some lessons learned. I am doing everything on my desktop that runs NixOS. [\[6\]](#fn6) This is the flake.nix I used to install everything I needed: ```nix { inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; outputs = { self, nixpkgs }: let pkgs = nixpkgs.legacyPackages.x86_64-linux; prsice = pkgs.fetchzip { url = "https://github.com/choishingwan/PRSice/releases/download/2.3.5/PRSice_linux.zip"; sha256 = "sha256-4e5e2vOV3zJz9tTqJUCaKSOTH+AdIqcwV0Txy4KQ2M0="; stripRoot = false; }; in { devShells.x86_64-linux.default = pkgs.mkShell { packages = with pkgs; [ htslib plink-ng R rPackages.data_table rPackages.ggplot2 ]; shellHook = '' export PRSICE_DIR="${prsice}" ''; }; }; } ``` If your unzipped, 23andme export is named `genome.txt`, you can use the following commands: ```bash plink --23file genome.txt --make-bed --out from_23andme_raw awk '{print $2, $1":"$4}' from_23andme_raw.bim > rsid_to_chrpos.txt plink --bfile from_23andme_raw \ --update-name rsid_to_chrpos.txt \ --make-bed \ --out from_23andme_valid awk 'BEGIN{FS=OFS="\t"} NR==1 {print; next} { $1 = $2":"$3 $4 = toupper($4) $5 = toupper($5) print }' CAD_GWAS_primary_discovery_meta.tsv | \ awk 'BEGIN{FS=OFS="\t"} NR==1{print; next} !seen[$1]++ {print}' > CAD_GWAS_chrpos.tsv ``` Note: I am not great at awk. This is a scenario where I am OK allowing Claude to help me out and sacrifice my chance to learn to use awk better. I know that if I went on a side quest to figure it out I would not finish the project. Then you need to run: ```bash Rscript "$PRSICE_DIR/PRSice.R" \ --prsice "$PRSICE_DIR/PRSice_linux" \ --base CAD_GWAS_chrpos.tsv \ --target from_23andme_valid \ --snp MarkerName \ --chr-id c:L \ --chr CHR \ --bp BP \ --A1 Allele1 \ --A2 Allele2 \ --stat Effect \ --beta \ --pvalue "P-value" \ --fastscore \ --bar-levels 5e-8,5e-6,1e-4,0.01,0.05,0.1,0.5,1 \ --no-regress \ --thread 8 \ --out cad_prs ``` After running you will see the message: > Error: A total of 37287 duplicated SNP ID detected out of > 17206872 input SNPs! Valid SNP ID (post --extract / > \--exclude, non-duplicated SNPs) stored at > cad\_prs.valid. You can avoid this error by using > \--extract cad\_prs.valid So you need to run this: ```bash Rscript "$PRSICE_DIR/PRSice.R" \ --prsice "$PRSICE_DIR/PRSice_linux" \ --base CAD_GWAS_chrpos.tsv \ --target from_23andme_valid \ --extract cad_prs.valid \ --snp MarkerName \ --chr-id c:L \ --chr CHR \ --bp BP \ --A1 Allele1 \ --A2 Allele2 \ --stat Effect \ --beta \ --pvalue "P-value" \ --fastscore \ --bar-levels 5e-8,5e-6,1e-4,0.01,0.05,0.1,0.5,1 \ --no-regress \ --thread 8 \ --out cad_prs ``` That is the magic incantation. So whats actually happening here? --- ## Why both PRSice-2 and plink? While [PRSice-2](https://choishingwan.github.io/PRSice/?ref=blog.matthewbrunelle.com) does the heavy lifting for us, we need to pre-process some data first. PrSice-2 actually takes two inputs, there is the "target dataset" which is the export from 23andMe and the "base dataset". PRSice-2 cannot use the export from 23andMe in its current form. That's where [plink](https://plink.readthedocs.io/en/latest/?ref=blog.matthewbrunelle.com) comes in. Plink can read the raw export from 23andMe (see the `--23file` arg) and convert it into a binary format with `--make-bed`. Technically you actually get [three files](https://plink.readthedocs.io/en/latest/plink%5Ffmt/?ref=blog.matthewbrunelle.com): - .bed - the "PLINK binary biallelic genotype table", which is a binary packing of just the genotype calls. - .bim - text format and one row per SNP containing the phenotype information. - .fam - One row per individual mapping into a family ID, which means only a single row for the 23andMe export. The example row in the previous section would be converted to the following row in the .bim file: | Chromosome | rsid | distance | position | Allele 1 | Allele 2 | | ---------- | --------- | -------- | -------- | -------- | -------- | | 1 | rs4477212 | 0 | 82154 | 0 | A | Now why the awk commands? Well that's because of the base dataset... The base dataset contains summary statistics from a genome-wide association study (GWAS) where many individuals are sequenced to see if a variant is associated with a trait. There are many GWAS out there. I went with *CARDIoGRAMplusC4D 2022 CAD GWAS: trans-ancestry (and female, male, and European subsets)* because: - There is a public download of the [summary statistics](https://personal.broadinstitute.org/ryank/Aragam%5F2022%5FCARDIoGRAM%5FCAD%5FGWAS.zip?ref=blog.matthewbrunelle.com) (direct link). - The study was fairly large and recent. - The population distribution of the base dataset should contain your ancestry. This study contained people primarily of European descent, which is not great for equitability, but works out in my case. - The study must track the trait you want to score. This is a study on coronary artery disease which is perfect for this experiment. When you download and extract the base dataset there will be a \~3gb file CAD\_GWAS\_primary\_discovery\_meta.tsv. Here are some example rows: [\[7\]](#fn7) | MarkerName | CHR | BP | Allele1 | Allele2 | Freq1 | FreqSE | MinFreq | MaxFreq | Effect | StdErr | P-value | Direction | HetISq | HetChiSq | HetDf | HetPVal | Cases | Effective\_Cases | N | Meta\_analysis | | -------------- | --- | ------ | ------- | ------- | ------ | ------ | ------- | ------- | -------------- | ----------- | ------- | ------------ | ------ | -------- | ----- | ------- | ----- | ---------------- | ------ | -------------- | | 1:61743\_C\_G | 1 | 61743 | c | g | 0.0061 | 0.0009 | 0.0019 | 0.0064 | \-0.11632669 | 0.079006163 | 0.1409 | \-???????+?? | 0.0 | 0.561 | 1 | 0.454 | 41651 | 18141.4 | 538622 | Cardiogram | | 1:88370\_A\_G | 1 | 88370 | a | g | 0.0033 | 0.0004 | 0.0019 | 0.0034 | \-0.011699052 | 0.093564733 | 0.9005 | \-???????+?? | 51.8 | 2.077 | 1 | 0.1496 | 41651 | 22939.1 | 538622 | Cardiogram | | 1:108382\_A\_C | 1 | 108382 | a | c | 0.0008 | 0.0000 | 0.0007 | 0.0008 | 0.26385216 | 0.17436496 | 0.1302 | +???????-?? | 0.0 | 0.932 | 1 | 0.3344 | 41651 | 26336.9 | 538622 | Cardiogram | | 1:233476\_A\_G | 1 | 233476 | a | g | 0.0055 | 0.0012 | 0.0049 | 0.0081 | 0.09460607 | 0.08184042 | 0.2477 | +???????-?? | 63.1 | 2.713 | 1 | 0.09955 | 41651 | 17869.6 | 538622 | Cardiogram | | 1:526832\_C\_G | 1 | 526832 | c | g | 0.0367 | 0.0076 | 0.0271 | 0.0427 | \-0.0019227546 | 0.02534286 | 0.9395 | +?????-???? | 0.0 | 0.584 | 1 | 0.4448 | 72859 | 50163.8 | 829210 | Cardiogram | | 1:532413\_A\_G | 1 | 532413 | a | g | 0.0030 | 0.0005 | 0.0012 | 0.0031 | \-0.13357347 | 0.10686249 | 0.2113 | \-???????-?? | 59.6 | 2.477 | 1 | 0.1155 | 41651 | 20089.1 | 538622 | Cardiogram | | 1:534302\_A\_G | 1 | 534302 | a | g | 0.0039 | 0.0006 | 0.0001 | 0.0040 | \-0.0099580088 | 0.10263685 | 0.9227 | \-?????-???? | 0.0 | 0.083 | 1 | 0.7733 | 72859 | 53551.5 | 829210 | Cardiogram | | 1:534540\_G\_T | 1 | 534540 | t | g | 0.0030 | 0.0006 | 0.0022 | 0.0035 | 0.012345323 | 0.084510691 | 0.8839 | \-?????+???? | 0.0 | 0.930 | 1 | 0.3349 | 72859 | 52910.4 | 829210 | Cardiogram | | 1:540818\_A\_G | 1 | 540818 | a | g | 0.0042 | 0.0009 | 0.0031 | 0.0049 | 0.067579902 | 0.072385271 | 0.3505 | +?????+???? | 2.2 | 1.022 | 1 | 0.3119 | 72859 | 51073 | 829210 | Cardiogram | | 1:544584\_C\_T | 1 | 544584 | t | c | 0.0026 | 0.0005 | 0.0019 | 0.0030 | 0.035908921 | 0.088336542 | 0.6844 | +?????-???? | 42.6 | 1.741 | 1 | 0.187 | 72859 | 54000 | 829210 | Cardiogram | This file is for one *single trait*, in this case coronary artery disease. MarkerName, CHR (chromosome), BP (Base Pair position), are used to identify the variant. These are used by PRSice-2 to match the genotypes in the target file. The Effect and P-value are used for weighing and cut off thresholds for the SNPs. Notice anything tricky about the files? The alleles are lower case, whereas in the export from 23andMe the alleles are upper case. This tripped me up quite a bit. Another issue is that the target dataset uses rsids, and base dataset uses "chr:pos" format based on the chromosome and position. So the first awk is there to create a mapping from rsid to chr:pos for each SNP. Once we have that mapping we can run plink a second time with `--update-name` to create a output using that mapping. I had to do that because nixpkgs currently only has plink 1.9\. If plink 2.0 was available there is the `--set-all-var-ids` args that can handle the id mapping with a pattern. Then you can skip the mapping file. The second awk is there to handle correcting the case in the base dataset, and to handle removing duplicates. Otherwise PRSice-2 will choke on the dataset. A final mystery: Why do we need to run the PRSice-2 twice? The first invocation of PRSice-2 produces outputs with duplicates. So I reran with `--extract cad_prs.valid` to handle those. --- ## So what is PRSice-2 actually doing? The PRSice-2 docs recommend [this paper](https://pubmed.ncbi.nlm.nih.gov/29484742/?ref=blog.matthewbrunelle.com) to learn more about the inner workings of GWAS. The docs also have a page on the [PRS calculation](https://choishingwan.github.io/PRSice/step%5Fby%5Fstep/?ref=blog.matthewbrunelle.com#prs-calculation). To greatly oversimplify: - The base dataset gives us an effect measurement for each SNP. - The base dataset also provides a p-value that is used as a threshold to cut off values. - The target dataset gives us a "dosage" of 0, 1 or 2 depending on how many copies of an allele the individual carries. For any SNP that passed the p-value threshold, PRSice-2 multiplies the effect by the dosage and takes the sum. The end result is a file `cad_prs.all_score` that contains a single line for each individual, which is my case is just one line for me. In the args for PRSice-2 I set the `--bar-levels` flag and supplied percentiles. The output file has a column for each percentile with the computed score for all SNPs that met the cutoff for that percentile. Normally, PRSice-2 would also output a second `cad_prs.summary` file that details which percentile is the best one to use. It decides the best fit by looking at which percentile explains the most variance in the trait. However, since the target dataset only has SNPs for one individual, this is not possible. So I passed the `--no-regress` arg to skip the regression all together. --- ## Attemping an analysis There are now scores, but they are not interpretable. Instead I need a reference population to compare my scores against. Thankfully there is [PGS004443](https://www.pgscatalog.org/score/PGS004443/?ref=blog.matthewbrunelle.com), a precomputed PRS for CAD available on the PGS Catalog: ```bash curl -O https://ftp.ebi.ac.uk/pub/databases/spot/pgs/scores/PGS004443/ScoringFiles/Harmonized/PGS004443_hmPOS_GRCh37.txt.gz gunzip PGS004443_hmPOS_GRCh37.txt.gz grep -v "^#" PGS004443_hmPOS_GRCh37.txt | tail -n +2 | awk '$1 != "." {print $1, $2, $3}' > pgs_score.txt plink --bfile from_23andme_raw \ --score pgs_score.txt 1 2 3 \ --out cad_pgs_catalog ``` The precomputed scores are for a population that is mostly European again. I used grep and awk to remove the header and remove any rows where the rsid is a ".", which happens when the ID is missing. PRSice-2 chokes on those rows. Then I run plink to process the SNPs. After that I grabbed the genomes from the [1000 Genome Project](https://www.internationalgenome.org/category/genotypes/?ref=blog.matthewbrunelle.com): [\[8\]](#fn8) ```bash curl -O https://ftp.1000genomes.ebi.ac.uk/vol1/ftp/release/20130502/supporting/hd_genotype_chip/ALL.chip.omni_broad_sanger_combined.20140818.snps.genotypes.vcf.gz curl -O https://ftp.1000genomes.ebi.ac.uk/vol1/ftp/release/20130502/integrated_call_samples_v3.20130502.ALL.panel awk '$3=="EUR" {print $1, $1}' integrated_call_samples_v3.20130502.ALL.panel > eur_samples_plink.txt plink --vcf ALL.chip.omni_broad_sanger_combined.20140818.snps.genotypes.vcf.gz \ --keep eur_samples_plink.txt \ --chr 1-22 \ --make-bed \ --allow-extra-chr \ --out 1kg_eur awk '$2!="." {print $2}' 1kg_eur.bim > valid_snps.txt plink --bfile 1kg_eur \ --extract valid_snps.txt \ --make-bed \ --out 1kg_eur_clean plink --bfile 1kg_eur_clean \ --score pgs_score.txt 1 2 3 \ --out 1kg_eur_scores awk 'NR>1 {print $6}' 1kg_eur_scores.profile > ref_scores.txt ``` I filtered the SNPs for people of European descent, ran the output through plink and filtered for valid SNPs again. Then, I ran plink again to generate a new set of scores. Finally, I could compute the mean and standard deviation of the reference population scores and compare my score to the reference population! Given a mean of 9.179013e-09 and a standard deviation of 1.407676e-07 my score put me in the -4.730150e-07 percentile... wait that makes no sense. [\[9\]](#fn9) Garbage in, garbage out as they say. --- ## On Claude I spent *a lot* of time trying to work through this challenge. I attempted many alternate approaches to what I detailed here. [\[10\]](#fn10) I want to take a moment to reflect on this process. The original challenge was to take my 23andme export, use Claude for assistance, and calculate my polygenic risk score for coronary artery disease. Some thoughts on the experience of working with Claude: **Learning about topic areas**: Claude was really useful for exploring the terms and concepts that I didn't know and needed to learn. I did not trust Claude's explanations of those terms and tried to find primary sources to read. When I did that I found that Claude's explanations were either wrong or even worse, just slightly misinformed, which is harder to identify. So for me, Claude was reasonable for turning unknown unknowns into known unknowns, but I needed to go elsewhere to make the knowledge truly known. Relatedly, I've found that recently the web interface required direct prodding to use web search. For example, the model would hallucinate that a package version was available, when I found that it was not I would state that was the case and say to reevaluate using web citation. [\[11\]](#fn11) **How Claude approached solving the problem**: Claude *really* wanted me to just awk my way through everything, creating half broken files. I frequently had to prompt Claude to help me correct the command I was trying to get correct, rather than generating a new command to modify the output of my previous command. **On the model and context:** I used Opus 4.5 on the Pro plan and the web interface rather than Claude code. [\[12\]](#fn12) I chose this because I wanted to cleanly separate what information I gave to Claude. Despite throwing a lot of tokens at the model, I only hit the usage limits a couple of times. However, I had to restart conversations pretty frequently as I could tell the context window was starting to fill up and I was getting hallucinations. For example, if I was trying to get a command to work I would supply the command and the error output. Claude would suggest changing an argument. I would rerun with the change and share the results, then a couple of chats later Claude would recommend that same fix again. I have Claude code set up to show me the percentage of the context used, so I can make a new session when necessary. There isn't the same transparency in the web interface. Finally the biggest issue. When I got a crazy, unrealistic output from the process Claude congratulated me: > A Z-score of **\-3.43** means you're 3.4 standard deviations **below** the population mean. For CAD PRS where higher = more risk: > **You have very low genetic risk for coronary artery disease** — lower than \~99.97% of the European reference population. --- ## Closing thoughts There were many, many mistakes along the way, iterations, and redos that I did not cover because this piece is already so long. I'm a little too fatigued to share every lesson learned. Out of all of this the main thing I wanted to highlight is that I don't think we are at the point where LLMs can just do this for us. I also worry that if someone tried this with a less careful eye towards the results they could easily be misled. On the other hand, I learned a lot from this process and have some thoughts on how I could have done better: - The 23andMe export is just a subset of the possible SNPs that can be sequenced. Clinical grade PRS uses whole-genome sequencing. This at home approach can only go so far in comparison. I have read there are services like Nebula and Dante that can do whole genome sequencing. - Imputation. You can use this to better handle missing SNPs by taking statistics from a reference population. The University of Michigan [provides a whole server](https://imputationserver.sph.umich.edu/?ref=blog.matthewbrunelle.com#!) that can do genome imputation for you. There is a whole side story I will never publish of trying to get that to work. You need to submit the genetic information for 5 individuals minimum, so I cannot use the server in this use case. There are some tools that you can run locally for imputation, but they draw from a much smaller reference distribution. - I initially chose PRSice-2 because it seemed like the quicker / easier option. The PRS study I based the analysis on used LDpred2\. I'm curious how this whole process would have gone if I used that instead. All in all, there has to be a better way to do this. If you made it to the end of this post, you might just be the kind of person that knows the right solution. Please reach out to me! Finally, [MGB launched a genetic test](https://broadclinicallabs.org/mass-general-brigham-launches-genetic-test-developed-in-collaboration-with-broad-clinical-labs-to-predict-risk-across-eight-cardiovascular-conditions/?ref=blog.matthewbrunelle.com) to predict cardiovascular risk. I am pretty close to them so I might just give that a try. --- --- 1. These numbers and stats are sourced from the lovely [wikipedia](https://en.wikipedia.org/wiki/23andMe?ref=blog.matthewbrunelle.com). [↩︎](#fnref1) 2. On the plus side, 23andMe was bought by a nonprofit founded by one of 23andMe's original founders. [↩︎](#fnref2) 3. Thank you, the good bean monk Mendel. [↩︎](#fnref3) 4. I love that there is a stackoverflow type site for bioinformatics [↩︎](#fnref4) 5. Those projects allow you to also include LD panels for improved accuracy. I happen to have some LD panels I can use. [↩︎](#fnref5) 6. I also have a [post on setting up my Framework 13 laptop with NixOS](https://blog.matthewbrunelle.com/setting-up-my-new-framework-laptop-13-diy-edition-with-nixos/). [↩︎](#fnref6) 7. TIL you can install miller to easily convert tsv files into markdown formatted tables. [↩︎](#fnref7) 8. Note that the reference dataset is \~5gb. [↩︎](#fnref8) 9. I also tried using Nextflow to redo and check the analysis, but it kept failing to parse the inputs. [↩︎](#fnref9) 10. I thought I would save you from having to read about the full adventure since this piece is already so long. [↩︎](#fnref10) 11. Another example, Claude kept suggesting I use the service [impute.me](https://isogg.org/wiki/Impute.me?ref=blog.matthewbrunelle.com) instead. The owner moved to a closed service several years ago and *took down the repo on Github*. [↩︎](#fnref11) 12. See my piece on [self hosting models with Claude code](https://blog.matthewbrunelle.com/using-self-hosting-language-models-so-you-can-evaluate-claude-code/). [↩︎](#fnref12) ### CGMs Part 2 - Using xdrip+ with Stelo URL: https://blog.matthewbrunelle.com/cgms-part-2-using-xdrip-with-stelo/ Last updated: 2026-01-27T21:37:02.000Z #### On this page This is part 2 of my post series on xdrip+ and the Stelo CGM. [Part 1](https://blog.matthewbrunelle.com/cgms-part-1-great-foss-tools-for-an-ok-quality-consumer-cgm/) covers OTC CGMs in general. ## Using xdrip+ with the Stelo CGM There are [official docs for setting everything up](https://navid200.github.io/xDrip/docs/Dexcom/G7.html?ref=blog.matthewbrunelle.com) and the whole process is pretty simple. xdrip+ has an autoconfig mechanism where you scan a QR code to configure the app for a specific CGM: ![Screenshot_xdrip_autoconfigure.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_autoconfigure.png) Once that's done you just need to follow their [recommended settings](https://navid200.github.io/xDrip/docs/G6-Recommended-Settings.html?ref=blog.matthewbrunelle.com) guide and [disable battery optimization](https://navid200.github.io/xDrip/docs/BluetoothBatteryOpt.html?ref=blog.matthewbrunelle.com). After that you can insert the Stelo into your arm as per the paper instructions in the box. The sensor applicator will have a 4 digit pairing code on the side that you set as the transmitter ID in xdrip+. That's it! ![Screenshot_xdrip_Dexcom_source_setup.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_Dexcom_source_setup.png) I had some overpatches leftover from when I wore the other CGMs. They are essentially little adhesive barriers you apply on top of the CGM. The overpatches would peel off and need to be replaced every other day due to my shirt rubbing against them. The way I see it, every discarded overpatch is a bump that the CGM didn't take. Ironically, they worked so well that the CGM was difficult to remove at the end of the two weeks. I had to apply some olive oil around the adhesive and let that sit to finally remove it. Once set up xdrip+ and Stelo will happily chug away and take readings. There is a 30 minute "warm up" period before you get your first reading. See the little green flag: ![Screenshot_xdrip_sensor_warming_up.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_sensor_warming_up.png) --- ## Actually wearing the Stelo day to day and using xdrip+ Part of the reason why the Stelo lasts 14 days is because of it's reduced transmit frequency. While the monitor takes a glucose reading every 5 minutes, the device only transmits the readings every 15 minutes. Transmitting less frequently helps save power. This is in line with the idea that the Stelo is not a diagnostic device, but a wellness device. For comparison, the Abbot Freestyle 3 [reads and transmits every minute](https://www.freestyle.abbott/uk-en/support/faq/question-answer.html?q=UKFaqquestion-113&ref=blog.matthewbrunelle.com) and the Dexcom G7 [reads and transmits every 5 minutes](https://www.adces.org/education/danatech/glucose-monitoring/continuous-glucose-monitors-%28cgm%29/cgm-selection-training/dexcom-g7-and-stelo-differences?ref=blog.matthewbrunelle.com). [\[1\]](#fn1) You can imagine how shorter transmit frequencies are more important when you have something like a closed loop system. Anyways, the Stelo will transmit readings and xdrip+ will graph them. When the simulation thinks you are on a downward trend towards low blood sugar the graph will show a dotted red line: ![Screenshot_xdrip_model_downwards.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_model_downwards-1.png) Or when the simulation thinks you are on an upwards trend towards high blood sugar the graph will show a dotted purple line: ![Screenshot_xdrip_model_upwards.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_model_upwards-1.png) Additionally, in the screenshot above I used the [treatments](https://navid200.github.io/xDrip/docs/Treatments.html?ref=blog.matthewbrunelle.com) functionality to log the carbs for a banana I had eaten, indicated by the green dot with the "13g". You can also configure xdrip+ to connect to a Bluetooth finger stick reader: ![Screenshot_xdrip_glucose_meter.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_glucose_meter.png) With the Stelo the "use meter for calibrations" and "automatic calibration" check boxes will essentially do nothing. See part 1 for more information on calibration. The device I used was the Contour Next One: ![contour_next_one.jpg](https://blog.matthewbrunelle.com/content/images/2026/01/contour_next_one.jpg) When you take a finger stick reading and sync the result the timeline will show a little white and red square: ![Screenshot_xdrip_glucose_meter_reading.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_glucose_meter_reading.png) You can also add text notes which are indicated by the little paper with the corner folded symbol. Here is an example of when I subjected myself to hot pot *for science*: ![Screenshot_xdrip_after_soup.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_after_soup.png) Notably, my finger stick reading was considerably higher than the Stelo every read. I believe that's because CGMs like this have wider error margins at more extreme values. --- ## On sync delays and location permission The Stelo can store about a days worth of readings if you cannot sync with the device. You will not miss readings if your phone is in another room, or xdrip fails to connect for some reason. Instead, xdrip+ will display a message about how far behind the sync is: ![Screenshot_xdrip_behind_sync.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_behind_sync.png) The next time 15 minute interval where Stelo sends an update, xdrip+ will recover all the missed readings. In the example above I had toggled location off on my phone using the quick tile. [\[2\]](#fn2) Apps can use BLE devices to figure out location. See my [post on testing location with BLE beacons](https://blog.matthewbrunelle.com/testing-out-ble-beacons-with-beacondb/) for more adventures in that space. Since this is possible, Android 12 [reworked the permissions](https://developer.android.com/develop/connectivity/bluetooth/bt-permissions?ref=blog.matthewbrunelle.com) around BLE. Essentially, in order to scan and interact with devices, apps will also need to have the location permission. If an app does not use scans to figure out location, the developer can [assert so](https://developer.android.com/develop/connectivity/bluetooth/bt-permissions?ref=blog.matthewbrunelle.com#assert-never-for-location). Without that assertion if the app does not have the location permission it will not be able to connect to BLE devices. There is an xdrip+ [issue to request new Bluetooth permissions of Android 12](https://github.com/NightscoutFoundation/xDrip/pull/1734?ref=blog.matthewbrunelle.com) which was supposed to resolve this by upgrading one of their dependencies, however I was unable to get xdrip+ to sync when I had location toggled off. Instead I just get this dialog: ![Screenshot_xdrip_location_permission.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_location_permission.png) --- ## Integrations with watches Aside from CGMs, insulin pumps and fingerstick readers, xdrip+ can also connect to watches to display readings. I don't use a Wear OS watch anymore, but [someone else has a nice write up](https://insulinpumplife.com/diabetes-tips/how-i-got-my-blood-glucose-on-my-oneplus-watch-face-using-xdrip-app/?ref=blog.matthewbrunelle.com) on how to use one with xdrip+. In the Garmin ecosystem there are a couple different options: there is a [watch face](https://github.com/swissalpine/GARMIN-xDrip-Spike-Nightscout-Watchface/?ref=blog.matthewbrunelle.com), a [data field](https://apps.garmin.com/apps/5a3e2cda-12f0-4afd-88ed-000e67a68d84?ref=blog.matthewbrunelle.com) and a [widget](https://apps.garmin.com/apps/73115e04-dc9f-4765-ad88-e7ae283ce995?ref=blog.matthewbrunelle.com). I went with the widgets since I didn't need to always see the information. Settings, Inter-app settings, enable "xDrip Web Service": ![Screenshot_xdrip_web_service.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_web_service.png) I sadly did not take a photo of my watch with the widget when the CGM was active. --- ## There were some hiccups, but xdrip+ was great overall By default xdrip+ is configured to have an alert when your glucose reads very low. This alert is able to pierce do not disturb on your phone. If you were not using the device for *wellness purposes* that makes sense. I didn't realize there was going to be an alarm and was awoken by an extremely loud and unexpected noise at 2am: ![Screenshot_xdrip_alarm.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_alarm.png) If you sleep on the sensor with enough pressure you can restrict the flow of interstitial fluid and get a false low reading, which I believe is what happened here. The following morning I figured out how to [disable the alert](https://navid200.github.io/xDrip/docs/Alerts%5Fpage.html?ref=blog.matthewbrunelle.com). There were also some periods with dropouts in readings: ![Screenshot_xdrip_drop_out.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_drop_out-1.png) Though they were surprisingly infrequent compared to the issues I had with the earlier devices. Then eventually, you will be told the sensor expired and greeted with a red flag, to mirror the green flag at the start of the session: ![Screenshot_xdrip_stelo_expired.png](https://blog.matthewbrunelle.com/content/images/2026/01/Screenshot_xdrip_stelo_expired.png) All of this is to say, I collected a lot of glucose readings from the Stelo using xdrip+. I haven't actually done anything useful with it yet. That will be in part 3. --- 1. Also, Stelo is quoted as having a transmit range of only 20' compared to the 33' of the G7 for additional power savings. [↩︎](#fnref1) 2. See my [post series on GrapheneOS](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/) for more about my Android phone usage. [↩︎](#fnref2) ### CGMs Part 1 - Great FOSS tools for an OK Quality Consumer CGM URL: https://blog.matthewbrunelle.com/cgms-part-1-great-foss-tools-for-an-ok-quality-consumer-cgm/ Last updated: 2026-01-22T14:27:36.000Z #### On this page This is part of a post series. See the final section for more details. Back in 2024 I was training for a marathon. Also, that year the FDA cleared the first two continuous glucose monitors for over the counter use: [Stelo by Dexcom](https://www.fda.gov/news-events/press-announcements/fda-clears-first-over-counter-continuous-glucose-monitor?ref=blog.matthewbrunelle.com) in March and [Lingo by Abbott](https://www.accessdata.fda.gov/cdrh%5Fdocs/pdf23/K233655.pdf?ref=blog.matthewbrunelle.com) in June. Also, also, I *really* like BLE devices. [\[1\]](#fn1) See my post on [testing out BLE beacons](https://blog.matthewbrunelle.com/testing-out-ble-beacons-with-beacondb/). Marathon training really puts you through the wringer and I was listening to the book *Outlive* at the same time. [\[2\]](#fn2) In the book Attia mentioned he anticipated that CGMs would soon be available OTC. He advocated for people to try them out as a way to learn about how food affects you. Since Lingo was released second and only received [official Android support last month](https://abbott.mediaroom.com/2025-12-08-Abbotts-Lingo-TM-Now-on-Android-TM-,-Expanding-Access-to-Real-Time-Glucose-Data?ref=blog.matthewbrunelle.com) I gave the Stelo a try. Results were mixed at that time. The Stelo app was sparse in terms of functionality and every single Stelo I received failed before the 14 day lifetime was up. On the plus side, when a device failed Dexcom would let you fill out a form to report the failure and get a replacement with essentially no questions asked. I had replacement devices fail and they replaced those too. I ended up with one CGM that I never used and packed away until recently when I realized it was going to expire. [\[3\]](#fn3) Stelo now has [support in the FOSS space](https://github.com/NightscoutFoundation/xDrip/discussions/3645?ref=blog.matthewbrunelle.com), so I decided to make a project out of the adventure. [\[4\]](#fn4) --- ## xDrip+ is a really great FOSS app for CGMs Based on the [original xdrip](https://github.com/StephenBlackWasAlreadyTaken/xDrip?ref=blog.matthewbrunelle.com) app from 11 years ago. [xDrip+](https://github.com/NightscoutFoundation/xDrip?ref=blog.matthewbrunelle.com) is a data hub for CGMs and other devices that is managed by the Nightscout Foundation. The [Nightscount Foundation](https://www.nightscoutfoundation.org/about?ref=blog.matthewbrunelle.com) formed in 2014 as a part of the [We Are Not Waiting movement](https://www.diabetes.co.uk/blog/2016/07/the-wearenotwaiting-movement-is-helping-people-with-diabetes-improve-their-health-now-not-later/?ref=blog.matthewbrunelle.com) and the [CGM in the cloud movement](https://diatribe.org/diabetes-technology/cgm-cloud-how-why-and-why-not-remote-cgm-watching?ref=blog.matthewbrunelle.com). [\[5\]](#fn5) They also have the software [Nightscout](https://nightscout.github.io/?ref=blog.matthewbrunelle.com), which is a FOSS application for managing CGM data in a personal cloud. Nightscout is very cool and I wanted to write about it, but this post is already pretty long. [\[6\]](#fn6) Anyways, as I mentioned, the Stelo Android app leaves a lot to be desired. Likely, this is to make it clear the device is not for managing blood sugar. In my [post on switching to GrapheneOS](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/) I talked about shifting to more FOSS apps. xDrip+ now supports the Stelo and I had a leftover Stelo so why not experiment. --- ## On FOSS grifters and closed loop systems Confusingly, on IOS there is the [xdrip4ios](https://xdrip4ios.readthedocs.io/en/latest/?ref=blog.matthewbrunelle.com) project which is not related to xdrip+ or the Nightscout foundation at all. Though, the project is licensed GPL-3.0 just like xdrip+. The app is not on the app store out of caution that CGM companies might be litigious. So confusingly, [Zukka](https://apps.apple.com/us/app/zukka/id6743448282?ref=blog.matthewbrunelle.com) on the app store is a build of xdrip4ios but is *not* built by the xdrip4ios folks. Though the docs describe the two as having "open and constructive discussions". Yet even more confusingly, Shuggah on the app store is also a build of xdrip4ios. However, the developer started [putting features from the core project behind a paywall](https://www.diabetes.co.uk/forum/threads/shuggah.210466/?ref=blog.matthewbrunelle.com). It is safe to say there are no constructive discussions between the author of Shuggah and xdrip4ios and we can infer that when the docs mention "Unlike another app that used our free source code and published it on the App Store despite previously promising not to profit from our work and keep it free for all users" the app referenced is Shuggah. Semi-related, there was a [recent Reddit thread](https://old.reddit.com/r/linux/comments/1puojsr/the%5Fdevice%5Fthat%5Fcontrols%5Fmy%5Finsulin%5Fpump%5Fuses%5Fthe/?ref=blog.matthewbrunelle.com) about how a person's insulin pump runs the Linux kernel and violates GPL. Both that thread and the [HN thread](https://news.ycombinator.com/item?id=46395184&ref=blog.matthewbrunelle.com) about the Reddit thread contain some really interesting discussion. Close loop systems are a whole other world of devices that not only measure glucose, but also infuse insulin when needed. I have no background with these devices though, so I'll just stop here with the links to those two threads. See my post on [terraform and openTofu](https://blog.matthewbrunelle.com/on-self-hosting-opentofu-and-formerly-oss-licensed-software/) if you want to read more about my disdain for people shirking FOSS licenses. --- ## CGMs are a bit different than other glucose monitoring methods In essence, the different devices out there measure different metrics, but they all try to get at the same concept. This [article](https://www.levels.com/blog/why-doesnt-my-cgm-reading-match-my-finger-stick-reading?ref=blog.matthewbrunelle.com) goes into more detail, but here is my understanding of the basics. A blood draw is the most accurate way to measure *blood glucose*, that's the sugar circulating in you. Whereas a finger-sticks measure blood sugar in capillaries, the small blood vessels, of your fingers. I have a finger-stick monitor called the Countour Next One because... I like Bluetooth devices? There is a standard [ISO 15197:2013](https://www.iso.org/standard/54976.html?ref=blog.matthewbrunelle.com) that requires these devices to be within ±15% of a reference value. [\[7\]](#fn7) My device [was measured](https://pmc.ncbi.nlm.nih.gov/articles/PMC4575307/?ref=blog.matthewbrunelle.com) to typically read ±10%. CGM's don't directly measure blood. Instead, they use interstitial fluid which is the liquid between cells: ![Illustration of a cross section of the interstitium.](https://upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Intersticio_%28%C3%B3rgano%29.jpg/512px-Intersticio_%28%C3%B3rgano%29.jpg?20180424142555) Glucose eventually goes from our capillaries into this fluid. This means that CGMs are technically not even measuring blood sugar, but instead the interstitial glucose level. Depending on several variables, it could take 2 to 24 minutes for interstitial glucose to catch up to blood sugar. This reminds me of when [Marco Altini talked about](https://marcoaltini.substack.com/p/the-ultimate-guide-to-heart-rate) how a HR chest strap measure can be used to derive HRV, whereas optical heart rate sensors on our wrists or fingers are actually measuring Pulse Rate Variability (PRV). The two are very similar, especially when sitting, but not the same measure. [Stelo cites 93% accuracy](https://support.levels.com/article/617-how-accurate-is-stelo?ref=blog.matthewbrunelle.com). Note they are using absolute values rather than percentages, but the device is around ±20%. One way to frame all of this is that there is a reasonable accuracy / frequency trade off here. You won't get blood work done every day and blood work results take time. You can do finger-sticks more frequently and a CGM is well, mostly continuous at 5 minute intervals. The increased frequency is useful if the short term trend is what you care about. --- ## On calibration and "wellness devices" Regardless of the accuracy, the Stelo is not approved for dosing decisions. The Stelo is sold OTC under the category of a *wellness device*. Not for clinical diabetes management. When I was trying out xDrip+ I kept seeing "calibration" mentioned. I was curious if I could use my finger-stick tests to improve the accuracy of the Stelo. The short answer is "no". The medium answer is "no, but that is OK". The longer answer has to do with factory calibration. The Stelo [FDA approval](https://www.accessdata.fda.gov/cdrh%5Fdocs/pdf23/K234070.pdf?ref=blog.matthewbrunelle.com) says: > The Stelo GSS firmware is designed to support a factory-calibrated only device. I saw a lot of conflicting information online about whether this was a choice by Dexcom, or a requirement by the FDA. For example, Signos is some weight management app which uses Stelo. [\[8\]](#fn8) Their [support page says](https://support.signos.com/hc/en-us/articles/39790928946708-Can-I-manually-calibrate-my-Stelo-biosensor?ref=blog.matthewbrunelle.com): > Because the Stelo biosensor is not intended for the treatment or management of medical conditions like diabetes where precise glucose readings are essential, the FDA does not allow calibration for this device. To the best of my reading this does not seem correct. I cannot find any requirement by the FDA to make this restriction. This [xDrip+ issue on calibration](https://github.com/NightscoutFoundation/xDrip/discussions/3954?ref=blog.matthewbrunelle.com) provided useful context as does this [page in the docs](https://navid200.github.io/xDrip/docs/Calibration.html?ref=blog.matthewbrunelle.com). Essentially, its not that these OTC devices cannot be calibrated and prescription CGMs can be calibrated. Instead, CGMs in general are moving away from user calibration to factory calibration. Early CGMs required daily calibration or they would drift. Modern CGMs do not drift as much, and manufacturers want to make adoption easier on users. This [piece by Abbott](https://pmc.ncbi.nlm.nih.gov/articles/PMC5444502/?ref=blog.matthewbrunelle.com) clarifies that factory calibration is simpler for people than manually calibrating. What this all means is that I can manually log finger-stick tests in xDrip+. They will show up on the timeline, but they will not be used for calibration. --- ## More to come on CGMs Similar to my [Framework Laptop](https://blog.matthewbrunelle.com/tag/framework/), [high school project](https://blog.matthewbrunelle.com/fond-memories-of-a-high-school-personal-project-cross-country-race-analysis/), and [GrapheneOS ](https://blog.matthewbrunelle.com/tag/grapheneos/) post series, sometimes when I sit down to write a post they get too long and become multiple posts. Usually I try to drop all posts in a seriesa the same time, but I'm going to release them individually this time. I'll have a part 2 soon about actually using the Stelo with xdrip+ and will eventually put up a part 3 about analyzing the exported glucose readings. --- 1. Which I mentioned in my [post on Pokemon Sleep](https://blog.matthewbrunelle.com/100-weeks-of-pokemon-sleep-is-probably-enough/#footnote-3). [↩︎](#fnref1) 2. I [read the book again this year](https://blog.matthewbrunelle.com/i-read-a-couple-of-books-in-2025/#outlive-the-science-and-art-of-longevity-by-peter-attia-514) so I could make highlights. [↩︎](#fnref2) 3. Yes the CGMs have a limited shelf life. Dexcom [states](https://www.dexcom.com/en-IS/safety-information?ref=blog.matthewbrunelle.com) this is because the sensors may no longer be accurate. It makes sense that *at some point* the sensors will fail. This [study](https://pubmed.ncbi.nlm.nih.gov/16939374/?ref=blog.matthewbrunelle.com) from 2006 concluded that "Prolongation of shelflife label and insertion time appears to be reasonable". [↩︎](#fnref3) 4. A long dormant [xDrip+ issue on Lingo support](https://github.com/NightscoutFoundation/xDrip/discussions/3804?ref=blog.matthewbrunelle.com) is active again now that there is Android support. [↩︎](#fnref4) 5. Which based on my research started with [this Facebook group](https://www.facebook.com/groups/cgminthecloud/). [↩︎](#fnref5) 6. I will share though that there is a guide for installing on [NixOS](https://nightscout.github.io/vendors/VPS/nixos/?ref=blog.matthewbrunelle.com). [↩︎](#fnref6) 7. Which I believe should be a blood test, but with more rigorous handling than you would get at the doctors. [↩︎](#fnref7) 8. I have never tried the app, but it came up as an example. [↩︎](#fnref8) ### It's Ploopin' Time URL: https://blog.matthewbrunelle.com/its-ploopin-time/ Last updated: 2026-01-13T15:35:17.000Z #### On this page Despite the fact that I frequently write about trying to purchase and own less things... I do continue to purchase and own more things. This time I bought the the Ploopy Touch when a friend ~~cursed me with the knowledge~~ messaged me to let me know it exists. --- ## Ploopy what? Ploopy Touch what? [Ploopy](https://ploopy.co/?ref=blog.matthewbrunelle.com) is a Canadian company that makes open hardware that [describes themselves](https://ploopy.co/who-we-are/?ref=blog.matthewbrunelle.com) as "radically open-source". I love that. I talk a lot about open software, see [my post on GrapheneOS](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/). I haven't talked much about open hardware though. The closest post is [about the Framework 13 laptop](https://blog.matthewbrunelle.com/im-a-laptop-weirdo-and-thats-why-i-like-my-new-framework-13/), but those are not truly open. In [I never thought I could be a maker](https://blog.matthewbrunelle.com/i-never-thought-i-could-be-a-maker/) I covered how I want to better understand how hardware is made. Buying a Ploopy product feels like both a great way to support a company in that space and also an opportunity for me to learn. I believe the Touch is the fourth category of products that Ploopy has released. The first three were a mouse, a trackball, and headphones. Their trackpad was followed by a knob they describe as something "you’ll want to touch all day long". I've wanted to buy [the mouse](https://ploopy.co/mouse/?ref=blog.matthewbrunelle.com) in the past, ever since a friend grabbed one. However, I have several mice that have not yet kicked the bucket so I can't justify getting a new one. I have a magic trackpad I use at work [\[1\]](#fn1), but no trackpad at home yet. So trackpad it was. Thankfully, Ploopy has [resumed US shipping](https://blog.ploopy.co/were-resuming-us-shipping-338?ref=blog.matthewbrunelle.com). Since the products are designed and manufactured in Canada, they fall under Canada-United States-Mexico Agreement (CUSMA) and are exempt from tariffs: > In order to demonstrate compliance with CUSMA, we need evidence *directly on our products* that they are indeed made in Canada. Every single product in a package needs to have a marker of its origin as Canada. Those of you based in the US will now see a dash of our Canadian pride in the form of these “Made in Canada” stickers. ![ploopy_touch_rear.jpg](https://blog.matthewbrunelle.com/content/images/2026/01/ploopy_touch_rear.jpg) --- ## The bad news There is a [DIY kit option](https://ploopy.co/shop/trackpad-full-kit/?ref=blog.matthewbrunelle.com) and I purchased that one. All the [instructions](https://ploopyco.github.io/trackpad/assembly-instructions/?ref=blog.matthewbrunelle.com) are on Github. However, I was accidentally sent a fully assembled kit. Oh the horror. This would be a boon for most people since the assembled version costs more, but I was hoping to document the assembly process to share here. Alas, I still opened the Touch up though to take a look. ![ploopy_touch_inside.jpg](https://blog.matthewbrunelle.com/content/images/2026/01/ploopy_touch_inside.jpg) As far as assembly for me, I only had to do from [step 8](https://ploopyco.github.io/trackpad/assembly-instructions/?ref=blog.matthewbrunelle.com#step-8) onwards, which was... four screws [\[2\]](#fn2) and then four friction pads. The quality of the printer case was pretty good. There was one sharp point, but that was easy to sand down. The print certainly was better than I ended up with for my [3D printed case for my SmartSpin2k](https://blog.matthewbrunelle.com/3d-printing-the-smartspin-2k-with-an-ender-3-v2/). Also of note, the instructions said: > The Trackpad comes with a stylus. However, due to some technical limitations, **the stylus is not enabled by default and therefore does not work**. We expect to support stylus usage in the future. In my post on the [Framework 13's hardware](https://blog.matthewbrunelle.com/framework-13-diy-edition-hardware-thoughts/#build-quality) I mentioned that Framework is not shipping haptic touch yet, but still wants to figure out how. Likewise this trackpad also does not have haptics. The Ploopy Knob seems to have some though, so maybe the company is experimenting with it now. I would love a non magic trackpad option for Linux with good feedback. --- ## My first time seeing an open hardware license All the hardware designs are in the [repo](https://github.com/ploopyco/trackpad?ref=blog.matthewbrunelle.com). Yes, all the designs: the STL/STEP for the case, the parts library, the PCB designs and the schematic. Everything there is under the [CERN-OHL-S-2.0 license](https://cern-ohl.web.cern.ch/?ref=blog.matthewbrunelle.com), [\[3\]](#fn3) which I had not heard about before. That makes sense given the license is designed for open-hardware and not FOSS. I see there are three versions: S, W, and P for Strongly reciprocal, Weakly reciprocal, and Permissive. Ploopy is using the strong version of the license for their hardware. In this [video](https://www.youtube.com/watch?v=idAz77Ilwy8&ref=blog.matthewbrunelle.com) from Week of Open Source Hardware (WOSH) 2019, Andrew Katz, who worked on the license, explains all the details nicely. Especially how FPGAs and ASICs have a lot of proprietary toolchains and they needed the license to work along side those. A strong copyleft software license like GPL triggers on *distribution* of software. That doesn't make sense for hardware though. Instead copyright triggers when you make and distribute the products. This is a good defensive measure, since companies will try to wiggle out of copyleft licences. See my post on [openTofu](https://blog.matthewbrunelle.com/on-self-hosting-opentofu-and-formerly-oss-licensed-software/) where I talk about FOSS products trying to relicense. --- ## On the software side As I mentioned in [HackerBox 0121 - MCU Lab 2025](https://blog.matthewbrunelle.com/review-of-hackerbox-0121-mcu-lab-2025/#the-matrix-keypad), I've only ever bought assembled mechanical keyboards before. My first was the KBT Pure, which I destroyed by spilling soylent. Since then I've used a Vortex Pok3r that's been running strong since 2019\. Both of these keyboards run a proprietary firmware made by their manufacturers. The Pure had some very basic key mapping and the Pok3r had some more substantive options, but everything was done through the keyboard. At work I have a split keyboard called the ZSA Moonlander. The device runs an open firmware called the Quantum Mechanical Keyboard ([QMK](https://qmk.fm/?ref=blog.matthewbrunelle.com)). The ErgoDox family of keyboards that the Moonlander comes from is directly supported by QMK thanks to contributions from ZSA. You can configure devices that run QMK through the [QMK Configurator](https://config.qmk.fm/?ref=blog.matthewbrunelle.com#/zsa/moonlander/LAYOUT) site, rather than directly through the keyboard. The firmware is also a really flexible foundation to create your own keyboards. [\[4\]](#fn4) The firmware also is flexible enough to make a touchpad. Ploopy has [their own branch](https://github.com/ploopyco/qmk%5Ffirmware/tree/multitouch%5Fexperiment?ref=blog.matthewbrunelle.com) of QMK, while they work on getting upstreamed. Now a touchpad doesn't require much configuration, so there is no need for the configurer site. In my case on NixOS with KDE simply plugging the Touch in and everything worked. All the KDE touchpad settings functioned as expected: ![kde_touchpad_settings.png](https://blog.matthewbrunelle.com/content/images/2026/01/kde_touchpad_settings.png) Even edge scrolling, which can be wonky sometimes, worked well. --- ## Final thoughts, maybe future mods Overall I like the Ploopy Touch a lot. The device does exactly what it says on the tin. Are there other things I would like? Of course, always. Thankfully Ploopy has a [modder's guide](https://blog.ploopy.co/modders-guide?ref=blog.matthewbrunelle.com) and their [mod index](https://blog.ploopy.co/mods-index?ref=blog.matthewbrunelle.com) already shows a glass top project someone has done. Personally I would love a wireless version one day, just to make the cable management a little better. That would require fitting a battery and a bluetooth chip inside though. I already have way too many [bluetooth projects](https://blog.matthewbrunelle.com/an-esp32-based-beacondb-scanner-prototype/) on my plate right now. Maybe someday I'll revisit the idea, or if I'm lucky someone else will beat me to it. The upside of open hardware is we all benefit from others contributions. --- 1. Since we use Macbook Pros at work. Hey, I'll take that over Windows desktops like I had at my first job. [↩︎](#fnref1) 2. Now, ironically the top screw wouldn't sink for me and started to strip. Luckily since this is open hardware I can order myself a new screw. [↩︎](#fnref2) 3. Yes that CERN, of the particle accelerators. [↩︎](#fnref3) 4. My friend is [working on an attachable thumb keyboard for phones](https://github.com/rileytuttle/thummingboar?ref=blog.matthewbrunelle.com) that uses another open firmware called ZMK. ZMK is more focused on wireless keyboards. [↩︎](#fnref4) ### Review of HackerBox 0122 - Ouroboros URL: https://blog.matthewbrunelle.com/review-of-hackerbox-0122-ouroboros/ Last updated: 2026-01-03T14:33:02.000Z #### On this page I think the HackerBox creators must stalk me. This month's box has a camera, and an ECG kit! I haven't written about HRV analysis yet, but it was a past hyper fixation of mine. [![hackerbox_0122_knolling.jpg](https://blog.matthewbrunelle.com/content/images/2026/01/hackerbox_0122_knolling.jpg)](http://) Which reminds me of a recent [episode on medical electronics](https://theamphour.com/711-medical-electronics-education-with-mark-palmeri/?ref=blog.matthewbrunelle.com) from the Amp Hour podcast. In the episode Dr Palmeri talked about using [Zephyr](https://www.zephyrproject.org/?ref=blog.matthewbrunelle.com) in his classes as an alternative to Arduino. As I mentioned in my [post about learning Rust](https://blog.matthewbrunelle.com/finally-actually-hopefully-learning-rust/#the-current-events), I appreciate having alternatives to Arduino given the current events. As always, the official instructions are up on the [Instructables guide](https://www.instructables.com/HackerBox-0122-Ouroboros/?ref=blog.matthewbrunelle.com). --- ## More bench tools for the tools god I am a tough person to buy gifts for. I'm trying to buy less physical stuff and I'm also the kind of person that will buy something right away when I truly need it. When I got into [HackerBoxes over the last year](https://blog.matthewbrunelle.com/tag/hackerbox/) I picked up just the essentials I needed for soldering. I've held off on the nice to haves and instead put them into a gift list for family members. [\[1\]](#fn1) Anyways, I received some nice new tools and they certainly made assembly go smoother this month. The item I wanted most was a box of task wipes for cleaning flux off PCBs. I should have realized that to someone who doesn't work with electronics, that would seem like the least important item on the list, so I had to buy that for myself. [\[2\]](#fn2) Outside of the wipes I also got some acid brushes to help with cleaning the board [\[3\]](#fn3), some helping hands and a solder sucker[\[4\]](#fn4). Now I have a new problem: how on earth to store an organize all this stuff. Alas, I'll figure that out another time. --- ## Assembling the OVcam Big bold warning to take your time and read carefully on this months box. Thankfully there was much less soldering this time vs last month's [HackerBox 0121 - MCU Lab](https://blog.matthewbrunelle.com/review-of-hackerbox-0121-mcu-lab-2025/), which had so many parts that I ended up soldering the SD card slot upside down. Here's the soldering details for this half of the kit. Remember, think of this less as instructions, and more as a quick measure of effort to compare the monthly kits. For the OVcam: - Solder the right angle 2x9 pin female header to the top. [\[5\]](#fn5) - Solder the right angle tactile pushbutton to the top. - Solder the two 1x13 pin male header to the ESP32 WeMos LOLIN32 Lite. - Solder the ESP32 to the bottom of the board. - Solder two 4.7K resistors to the board. The OV7670 camera module goes into the female header so there is no soldering for that. This is the first time the I've gotten a HackerBox that included resistors. Thankfully they are both 4.7k, so I don't have to think about resistor color codes like I did in the electronics class I took. I eventually learned they are for keeping the clock and data pins for i2c pulled high. This led to [some reading](https://www.totalphase.com/blog/2024/07/what-are-i2c-pull-up-resistors-calculate-their-values/?ref=blog.matthewbrunelle.com) that really helped me better understand i2c: - The lines are open drain - so attached components are able to pull the line low, but not high. Something else is needed to keep the line high. - Each bus on i2c has its own [pull-up resistor](https://learn.sparkfun.com/tutorials/pull-up-resistors?ref=blog.matthewbrunelle.com) connected to the voltage supply that make the pin read high by default. 4.7k resistors are considered a good starting point. - The lower the resistance, the faster the line will return to high. - This ensures that the pins clearly reads high or low and do not float. Also, this was my first time soldering a right angle header. [This video guide](https://www.youtube.com/watch?v=ALZ4FIUClrk&ref=blog.matthewbrunelle.com) shows how you can solder one pin from the *top* and adjust the alignment before flipping and getting all the pins from the bottom. The video also has the bonus tip for clipping headers: clip the headers *into a bag* so they don't fly off to the other side of the room. Notably, there are 8 holes in the middle of the board labeled "EXT" that are not used: ![unused_pins.png](https://blog.matthewbrunelle.com/content/images/2026/01/unused_pins.png) [\[6\]](#fn6) They are there if you want to attach a TFT display. --- ## The OVcam software Even though the sketch for this project includes a web server, it is very different from my [self hosted dog camera](https://blog.matthewbrunelle.com/self-hosting-a-dog-camera-for-away-from-home-monitoring/). After all, there is no dedicated video acceleration hardware on this board, so the framerate can only go so high. The project is based off this [ESP32CameraI2S](https://github.com/bitluni/ESP32CameraI2S?ref=blog.matthewbrunelle.com) project. The Instructable doesn't mention, but the repo has a [video explaining](https://www.youtube.com/watch?v=S2yTQHM82jc&ref=blog.matthewbrunelle.com) how the project works. He mentions the SCCB (SCL) and SCCB (SDA) [\[7\]](#fn7) pins for i2c. I talked about i2c before in [HackerBox 0120 - current affairs](https://blog.matthewbrunelle.com/review-of-hackerbox-0120-current-affairs/#assembling-the-current-affairs-power-monitor) and my [post on the ESP32 based beaconDB scanner](https://blog.matthewbrunelle.com/an-esp32-based-beacondb-scanner-prototype/#saving-csv-files-to-the-sd-card-and-learning-about-spi). I also recently found this great [written guide to i2c](https://learn.sparkfun.com/tutorials/i2c?ref=blog.matthewbrunelle.com) on sparkfun. That project is based off an even older project [esp32-cam-demo](https://github.com/igrr/esp32-cam-demo?ref=blog.matthewbrunelle.com) from someone at expressif. They provide a approach for transferring the frames without FIFO using i2s parallel mode. The readme also include a nice [table explaining the pin out](https://github.com/igrr/esp32-cam-demo?tab=readme-ov-file&ref=blog.matthewbrunelle.com#connect). Putting all that info together here, this how I think parallel mode works: - As mentioned, XCLK is used for providing the clock to the camera. Instead of using an oscillator, the project uses LEDC, the LED control from the ESP32, to send a 20mhz signal. - The horizontal sync, vertical sync, and pixel clock all work together to control when i2s will push new data into memory using parallel mode. - Horizonal reference (HREF) is sent when a line is ready and vertical sync (VSYNC) when a new image is ready. - There are also 8 pins for pixel data and a pixel clock PCLK pin which is sent when the next byte of a pixel is ready. - They use two DMA buffers in a ring. When one fills there is an interrupt to signal to read a buffer, and the other buffer will start to be filled. - Those buffers are read into the frame buffer. This is a great [video explaining Direct Memory Access (DMA)](https://www.youtube.com/watch?v=M16l%5Fymlfcs&ref=blog.matthewbrunelle.com) and how DMA allows the CPU to step out of the way of an I/O device writing into memory. This is done using a dedicated controller that handles moving the memory. The preceding [video on interrupts](https://www.youtube.com/watch?v=dDA3PUr16As&ref=blog.matthewbrunelle.com) is also useful. --- ## Getting the OVcam code working on the new version of ESP-IDF > **GenAI Callout:** I used Claude Code to help me learn about the project and bounce ideas / build errors off it. I made sure to prompt Claude to not make code changes, but instead to help me understand what changed between the library versions. The guide mentions: > Part of the [migration from 2.x to 3.x](https://docs.espressif.com/projects/arduino-esp32/en/latest/migration%5Fguides/2.x%5Fto%5F3.0.html?ref=blog.matthewbrunelle.com) involved a change in the LED Control (LEDC) API to support the Peripheral Manager. LEDC is use in the bitluni project to generate an XCLOCK signal supplied to the camera module. It should be fairly straight forward to update the LEDC functionality to work with the latest ESP32 board package. Should you wish to take on that mission, please share your results with the rest of us. I gave it a try and was able to get everything to work! In `ESP32_I2S_Camera/I2SCamera.h` I needed to update headers as various pieces were moved around in the library upgrade: ```diff #pragma once +#include "esp_attr.h" +#include "rom/gpio.h" #include "soc/soc.h" #include "soc/gpio_sig_map.h" #include "soc/i2s_reg.h" #include "soc/i2s_struct.h" #include "soc/io_mux_reg.h" +#include "soc/gpio_struct.h" #include "driver/gpio.h" -#include "driver/periph_ctrl.h" +#include "esp_private/periph_ctrl.h" #include "rom/lldesc.h" #include "XClk.h" #include "DMABuffer.h" ``` In `ESP32_I2S_Camera/XClk.cpp` I had to update a header since some methods were moved into a private header. I also had to add a memset call to clear `timer_conf` before using the structs as per this [issue](https://github.com/espressif/esp-idf/issues/15806?ref=blog.matthewbrunelle.com). Finally, the `bit_num` field on the configuration was renamed to `duty_resolution` so I updated that as well. ```diff #include "XClk.h" #include "driver/ledc.h" +#include "esp_private/periph_ctrl.h" +#include bool ClockEnable(int pin, int Hz) { periph_module_enable(PERIPH_LEDC_MODULE); ledc_timer_config_t timer_conf; - timer_conf.bit_num = (ledc_timer_bit_t)1; + memset(&timer_conf, 0, sizeof(ledc_timer_config_t)); + timer_conf.duty_resolution = LEDC_TIMER_1_BIT; timer_conf.freq_hz = Hz; timer_conf.speed_mode = LEDC_HIGH_SPEED_MODE; timer_conf.timer_num = LEDC_TIMER_0; ``` Here is the [commit in my fork](https://github.com/ciferkey/ESP32CameraI2S/commit/69f9d6af50d8caae04f193d3d4a4a5afda99be48?ref=blog.matthewbrunelle.com#diff-0edf8a226aac1d4488851246f8ee7a5e7118febbd73b9f962afb506acbc47263L5-R15.patch). Also, I believe the new version of ESP-IDF has a totally different and cleaner way to set up LEDC, but I avoided this since I wanted to keep the new code as close to 1:1 as possible. Somewhat related, I also found a [discussion on porting the Arduino camera and display ecosystem to Zephyr](https://github.com/zephyrproject-rtos/zephyr/discussions/93058?ref=blog.matthewbrunelle.com). They aren't there yet, but are making progress. --- ## Soldering the ESP ECG The section was *very complicated* and I'm happy I took my time: - Solder the right angle tactile push button onto the side of the PCB with "HACKERBOX" - Trim the leads. There will not be much clearance between the stacked PCBs. - Solder the two 1x13 pin male header to the ESP32 WeMos LOLIN32 Lite. - Solder the ESP32 to the bottom of the board. - Trim all the leads. - Break the 1x9 pin male header into a 1x3 pin and 1x6 pin. - Solder two 1x3 and 1x6 pin headers onto the AD8232 ECG Sensor Module. - Solder the sensor module onto the back of the board. - Trim all the pins. - Solder the 1x4 pin male header to the MSP2401 display module. - Solder the display module to the ESP ECG PCB. - Make sure to keep a little space between the two boards. I temporarily inserted thick piece of paper and use helping hands to stabilize. Here's an example of the trimmed leads: ![trimming_the_leads.jpg](https://blog.matthewbrunelle.com/content/images/2026/01/trimming_the_leads.jpg) Here's the spacing for the two PCBs: ![spacing_pcbs.jpg](https://blog.matthewbrunelle.com/content/images/2026/01/spacing_pcbs-1.jpg) Inspired by the header cutting idea, I put the board and my hands inside of a gallon freezer bag and trimmed all the pins so they would not fly everywhere. This worked surprisingly well, though I assume there is a better more standard way to do this. --- ## Using the ECG I don't have a formal background in signal theory, but last year I read a bit of `Understanding Digital Signal Processing` by Richard G. Lyons to learn some basics when I was messing around with HRV measurement. In that scenario I was looking into PPG, which is optical, rather than ECG. Marco Altini does a lot of excellent [writing on this](https://marcoaltini.substack.com/i/109968541/photoplethysmography-ppg) in his Substack. The guide describes the AD8232 as a "integrated signal conditioning block" that helps measure small biological signals under noisy conditions. The instructables also links to this [guide on the AD8232](https://lastminuteengineers.com/ad8232-ecg-monitor-arduino-tutorial/?ref=blog.matthewbrunelle.com) which was very thorough and I recommend reading. The pin out analysis: - The OUTPUT pin is an analog signal of the boosted and cleaned up HR. - LO- and LO+ are used for detecting if the electrodes are off the body. - RA, LA and RL are used for Right Arm, Left Arm and Right Leg electrodes in a different setup without using the 3.5mm jack. [![Limb leads of EKG](https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/Limb_leads_of_EKG.png/512px-Limb_leads_of_EKG.png?20210602134630)](https://commons.wikimedia.org/wiki/File:Limb%5Fleads%5Fof%5FEKG.png?ref=blog.matthewbrunelle.com "Npatchett, CC BY-SA 4.0 , via Wikimedia Commons") The electrode placement in Einthoven's triangle. I like HR devices *a lot*. As I mentioned in my [post race report](https://blog.matthewbrunelle.com/post-race-report-boston-run-to-remember/), they are useful for estimating load and recovery to prevent over training. At any moment in time I typically have between two and five HR devices on me: - A strap that measure recovery on one wrist. - A watch that's more specialized for running on another wrist. - When I'm exercising I'll put on a chest strap. - Sometimes other devices I'm testing and comparing. [\[8\]](#fn8) Outside of a quick test with this ECG I didn't do much more with it yet. In the future I might try to compare the signal with a reference device to see how accurate it is. --- 1. I also [directed them to not give me physical gifts if possible](https://blog.matthewbrunelle.com/please-no-physical-gifts/), but many people still wanted to give something, so I made a list. [↩︎](#fnref1) 2. I kept doing HackerBoxes over the months thinking, man I'm excited to get my task wipes since this paper towel isn't doing it. [↩︎](#fnref2) 3. You can cut the bristles shorter to make them easier to use. [↩︎](#fnref3) 4. Which saved my bacon on the ECG build in this months box [↩︎](#fnref4) 5. The top side of the PCB says "HACKERBOX". [↩︎](#fnref5) 6. I had to look up the symbol since I had not seen it before, but the right angle under the left most pin is a [pin 1 indicator](https://www.protoexpress.com/kb/how-to-add-and-identify-pin-1-marking-in-your-pcbs/?ref=blog.matthewbrunelle.com). [↩︎](#fnref6) 7. As I had mentioned in a previous review, there is great inconsistency in i2c pin naming. [↩︎](#fnref7) 8. My Pebble should be shipping soon. [↩︎](#fnref8) ### I Read a Couple of Books in 2025 URL: https://blog.matthewbrunelle.com/i-read-a-couple-of-books-in-2025/ Last updated: 2025-12-30T17:22:04.000Z #### On this page As I mentioned in my post [on practicing writing](https://blog.matthewbrunelle.com/on-practicing-writing/), I started reading more a couple of years ago when my grandfather passed. Another year has gone by and I wanted to reflect on how this new habit from him went in 2025\. This post is about the books I read. See my other [post for the books I listened to](https://blog.matthewbrunelle.com/i-listened-to-a-couple-of-books-in-2025/). I have a Goodreads account, but I keep my profile private and do not review books there. Instead, I use the site more for keeping track of when I've finished books. Also, this year I started tracking on the fediverse version of Goodreads, [Bookwyrm](https://bookwyrm.social/?ref=blog.matthewbrunelle.com), but I also do not post there. [POSSE](https://indieweb.org/POSSE?ref=blog.matthewbrunelle.com) as they say, so here are all my reviews that are due. Also, I like to make highlights in Readwise Reader. [\[1\]](#fn1) I have a "useful" tag I put on highlights that really impacted me. So I'll sprinkle some of those highlights throughout the reviews. --- ## DRM and books Note: I am not an affiliate, and I *do not* use affiliate links. If I link to a book here, its because I'm excitered there is a DRM-free version available. Anyway, having said all of that I wanted to talk about how I source my books. If a book is available on bookshop.org [\[2\]](#fn2) and the book is DRM free I will buy the book there and load the epub into Reader. I'll also backup the book onto my server. If the Audiobook is on Libro.fm, I will buy the book there to support the author, and a portion of the sale will go to my local bookstore. All books on Libro.fm are DRM free, and I want to support that ecosystem when I can. I use their 1 credit a month plan and spend those credits on books that are more than $14\. I'm looking at you Brandon Sanderson. I usually download the audiobook files and upload them to my [Audiobookshelf](https://www.audiobookshelf.org/docs?ref=blog.matthewbrunelle.com) instance I self host. On Android the official app works really well, even with Android Auto [\[3\]](#fn3). The iOS app for Audiobookshelf has been stuck in testpilot forever with no open slots, but [AudioBooth](https://github.com/AudioBooth/AudioBooth?ref=blog.matthewbrunelle.com) is a great FOSS option and [SoundLeaf](https://soundleafapp.com/?ref=blog.matthewbrunelle.com#download) is a reasonable paid option. Audiobookshelf is great for audiobook listening on roadtrips where either of our phones could be connected to the car. The official Libro.fm app is a nice backup as well. Finally, if those fail, I will try to grab the books off of Libby. Having to wait for books to be available and having a limited about of time to read the books creates some friction, but the Libby app can still sync highlights with Reader. --- ## I read a lot of Cal Newport this year ### *Slow Productivity* (2024) by Cal Newport, 216 highlights [\[4\]](#fn4) I read *Deep Work* (2016) and *Digital Minimalism* (2019) last year. Deep Work was really a core thesis for Cal. In his following books he builds on smaller ideas from the book and expands on them. Slow Productivity is: > A philosophy for organizing knowledge work efforts in a sustainable and meaningful manner, based on the following three principles: > > 1. Do fewer things. > 2. Work at a natural pace. > 3. Obsess over quality. And what he advocates for is: > the core idea that this book will explore: perhaps knowledge workers’ problem is not with productivity in a general sense, but instead with a specific faulty definition of this term that has taken hold in recent decades. I found his ideas to be really practical and like his other books I've started using many parts in my day to day. ### *How to Become a Straight-A Student* (2006) by Cal Newport, 278 highlights This is a much earlier work by Cal and its cool to see how his writing style has changed over the years. I read this book after his more recent books because I was planning to take some courses and realized that I had never really thought about *how* to take a course growing up. I really wish I had a book like this when I was younger. So much wasted time spinning my wheels on poor ways to study. Anyways, I did a course on photography through RISD before my [trip to Japan where I used my Ricoh GRIII](https://blog.matthewbrunelle.com/ricoh-griii-in-japan/) and this book was really useful. ### *So Good They Can't Ignore You* (2012) by Cal Newport, 103 highlights This book is based on the idea that the normal recommendation to follow your passion fails most people. Instead, Cal talks about working on "the craftsman mindset" and the need to work on building up rare an valuable skills. This will help you build up career capital, which in turns helps you control what your work life is like. This is also his first book I read that really explains deliberate practice, which I talk about in my post on [learning Rust](https://blog.matthewbrunelle.com/finally-actually-hopefully-learning-rust/#on-mastery-learning-and-stretching-with-deliberate-practice). ### *Time Anxiety* by Chris Guillebeau, 222 highlights I heard about the book through [this episode](https://podcasts.apple.com/us/podcast/in-depth-the-art-of-non-conformity-w-chris-guillebeau/id1515786216?i=1000712577934&ref=blog.matthewbrunelle.com) with Chris on Cal's podcast. The writing style threw me off a bit, but I like how compassionate the author is. I took a couple recommendations from the book and use them in my day to day. --- ## I trained for a Triathlon sprint that I never ran This year I shared a [post about my experience training for the Boston Run to Remember Half Marathon](https://blog.matthewbrunelle.com/post-race-report-boston-run-to-remember/). What I haven't talked about yet was that I planned to do a triathlon sprint in October, with a friend, but then never ran the race. My friend had to get knee surgery, and I myself was feeling overtrained trying to swim for the first time, and balance training for all three events. So we punted to next year. The up side is I read a lot of good books. ### *Training and Racing with a Power Meter*: Third Edition (2019) by Hunter Allen, 169 highlights While this book was really interesting, I feel like I didn't take much practical knowledge away from it. The author goes deep into the details of all the metrics like Intensity Factor and Training Stress Score that you would see in your training apps. However, the main lessons were about how to find your training thresholds, and how to use them in training. If you pick this up, make sure to grab the most recent edition since the technology behind power meters moves fast. After reading the book I bought a power meter for my bike. Also see my post on [modifying my stationary bike to work with SmartSpin2k](https://blog.matthewbrunelle.com/assembling-and-testing-the-smartspin2k-on-a-schwinn-ic4/). ### *The Cyclist's Training Bible*: Fifth Edition (2018) by Joe Friel, 344 highlights I like this *much* more than the previous book. The focus is much more practical: > The most crucial take-home message is that you need a standard weekly and daily routine that allows you to fit everything important into your life in the order that reflects your priorities. You’ve got some level of responsibility to other people, including immediate family, relatives, employers, teachers, coworkers, classmates, friends, teammates, and others. Fitting only these responsibilities into your life is difficult. Also fitting in training that meets the demands of your goal makes establishing a daily routine extremely challenging. So your goal must be realistic with regard to your life. And the higher the goal, the more important it is that you have a standard lifestyle routine in order to produce the desired result. I handle my training plans through TrainingPeaks and the Joe co-founded the company TrainingBible. Ironically, Allen founded CyclingPeaks and the two eventually merged to become training peaks. I didn't realize this before I picked up the two books. ### *Run Like A Pro* (2022) by Matt Fitzgerald, 220 highlights I read Matt Fitzgerald's books *80/20 Running* and *The Endurance Diet* in 2023 when I trained for my marathon. I also used his training plans through TrainingPeaks for both the Marathon and Half Marathon. This is a much more recently written book, with a focus on adjusting his recommendations for recreational running. He touches on all the major topics like training intensity, strength training, nutrition, sleep, etc. I do not see this as a replacement for his older books, rather as a complementary addition. ### *80/20 Triathlon* (2018) by [Matt Fitzgerald](https://www.amazon.com/Matt-Fitzgerald/e/B001IGNJLW/ref=dp%5Fbyline%5Fcont%5Fbook%5F1?ref=blog.matthewbrunelle.com) ??? highlights [\[5\]](#fn5) Prior to this book I had an idea how to periodize running and biking, but not both at the same time, and certainly not both *while also swimming*. This book breaks down that problem in a really clear way. Now note, that doesn't make the task of triathlon training any less daunting, but the structure does set you up for as much success as possible. I also used the 80/20 triathlon level 0 training plan, so this book was required reading. \[^ I used the version with HR based running, pace based swimming, and power based cycling. I love that there is a version that lets you use all three types!\] ### *Peak Performance* (2017) by Brad Stulberg, 166 highlights I thought I felt tired during the marathon training, but that was actually nothing compared to how I felt on the triathlon plan. This book doesn't tread any new ground compared to other books I've read, but it does a great job of pulling together and summarizing all the standard advice around training cycles and recovery. This was a nice refresher for me, and I think it would be a nice introduction for someone new to the space. As a side note, I read the *Seven Day Sleep Prescription* during my marathon training and my recovery improved significantly. [\[6\]](#fn6) Another side effect of this is that I now read before bed as a part of my wind down ritual. I typically only need to read for about 15 minutes before turning off the lights, but the page count really adds up over time. ### *Outlive: The Science and Art of Longevity* by Peter Attia, 514 [\[7\]](#fn7). I listened to the audiobook version last year during my marathon training, but I revisited the book again this year so I could highlight and better retain the information. Man does this book have a lot of information. The chapters include a combination of science, and stories to explain the science. Since this was my second time through I only skimmed the more narrative sections. The core thesis is that we often treat conditions far too late in life. [\[8\]](#fn8) Instead, if we make lifestyle changes earlier we greatly reduce our risk of "the four horsemen": heart disease, cancer, neurodegenerative disease, and metabolic disfunction. Also, Attia acknowledged that he promoted narrow viewpoints when he was younger and that this book was partly a way to correct that. I love seeing someone demonstrate introspection and growth. I also appreciated him including a chapter on the importance of mental health, and sharing personal experiences with his own struggles that he has worked through. ### *Tactical Barbell I* (Second edition), by K. Black, 140 highlights I adopted a dog this year and while I was adjusting my schedule I didn't get to the gym much. So I reread *Simple and Sinster* by Pavel and ran that at home since the workouts are quick and you only need kettle bells. The workout is my resistance training fallback when life gets hectic. When I had time to start going back to the gym I ran a couple Tactical Barbell templates because I previously ran Wendler's 531 in the past and wanted something similar, but a little lighter. I'm not into the whole military theme, but this program kept coming up as a good option to sustainably combine with cardio endurance training without burning out. I'm happy I also sat down to read the book, because it spells out the philosophy well: > If strength is very low on the list of priorities for you but still needs your attention, you might choose a minimalist exercise cluster to go with Fighter template. This is a very popular combo for endurance athletes such as marathoners or triathletes. For now I've been running the Fighter template so I can get two days a week doing resistance training, and I've been able to hit my recovery needs. [\[9\]](#fn9) Also he spelled out the golden rule *very* clearly: > **THOU SHALT REST A *MINIMUM* OF TWO MINUTES BETWEEN SETS** > > Not a minute, not 90 seconds. *Not even if you feel completely rested before the two minutes is up.* Don’t trust your body in this case. Notice the Golden Rule states ‘a minimum’. That’s right. The idea is that you are fully rested for the next set, so that we avoid muscle failure. Take as long as you need, as long as it is a minimum of two minutes. Increasing my rest time so that the workout is focused on maximal strength has really gone a long way for helping my day over day recovery. ### *Tactical Barbell II*: Conditioning by K. Black, 112 highlights Since I have experience with cardio programming already, this was primarily helpful for figuring out how to program the strength work alongside the cardio endurance. I also like the focus on training efficiency: > First, you have to know how to train each system in the most efficient manner possible. Efficiency becomes important, because as a multi-tasking athlete you can’t afford to lose training time or energy doing things in a less than ideal manner. You have to master multiple skills. Your time and energy are going to be tight and precious... Choose fewer, but more effective tools. ### *Ageless Athlete* by Jim Madden, 158 highlights This is what happens when a philosophy professor reads the tactical barbell books: > I also have a very low tolerance for the “tacticool” fitness trend. I loathe products aimed at civilians with titles like “Warrior Fitness” or “Train like a SEAL.” If you want to find out what it is like to be a warrior, there are several active “government programs” that would be happy to let you try out the real thing! While this is more for people in the 40 plus age range, the book was still worth reading. He builds on the ideas about sustainable and efficient training from the previous two books. There is a little more emphasis on strength over cardio than I would like, but he gives you all the tools to adjust the ratio if you want to. Especially the pieces on seasonal training. I now have options around shifting cardio and strength work in the winter. He gives reasonable advice focused on exercise over the long haul, rather than chasing PRs right now and blowing yourself up. This book ties in nicely with the other books I read this year. --- ## I read some books about the internet now a days ### *Superbloom* by Nicholas Carr, 178 highlights I can't remember where I saw this recommended. I remember enjoying this book while I was reading. Looking back now, I don't remember taking much away though. I think that is because the content overlaps with a lot of other material I read. This book would be a great introduction for people new to the topic. ### *The Internet Con* by Cory Doctorow, 170 highlights I got to see Cory Doctorow at [PyCon this year](https://blog.matthewbrunelle.com/pycon-2025-friday-keynote-and-talks/) and wanted to pick up one of his books. At that time *The Internet Con* was his latest so I started with that. On the whole I liked the book and how he addresses specific policy problems with specific potential solutions. ### *Enshitification* by Cory Doctorow, 252 highlights This is Cory's newly released book. A lot of the material is going to be familiar if you are a regular reader of his blog Pluralistic. However, the book collects and organizes the content really cleanly: - First a tour of the history of how we got the Enshitified internet. - Then talks through the *pathology*, the processes that led to Enshitification. - The *epidemiology*, the causes of "how competition, regulation, interoperability, and worker power all slipped away." - Finally he talks through the "cure". Out of all of the sections I really appreciated the final one on the cure. That felt the most novel compared to what I usually read on his blog. ### *Repair revolution* [\[10\]](#fn10) by John Wackman, 123 highlights This year I went on a road trip for a wedding. On the way I stopped at [Grassroots Kitchen](https://grassroots.kitchen/?ref=blog.matthewbrunelle.com) in Tarrytown, NY. On their bookshelf they had this book which introduced me to the concept of Repair Cafes. It includes *a lot* of examples from actual repair cafe events. Those parts felt a bit much and I often skimmed them vs the more explanatory parts of the chapters. The rest of the book on the history and ethos of the movement was compelling. ### *Hello, My Name Is Ice Cream: The Art and Science of the Scoop: A Cookbook* by Dana Cree, 172 highlights A friend who makes ice cream recommended this one. The book both gives a clear explanation of the science behind ice cream making and provides a framework. This will allow you to create your own ice cream going forward. For me, this means modifying recipes to be low FODMAP for my fiancée. [\[11\]](#fn11) If you are interested in learning about ice cream I highly suggest starting with this. ### *Here Comes the Sun* by Bill Mckibben, 146 highlights This was enthusiastically [recommended by](https://pluralistic.net/2025/10/02/there-goes-the-sun/?ref=blog.matthewbrunelle.com) Cory Doctorow. The book was a really digestible read. Equal parts encouraging and demoralizing: - paints a really clear picture of how the economics have shifted in a way that makes solar and wind inevitable. [\[12\]](#fn12) No matter how much the fossil fuel industry wishes that was not true. - By stepping away from renewables and doubling down on fossil fuels the US has given up its opportunity to be a global leader. No matter how much people wish that was not true. I recommend this as a read for anyone that wants to be able to explain to others why either of the two above points are true. ### *The Daily stoic* by Ryan Holiday I read this on recommendation from my Therapist. The book is set up to be read one page a day. The structure gives you time to take all the content in and try to integrate the practices. I missed a couple of days here and there, but I know I will catch them in future years when I repeat the book. Also, each month of the book has a theme and not all of the themes hit for me. However, on the whole I'm happy I read the book. The lesson from December 22 stood out to me: > It’s easier to quote, to rely on the wise words of others. Especially when the people you’re deferring to are such towering figures! > It’s harder (and more intimidating) to venture out on your own and express your own thoughts. But how do you think those wise and true quotes from those towering figures were created in the first place? > Your own experiences have value. You have accumulated your own wisdom too. Stake your claim. Put something down for the ages—in words and also in example. Yes, I know the irony to quote these lines and to use the other quotes I have in this post. This is a nice reminder to write my own words when I can. --- ## I not only read books, but also... Newsletters! I wanted to call out a couple other pieces of long form content that I consistently read over the years. As I mentioned in my post on [converting article HTML to SSML for Text to Speech](https://blog.matthewbrunelle.com/automatic-conversion-of-article-html-to-ssml-for-text-to-speech/), I use to run a custom program for turning Matt Levine's [Money Stuff](https://www.bloomberg.com/account/newsletters/money-stuff?ref=blog.matthewbrunelle.com) newsletter into an audio podcast RSS feed I could listen to. Now a days I'm using the TTS feature in Reader instead, but I still listen every time he publishes. [\[13\]](#fn13) Similar but different is the [Bits about Money](https://www.bitsaboutmoney.com/?ref=blog.matthewbrunelle.com) newsletter by Patrick McKenzie. [\[14\]](#fn14) Finally I started reading Cory Doctorow's newsletter [Pluralistic](https://pluralistic.net/?ref=blog.matthewbrunelle.com) this year after I read his books. --- ## In progress and on deck books I have too many books I am currently reading: - Seeing Like a State - a heavy tome that will take a while. - Climate Obstruction: A Global Assessment - Style: Lessons in Clarity and Grace - The Hardware Hackers Handbook I have too many books I want to read: - Two recommended by the [Economist](https://www.economist.com/culture/2025/11/20/the-best-books-of-2025?ref=blog.matthewbrunelle.com), *Is a River Alive?* by Robert Macfarlane and *Lone Wolf* by Adam Weymouth. - *Steps to an Ecology of Mind* by Gregory Bateson - *The Origins of Efficiency* by Brian Potter of Construction Physics - *Bird by Bird* by Anne Lamott And so, so many more. In a [recent podcast episode](https://www.youtube.com/watch?v=dSNT4mtYR7o&ref=blog.matthewbrunelle.com) Cal Newport answered a listener question about how to manage one's lists of what to read and watch. What he said really resonated with me: > The way I see it is, look, do I always have something interesting to read? Yes. Do I have no shortage of interesting things to watch when the time comes around? Yes. Do I have no shortage of podcasts to listen to when I have downtime? Yes. Then I'm happy, because the problem is I make a list of all this stuff, and then I feel lost for not doing the things on my list that I haven’t gotten to. But the lists, technically speaking, could be unending. I mean, there's no shortage of books you could be reading. There's no shortage of visual content you could be watching. So, why create loss, right? I'm just like, what am I excited about? Let me do that now. So I'm pretty set for good books to read into the future. --- 1. I've been using a Boox eink android tablet for a little over a year now. I like that I can run regular Android apps like Reader. On the whole things work pretty well! [↩︎](#fnref1) 2. A portion of the sale will go to my local bookstore. [↩︎](#fnref2) 3. Though if you install through fdroid or Unobtainium you will need to [enable unknown sources similar to Antennepod](https://forum.antennapod.org/t/cant-add-antennapod-to-androidauto/6592?ref=blog.matthewbrunelle.com). [↩︎](#fnref3) 4. I don't have a specific reason for including the highlight count. Its not a great signal since the length of the books can vary so much. [↩︎](#fnref4) 5. Due to availability issues I had to buy this through Amazon. In the past I could export using the "Download & transfer via USB" option and strip their DRM using DeDRM and Calibre because I had an old Kindle device. However, that no longers works so I had to read through the Kindle app and while I though I had exported my highlights into Readwise, I actually only see a couple in there. [↩︎](#fnref5) 6. Speaking of which, I noticed while writing this that I was nearing my wind down for the night and stopped writing for the even. One habit that I built thanks to that book. [↩︎](#fnref6) 7. Ok, in this case I do think the highlight number is meaningful. [↩︎](#fnref7) 8. Especially in America. [↩︎](#fnref8) 9. Its a bit of a hike to get to the gym. One day when I can set up a home gym, I'd love to run a 4 day a week, but shorter session template like Operator. [↩︎](#fnref9) 10. You can get the ebook DRM free off [Bookshelf](https://bookshop.org/p/books/repair-revolution-how-fixers-are-transforming-our-throwaway-culture-john-wackman/10954195?ref=blog.matthewbrunelle.com). [↩︎](#fnref10) 11. I keep saying I'll write a post on this, but there is only so much time. [↩︎](#fnref11) 12. Its hard to beat the [learning curve](https://www.construction-physics.com/p/how-accurate-are-learning-curves?ref=blog.matthewbrunelle.com) for silicon. [↩︎](#fnref12) 13. Which used to be most weekdays, but not Fridays there is a [podcast](https://podcasts.apple.com/us/podcast/money-stuff-the-podcast/id1739582836?ref=blog.matthewbrunelle.com) on Fridays. [↩︎](#fnref13) 14. He also has his own [podcast](https://www.complexsystemspodcast.com/?ref=blog.matthewbrunelle.com) as well. [↩︎](#fnref14) ### I Listened to a Couple of Books In 2025 URL: https://blog.matthewbrunelle.com/i-listened-to-a-couple-of-books-in-2025/ Last updated: 2025-12-30T17:22:59.000Z #### On this page This is my post on the audiobooks I listened to this year. I also have post on [the books I read this year](https://blog.matthewbrunelle.com/i-read-a-couple-of-books-in-2025/). ## Audiobooks vs Reading I think both reading and audiobooks are great. For me, they serve different purposes. If I want to really absorb the information in a particular book, I will sit, read and make highlights. I'm also trying to do more non-fiction and for those books I will listen to the audio version. In my mind these books compete for the same time slots I would listen to podcasts and I listen to a lot of podcasts. See my post on [what podcasts I'm listening to this year](https://blog.matthewbrunelle.com/what-podcasts-am-i-listening-to-2025/). I will also listen to nonfiction books that I care less about retaining all the details and are more for entertainment. This is what works well for me, and I want to reinforce that each person learns differently. I'm trying to be descriptive here about what works well for me rather than prescriptive for others. ## The fiction books ### *Children of Memory*, *Children of Time* and *Children of Ruin* by Adrian Tchaikovsky I found out about the first book in this series from a Reddit comment where someone said something along the lines "this book made me like spiders". It's been a bit since I was really pulled into a fiction book and this series hooked me. I adore non linear / intersecting story lines and the first book in this series pulls it off in such a satisfying way. I won't say more because I do not want to spoil anything. The first book is my favorite of the three, but I truly enjoyed all of them. I also appreciated that these book each end with a hopeful sentimentality that is often missing in this genre. I'm looking forward to listening to *Children of Strife* soon when it releases. ### *The Diamond Age* by Neal Stephenson I hadn't done a Stephenson book since I read *Snow Crash* as a kid. This book was recommended on some podcast I cannot find, where a guest mentioned it was topical given the current use of generative AI. I was really pulled in for the first three quarters, but similarly to his other books the end felt a little loose. Ultimately I am happy I gave the book a try though. ### *The Way of Kings* by Brandon Sanderson I bounced off this initially when I first tried the book last year. This year I gave the book another try because I knew the time spent would pay off and boy did it. I really enjoy the later part of the book where the different narrative points intersected. ### *Words of Radiance* by Brandon Sanderson All of the world building in The Way of Kings sets up the second book in the Stormlight Archive series to really play in the space that Sanderson created. I chewed through a lot of this book on my flight out and back from [NeurIPS 2025](https://blog.matthewbrunelle.com/tag/neurips-2025/). *Edgedancer* by Brandon Sanderson After I finished Words of Radiance I immediately started Oathbringer. In the intro Sanderson has a little note where he suggests listening to Edge dancer first. So I talked to my friend that love Sanderson's books and he pointed me towards the most recent [official reading order recommendation video](https://youtu.be/0mC8dsQJK7w?si=T2fGgj9rWhpcVhcn&ref=blog.matthewbrunelle.com). After watching I decided to listen to Edgedancer before Oathbringer. Now that I'm listening to Oathbringer, I am happy I did that. Edgedancer is a short story and provides some important context. Once I finish Oathbringer I will put Stormlight on pause so I can listen to the Mistborn series. --- ## The non fiction books ### *For All the Tea in China: Espionage, Empire and the Secret Formula for the World's Favourite Drink* by Sarah Rose There is a peculiar genre of books that *really* tickles my brain just right: Commodity history books. [\[1\]](#fn1) I usually go through one or two a year. Last year I listened to *A History of the World in 6 Glasses* by Tom Standage which covers human history and culture through six drinks: beer, wine, spirits, coffee, tea, and cola. After I listened to that I wanted to know more about tea in particular and the dark imperial history involved so I picked up this book. I think the author did a pretty fair job of covering how the events unfolded. ### *Salt: a History* by Mark Kurlansky This book was dry, but not in an off putting way. Sometimes this genre is too high level and reads more like popular science. For example I picked up *Ten Tomatoes that Changed the World* by William Alexander this year and I'm not sure if I will go back and finish. This book though, was packed full of historical details. I also loved how the book was a truly global view of the topic. Sometimes authors in this genre will focus too much on Europe and America. Where as in this book, when Kurlansky talked about people in Cape Cod "discovered" a new type of drill to mine deeper, he also mentioned that people in China had use the exact same approach *700 years earlier*. ### *Eager: The Surprising, Secret Life of Beavers and Why They Matter* by Ben Goldfarb This is probably my favorite book out of everything this year. Eager is the kind of book I would normally want to read and highlight every detail. This time though I listened because I wanted to share the experience with my partner and talk through the book with her. Her work overlaps with the book and she had a really great perspective to share. So we made this into a road trip book! The book was really mind expanding just like Goldfarb's other book *Crossings*, which I also recommend reading. ### *Why Zebras Don't Get Ulcers* by Robert Sapolsky I had seen recommendations for this book several times, but didn't listen until my therapist encouraged to me look into it when I asked about books on stress management. This is a book I will definitely re-read in text form to make highlights. --- ## In progress and on deck audio books I have two more commodity history books in progress: - *Material World: The Six Raw Materials That Shape Modern Civilization* by Ed Conway - this is extremely good so far. I'm working me way through slowly though since its my new road trip audiobook - *The World Atlas of Coffee* by James Hoffman - I was going to listen to this earlier in the year, but then realized that the second edition was releases in October so I waited. Excited to listen now that its out, especially since Hoffman narrates himself. I'm also listening to Oathbringer. Once those are finished I plan to listen to: - The Mistborn books - Children of Strife I'll have to temper my expectations though considering how long the Sanderson books are. Luckily, I have a lot of running and biking coming up in the warmer months. --- 1. Which is probably why I love the [Gastropod](https://gastropod.com/?ref=blog.matthewbrunelle.com) podcast so much. [↩︎](#fnref1) ### (My) Second Year of the Linux Desktop (For Gaming) URL: https://blog.matthewbrunelle.com/my-second-year-of-the-linux-desktop-for-gaming/ Last updated: 2025-12-22T16:55:45.000Z #### On this page As much as we joke about the phrase, I don't think there will ever be a true [Year of the Linux Desktop](https://yearofthelinuxdesktop.net/?ref=blog.matthewbrunelle.com#state). However, I want to advocate that people should talk about *their* Year of the Linux Desktop, and I want to leave what exactly the requirements are for that up to the individual. You will know when its the time. For me, this is my Second Year of the Linux Desktop and I want to share why I think so. --- ## I have some history of using Linux on desktop In my post on [being a laptop weirdo](https://blog.matthewbrunelle.com/im-a-laptop-weirdo-and-thats-why-i-like-my-new-framework-13/) I covered my personal history of using Linux on laptops since 2008 and the *terrible terrible* things I did to those laptops. However, my history with Linux on the desktop goes back even earlier than that. I am *really* fortunate I had access to computers and dial-up at a young age because my dad needed them for billing insurance from his office. Insurance also forced him to upgrade his computer pretty regularly, which gave me a stream of old computers to dismember and reassemble. At some point I ended up with an old PC in my bedroom, but I had no internet of course. I think the very first distribution of Linux I installed was Puppy Linux. That's because Puppy was *tiny* so I could download the ISO in a reasonable amount of time and burn it onto a CD. This was probably circa 2003 when it first released, and I did very little aside from try it out. I did realize though, that I was only using Windows at the time since it felt like the only option. I've never had an affinity towards Windows. In 2005 I did a Spanish class video project. My room was on an upper floor and extremely hot. I remember sitting up there with my friends trying to edit the footage in Windows Movie Maker and get the project finished. Around that time I received in the mail a CD of Ubuntu 5.10 and installed onto a second drive I had put into my computer. I made the classic mistake of not checking my drives and accidentally wiped the Windows drive, destroying the video project in the process. We had to refilm and edit everything all over again. Despite all odds, I am still friends with that project-mate and I still like Linux. Around 2008, I shifted to mostly using Linux on my laptop, as I describe in the linked post. However, up until I went to college in 2011 I also ran Linux in VirtualBox on Windows. In my post [fond memories of a high school personal project](https://blog.matthewbrunelle.com/fond-memories-of-a-high-school-personal-project-cross-country-race-analysis/#i-made-a-django-site) you can see some *scans* of *printouts* of *screenshots* of the Ubuntu VM. In college I triple booted Windows, Arch Linux and OS X on my MBP and mostly went without a desktop. Post-college I dual booted Windows and Linux on my desktop. I primarily used the Linux installation for personal projects because investment banks forced my work to do our development on Windows and I was sick of that. This takes us up to around 2019 when I started [playing with Ansible](https://blog.matthewbrunelle.com/media-center-series-part-1-install-docker-with-ansible/) on various hardware I owned, which eventually led to me moving from Arch to NixOS. I haven't done a full post on NixOS, but I have talked about [setting up my Framework Laptop 13 with NixOS](https://blog.matthewbrunelle.com/setting-up-my-new-framework-laptop-13-diy-edition-with-nixos/). Anyways, one nice thing about NixOS is you have a full history of your system configuration over the years, which is excellent if you are feeling nostalgic. In 2020 I [checked in my first commit](https://github.com/ciferkey/Old-NixOSConfig/commit/5f8a024a089b2e9c5b97f977b09acd2faa1301b2?ref=blog.matthewbrunelle.com) for a NixOS config. Also around that time, the pandemic hit and I started to work from home, I had to run Citrix to connect to a remote machine for work. I spent *a long time* trying to get that to work on Linux but never succeeded. This was as clear a sign as any that I wanted out from Windows. --- ## A Steam Replay Comparison A couple years ago steam started doing replays like everyone else out there. The interesting thing is they included a pie chart showing time spent playing games on each OS. Note: the color coding for the OS playtime pie chart is not consistent across years. Also note, from the FAQ: > No, Steam Replay does not include any playtime played in offline mode or when disconnected from internet. I had several flights and road trips over the years where I got in a lot of Steam Deck time that wasn't captured. ### 2021 - Another year of the windows desktop There is no steam replay for 2021\. If there was, it would show a majority of my time playing games on Windows and a small sliver playing on Linux desktop. That's because in December of 2021 I see a [commit](https://github.com/ciferkey/Old-NixOSConfig/commit/b65a3c612121a3edcda7aa190402e5a22661362d?ref=blog.matthewbrunelle.com) on my NixOS config adding Steam. Also, I tried, and failed to buy a Steam Deck at launch in 2021\. I got caught in the payment processing crunch. FFXIV came to steam in 2014, a year after the A Realm Reborn relaunch. If you bought XIV on Steam you would eventually get easy native proton setup.[\[1\]](#fn1) However, if you bought the game outside of Steam you couldn't log into the Steam Version. Running the game outside of Steam was finicky so I continued to play on Windows. ### 2022 - A year of some amount of Linux desktop ![Pasted image 20251217110138.png](https://blog.matthewbrunelle.com/content/images/2025/12/Pasted-image-20251217110138.png) In 2022 I still mostly played PC games on Windows 10, but times were changing. In February of that year I changed jobs and no longer needed to run Citrix. My desktop Linux gaming usage increased and I finally was able to order my Steam Deck on June 30, 2022 when I got my confirmation email that I had made my way through the queue. The Steam Deck brought [Linux support to XIV launcher](https://www.gamingonlinux.com/2022/05/xivlauncher-now-on-linux-gets-final-fantasy-xiv-online-running-on-steam-deck/?ref=blog.matthewbrunelle.com) which mean I could start playing FFXIV on Linux desktop! However, since FFXIV playtime is not tracked by Steam, this does not impact the playtime shown above. When Overwatch 2 released in October of 22 it was almost [immediately playable on Linux through Bottles](https://www.gamingonlinux.com/2022/10/heres-how-to-play-overwatch-2-on-steam-deck-linux/?ref=blog.matthewbrunelle.com) and I essentially only played on Linux. [\[2\]](#fn2) The game was also not out on Steam yet so this was not tracked by my replay either. ### 2023 - A year of quite a lot less of windows desktop ![Pasted image 20251216180440.png](https://blog.matthewbrunelle.com/content/images/2025/12/Pasted-image-20251216180440.png) As I mentioned in my [post on self hosting LLMs](https://blog.matthewbrunelle.com/using-self-hosting-language-models-so-you-can-evaluate-claude-code/), in 2023 Newegg accidentally sent me a very nice AMD Radeon RX 7900 XT and never asked for it back. This was a major boon since AMD support was much better than my Nvidia RTX 3080\. At this point I was booting off my my Windows drive less and less. I [added an alias](https://github.com/ciferkey/NixOSConfig-2023/commit/c3797ea0e174bb00711e873f5a5a86aa5111cc19?ref=blog.matthewbrunelle.com#diff-31229fa992b403d35ed9d8652247285873be74fb3210d541767d0cf74a231371R139) to my NixOS config for quickly switching between Windows and Linux. Overwatch 2 [released on Steam](https://news.blizzard.com/en-gb/article/23982127/overwatch-2-coming-to-steam-on-august-10?ref=blog.matthewbrunelle.com), so this now includes my playtime for the game. The process for trying a new game on Linux was essentially: - Install the game on Linux and try to play. - If I have problems, have a look at [protondb](https://www.protondb.com/?ref=blog.matthewbrunelle.com) to see if there are any tweaks for them. [\[3\]](#fn3) - If that doesn't help, go to the Github issue and see if the game is flagged as broken. - If the game is broken, install on Windows, and subscribe to the Github issue. [\[4\]](#fn4) - If the issue gets resolved, repeat the loop. Its as "simple" as that. [\[5\]](#fn5) ### 2024 - my first Year of the Linux Desktop for gaming ![Pasted image 20251216180713.png](https://blog.matthewbrunelle.com/content/images/2025/12/Pasted-image-20251216180713.png) I can say with certainty that 2024 was the Year of the Linux Desktop *for me*. I still had Windows 10 installed on my desktop but never booted once to play a game. I pulled the Windows NVME drive from my computer at the end of the year to [add a second drive for game storage](https://github.com/ciferkey/NixOS-Config/commit/f863e72f2371659770bdf8da227fd9f8a683d07e?ref=blog.matthewbrunelle.com#diff-d178ee88e4eff4c94cb5eab5653e6f9096f5ca83fd6d28e3da46e29b7f22901fR21-R25). As far as gaming habits, I had played basically every season of Overwatch 2 from 1 - 12\. When they reworked Sombra around season 7, I really enjoyed playing her and had a lot of fun matches. [\[6\]](#fn6) I played up until season 13 when they, rightfully, nerfed and reworked her again. I also was training for a marathon in 2024 and started recording my sessions on my Garmin watch: ![Pasted image 20251222091044.png](https://blog.matthewbrunelle.com/content/images/2025/12/Pasted-image-20251222091044.png) The results were eye opening, and each sweaty competitive session would result in a measurable hit to my training statistics. I took the nerf to Sombra as a chance to step away from games that involve match making so I could focus on training more. In July of 2024 the Dawntrail expansion for FFXIV launched. I still took time off from work to play the expansion, but post launch I was playing the game less and less. The time between patches went from around 3 months to around 4 months. Also, life was getting busier and I was spending more of my time reading. Ironically, just as Linux desktop was really finding its stride for games, I was starting to play games less and less. ### 2025 - my second Year of the Linux Desktop for gaming Or more accurately, my first Year of the Linux Handheld for gaming. ![Pasted image 20251216180742.png](https://blog.matthewbrunelle.com/content/images/2025/12/Pasted-image-20251216180742.png) While the the switch from Windows to Linux was a deliberate choice, the switch from desktop to handheld gaming is more a reflection of my changing lifestyle. Earlier in the year I was talking with a coworker about the amazing game Blue Prince and he mentioned that he mostly only plays games like that now. He used to have a bad addiction to Starcraft and eventually made a rule that he "doesn't play games that do not have an end". While there still are some endless games that I enjoy playing [\[7\]](#fn7), I seem to have also moved in this direction without intentionally trying to do so now that I don't play OW2 and only try out FFXIV for a day or two when new patches drop. I played since the 2.0 beta, so that's 10 years of really great moments and I do not feel the need to try and extract much more. Another change this year was actually using the PS5 I had a bit more. I bought Expedition 33 on Steam, but then ended up playing on the PS5\. Ghost of Yotei was really fun too. [\[8\]](#fn8) Playing on the PS5 was mostly driven by the fact that its nice to sit on the couch with my fiancée and game while she is playing on her Steam Deck. I tried running HDMI cables from my desktop, but I often only have short windows to game in the evening now, and that approach was finicky enough that I often defaulted to just picking up my PS5 controller. I [ran another race this year](https://blog.matthewbrunelle.com/post-race-report-boston-run-to-remember/) which means more time training, and going to bed earlier. Also, as I mentioned in my post [on practicing writing](https://blog.matthewbrunelle.com/on-practicing-writing/), after I started reading more, I've gotten into writing for the first time in my life. Which you can see in the sharp rise in new posts this year. All of this means less time for games. --- ## A shout out to Valve's work with the Steam deck and beyond There are *so* many people that contribute to the Linux desktop and I don't want to diminish their work. I just want to call out how much Valve has invested. [\[9\]](#fn9) You can scrub through the Phoronix [tag for Valve](https://www.phoronix.com/linux/Valve?ref=blog.matthewbrunelle.com) and the GamingOnLinux [tag for Valve](https://www.gamingonlinux.com/articles/category/valve/?ref=blog.matthewbrunelle.com) for plenty of examples. This [write up circa 2023](https://www.gamingonlinux.com/2023/08/5-years-ago-valve-released-proton-forever-changing-linux-gaming/?ref=blog.matthewbrunelle.com) tries to establish some of the history. I initially was going to list out all the project Valve contributed to and put together my own links to articles, but I honestly felt overwhelmed by the volume. Instead I just want to highlight how many of the different parts of the stack Valve either directly worked on or payed contractors to work on: - Kernel level changes around futex. Also, various gaming hardware drivers. - Mesa drivers like RADV and related parts like ACO. - The translations layers alphabet soup like DXVK and VKD3D. - The compatibility libraries like Proton, but also upstreaming work into Wine proper. - Also, emulation layers FEX-emu. - Compositor components like Gamescope and wlroots. - DE support through contributions to KDE. This is of course, a non exhaustive list. This road started with Valve releasing the official Steam Linux client in 2013 [\[10\]](#fn10) and their first attempt at a Steam Machine in 2015\. Those devices were a failure, and devs didn't want to port their games to Linux, but Valve continued on. In 2018 Valve released Proton and worked their way to the Steam Deck in 2022\. They have patiently been laying all this ground work. This [interview with Valve](https://www.theverge.com/23499215/valve-steam-deck-interview-late-2022?ref=blog.matthewbrunelle.com) from 2022 really helps explain their mindset. As well as this quote from the article [Valve Is A Wonderful Upstream Contributor To Linux & The Open-Source Community](https://www.phoronix.com/news/Valve-Upstream-Everything-OSS?ref=blog.matthewbrunelle.com) (2022): > Griffais says the company is also directly paying more than 100 open-source developers to work on the Proton compatibility layer, the Mesa graphics driver, and Vulkan, among other tasks like Steam for Linux and Chromebooks. > > “There’s a larger strategy effort to coordinate all these products and set up kind of an overall architecture, to make sure they can help PC gaming technology thrive in a Linux-based environment that is not really the target platform for all those games,” he says. I'm making a note here, HUGE SUCCESS. [\[11\]](#fn11) --- ## Some final thoughts When I graduated high school, my social studies teacher gave everyone a copy of the poem Desiderata. Lines from the poem will come to my mind from time to time. Right now I'm reminded of the part: > Take kindly the counsel of the years, gracefully surrendering the things of youth. Ironically, the two major games that blocked Linux for me, FFXIV and Overwatch 2, are games I don't play much anymore. Still, its really nice to see how things have come along. I still play games, I'm just a bit careful now about how I choose to spend the time I do play. Also I'm *really* excited about the upcoming Valve [hardware](https://store.steampowered.com/sale/hardware?ref=blog.matthewbrunelle.com). I own the original Steam Controller and have been thirsting for a [Steam Controller 2](https://store.steampowered.com/sale/steamcontroller?ref=blog.matthewbrunelle.com). The PS5 Dual Sense edge mostly checks all my boxes: - Symmetric joy sticks. [\[12\]](#fn12) - A touch pad for compatibility with games where I may need a mouse input occasionally. - Fully programmable back buttons. [\[13\]](#fn13) - Motion controls for compatibility with games that need it. The Steam Controller does all of that, with a more Steam native experience. The [Steam Frame](https://store.steampowered.com/sale/steamframe?ref=blog.matthewbrunelle.com) has gotten me excited for VR again. I was an original backer for the Oculus Dev Kit 1 back in 2012\. Then [Facebook acquired Oculus in 2012](https://about.fb.com/news/2014/03/facebook-to-acquire-oculus/?ref=blog.matthewbrunelle.com) [\[14\]](#fn14) and I never bought the Consumer Rift. In 2019 I grabbed a Valve Index and played with it for a while. However, having to use floor to ceiling poles to attach the Lighthouse for outside-in tracking, as well as needing to recalibrate every time I played, was enough friction that I stopped. Inside-out tracking on a open ecosystem device is something I've been waiting a long time for. Also, the fact that FEX works well enough for x86 to ARM translation is very exciting. I don't think I'll buy a [Steam Machine](https://store.steampowered.com/sale/steammachine?ref=blog.matthewbrunelle.com), but I'm very happy it will exist. I've been testing out dual booting Bazzite on my desktop and I'd love to replace that with SteamOS proper. If I can pick up a controller and play a game on Steam with my TV as easily as the PS5, then I won't need to boot that up as much. Either way, I'm looking forward to a third Year of the Linux Desktop. Especially considering the [intentional enshittification of Windows](https://www.windowscentral.com/gaming/xbox/microsoft-has-made-it-impossible-to-be-a-fan?ref=blog.matthewbrunelle.com). --- 1. The game would not be flagged as playable until 2022\. [↩︎](#fnref1) 2. I really liked TF2 as a kid and not many other games aside from Overwatch scratch that itch. There was a month where I played Gundam Evolution quite a bit. [↩︎](#fnref2) 3. Another great sign about the Linux community is projects like this to collect and share knowledge. [↩︎](#fnref3) 4. I'd like to say that the fact that each game gets an issue is *really nice* for collecting problems. Compared to Windows where you just sort of scream into the void hoping to find someone with a similar problem. There is also the upside, that since the compatibility layer is open source, people can actually try to fix the issue, rather than waiting for Microsoft to maybe do something. [↩︎](#fnref4) 5. My friends and I wanted to play L4D2 recently and wanted to get a 5 player server working. We spent two hours trying to stand up a server for it, combing forums for solutions. Each time we rebooted and the game failed to run we would say "See, its as simple as that". [↩︎](#fnref5) 6. I set up a channel on my Discord server where my friends and I could post screenshots of people being upset at us playing Sombra. [↩︎](#fnref6) 7. Especially, if it means hopping online to catch up with some friends. [↩︎](#fnref7) 8. Especially, after [going to Japan last year](https://test.matthewbrunelle.com/ricoh-griii-in-japan/?ref=blog.matthewbrunelle.com). I have some friends going to northern Japan next year, and Yotei has me excited to go with them. [↩︎](#fnref8) 9. Turns out they want to escape Microsoft as much as we all want to. [↩︎](#fnref9) 10. Which only supported Linux native games. I received a surprising amount of compatible games through Humble Bundles and played a few, but mostly stuck to Windows for games at that point. [↩︎](#fnref10) 11. I'd also like to say how important this is for game preservation. There are many old games that no longer work on Windows, but will work with WINE! [↩︎](#fnref11) 12. I know this is controversial, but I very much prefer it. [↩︎](#fnref12) 13. SCUF's [lawsuits](https://www.polygon.com/2021/2/3/22264213/valve-steam-controller-lawsuit-scuf-4-million/?ref=blog.matthewbrunelle.com) against anyone that tries to use back buttons on a controller without paying this is absolutely criminal. [↩︎](#fnref13) 14. Which also was a huge bummer since John Carmack used to share a lot of detailed technical talks and stopped doing those. [↩︎](#fnref14) ### Review of HackerBox 0121 - MCU Lab 2025 URL: https://blog.matthewbrunelle.com/review-of-hackerbox-0121-mcu-lab-2025/ Last updated: 2026-01-12T20:48:33.000Z #### On this page This is my review of [HackerBox #0121 - MCU Lab 2025](https://hackerboxes.com/products/hackerbox-0121-mcu-lab-2025?ref=blog.matthewbrunelle.com). See the [HackerBox tag](https://blog.matthewbrunelle.com/tag/hackerbox/) for my other reviews. The instructions for the kit are available on [Instructables](https://www.instructables.com/HackerBox-0121-MCU-Lab-2025/?ref=blog.matthewbrunelle.com) as usual. The MCU Lab 2025 is a remix of [HackerBox 0051 - The MCU Lab](https://www.instructables.com/HackerBox-0051-MCU-Lab/?ref=blog.matthewbrunelle.com) from 2019. The box actually arrived early this time, but I was busy [attempting to install Jellyfin on Tizen at my inlaws](https://blog.matthewbrunelle.com/installing-jellyfin-tizen-on-your-inlaws-samsung-tv-in-just-n-simple-steps/) around Thanksgiving and then [off at NeurIPS 2025](https://blog.matthewbrunelle.com/neurips-2025-retrospective/) in San Diego. Now I've finally had some time to wrap up the box and do a write up. This month involves working with *four* different micro controllers: - Ultimate Pico RP2040 16MB Development Board - ESP-WROOM-32 Development Board - STM32F401CCU6 Black Pill Development Board - NRF52840 SuperMini Development Board Which is pretty exciting because I have not worked with an STM nor Nordic board yet. As I mentioned in my [post on learning Rust](https://blog.matthewbrunelle.com/finally-actually-hopefully-learning-rust/#the-current-events), its a good time to have other options than the Arduino ecosystem, considering the Qualcomm acquisition. --- ## Comparing the four MCUs The guide starts with a nice video comparing the for microcontroller options the kit provides. In the ESP32 section he describes the chip as a "wireless powerhouse". Its nice to hear an affirmation about using that chip in my [ESP32 WiFi and BLE wardriving scanner prototype](https://blog.matthewbrunelle.com/an-esp32-based-beacondb-scanner-prototype/). Also, I hadn't known that you need to get FCC approval if you want to ship a product using a bare chip ESP32\. Sparkfun made a guide about the process, but it seems to have disappeared this year. I found [a copy from 2024](https://web.archive.org/web/20190605071555/https://www.sparkfun.com/tutorials/398) in the wayback machine. Now the trade off of course for the ESP32 is the higher power draw. The STM32 and the NRF52 can work better for low and ultra low power use cases. The RP2040 is a nice affordable option, though I hadn't realized there is no internal flash. Outside of the video, the written guide provides an overview of all four development boards and directions on configuring the Arduino IDE. --- ## Back with the ESP32 and RP2040 again The ESP32 and RPi chip sections were very familiar. The ESP32 dev board is the same one I [tried out in box #0191 - Geopositioning](https://blog.matthewbrunelle.com/review-of-hackerbox-0119-geopositioning/#trying-out-the-esp-wroom-32-development-board). The guide had me run the WifiScan sketch example from the Arduino IDE examples just like in box 0191\. This is also the same sketch I used in my wardriving prototype. RP2040 is the chip the Raspberry Pi Pico is built off, which was used in [box #0118 - More Human](https://blog.matthewbrunelle.com/review-of-hackerbox-0118-more-human/#using-a-raspberrry-pi-pico-2w-as-an-arduino). However, this time I got to play around with [CircuitPython](https://circuitpython.org/board/raspberry%5Fpi%5Fpico/?ref=blog.matthewbrunelle.com)! The guide had me do a simple blink sketch, but I would love to play with CircuitPython more in the future for quick prototypes. Oh and the guide also had me setup and use the Arduino blink sketch. --- ## New experiments with the STM32F401CCU6 The chip's description notes there are specific instructions for DSP. If I ever get back to HRV processing, I wonder if these chips can be used for the signal processing. Anyways, the guide had me set up the Arduino IDE by adding the chip support. They didn't instruct me to run any specific sketch, so I did the classic blink to verify everything works. They also linked to a guide for using [MicroPython](https://micropython.org/?ref=blog.matthewbrunelle.com). A second framework for embedded Python? Not quite. Adafruit has an [article](https://learn.adafruit.com/getting-started-with-raspberry-pi-pico-circuitpython/micropython-or-circuitpython?ref=blog.matthewbrunelle.com) explaining that CircuitPython is a fork of MicroPython designed to be a little easier to use. The [CircuitPython documentation](https://github.com/adafruit/circuitpython?ref=blog.matthewbrunelle.com#differences-from-micropython) details the full set of differences. Also, some quick searching showa that MicroPython would also work on the RP2040, but CircuitPython does not seem to support the STM32F401CCU6 [\[1\]](#fn1) --- ## Sniffing with the nRF52840 Now this is a cool chip. The nRF52840 supports both Zigbee and Thread which would be fun to use in a home automation project. Even less common than that is the Ant+ support! Maybe I could mess around with my [SmartSpin2k](https://blog.matthewbrunelle.com/assembling-and-testing-the-smartspin2k-on-a-schwinn-ic4/), or other fitness sensors that use the protocol. The guide had me try out the nRF Sniffer project using an Adafruit tutorial and work with Wireshark. Interestingly enough the process starts by loading the [Adafruit nRF52 Bootloader](https://github.com/adafruit/Adafruit%5FnRF52%5FBootloader?ref=blog.matthewbrunelle.com) firmware on the device. Then I loaded [Nordics nRF sniffer](https://www.nordicsemi.com/Products/Development-tools/nRF-Sniffer-for-Bluetooth-LE?ref=blog.matthewbrunelle.com) which [seems to be closed source](https://devzone.nordicsemi.com/f/nordic-q-a/64974/could-you-provide-source-code-of-nrf-sniffer-for-bluetooth-le-firmware?ref=blog.matthewbrunelle.com#:~:text=To%20be,LE%20documentation.). This is fortuitous, since I have project on the back burner to reverse the BLE protocol for my water bottle [\[2\]](#fn2) and add support to [Gadgetbridge](https://gadgetbridge.org/?ref=blog.matthewbrunelle.com). So many project ideas, so little time. Oh, also CirtuitPython supports the nRF52840 as well. --- ## MCU Lab 2025 Its time to discern the transmundane again: > The MCU Lab is a compact development platform for prototyping and testing various microcontroller based designs. Six rows of 24 pin female headers comprise an MCU landing zone supporting the insertion of any DIP (dual inline package) format microcontroller development board having a width between 0.6 and 1.0 inches. I see a [dual inline package](https://www.elepcb.com/blog/dual-inline-package/?ref=blog.matthewbrunelle.com) is a component for housing an integrated circuit, but more importantly there is the "JEDEC" standard that helps make it easy for everything to interoperate. The width is in reference to the distance between each pin in a row. Also, its not super important for this box, but I realized that the pin numbering typically starts from the top left with pin 1, moves down, and then goes up the right side. The top is indicated by a little notch: ![Direction of reading the pins of an integrated circuit.](https://upload.wikimedia.org/wikipedia/commons/thumb/b/b3/Pin_numbering_01_Pengo.svg/256px-Pin_numbering_01_Pengo.svg.png?20250627061753) The setup for the MCU Lab board is pretty slick: Components are soldered onto the board, along with pins. Traces on the board connect the components to the pins, but the components are not connected together. Instead I can use jumpers to connect the pieces as needed to experiment with the different MCUs. This kit provides *a lot* of options for experimentation: ![Hackerbox_0132_knolling.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/Hackerbox_0132_knolling.jpg) That also means a lot of soldering that needs to be done. Yes, I'm still trying to practice my soldering, but this seems like a monkey's paw moment. To give you an idea of how much soldering there was, I had to: - Solder two 1x24 male headers into the "MCU Landing Zone" in the top left of the board. - Solder *12* 1x6 female headers into the landing zone. - They note that "the two headers in each row should fit quite snuggly against each other". Most rows went in without issue for me. One was quite tricky. - PCM5102A DAC - Solder the four jumpers on the back of the board *first*. Since they cannot be accessed if the board is soldered down. See the audio section for a picture. - Solder the 1x9 and 1x6 male header pins to the DAC board. - Solder the board to the PCB. - Solder the 1x6 male headers for jumpers to the MCU board. - 4x4 matrix switch keypad - The keypad has *10* pin holes, but the PCB only has 8\. The outer pins on each side can be ignored, so pin 1 and 10. - Solder the 1x8 male header to the keypad. Make sure to not position the header like normal. Instead of positioning the plastic between the PCB and the keypad, insert the longer pins into the PCB and solder them in place. - Solder the keypad to the pins. There are plastic risers on the keypad for separation and this will make the keypad feel more sturdy. - Solder the 1x8 male header for the jumpers to the MCU board. - Color TFT LCD display - Similar to the keypad, solder the 1x8 male header to the MCU board with the long pins sticking through. See below for a photo of how the pins are inserted. - Then solder the display to the long pins. This will help the display sit flat. - Above the board, solder the 1x8 male header for the jumpers onto the board. - SD card slot - Solder the 1x6 male header to the SD card slot. Note: I did this upside down. You should instead solder the board with the SD card on the *underside*. That way the pin numbering lines up with the jumper pins rather than being mirrored. - Solder the SD card board to the MCU board. - Solder the 1x6 male header for jumpers to the MCU board. - Solder the 9 pins on the 8 Bit Blue LED Module to the top of the board. - Add the three sets of 1x5 male headers to the middle of the PCB. - Solder the three pins on the 100K Ohm Slide Potentiometer onto the board. I saved this for last since the slider is long and would make soldering the other pieces awkward. Also each of the 4 MCUs needed to have their headers soldered on. Phew! --- ## The matrix keypad This is my first time working with a keypad like this! It connects through 8 pins: C1-C4 and R1 to R4\. As expected the C pins are for the columns and the R pins are for the rows. The guide notes this pinout is setup for the ESp32, but can work with the other MCUs by changing the pin numbers, though doesn't clarify how. The example uses this [Keypad library](https://github.com/Nullkraft/Keypad?ref=blog.matthewbrunelle.com). The code sets up a 4x4 char array that maps each button to a specific character. I wanted to know how this all works under the hood though. Here is a [nice written guide](https://www.circuitbasics.com/how-to-set-up-a-keypad-on-an-arduino/?ref=blog.matthewbrunelle.com) that walks through how a membrane keyboard works with pictures, this [guide](https://appcodelabs.com/read-matrix-keypad-using-arduino?ref=blog.matthewbrunelle.com) talks about some of the details like timing and debounce, and [this video](https://www.youtube.com/watch?v=To2PU5xWmQM&ref=blog.matthewbrunelle.com) is a useful visualization. Essentially, - The buttons are switches, like a membrane switch. - The buttons in each column are connected to a shared trace that is exposed as a pin. - Likewise the switches in each row have their own trace and pin. - When a button is pressed, the trace for that button's row and column are connected. - When the keypad is used with an MCU, the column pins are held high and the row pins are held low. - Then when a button is pressed, the circuit is connected and the column pin is pulled low. That lets the MCU know what column was pressed. - To get the row the MCU does a matrix scan and holds each column high one at a time. If any of the rows read high then that row is the correct column. Its cool to finally learn how keypads actually work. I have used many mechanical keyboards, but I always buy them. The next time my keyboard dies I'm planning to build one instead. So this guide provided some nice background knowledge on the basics. Oh, also my [friend](https://rileytuttle.github.io/?ref=blog.matthewbrunelle.com) is [designing a phone/travel thumb keyboard](https://github.com/rileytuttle/thummingboard?ref=blog.matthewbrunelle.com) and now maybe I can actually understand the project a bit. --- ## The ST7789V Display The first time I used a display was in [box 0119 - Geopositioning](https://blog.matthewbrunelle.com/review-of-hackerbox-0119-geopositioning/) which used a [Adafruit ILI9341](https://github.com/adafruit/Adafruit%5FILI9341?ref=blog.matthewbrunelle.com). This time the display is different: - Last time the screen was 240x320, this time it is 240x240, so I have a new aspect ratio. - The screen is physically smaller, 1.54 Inch instead of 2.8. - The screen is color instead of black and white. - The new screen is not touch sensitive. - There is no build in SD card slot this time. - This display uses the ST7789V driver chip instead of the ILI9341. Here is a photo showing how to solder the pins for this board, as mentioned in the previous section: ![Mcu_pin_placement.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/Mcu_pin_placement.jpg) You need to install the [Adafruit ST7735 and ST7789 library](https://github.com/adafruit/Adafruit-ST7735-Library?ref=blog.matthewbrunelle.com). The instructions note the screen will appear upside down and to use `tft.setRotation(2);` after setup, but doesn't provide an explanation why. I believe this is because by default the display is designed to have the pins on the bottom, but in our case we have it oriented the other way to make the traces to the pins easier. --- ## SD card slot The guide itself doesn't have much to say about the SD card. In my write-up on the scanner prototype I [talk about using SPI to write to an SD card](https://blog.matthewbrunelle.com/an-esp32-based-beacondb-scanner-prototype/#saving-csv-files-to-the-sd-card-and-learning-about-spi). The process for setup in this guide is very similar so I won't go into the details too much. In fact the setup here is even simpler because we can use HSPI instead of VSPI. So we do not need the work around I had to use in my post. --- ## More audio experiments PCM5102A is a stereo audio DAC. The board has four jumpers on the back that need to be solder to connec them: - Jumper 3 is set HIGH - Jumpers 1, 2, and 4 are set LOW They don't explain why in the guide, so I went to read [the datasheet](https://www.ti.com/lit/ds/symlink/pcm5102a.pdf?ts=1764168862252&ref%5Furl=https%253A%252F%252Fwww.ti.com%252Fproduct%252FPCM5102A&ref=blog.matthewbrunelle.com), but realized that only covered the chip itself and not the breakout board. The jumpers are labeled H1L H2L H3L H4L and after digging for info on those its looks like the kit uses the [GY PCM5102 DAC module](https://github.com/dwhinham/mt32-pi/wiki/GY-PCM5102-DAC-module?ref=blog.matthewbrunelle.com). I found this helpful [discussion thread about the jumpers](https://github.com/probonopd/MiniDexed/discussions/521?ref=blog.matthewbrunelle.com#discussioncomment-6513812) which uses the same configuration with only H3L high and provides the explanation. There are screenshots to *something*, but with no link to the source. I've created the table here for better readability: | H | Name | Description | LOW(GND) | HIGH(D 3.3V) | | --- | ---- | --------------------------------------------- | -------------- | -------------- | | H1L | FLT | Filter Select | Normal latency | Low Latency | | H2L | DEMP | De-emphasis control for 44.1kHz sampling rate | Off | On | | H3L | XMST | Soft mute control | Soft Mute | Soft un-mute | | H4L | FMT | Audio format selection | I2S | Left justified | A wonderful, beautiful old fashion [forum post from 2013 on diyAudio](https://www.diyaudio.com/community/threads/pcm5102a-can-anyone-explain-de-emph-and-the-filter-options.245783/?ref=blog.matthewbrunelle.com) provided some context. So putting that all together: - Use normal latency mode. It looks like low latency mode is typically only used in studio editing applications. - Some old CDs had [pre-emphasis](https://wiki.hydrogenaudio.org/index.php?title=Pre-emphasis&ref=blog.matthewbrunelle.com) where weaker frequencies were boosted and would be de-emphasized during playback. This would improve signal-to-noise ratio. We are not using any audio with pre-emphasis so this is not needed. - The "soft" part means that muting / unmuting is gradual rather than immediate. This [helps reduce popping](https://e2e.ti.com/support/audio-group/audio/f/audio-forum/1540204/pcm5102a-mute-un-mute-finction?ref=blog.matthewbrunelle.com) - Use I2S for the audio format, similar to what I did in [box 0118 More Human](https://blog.matthewbrunelle.com/review-of-hackerbox-0118-more-human/#the-pulse-width-modulated-audio-experiment) with the INMP441 microphone. Also, I've never soldered jumper pads before and sparkfun [had a nice guide](https://learn.sparkfun.com/tutorials/how-to-work-with-jumper-pads-and-pcb-traces/all?ref=blog.matthewbrunelle.com) that was helpful. Here is how they look soldered: ![PCM5102A_jump_placement.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/PCM5102A_jump_placement.jpg) Outside of that the guide includes three audio experiments: - Connect to WiFI, stream an mp3 of a DEFCON 2025 recording. - Read an mp3 file off of the SD card. - Setup the board as a Bluetooth audio device to stream music. This one was really cool. A single sketch is used for all three experiments, commentinging/uncommenting lines as needed. The [ESP32-audioI2S](https://github.com/schreibfaul1/ESP32-audioI2S?ref=blog.matthewbrunelle.com) does a lot of the heavy lifting, which is nice, but obscures what is actually happening. Also, all three experiments use the ESP32, so using the other MCUs is left as an exercise to the reader. --- ## Closing thoughts The remainder of the guide talks a bit about cyberpunk literature and careers in embedded systems. I mostly glossed over those sections. On the whole, I really liked this months box. I'd say this one is more potential, rather than hands on activities. I now have a lot of options to play with, which means this box will be used more often in the future than the one off projects in past boxes. --- 1. Though you can use other STM32 chips. [↩︎](#fnref1) 2. I know, I know, why use a smart water bottle? I forget to drink water and it will yell at me all day. [↩︎](#fnref2) ### NeurIPS 2025 Retrospective URL: https://blog.matthewbrunelle.com/neurips-2025-retrospective/ Last updated: 2025-12-10T18:29:36.000Z Here is the [post series](https://blog.matthewbrunelle.com/tag/neurips-2025/) for the conference. --- NeurIPS 2025 is upon us! Expo talks and tutorials started December 2nd. I am fortunate that my work provides a pretty large learning budget each year which can be used on conferences. I [attended PyCon 2025](https://blog.matthewbrunelle.com/tag/pycon-2025/) this year, which took up a portion of the budget, so I did not attend any tutorials on the first day of neurIPS. On the plus side, one less day of a conference means I won't be as exhausted. [\[1\]](#fn1) This year the conference is in both San Diego and Mexico City. Partially because of how many people want to attend, but also [because of visa accessibility problems in the US](https://blog.neurips.cc/2025/07/16/neurips-announces-second-physical-location-in-mexico-city/?ref=blog.matthewbrunelle.com). I am attending at the San Diego location. The full [schedule is available](https://neurips.cc/virtual/2025/calendar?ref=blog.matthewbrunelle.com) online. [\[2\]](#fn2) Unlike PyCon, I don't know if the recordings will be made available online for non-attendees. Some of the links I provide in the section titles include slides though. Also, sometimes the descriptions will be a little terse. I promise you, I wrote them myself, I'm was just out of brain juice after the long days. I have notes from the three days I attended: - [Wednesday](https://blog.matthewbrunelle.com/neurips-2025-wednesday/) - [Thursday](https://blog.matthewbrunelle.com/neurips-2025-thursday-notes/) - Friday --- ## Why did I attend NeurIPS and a thank you to my coworkers I work on software products that use machine learning under the hood. However, I am squarely on the applications side in the org chart. I get to work closely with the ML side though and I enjoy that a lot. Last year I went to EMNLP 2024 in Miami with the goal of reducing my unknown unknowns. Ultimately, I want to understand the different choices that go into the ML side so I can better collaborate on projects. At EMNLP I was able to spend time with the ML folks and learn from them. For that I am really appreciative. They showed me the ropes for attending an academic conference like that. Some of my takeaways were: - Take notes! There will be so much content you will not remember all of it. - Pace yourself. Its tempting to go to everything, but that's not sustainable. - On the flip side, you get out of a conference what you put into it. If you just randomly go to things you won't gain much. This time at NeurIPS I am leaning on them the same way. Some new lessons: - If you're not sure what talks to go to, have a look at the [best paper awards](https://blog.neurips.cc/2025/11/26/announcing-the-neurips-2025-best-paper-awards/?ref=blog.matthewbrunelle.com) and use that as a starting point. - If you want the coveted conference branded water bottle, go early because they are gone quickly. - For some reason now, there are cafes in the expo hall. The companies fight over hiring the best baristas they can. [\[3\]](#fn3) - My coworkers also went to a event by Weights & Biases after the sessions. I didn't sign up in time so I couldn't go, but this was recommended because we already use their product at work. That means they won't bother selling you anything and you can eat the free food unbothered. The Cafes: ![desres_cafe_neurips_2025.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/desres_cafe_neurips_2025.jpg) ![jump_trading_cafe_neurips_2025.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/jump_trading_cafe_neurips_2025.jpg) ![microsoft_cafe_neurips_2025.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/microsoft_cafe_neurips_2025.jpg) ![susquehanna_cafe_neurips_2025.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/susquehanna_cafe_neurips_2025.jpg) Also, somewhat uncharacteristically, I organized the team dinner this time. --- ## For some reason, people are writing a lot of papers about AI right now This is my first time attending NeurIPS, so I cannot compare my experience to previous years, but it seems there were a lot of papers this time. Melanie Mitchell mentioned in her talk that in the 90s, NeurIPS was just a couple hundred people and they would spend most of the day skiing. In comparison this year is the 39th conference and in the conference's [reflections post](https://blog.neurips.cc/2025/09/30/reflections-on-the-2025-review-process-from-the-program-committee-chairs/?ref=blog.matthewbrunelle.com) they mention "The main track this year received 21,575 valid paper submissions, of which 5,290 were accepted". I heard some people mention that papers were accepted, but then later rejected due to limited space. I can't find a solid source to cite though. The San Diego Convention Center was *huge*. I would frequently underestimate how much time I needed to get from one side to another to catch a talk. The fact that they still didn't have enough space really speaks to just how many papers were submitted. --- ## Finally a bit of meta commentary on writing I've been [trying to practice new kinds of writing](https://blog.matthewbrunelle.com/on-practicing-writing/). At this conference I wanted to challenge myself to try to write and share summary posts as close to daily as possible. The process felt a little like journalism, though I never took a class on that at school. I'm not sure if I like it, but the stretch was a nice challenge. The downside was that I sometimes felt like I was missing out on the conference since my attention was split with writing. I think in the future I will take notes, but not edit and post them until after the conference is over. --- ## Also Meshtastic Totally unrelated to all of this, but I brought my MeshPocket so I could see the San Diego and [Californian Meshnets](https://meshtastic.org/docs/community/local-groups/?ref=blog.matthewbrunelle.com#california) when I was in town. It's looking pretty healthy: ![san_diego_meshtastic.png](https://blog.matthewbrunelle.com/content/images/2025/12/san_diego_meshtastic.png) I managed to get one node over the boarder in Mexico, and not pictured here, but I hopped to some nodes in LA. --- 1. No actually, no matter what, you are tired after a conference like this. Especially with a 3 hour time change. [↩︎](#fnref1) 2. I did not review the schedule ahead of time, and boy do I wish I did. The way it was presented made it very hard to parse. [↩︎](#fnref2) 3. Certainly there is no bubble, and this is not a gilded age. [↩︎](#fnref3) ### NeurIPS 2025 - Friday Notes URL: https://blog.matthewbrunelle.com/neurips-2025-friday-notes/ Last updated: 2025-12-10T16:03:07.000Z #### On this page ## [Oral 5D Application 3](https://neurips.cc/virtual/2025/loc/san-diego/session/122567?ref=blog.matthewbrunelle.com) ### [WebGen-Bench: Evaluating LLMs on Generating Interactive and Functional Websites from Scratch](https://neurips.cc/virtual/2025/oral/121452?ref=blog.matthewbrunelle.com) I only caught the very tail end of the Q&A for this one. ### [QoQ-Med: Building Multimodal Clinical Foundation Models with Domain-Aware GRPO Training](https://neurips.cc/virtual/2025/oral/117310?ref=blog.matthewbrunelle.com) This work is especially important in emergency departments at hospitals and under served countries. Doctors do not have much information or time to make a diagnosis. The hope is machine learning can help make a quick *initial* diagnosis that can then assist doctors to make the final diagnosis. However, traditional classifier models are limited and LLM based techniques struggle with multimodal inputs. They also have poor interpretability in a way that is useful for medical practitioners. So the labs goal was to develop a multi model LLM that can reason across many domains. The bulk of the work went into the training techniques. Reasoning can help with verifying a models logic and they especially wanted reasoning on time series data. So the reward setup for the reinforcement learning was: - A binary reward for accuracy. - Semantic match to a ground truth. - Rewarded for a response if the response is at least 100 tokens to get a nice middle ground of not too short and not too long. The ICML25 CLIMB data set was used which has 4.57 annotated samples at 19tb. One of the largest public data sets for clinical diagnosis. They released a [public repo](https://github.com/DDVD233/CLIMB?ref=blog.matthewbrunelle.com) to provide a script to do all the dataset downloading and processing. They have a high heterogeneity of the inputs. GRPO treat all samples equally, which means the models focuses too much on the easy and abundant samples. Eg a chest x ray is handled easily, but ultrasound is not. So they created domain aware GRPO (DRPO) : - Upscales scarce and hard questions, downscale easy abundant via hierarchical clustering by domains using the Elbow method. - Performed better than both open source and closed models. - Only adds less than a percent to the total training time. Additionally, they worked with medical professionals to annotate reasoning samples with the degree of relevance. Their work is available in the repo [MIRL: Multisensory Intelligence Reinforcement Learning for LLMs](https://github.com/DDVD233/mirl?ref=blog.matthewbrunelle.com). ### [NOVA: A Benchmark for Rare Anomaly Localization and Clinical Reasoning in Brain MRI](https://neurips.cc/virtual/2025/oral/121771?ref=blog.matthewbrunelle.com) They want models to generalize on inputs that are not seen in training. LLMs can ground semantic knowledge for disease which works well for what they have seen before. In healthcare though, doctors frequently see new problems. Eg 100M people in the US alone suffer from neurological disorders. Of those, up to 20M suffer from a "rare" disease, with more than 2000 distinct classes that a model would not be able to see. What we want is open-world recognition. However when you evaluate these medical models, it collapses because medical benchmarks mimic a closed-world setting. This is not good for checking how models generalize. The cant handle when a case comes in that is different than what was expected. A brain scan anomaly could just be a result of age, or could be an early sign of Alzheimers. NOVA is a benchmark for open-world generalizations and reasoning in vision-language models. It provides real world samples, rather than nicely cleaned up ones. Eurorad is a system that allows doctors to upload bizarre cases they find, so it by nature includes rare cases. They had doctors mark up the images up with bounding boxes, taking multiple bounds to find agreement. Captioned were added to provide details in a clinically meaningful way. Also doctors are allowed to ask for follow up information and additional tests. Compared to previous benchmarks, Qwen's performance dropped by 65% on NOVA. The dataset is [available on huggingface](https://huggingface.co/datasets/c-i-ber/Nova?ref=blog.matthewbrunelle.com). --- ## More posters. So many posters. I took way too many pictures of posters, but there are a few I would like to share. ![poster-merlin-l48-dataset.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/poster-merlin-l48-dataset.jpg) The first is [Merlin L48 Spectrogram Dataset](https://neurips.cc/virtual/2025/loc/san-diego/poster/121824?ref=blog.matthewbrunelle.com). This one is from my alma mater Umass Amherst. Also, I [monitor birds at home](https://blog.matthewbrunelle.com/birdnet-go-but-we-have-bird-monitoring-at-home/) using BirdNetGo which is built on the eBird dataset. I chatted with the presenter and there were some fun problems they are dealing with around labeling. ![poster-snesnorLM.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/poster-snesnorLM.jpg) The next is [SensorLM: Learning the Language of Wearable Sensors](https://neurips.cc/virtual/2025/loc/san-diego/poster/117808?ref=blog.matthewbrunelle.com) out of Google. I really like wearable technology and had worked on personal projects around it in the past. ![poster-scaling-up-test-time-compute.MP.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/poster-scaling-up-test-time-compute.MP.jpg) Yes I was drawn to this one because of the corvid, but I also noticed the spotlight flag.I got to talk with one of the presenters about their work [Scaling up Test-Time Compute with Latent Reasoning: A Recurrent Depth Approach](https://neurips.cc/virtual/2025/loc/san-diego/poster/117966?ref=blog.matthewbrunelle.com). They noticed my employers name on my badge and said that had spoke with some of us at last years NeuroIPS. I definitely want to read the paper for this one. ### NeurIPS 2025 - Thursday Notes URL: https://blog.matthewbrunelle.com/neurips-2025-thursday-notes/ Last updated: 2025-12-10T15:59:12.000Z #### On this page ## Invited Talk: [The Art of (Artificial) Reasoning](https://neurips.cc/virtual/2025/invited-talk/109603?ref=blog.matthewbrunelle.com) by Yejin Choi I caught the back half of this presentation, so I missed the technical details, but the concluding remarks were interesting. She wanted to impress a couple main ideas: The importance of democratizing Generative AI (by transcending scaling laws): - Ownership - AI belongs to and originates from humans and should reflect their values. - Creation - AI is developed and governed by people worldwide, rather than a small group who can afford. - Beneficiary - AI should serve all humans and not just a small subgroup. We need collaboration that is: - Implicitly, open-science and open-source community. - Explicitly, cross-institutional, cross-boarder collaborations. They are trying to do this with the [OpenThoughts Team](https://www.open-thoughts.ai/?ref=blog.matthewbrunelle.com). --- ## [Oral 3C Position Paper Orals](https://neurips.cc/virtual/2025/loc/san-diego/session/122560?ref=blog.matthewbrunelle.com) This was the first year NeurIPS had a position paper track. I enjoy attending sessions like this, the ones that take a stronger moral stance. The oral session "Computational Social Science and Cultural Analytics 1" at EMNLP 2024 was similarly enjoyable. There doesn't seem to be a way to link to presentations from last year, but the [pdf handbook](https://media.githubusercontent.com/media/acl-org/emnlp-2024/refs/heads/main/downloads/EMNLP%5F2024%5FHandbook%5Fdigital.pdf?ref=blog.matthewbrunelle.com) still lists the talks: - "We Demand Justice!": Towards Social Context Grounding of Political Texts - Fine-Grained Detection of Solidarity for Women and Migrants in 155 Years of German Parliamentary Debates - On the Relationship between Truth and Political Bias in Language Models - HEART-felt Narratives: Tracing Empathy and Narrative Style in Personal Stories with LLMs - Outcome-Constrained Large Language Models for Countering Hate Speech Anyways, I really appreciated this session: ### [Position: If Innovation in AI systematically Violates Fundamental Rights, Is It Innovation at All?](https://neurips.cc/virtual/2025/oral/126305?ref=blog.matthewbrunelle.com) Talked about the Collingridge dilemma - a control paradox in regulating new technologies: - Regulate too early: change is easy, but we don't know enough to intervene correctly. - Regulate too late: risks are higher, but change is very costly. A prime example is social media. We could have changed it early, but couldn't imagine the impact yet. Now, due to corporate entrenchment social media is difficult to change, > AI is not God-given, we are designing it. We can do better." - Professor Carissa Veliz Instead, we need something better. The old paradigm for managing change is based in management and economics. The new paradigm is responsible innovation which is: - Guided by democratic values. - Accountable to society. - Responsive to social needs. We are dealing with systemic failures like bias & discrimination. The speaker gave the EU AI Act as a potential solution: - Risk-based approach - Future-proof - Inovation catalyst Personally I'm torn on this. The EU has had some great policies like the GDPR, but it seems they are as prone to capture as we are in the US. The continued push for Chat Control is a great example of this. See [a recent write up by the EFF](https://www.eff.org/deeplinks/2025/12/after-years-controversy-eus-chat-control-nears-its-final-hurdle-what-know?ref=blog.matthewbrunelle.com). One parting thought that was really impactful is that "rights violations != to progress". > No remedies, no rights. ### [More effort is needed to protect pedestrian privacy in the era of AI](https://neurips.cc/virtual/2025/oral/126301?ref=blog.matthewbrunelle.com) The speaker couldn't go to the presentation due to travel difficulties, but they did provide a recording of their talk. > "we all have human bodies and need to move through the world" Current methods for protecting pedestrian privacy are insufficient: - People are recorded by sensors like cameras without their consent. - Recordings make their way into public datasets for training models, also without their consent. - It's possible for malicious users to recover pedestrian information from these datasets. - People may feel uncomfortable in public spaces, leading to a loss of trust. There is some regulatory support for consent in the GDPR and California has the Consumer Privacy Act (CCPA). However, since it's impossible to get consent from every person in a public space, companies will not and take the risk of having to pay fees in court instead. If we could automatically anonymize pedestrians, this would help, but current methods are insufficient: - Face anonymization (like in Google Maps) alone is inadequate because it can be reversed. Also, it is insufficient at protecting against other techniques like gait analysis. - Generating fake faces and bodies may cause copyright issues, and there is no systematic analysis of how useful this actually is for privacy protection. To better protect privacy a method should: - Not reduce the utility of the dataset. - Ensure temporal consistency. - Protect the privacy for multiple pedestrians, not just the nearest one. - Be resistant to mode attacks. We need something like full body and gait anonymization. Additionally, we need a benchmark for confirming how well these new techniques will work. See [Benn Jordan's videos on street cameras](https://www.youtube.com/watch?v=Pp9MwZkHiMQ&ref=blog.matthewbrunelle.com) for some wonderful commentary on this topic. ### [Real-Time Hyper-Personalized Generative AI Should Be Regulated to Prevent the Rise of "Digital Heroin"](https://neurips.cc/virtual/2025/oral/126308?ref=blog.matthewbrunelle.com) Real time generative AI content is something we need to regulate right away. Short form social media already shows addictive dynamics and personalized AI content is rapidly improving. Traditionally creators upload content, and the system optimizes which content to show each user. That alone has already been show to be addictive. Now, generation latency is going down, making real time generation feasible at scale. Companies like Google and OpenAI are already releasing tools for watching AI generated content. Personalization and fine-tuning are getting better. This all means platforms can generate content in real time just for you. They can show you a video, see your reaction, and adjust as needed. > Addiction is an optimizer's shortcut to maximizing engagement. So the speaker proposed a Designated Addictive System (DAS) label for services with a primary function to maximize user engagement. DAS should be required to submit a pre-market safety case showing mitigation with regular, transparent, and public audits after release. Some example mitigation are: - Friction-by-design: autoplay, infinite scroll off, slow mode, enforced breaks. - Age protections: limited access for teens and children. - Liability and enforcement: dark-pattern bans, data subpoenas, revenue-linked penalties. Finally, the DAS label can sit on top of existing acts like the UK Online Safety Act. Ultimately we should compare GenAI content regulations to how we handle seat belts, cigarettes and alcohol, the UK sugar tax, etc. Also, the impetus should be on the platforms and not the users and families. Also, as is tradition, the talk calls for an addiction risk benchmarks. --- ## Panel: [Emergent Stories: AI Agents and New Narratives](https://neurips.cc/virtual/2025/loc/san-diego/panel/131735?ref=blog.matthewbrunelle.com) Somewhat in opposition to the moral of the previous session, this panel was about using AI to generate content in media. However, in this case the speakers were exploring mixed media. Back in 2017 they only had a small room for their workshop. Now they have their own track and a packed double room. There were five speakers (all poster presenters) - Parag Mital (Emergentic) - Vanessa Rossa - Gottfried Haider and Jie Zhang (LLMscape) - Manuel Flurin Hendry Each had a chance to show their work, then sat down for audience questions. I enjoyed just sitting and listening to the panel, so I didn't take many notes. --- ## Posters The center has space to hold 900 posters. There were two poster sessions a day, for three days. That's a lot of posters! Here are two that I thought were interesting: [Collective Bargaining in the Information Economy Can Address AI-Driven Power Concentration](https://neurips.cc/virtual/2025/loc/san-diego/poster/121937?ref=blog.matthewbrunelle.com): ![neurips_position_poster_collective_bargaining.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/neurips_position_poster_collective_bargaining-1.jpg) [MoonCast: High-Quality Zero-Shot Podcast Generation](https://neurips.cc/virtual/2025/loc/san-diego/poster/118463?ref=blog.matthewbrunelle.com): ![neurips_poster_mooncast.jpg](https://blog.matthewbrunelle.com/content/images/2025/12/neurips_poster_mooncast-1.jpg) This one caught my eye because I [have strong opinions about the podcast ecosystem](https://blog.matthewbrunelle.com/podcasts-you-altered-the-deal-so-i-will-alter-your-app/). As I mentioned in my post [about what podcast I am listening to](https://blog.matthewbrunelle.com/what-podcasts-am-i-listening-to-2025/) I mentioned that I like the Economist Audio Edition because its people reading the Weekly Edition word for word. Sadly in November [they announced they would start using AI generated voices](https://myaccount.economist.com/s/article/What-is-our-text-to-speech-policy?ref=blog.matthewbrunelle.com): > However, audio versions of a minority of our articles, including those published in response to breaking news, are now created using a third-party text-to-speech AI service. This AI audio has not been reviewed prior to publication. We make no representations or warranties in relation to this content, its accuracy or completeness, and we disclaim all liability regarding its receipt, content and use. I feel mixed about this. On the plus side they say: > This is designed to fill the gap before the human-narrated version from the weekly edition is available... Please note that this AI narration is only temporary and does not replace human narration. The AI-narrated audio will be automatically updated with the full human recording around 9 pm GMT on Thursdays. This will work well if you use their app, but I like the open RSS based podcast ecosystem and its unclear how the generation will work in that case. --- ## Invited Talk: [On the Science of “Alien Intelligences”: Evaluating Cognitive Capabilities in Babies, Animals, and AI](https://neurips.cc/virtual/2025/loc/san-diego/invited-talk/109607?ref=blog.matthewbrunelle.com) by Melanie Mitchell The title is a nod to this quote: > "Something is beginning to happen that was not expected even a few years ago. A threshold was reached, as if a space alien suddenly appeared that could communicate with us in an eerily human way.…Some aspects of their behavior appear to be intelligent, but if it's not human intelligence, what is the nature of their intelligence?" - Terrence Sejnowski > [\[1\]](#fn1) She argues benchmark performance often does not predict real world capabilities. So she has tried to combine several similar discussions from others into six principles: 1. Be aware of your bias towards anthropomorphism. See the Eliza Effect. 2. Be skeptical of hypothesis. Are there other strategies that might have cause the desired behavior? 3. Analyze failure types because they give more insight than the successes. 4. Design novel variations to test robustness and generalization. 5. Consider performance vs competence, does the system possess the capacity to *perform* a task that it has figured out how to do. 6. Replicate and build on others results. Killjoy explanations and negative result publications get less publication, but are still important. In some cases, we want AI systems to think differently than us, eg AlphaFold's protein search. In other cases we need AI to understand the world like us. Determining the nature of "alien intelligence" takes substantial rigor and creativity. We need that in AI evaluations. --- 1. As well as [Baby steps in evaluating the capacities of large language models](https://www.nature.com/articles/s44159-023-00211-x?ref=blog.matthewbrunelle.com) by Michael C. Frank [↩︎](#fnref1) ### NeurIPS 2025 - Wednesday Notes URL: https://blog.matthewbrunelle.com/neurips-2025-wednesday/ Last updated: 2025-12-10T16:11:37.000Z #### On this page Updated 2025-12-19: I've moved any metacommentary about the conference into a retrospective post. ## Invited talk: [The Oak Architecture: A Vision of SuperIntelligence from Experience](https://neurips.cc/virtual/2025/invited-talk/109601?ref=blog.matthewbrunelle.com) by Rich Sutton First up in the morning was an invited talked. I enjoyed starting the day with something more abstract and philosophical. To quote the speaker: > "I'm not going to share any results today." Instead, he talked about how with all our successes in this field, we might lose sight of our goal. We should make sure to focus on all attributes of intelligence, not just a narrow version. As he phrased it, "a vision of super-intelligence without bitterness". To him, the "bitter lesson" is an observation based off history of the field that models shouldn't depend on human input. The world is big and we need a model that can grow at run time. There should be nothing domain dependent that is built into the model. Agents only approximate the world, nothing is exactly correct or optimal. So they must be able to form new abstractions at runtime. Professor Sutton described himself as "not a contrarian, but a classicist" who is looking for a consensus point of view. He shared this really cool table where he tried to compare different terminology across fields: | Reinforcement Learning / AI | agent | environment | action | observation | reward, goal | value function | state-update function | policy | world model, knowledge | state representation | | --------------------------- | ---------------------- | ------------- | -------- | ----------- | ------------------ | ----------------------------- | --------------------- | ----------------------------- | ---------------------- | -------------------- | | Psychology / Neuroscience | subject, organism | world | response | stimulus | reward | secondary reinforcement | perception | stimulus-response association | world model | situation | | Control Theory / OR | controller | system, plant | control | input | payoff, gain, cost | value function | state estimator | control law | system model | state | | Economics / Philosophy | person, agent, company | world | choice | sensation | payoff, goal | utility, value system, ethics | perception | decision rule | knowledge | state | | "consensus" terminology | agent | world | action | observation | reward | value function | **perception** | **reactive policy** | **transition model** | **subjective state** | | [\[1\]](#fn1) | | | | | | | | | | | He set up this "concensus" terminology so he could describe Objects and Knowledge (OaK). He designed it to get past the one step trap: thinking one step predictions are sufficient and that long term predictions can be made by many iterations of individual steps. Instead, OaK is based on the insight that: - Every feature has a corresponding sub problem. - If an agent can generate new features, then it can create sub problems. OaK is a vision to a shallow "seed" super-intelligence. However we cannot get there until we develop reliable deep continual learning and meta learning first. --- ## Oral Session: [Oral 1A Language Model 1](https://neurips.cc/virtual/2025/loc/san-diego/session/122546?ref=blog.matthewbrunelle.com) During this block there were a couple different talks I wanted to go to, but they were in different rooms. You can run around between sessions if you want, but the timing is so tight you'll likely miss parts of the talk. I wanted to attend this sessions because the second talk won a best paper award. The three talks were: ### [Understanding and Mitigating Numerical Sources of Nondeterminism in LLM Inference](https://neurips.cc/virtual/2025/oral/118170?ref=blog.matthewbrunelle.com) - Floating point math is not associative. - The order of operations during training and inference is non-deterministic. Both batch size and GPU count can also impact the ordering. - This gets even worse at lower precision. - Mixure of Experts is particularly vulnerable to this due to both routing and low precision. Accuracy typically varies \~1-2% from this non-determinism, but can be as high as 9%. So you need about a 4% improvement during evaluation to really feel confident. You can use Batch Invariant kernels and Tensor Parallel Invariant Kernels to get around this. ### [Artificial Hivemind: The Open-Ended Homogeneity of Language Models (and Beyond)](https://neurips.cc/virtual/2025/oral/121422?ref=blog.matthewbrunelle.com) - How well do LLMs handle open-ended queries? - They classified a collection of language model queries as open-ended by using a model-as-a-judge. - Using their Infinity-Chat, they do a large scale study on model collapse. - Found intra-model repetition and inter-model homogeneity. ### [SAVVY: Spatial Awareness via Audio-Visual LLMs through Seeing and Hearing](https://neurips.cc/virtual/2025/oral/115002?ref=blog.matthewbrunelle.com) - Spacial audio can provide important context about the world to multi-modal language models. - The authors made a benchmark by gathering scene recordings with Project Aria glasses. - Most major models today compress stereo audio into mono and because of that do poorly on the benchmark. - Two terms I didn't know from the spacial processing field: - allocentric - understanding the relative locations of things in the world - egocentric - understanding things in the world relative to the agents own position and direction. This is a great example of why its nice to stay in a session sometimes and give all the talks a listen. The first and third talks were not ones I would have picked out from the list, but I ended up liking them more than the second talk that drew me to that session. --- ## Workshop: [On Device/Edge AI](https://neurips.cc/virtual/2025/loc/san-diego/128674?ref=blog.matthewbrunelle.com) by the PyTorch Foundation folks A "packed" schedule: - LFM2- Designing the next-generation foundation model architecture for edge AI | **Matthias Lecher** (Liquid AI) - Leveraging ExecuTorch as a platform for mixed reality at scale | **Andrey Tovchigrechko** (Meta) - Realtime reasoning at the edge | **Varun Khare** (Nimble Edge) - Bendable non-silicon RISC-V microprocessor | **Vijay Janapa Reddi** (Harvard) - Mamba and SSM running at the edge | **Karan Goel** (Cartesia) - Unlocking AI on the Edge for PyTorch Developers | **Felix Baum** (Qualcomm) - What's next in LM Studio: customize local models with Inference Lifecycle Plugins | **Yagil Burowski** (LMStudio) The workshop was designed to highlight that "the future is not just the cloud, and private edge devices are needed", which really caught my eye. See my post [Using Self-hosting Language Models So You Can Evaluate Claude Code](https://blog.matthewbrunelle.com/using-self-hosting-language-models-so-you-can-evaluate-claude-code/) for some of my thoughts. The talks were also cool because I only just started to realize the difference of [embedded world vs server land](https://blog.matthewbrunelle.com/an-esp32-based-beacondb-scanner-prototype/#the-embedded-world-vs-server-land-and-final-thoughts). Some of the talks really stood out to me. ### LFM2- Designing the next-generation foundation model architecture for edge AI | **Matthias Lecher** (Liquid AI) - With embedded devices there is not just a compute gap, but memory as well, which is extra important for language models. - There is a need inference aware architectures specific to their deployment. - We have transformers, but there are other architectures. - They are trying to automate foundation model design. - They created an evaluation strategy to test the designs using ExecuTorch to do actual operations on device to measure speed and memory. - [LFM2](https://www.liquid.ai/blog/liquid-foundation-models-v2-our-second-series-of-generative-ai-models?ref=blog.matthewbrunelle.com) is the architecture that fell out of the search. ### Leveraging ExecuTorch as a platform for mixed reality at scale | **Andrey Tovchigrechko** (Meta) - The slide had a different title - ExecuTorch: On the Path to the "Ideal Compiler". [\[2\]](#fn2) - Meta has to run software on devices like the Rayband Meta AI Glasses - A lot of this comes down to a good compiler. ExecuTorch does this - Hard to meet all three at once: - Usability - reduce time to deploy. - Performance - need to overcome the limits of vendor tools. There are varying degrees of openness in vendor tools and sometimes direct hardware access is not available. - Portability - there are many different model types and hardware types, so there are a large number of combinations - They created an [open platform](https://github.com/pytorch/executorch?ref=blog.matthewbrunelle.com) that is a library of compilation recipes as a wiki. As well as tooling that automatically picks a compilation path based on the hardware. - This is the power of open source: each optimization method someone contributes helps everyone else. ### Mamba and SSM running at the edge | **Karan Goel** (Cartesia) - Context allows models to provide personal experiences. On device models require long contexts, but don't have as much memory. - Cloud models are large and not feasible to deploy on edge devices. - They want to move the Pareto frontier to not have to make the trade off. - Compressing models without sacrificing quality. - One way is distillation: take outputs from a teacher model to train a smaller model. The main take-away here is the models do not need to have the same architecture. For example, going from attention based models into mamba-based recurrent models. The easier we make this conversion, the more we can explore this solution space. - They created [Llamba](https://cartesia.ai/blog/llamba-distillation?ref=blog.matthewbrunelle.com) for systematically scaling distillation architecture search. --- ## Exhibitor Spot Talk: [Towards A Blueprint for Open Science of Foundation Models](https://neurips.cc/virtual/2025/loc/san-diego/talk/127787?ref=blog.matthewbrunelle.com) by Zeynep Tufekci I saw that title and was quite excited as I'm a big fan of open science. Weirdly enough the title they used on presentation day was instead *Towards Foundation Models with World Intelligence*. At first I thought this was a different presentation, but the same authors were listed. Bummer. Not much was said on the open science side. The presentation was a part of the [Exhibitor Spot Talks - Session 2](https://neurips.cc/virtual/2025/loc/san-diego/session/127737?ref=blog.matthewbrunelle.com), but I only dropped in for that one talk. --- ## [Are We Having the Wrong Nightmares About AI?](https://neurips.cc/virtual/2025/loc/san-diego/invited-talk/109606?ref=blog.matthewbrunelle.com) People are asking if AI going to lead to mass unemployment or extinction? She's Zeynep argues that neither of those are the right questions. Also, that there is a huge role we can all play to get us ready for the AI crisis. Historically we get it wrong when trying to predict major technology transformations. Eg, with the steam engine people mostly compared them to a horse. [\[3\]](#fn3) Scale matters though as a destabilizing force. We're going to have generative and creative output *at massive scales*. Generative AI is not a form of human intelligence and not a small human on a trajectory to getting smarter. This mismatch actually makes things more difficult because we are prone to making the human comparison. Instead, she describes it as a "plausibility engine": - In a way models are always hallucinating, but sometimes create realistic output. - This is powerful in a verifiable system like programming because we can check its work. She also drew a comparison to convergent evolution. LLMs do some things the same because that is functionally a good choice for that purpose. Currently we use certain forms of proof as shortcuts for checking things. The result is that in society we have intention "load bearing friction" and if you can make those things easy at scale that breaks our old defenses. Proof of: - Effort - Taking time to write a custom job cover letter which breaks gate keeping. - Authenticity - using a video as evidence is gone. - Accuracy - something well written no longer demonstrates expertise. - Sincerity - genuine sentiment is replaced with sycophancy. - Humanity - the point of art is its humanity, our shared experience. If you remove those five proofs, it creates a demand for mass surveillance. It will "solve" the problem, but not in a way compatible with human decency and liberty. Also in the private sector there is a big misalignment: the business model. Right now companies depend on engagement driven models paid by ads. The AI driver version of this will be disastrous. We, as people at a large AI conference need to be the ones to create solutions to these problems in a way preserves our dignity instead of trying to trick people into scrolling more. You are being offered a lot of money, but you also have a legacy. --- 1. GenAI disclosure: I used Claude to transcribe this table from a photo, but I made sure to review the contents. [↩︎](#fnref1) 2. Mmmm langauge theory. [↩︎](#fnref2) 3. Also, Freakonomics did a great [series on horses](https://freakonomics.com/podcast/inside-the-horse-industrial-complex/?ref=blog.matthewbrunelle.com) recently that deals with the history of them in the economy. [↩︎](#fnref3) ### Installing Jellyfin Tizen On Your Inlaw's Samsung TV In Just N Simple Steps URL: https://blog.matthewbrunelle.com/installing-jellyfin-tizen-on-your-inlaws-samsung-tv-in-just-n-simple-steps/ Last updated: 2026-02-05T01:10:22.000Z #### On this page **Update 2026-01-02**: Confirmed in a [thread on the repository](https://github.com/jellyfin/jellyfin-tizen/issues/222?ref=blog.matthewbrunelle.com#issuecomment-3831497018), the Tizen Jellyfin app is now avaible in the Tizen app store! I have some strong views on software use, which I've mentioned with [my interest in GraphemeOS](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/), [license changes in FOSS projects](https://blog.matthewbrunelle.com/on-self-hosting-opentofu-and-formerly-oss-licensed-software/), and [changes to grandfathered plans](https://blog.matthewbrunelle.com/podcasts-you-altered-the-deal-so-i-will-alter-your-app/). So none of this should come as a surprise. I was visiting some family for the holidays that had a Samsung TV. Last year I tried to install Jellyfin and failed. However, last year I also took an edible before trying so... This year I tried again. Also, these are not actually instructions. Do not follow them top to bottom. If you actually want to install Jellyfin Tizen, skip all the way to the end of the article. The full steps are written here to highlight how absurd the whole ordeal was. --- ## As easy as 1,2,3... 4... 5... 6.. Even though Samsung doesn't use the Tizen branding anymore on their TVs they are still running Tizen under the hood. [Jellyfin Tizen](https://github.com/jellyfin/jellyfin-tizen?ref=blog.matthewbrunelle.com) is the app to install. However, Samsung will not allow Jellyfin on the app store so I have to directly install it. [\[1\]](#fn1) This involves installing Tizen Studio and a certificate manager. That shouldn't be so bad, they have a Linux build so I can just grab the package... There's no package on NixOS. That's Ok, I can just have a look for any open [issues](https://github.com/NixOS/nixpkgs/issues/346201?ref=blog.matthewbrunelle.com): > nvm its so cursed > Closed as not planned Oh. Oh no. That's OK, someone has [created a docker based project](https://github.com/Georift/install-jellyfin-tizen?ref=blog.matthewbrunelle.com) for installing the package so I don't need to install Tizen Studio, I can just run that. The instructions required me to enable developer mode, but did not work for me, but the [steps in this reddit comment](https://old.reddit.com/r/Tizen/comments/186s2yf/samsung%5Ftv%5Fdeveloper%5Fmode/ljndh9h/?ref=blog.matthewbrunelle.com) I found did. Except I *also* Had to restart the TV by holding the power button for two seconds for the changes to take effect. Also, also not in the notes, but I had to enter my laptops IP address when enabling developer mode so I could connect. Ok, now I just need to a simple docker run command to install and- `install failed[118, -12], reason: Check certificate error : :Invalid certificate chain with certificate in signature.:<-3>` Oh right the certificate manager. I have to get a certificate to sign the build with that should be easy, wait I also need Tizen Studio for that. Well thankfully there's this [random gist](https://gist.github.com/andreas-mausch/026413d908ba61cda15981bb4fbcd276?ref=blog.matthewbrunelle.com) of openssl commands I can run- no actually that's out of date and doesn't work anymore. Thankfully in the comments of the gist someone has [done a more thorough write up](https://andreas-mausch.de/blog/2020-06-22-tizen/?ref=blog.matthewbrunelle.com)\- wait no that page doesn't exist anymore because the repo was taken down- wait no, some entirely different person has [bundled the whole process into a script](https://github.com/sreyemnayr/tizencertificates?ref=blog.matthewbrunelle.com). Oh, I need to sign up for a Samsung developer account to use with the script. Let me do that too. I need the device ID for that scrupt and after stumbling through the TV's settings I finally found it. Also, the docs don't mention needing openssl, but turns out I'll have to `nix-shell -p openssl` to run. Finally, I have the cert generated-the script generates certs with no password, and the docker project requires a cert with a password, so I need to [patch the docker app](https://github.com/Georift/install-jellyfin-tizen/issues/21?ref=blog.matthewbrunelle.com#issuecomment-2614108372)\- now wait that's just out of date and I can use the `--cert-password` arg. So, I'll just regenerate the cert a second time. Now finally! I can install Jellyfin: ``` - `install failed[118, -12], reason: Check certificate error : :Invalid certificate chain with certificate in signature.` Recent TV models require the installation packages to be signed with a custom certificate for your specific TV. See [official documentation](https://developer.samsung.com/smarttv/develop/getting-started/setting-up-sdk/creating-certificates.html) on creating your certificate and use the custom certificate arguments. ``` It still fails... It still fails... --- ## Finally a solution Wait, in the comments of one of the above issues someone says they've made a Windows program to [simplify the whole process](https://github.com/PatrickSt1991/Samsung-Jellyfin-Installer?ref=blog.matthewbrunelle.com) and it's since been updated to support Linux! I might as well try that before I turn in for the night. ``` /home/ciferkey/tizencertificates/liblibICE.so.6.so: cannot open shared object file: No such file oirectory ``` Forgive me for I have sinned. It was very late and in my tiredness I used Claude to generate the nix expression to make this work: ```nix { pkgs ? import {} }: (pkgs.buildFHSEnv { name = "jellyfin2samsung-env"; targetPkgs = pkgs: with pkgs; [ dotnet-runtime xorg.libICE xorg.libSM xorg.libX11 xorg.libXi xorg.libXcursor xorg.libXrandr fontconfig icu openssl zlib ]; runScript = "bash"; }).env ``` `nix-shell` and `./Jellyfin2Samsung` and a moment of beauty. The GUI starts, I use some drop downs... Everything works. I'm free. --- ## It shouldn't be like this. I hate that the manufacturer makes it so hard to run something on hardware I own. I love that the community continues to work so hard to make sure we can choose our own software. These work arounds change and break often, but this is what works as of now. The breadcrumbs I found online helped me find my way through this. So my hope here is to provide more breadcrumbs for future travels. My partner sat beside de on the couch the whole time, being more patient than I really deserved as I hogged the TV trying to do all of this. Thankfully the rest of the family was asleep. Happy holiday hacking to all. --- 1. I'll avoid calling it sideloading [because we should just be able to install apps on hardware we own](https://f-droid.org/2025/10/28/sideloading.html?ref=blog.matthewbrunelle.com). [↩︎](#fnref1) ### Finally Actually (Hopefully) Learning Rust URL: https://blog.matthewbrunelle.com/finally-actually-hopefully-learning-rust/ Last updated: 2025-11-24T15:04:38.000Z #### On this page ## The current events As I mentioned in my [post about prototyping a ESP32 based beaconDB Scanner](https://blog.matthewbrunelle.com/an-esp32-based-beacondb-scanner-prototype/), I've been looking to use Rust for the actual version of the project after the prototype. My intention was to write up this post after I had gone through several resources to be properly productive in the language. [\[1\]](#fn1) However, in my post [on practicing writing](https://blog.matthewbrunelle.com/on-practicing-writing/) I mentioned how I'm enjoying the writing format of taking some notes from a current project and pairing that with commentary on a current event. See [my post on OpenTofu vs Terraform](https://blog.matthewbrunelle.com/on-self-hosting-opentofu-and-formerly-oss-licensed-software/) for an example. So I'm putting this post out sooner than I thought because of *[current events](https://archive.ph/05KK2?ref=blog.matthewbrunelle.com)*: (From Adafruit on November 19th) > Qualcomm-owned Arduino quietly pushed a sweeping rewrite of its Terms of Service and Privacy Policy, and the changes mark a clear break from the open-hardware ethos that built the platform... > > ...users are now explicitly forbidden from reverse-engineering or even attempting to understand how the platform works unless Arduino gives permission. Now after digging in further, this seems to only apply to their cloud services. [\[2\]](#fn2), Then there's [Arduino's reply](https://blog.arduino.cc/2025/11/21/the-arduino-terms-of-service-and-privacy-policy-update-setting-the-record-straight/?ref=blog.matthewbrunelle.com) on November 21st [\[3\]](#fn3) : > **Let us be absolutely clear: we have been open-source long before it was fashionable. We’re not going to change now.** The Qualcomm acquisition doesn’t modify how user data is handled or how we apply our open-source principles. > ... > Restrictions on reverse-engineering apply specifically to our Software-as-a-Service cloud applications. Anything that was open, stays open. But Qualcomm has a [giant wall of patents on display](https://www.gleamlaw.com/blog/intellectual-property/pictures-of-qualcomms-patent-wall/?ref=blog.matthewbrunelle.com) at their headquarters in San Diego and that doesn't inspire much confidence. I should be clear too, I don't actually use Arduino hardware, only Raspberry Pis and ESP32 based boards. However, I've [done a couple HackerBoxes](https://blog.matthewbrunelle.com/tag/hackerbox/) which use the Arduino IDE a lot. I wonder if HackerBox will still base their tutorials around that going forwards. Either way, this is a sad turn of events and I am happy to be moving further away from the ecosystem with the change over to Rust. Anyways, back to talking about learning a new programming language. --- ## Why did I fail at learning Rust in the past? I liked the idea of Rust since the project was first announced and I followed it closely all these years. Even waaay back when rust had [different pointer types with weird symbols](https://stackoverflow.com/a/64770196?ref=blog.matthewbrunelle.com). Yet time and time again I bounced off it an never really learned enough to be productive. Sure I could do little toy programs, or finish Advent of Code problems, but that's not really knowing a language. With other languages I can usually learn how to use it and hack away at a fun project at the same time. With Rust that didn't work for me and yet I kept approaching it that way. I should have known better, considering learning Haskell was the same. [\[4\]](#fn4) Learn You A Haskell is what got me productive with the language back in college. To be honest, I think it was a matter of hubris. The assumption I could just quickly pick up Rust since I knew other languages. Rust does a couple of things differently, and I love it for that, but it means I need to devote a little more time to really make sure I understand everything. --- ## On mastery learning and stretching with deliberate practice This is the section where I toss out the buzz words. Despite enjoying math, I had a really hard time learning it growing up. In part this was because I moved up to honors math at one point. The paces between different course levels was different and so I missed a couple of sections. So I had to rush learning some pieces and they were a bit shaky. [\[5\]](#fn5) Then I had to learn more on top of that shaky foundation so the new sections were also shaky. Rinse and repeat and somehow I finished differential equations in college, but left feeling like I was only going through the motions. Then a couple years ago I heard Sal Khan [talk about Mastery Learning](https://support.khanacademy.org/hc/en-us/articles/360030753412-Why-Mastery-Learning-by-Sal-Khan?ref=blog.matthewbrunelle.com) and it all made sense: > Mastery learning simply means allowing a student to continue to work on a concept until they can master that concept or skill. You can tell a student has mastered a skill when they apply that skill to successfully complete a job, or task, that requires that skill. Growing up I essentially did the exact opposite of that with Math. Anyways, my previous attempts at learning Rust were scattered and I plan to be more thoughtful this time. Especially making sure I *actually* understand each piece before continuing. I'm also going to take another term, this time from Cal Newport's book So Good They Can't Ignore You: > "In the early 1990s, Anders Ericsson... coined the term “deliberate practice” to describe this style of serious study, defining it formally as an “activity designed, typically by a teacher, for the sole purpose of effectively improving specific aspects of an individual’s performance.” Additionally, when it comes to deliberate practice you need to find the right balance of how difficult it is: > "Doing things we know how to do well is enjoyable, and that’s exactly the opposite of what deliberate practice demands…. Deliberate practice is above all an effort of focus and concentration. That is what makes it “deliberate,” as distinct from the mindless playing of scales or hitting of tennis balls that most people engage in." > "I like the term “stretch” for describing what deliberate practice feels like, as it matches my own experience with the activity. When I’m learning a new mathematical technique—a classic case of deliberate practice—the uncomfortable sensation in my head is best approximated as a physical strain..." > "Pushing past what’s comfortable, however, is only one part of the deliberate-practice story; the other part is embracing honest feedback—even if it destroys what you thought was good." and from his book Deep Work: > ...what deliberate practice actually requires. Its core components are usually identified as follows: (1) your attention is focused tightly on a specific skill you’re trying to improve or an idea you’re trying to master; (2) you receive feedback so you can correct your approach to keep your attention exactly where it’s most productive. Smooshing together ideas from those books and Slow Productivity, a rough sketch for my personal plan looks like: - Regularly block off time when I can sit down to learn Rust. Don't just assume I can magically find time in the week. I know I won't. - Understand that I can't focus indefinitely and only block off a reasonable amount of time followed by breaks. - Regularly practice over time rather than trying to cram. [\[6\]](#fn6) - Structure the practice so that I can quickly get feedback as I go. Problem based exercises are a great way to do this for programming languages. - Progress through problems so they stay just hard enough, "comfortably uncomfortable": - Not so easy its rote and I can't stretch. - Not so tough I spin my wheels and give up. - Work up through more complexity as I master concepts. Easy right? The great news is there are some wonderful resources from the Rust community for learning. The road map is: - *[The Rust Programming Language](https://doc.rust-lang.org/book/title-page.html?ref=blog.matthewbrunelle.com) book and [Rustlings](https://rustlings.rust-lang.org/?ref=blog.matthewbrunelle.com)*, which are both completely free online, to learn the basics. - *Command-Line Rust*, which is DRM free on Bookshop.org, to make small single process programs. - *Rust in Action*, to practice systems programming. - *Asynchronous Programming in Rust*, which is available on packtpub DRM free, and the [Async Section](https://rust-exercises.com/100-exercises/08%5Ffutures/01%5Fasync%5Ffn.html?ref=blog.matthewbrunelle.com) of *100 Lessons to Learn Rust* - *Zero To Production In Rust*, which involves writing an email newsletter service from scratch and teaches the pieces like CI. - *[The Embedded Rust Book](https://docs.rust-embedded.org/book/?ref=blog.matthewbrunelle.com)*, which is completely free online, to get the embedded pieces. I want to be clear I'm not trying to be *prescriptive* to others. Rather, I want to describe what I plan to do, and how that goes. Hopefully that can act as inspiration for others to design their own road map for their own needs. Finally, I should note I am making sure all AI suggestions and auto completion turned off. Practicing recall is important for concretizing memory. --- ## The Rust Book and Rustlings Rustlings is a very cool project. There's a tool you use to generate the project directory and interact with as you go: ![rustlings_select_problem.png](https://blog.matthewbrunelle.com/content/images/2025/11/rustlings_select_problem.png) Each exercise is written with Rust tests to check if you have solved the problem. The interactive tool will compile and run the test automatically as you update the exercise, and show you the error messages: ![rustlings_unsolved_problem.png](https://blog.matthewbrunelle.com/content/images/2025/11/rustlings_unsolved_problem.png) You can also get problem hints and when you finish the problem you can see the example solution. The exercises are organized by topic and each section is pretty small. This makes it very manageable to block off small periods of time to work through the book. Also, each topic includes a readme with links to important sections in The Rust Book. This is great because in the past I had tried reading The Rust Book front to back and failed at that. A more exercise driven tour through, combined with looking up sections when needed served me better. I'm almost finished with all the exercises, just leaving the last couple around threading and macros until I've done a bit of Command-Line Rust. I have a [repo with my solutions](https://github.com/ciferkey/rustlings-problems?ref=blog.matthewbrunelle.com) for the problems. --- ## Command-Line Rust I've only just started working through the book. [\[7\]](#fn7) I skimmed the preface and chapter 1 since there is a lot of overlap with the Rust Book. Each chapter covers a different Unix coreutils program and starts with an explanation of how the command works. So a nice side effect is getting to learn the internals of common unix programs like echo and cat. Also basic Unix concepts like exit values. Then you are walked through recreating a subset of the command's behavior with Rust. The book introduces writing tests basically from the start, which is a nice way to build on top of what I learned from Rustlings. The author has scripts you run on your machine to save the output of the command you are duplicating. Then the tests will compare those outputs to your implementation which is pretty slick. The author's [solutions](https://github.com/kyclark/command-line-rust?ref=blog.matthewbrunelle.com) are on GitHub for reference and I'll push my own after I get further into the book. --- ## Closing thoughts I've enjoyed the resources I've gone through so far and I'm excited to get to the rest. As expected, the only difficulty has been blocking off the time to work through everything. I'm hesitant to post this and never follow up, but in a way this post should act as a form of accountability. 🦀 --- 1. Since its very easy to make claims about what you are going to do and never follow up on them. [↩︎](#fnref1) 2. Here's the [HN thread](https://news.ycombinator.com/item?id=45984143&ref=blog.matthewbrunelle.com). [↩︎](#fnref2) 3. The [HN thread](https://blog.arduino.cc/2025/11/21/the-arduino-terms-of-service-and-privacy-policy-update-setting-the-record-straight/?ref=blog.matthewbrunelle.com) for the reply. [↩︎](#fnref3) 4. In general, I'm a fan of how the whole MetaLanguage family feels. [↩︎](#fnref4) 5. Trig still really gets me because of this. [↩︎](#fnref5) 6. Related, I once did this Coursera [Learning How to Learn](https://www.coursera.org/learn/learning-how-to-learn?ref=blog.matthewbrunelle.com), which touched on this point. I feel like its the kind of topic we should probably all be taught at a young age to prepare us for school. [↩︎](#fnref6) 7. The book was originally released in 2022, but I used the updated version from 2024\. That version provides an opportunity to learn the clap crate. [↩︎](#fnref7) ### Using Self-hosting Language Models So You Can Evaluate Claude Code URL: https://blog.matthewbrunelle.com/using-self-hosting-language-models-so-you-can-evaluate-claude-code/ Last updated: 2025-11-13T16:16:00.000Z #### On this page I've avoided hopping on the AI hype train on this blog, but this topic is in line with what I usually write about for self-hosting. My work had a nice workshop that I want to write about. However, that requires using Claude Code and that in turn requires paying Anthropic money. So I first wanted to do a little guide on a self-hosted alternative for running a language model that works with Claude Code. Normally, I self-host all my services on my Unraid server. I explicitly built it with no dedicated GPU to reduce power consumption. The Intel Core i5-13500 CPU I have is more than enough for video transcoding and surprisingly power efficient. Technically, for my [self-hosted dog camera](https://blog.matthewbrunelle.com/self-hosting-a-dog-camera-for-away-from-home-monitoring/) I also added a Coral Dual Edge TPU, but that also sips energy. However, in my desktop I have a AMD Sapphire 7900 XT [\[1\]](#fn1) so I will install everything for Claude code there. This works out well, because I do most of my personal projects on my desktop anyways and I don't need the model to always be available, only when I'm working. Additionally, I run NixOS on my desktop so I will share my nix configuration, but the ideas here will work more broadly. Essentially all I need is: - Something to run the model on. - Something to make the model look like it's an Anthropic model. - Some configuration changes for Claude Code. --- ## On model choice [MiniMax M2](https://www.minimax.io/news/minimax-m2?ref=blog.matthewbrunelle.com) released last week, and on benchmarks it compares really well to Claude's official models. The model is also specifically designed for use in agentic coding. It is self-hostable in the sense that the weights are open, but the [requirements](https://huggingface.co/MiniMaxAI/MiniMax-M2/blob/main/docs/vllm%5Fdeploy%5Fguide.md?ref=blog.matthewbrunelle.com#:~:text=OS%3A%20Linux,context%20tokens.) state: > Memory requirements: 220 GB for weights, 240 GB per 1M context tokens > > - 4x 96GB GPUs: Supported context length of up to 400K tokens. > - 8x 144GB GPUs: Supported context length of up to 3M tokens. So self hosting is not particularly feasible. Especially since I am trying to be considerate of power usage. I think conceptualizing the requirements for these models is important for understanding just how much resources these tools are sucking up. Additionally, Ollama does not even support MiniMax-M2 yet. The only supported inference options are SGLang, vLLM, and MLX. Of those MLX is Apple Silicon only. Of the remaining two, vLLM has better LiteLLM integration, but getting vLLM working is a giant can of worms currently. So for models that could reasonably be run on a desktop single GPU, there's GPT-OSS 20B or [Qwen3-Coder 30B](https://ollama.com/library/qwen3-coder?ref=blog.matthewbrunelle.com) which are both good options. I chose the later because of the larger context window, which will be relevant in the next post I make. Qwen is also nice since it uses Mixture-of-experts which reduces the number of active layers. Finally, Claude Code needs a model that supports [tool calling](https://docs.ollama.com/capabilities/tool-calling?ref=blog.matthewbrunelle.com) which Qwen checks the box on. --- ## Setting up Ollama [Ollama](https://docs.ollama.com/?ref=blog.matthewbrunelle.com) is a very quick and easy way to download and run language models. The NixOS wiki has a nice [guide for Ollama](https://wiki.nixos.org/wiki/Ollama?ref=blog.matthewbrunelle.com). In my case I added the following configuration: ```nix services.ollama = { enable = true; loadModels = ["qwen3-coder:30b"]; acceleration = "rocm"; }; ``` The example from the wiki uses cuda for acceleration, but I searched nixpkgs for the [services.ollama.acceleration option](https://search.nixos.org/options?channel=25.05&show=services.ollama.acceleration&query=ollama.acceleration&ref=blog.matthewbrunelle.com) and see that I can use "rocm" instead. Typically on Linux, AMD hardware will get better performance with a Vulkan backend, but Ollama only [merged support last month](https://github.com/ollama/ollama/pull/11835?ref=blog.matthewbrunelle.com) and [nixpkgs is still waiting](https://github.com/NixOS/nixpkgs/issues/456454?ref=blog.matthewbrunelle.com) to add support. Also, note that I preloaded the Qwen model, but models can also be downloaded with: ``` $ ollama run qwen3-coder:30b ``` I do not use `services.ollama.openFirewall` since everything will run on the same machine. With a nixos-rebuild I then should see: ``` ❯ ollama list NAME ID SIZE MODIFIED qwen3-coder:30b 06c1097efce0 18 GB About a minute ago ``` --- ## LiteLLM Proxy Server setup LiteLLM can [be configured to work with ollama](https://docs.litellm.ai/docs/providers/ollama?ref=blog.matthewbrunelle.com). [\[2\]](#fn2) Additionally, LiteLLM provides a proxy server for models and NixOS supports running the proxy server as a service. I couldn't find a wiki entry for LiteLLM, but I found all the options using nixpkgs search. Normally, the LiteLLM proxy settings are written in yaml: ```yaml model_list: - model_name: "qwen3-coder" litellm_params: model: "ollama/qwen3-coder:30b" api_base: http://localhost:11434 model_info: supports_function_calling: true ``` On NixOS they can be set using [services.litellm.settings](https://search.nixos.org/options?channel=25.05&show=services.litellm.settings&query=litellm&ref=blog.matthewbrunelle.com). The settings field is a type "yaml 1.1 value" which maps into a [nix attribute set](https://nix.dev/manual/nix/2.18/language/values.html?highlight=attribute+set&ref=blog.matthewbrunelle.com#attribute-set). So I converted the above yaml into the following: ```nix services.litellm = { enable = true; settings = { model_list = [ { model_name = "qwen3-coder"; litellm_params = { model = "ollama/qwen3-coder:30b"; api_base = "http://localhost:11434"; model_info = { supports_function_calling = true; }; }; } ]; environment_variables = { LITELLM_MASTER_KEY = "sk-1234"; }; }; }; ``` Alternatively, I could have mapped the model to a Anthropic model name: ```yaml - model_name: claude-sonnet-4-20250514 litellm_params: model: ollama/qwen3-coder api_base: http://localhost:11434 ``` Also, the [default Ollama port on nixos](https://search.nixos.org/options?channel=unstable&show=services.ollama.port&query=ollama&ref=blog.matthewbrunelle.com) is 11434. Weirdly, the LiteLLM docs reference 8000 as the port for the web UI, but on NixOS the [port defaults to 8080](https://search.nixos.org/options?channel=unstable&show=services.litellm.port&query=litellm&ref=blog.matthewbrunelle.com): ![litellm_web_ui.png](https://blog.matthewbrunelle.com/content/images/2025/11/litellm_web_ui.png) When I attempted to go to the admin panel I was redirected to `http://localhost:8080/sso/key/generate`. LiteLLM needs a master key set and above I used an example key, but any random string starting with "sk-" works. For example, `openssl rand -hex 32` to generate an appropriate string. Hitting the /models endpoint gets a response similar to: ```json { "data": [ { "id": "qwen3-coder", "object": "model", "created": 1677610602, "owned_by": "openai" } ], "object": "list" } ``` That confirms LiteLLM is configured with the Ollama model. The model can be tested quickly with: [\[3\]](#fn3) ```bash curl -X POST http://0.0.0.0:8080/v1/messages \-H "Authorization: Bearer $LITELLM_MASTER_KEY" \-H "Content-Type: application/json" \-d '{ "model": "qwen3-coder", "max_tokens": 1000, "messages": [{"role": "user", "content": "What is the capital of France?"}]}' ``` --- ## Claude Code Setup All that's left now is to make Claude code work with the qwen. Claude Code has [open issue for self hosting models](https://github.com/anthropics/claude-code/issues/7178?ref=blog.matthewbrunelle.com). So until they support that I have to trick it into using the model. This works because LiteLLM will conform to the shape of [Anthropic's Gateway API](https://code.claude.com/docs/en/llm-gateway?ref=blog.matthewbrunelle.com). So after [installing Claude Code](https://code.claude.com/docs/en/setup?ref=blog.matthewbrunelle.com) or just adding the `claude-code` package on NixOS, I just needed to [point it at LiteLLM](https://docs.litellm.ai/docs/tutorials/claude%5Fresponses%5Fapi?ref=blog.matthewbrunelle.com#4-configure-claude-code): ```bash export ANTHROPIC_BASE_URL="http://0.0.0.0:8080" export ANTHROPIC_AUTH_TOKEN="$LITELLM_MASTER_KEY" export ANTHROPIC_MODEL="qwen3-coder" ``` Now, there is a chance it may not work due to a a [known bug](https://github.com/BerriAI/litellm/issues/13429?ref=blog.matthewbrunelle.com). If that happens, OpenRouter could be used as an alternative. --- ## Final thoughts I'm not convinced that Claude Code or tools like it will be necessary for writing code in the future. However, I do think there is value in trying to evaluate new tools to figure that out for yourself. That way you can have a more informed opinion. Normally, you would have to spend $20 to get a month's access to the models you would need to run Claude Code in order to do the evaluation. Self-hosting is a nice opportunity to just try it out and see how it goes. Even if the models you run locally aren't quite as good as the official ones. --- 1. Newegg somehow accidentally sent me this at one point, and never noticed... [↩︎](#fnref1) 2. MiniMax-M2 support for LiteLLM was [added last week](https://github.com/BerriAI/litellm/pull/15990?ref=blog.matthewbrunelle.com) if you are somehow able to run it.\] [↩︎](#fnref2) 3. This example is taken from the [LiteLLM docs](https://docs.litellm.ai/docs/tutorials/claude%5Fresponses%5Fapi?ref=blog.matthewbrunelle.com#3-verify-setup). [↩︎](#fnref3) ### Assembling and Testing the SmartSpin2k On A Schwinn IC4 URL: https://blog.matthewbrunelle.com/assembling-and-testing-the-smartspin2k-on-a-schwinn-ic4/ Last updated: 2025-11-09T16:53:46.000Z #### On this page ## Gathering the full parts list This is a follow up to my previous post about [printing all the parts](https://blog.matthewbrunelle.com/3d-printing-the-smartspin-2k-with-an-ender-3-v2/) for a SmartSpin2k. Now that I had everything ready, I could finally get to assembling. The SmartSpin2k wiki has a nice [parts page](https://github.com/doudar/SmartSpin2k/wiki/Recommended-Hardware?ref=blog.matthewbrunelle.com#common-hardware---all-versions) that I used as a reference. There are three broad categories or parts for this build: - The 3D printed pieces. - The electronics. - The mechanical parts. To quickly recap the 3D parts, I had to print: - The left and right halves of the main body. - Two spur gears. - A window piece. [\[1\]](#fn1) - A mount specific to my bike. [\[2\]](#fn2) - An arm that connects the mount to the body. - A cup fit for the specific resistance knob on my bike. - An insert that connects the motor to the cup knob. I did not print out the case for the shifter, since that requires printing with TPU. [\[3\]](#fn3) For the electronics: - I bought this [PBC + wiring harness kit](https://www.etsy.com/listing/1447959757/smartspin2k-rev-3-pcb-wiring-harness?ref=blog.matthewbrunelle.com) off of Etsy... last year. It's been sitting in my closet for a while. Also, oddly enough, the listing no longer shows all the parts I received. My kit also contained the remote for gear shifting and the cable for the remote. - 38mm NEMA 17 Stepper - I ordered the one linked in the guide, however that link now seems to point to a laser engraver? - I did not have to order the Stereo RCA to 3.5mm headphone "Y" Cable, because there was one included in the PCB kit. - 12v power supply - I ordered the one linked in the guide. For the mechanical parts, I ordered the following online: - 608 Skate Bearings - I accidentally ordered *16* of these. The project requires 2\. So if you want any, *please* message me and I will mail them to you. I was able to obtain the remaining mechanical parts from my local ACE hardware. However, the recommended hardware page does not list *everything* that was needed. So I ended having to take two trips to the store... The complete set of the hardware store parts are: - 1 x 5/16" x 1-1/2" Hex Head Bolt - for connecting the insert to the larger spur gear. - 2 x 5/16” Hex Nuts - 5/16” Flat Washer - 4 x 2” Black Oxide Sheet Rock Screws - the only ones I could find were threaded all the way to the top. They also didn't really sink well. The M5 Screw helped keep it together though. - 2 M5 X 30mm Cap Screw (or #10x1.25”) and the appropriate nut - Zip ties --- ## Putting the body together There are [build instructions](https://github.com/doudar/SmartSpin2k/wiki/Building-How-To?ref=blog.matthewbrunelle.com) for the V3 case. Specifically, the `SS2kR3BuildingInstructions.pdf` is what I used. The rough flow of the process is: - Assemble the axle. - Put the stepper motor, axle, wire, and PCB into the left side of the case. - Screw the two halves of the case together. - Attach the arm to the case. - Attach the mount to the bike with a zip tie. Some notes from the process: ### #1 My stepper motor size was different The instructions depict the stepper motor fitting like this: ![stepper motor in case from the instructions](https://blog.matthewbrunelle.com/content/images/2025/10/Screenshot_20251028_193522.png) However the motor I received had a flange at the base of the D shaft which prevents the gear from sitting flush: ![IMG_20251011_171907_897.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/IMG_20251011_171907_897-1.jpg) This leaves a *very* small amount of clearance between the spur and the case. ### #2 My stepper motor cable was different If I'm getting the terminology right, the PCB is designed to use a JST connector for connecting the stepper motor. However, the cable off of the stepper motor just uses simple female pins: ![IMG_20251011_175422_178.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/IMG_20251011_175422_178.jpg) ### #3 My PCB cable was different There is a small plastic stud in the case that is supposed to keep the PBC cable in place. My cable seemed a little thicker than what the design expects so the pin broke almost immediately: ![IMG_20251011_175102_846.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/IMG_20251011_175102_846.jpg) ### #4 My stepper motor cable was different yet again The stepper motor came with a *very long* cable: ![IMG_20251011_175617_891.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/IMG_20251011_175617_891.jpg) But with some patience I was able to fit everything without bending the connectors: ![IMG_20251011_175758_171.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/IMG_20251011_175758_171.jpg) ### #5 My mount was different The instructions show the bike mount having holes that you can zip tie through: ![Screenshot_20251028_195856.png](https://blog.matthewbrunelle.com/content/images/2025/10/Screenshot_20251028_195856.png) However the file I printed did not: ![IMG_20251011_181406_462.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/IMG_20251011_181406_462-1.jpg) So I had to zip tie both sides, and then strap the mount on using a velcro strap I had: ![modified smart spin mount](https://blog.matthewbrunelle.com/content/images/2025/11/modified_smart_spin_mount.jpg) ### #6 Some 3D printed pieces didn't fit well The hex nut had a hard time fitting into the knob cup since the hole was not chamfered. The nut finally fit through after some sanding. The 11 tooth spur had a hard time sliding onto the stepper motor. My friend recommended using a hair dryer to warm the spur. That did not work for me, but I heated water in my kettle to 78 C and submerged the spur. That did the trick! It turns out if I had read the instructions more carefully I would have seen: > Depending on printer tolerances, you may need to heat the gear to prevent damaging it. Finally, the cut out in the window was not big enough for the diameter of the cable in the kit. --- ## Companion app and software There is a [companion app](https://www.smartspin2k.com/companion-app?ref=blog.matthewbrunelle.com) [\[4\]](#fn4) that can connect to the SmartSpin2k over WiFi or Bluetooth. My PCB came with a firmware installed, but I had version `24.10.14-develop` installed so I [updated using the app](https://docs.smartspin2k.com/documentation/firmware.html?ref=blog.matthewbrunelle.com) to the latest firmware which was `25.9.30`. Also, I should note that the app failed to connect over WiFi but "fell back to Bluetooth". The whole update process took about 2 minutes. --- ## Installing and running There are [installation instructions](https://docs.smartspin2k.com/getting-started/installation.html?ref=blog.matthewbrunelle.com) with very clear GIFs. So I set everything up and it worked perfectly... I wish. Instead, I started the [homing](https://docs.smartspin2k.com/documentation/homing.html?ref=blog.matthewbrunelle.com) procedure. The SmartSpin is supposed to home to minimum resistance, detect when the knob stops, home to maximum resistance, detect when the knob stops and then return to 0\. In my case the device homed to max resistance on the bike, but acted as if it was 0 resistance. When I tried to control the SmartSpin2k it proceeded to spin backwards and unscrew the axle. I thought I had reversed the wires on the motor, but after double checking everything I confirmed the wiring was correct. After some reading I saw there was [configuration](https://docs.smartspin2k.com/documentation/configuration.html?ref=blog.matthewbrunelle.com) option "Stepper Motor Direction": > This setting controls the orientation of the motor and the direction it turns. Depending on how your motor is wired, this may need to be toggled. "May need to be toggled" indeed. Oddly enough, all of the other configuration options can be set through the app, *except* for this option. Instead you have to [configure WiFi](https://docs.smartspin2k.com/documentation/configuration.html?ref=blog.matthewbrunelle.com#configure-wi-fi) to connect to your AP and then access the web interface. The documentation says you can use the hostname `smartspin2k.local`, however that never resolved for me. Instead I had to go into Opnsense and find the IP address for the device. --- ## My first ride The SmartSpin2k website lists the [compatible training programs](https://docs.smartspin2k.com/compatibility?ref=blog.matthewbrunelle.com#supported-training-programs). However, I don't bike using any bike simulators. Instead, as I mentioned in my [Boston Run to Remember Half Marathon race report](https://blog.matthewbrunelle.com/post-race-report-boston-run-to-remember/), I use [Qdomyos-Zwift](https://www.qzfitness.com/?ref=blog.matthewbrunelle.com) on my phone with an ANT+ usb stick. As per the [first ride](https://docs.smartspin2k.com/getting-started/first-ride.html?ref=blog.matthewbrunelle.com) instructions, you want to power on your bike first since the device will automatically connect to the bike over Bluetooth when powering on. Then I simply had to: - Switch QZ to connect to my SmartSpin2k instead of my IC4 bike. - Under "Accessories", I set the [SmartSpin2k device setting](https://github.com/cagnulein/qdomyos-zwift/wiki/Settings-Documentation?ref=blog.matthewbrunelle.com#smartspin2k-options) in QZ to enabled and selected my device. - Made my Garmin watch search for more devices and detect the new "trainer" type accessory. Then I tried to go for a ride! My Daily Suggested Workout was sets of 10s sprints with 2 minute rests. With the default settings, the bike barely got up to target resistance by the end of the sprint. In the app settings you can change the "ERG Sensitivity" setting: > This setting adjusts the aggressiveness of the ERG. This can be adjusted from 1 to 20\. Too low will cause the ERG to be slow at reaching the target wattage. Too high will cause it to overshoot too far and miss the target in each direction before it settles. A good starting value is 5\. A little bit of overshoot is okay if it settles on the target output quickly. I changed this to 9 and it ramped up much quicker. I wish there was a way for the bike to start ramping a second or two before the interval starts, but could not find an option. --- ### Final thoughts Projects like this can be janky. Projects like this can take a while to get working. Projects like this are not as simple to operate compared to a ERG you might buy. You know what though? I was able to build all of this using open designs and I saved *a lot* of money along the way. Plus, this is a device I can control. If you just want to go ride, probably don't build a SmartSpin2k. Do you want to avoid Peloton forcing a [$95 activation fee on used bikes](https://news.ycombinator.com/item?id=41322266&ref=blog.matthewbrunelle.com) and increasing subscription fees? I don't know what the long term durability of the SmartSpin2k will be, but because I put the device together, I know how to repair it when it breaks. Ultimately, if you want to stop buying new stuff, make old stuff work and tinker than give this project a go! --- 1. I did not have any clear filament to use, so mine was less of a window and more of a side cover. [↩︎](#fnref1) 2. Which is a used Schwinn IC4\. [↩︎](#fnref2) 3. Also, it seems there are files for both "PCB Shifter" and a "Hand wired Shifter". For the kit I bought, I would need the former and not the latter. [↩︎](#fnref3) 4. Which oddly enough doesn't seem to be open source? [↩︎](#fnref4) ### An ESP32 based beaconDB Scanner Prototype URL: https://blog.matthewbrunelle.com/an-esp32-based-beacondb-scanner-prototype/ Last updated: 2025-11-04T14:16:24.000Z #### On this page In [HackerBox 0118 More Human](https://blog.matthewbrunelle.com/review-of-hackerbox-0118-more-human/) I talked about BLE and WiFi scanning using Arduino. In [HackerBox 0119 Geopositioning](https://blog.matthewbrunelle.com/review-of-hackerbox-0119-geopositioning/) I talked about using a GNSS with an ESP32. In [Testing Out BLE Beacons With beaconDB](https://blog.matthewbrunelle.com/testing-out-ble-beacons-with-beacondb/) I set up some BLE beacons around my house. What on earth am I up to? Well, after [switching to GrapheneOS](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/) I've been thinking about alternatives for power efficient location tracking. The modern position tracking system provided by Google Play Services is *really* cool in terms of how they balance power and accuracy. I want to do a piece breaking that down, but I'll wait for a future post. Anyways, [beaconDB](https://beacondb.net/?ref=blog.matthewbrunelle.com) is a really interesting project for crowd sourcing cell tower, BLE Beacon and WiFi access point observations. I've been contributing observations using the [Neostumbler](https://github.com/mjaakko/NeoStumbler?ref=blog.matthewbrunelle.com) app, but there is a noticeable battery impact. The app has a passive mode that only records observations when other apps on the phone request location, however that [cannot be used on GrapheneOS](https://github.com/mjaakko/NeoStumbler/issues/594?ref=blog.matthewbrunelle.com). Regardless, Neostumbler got me thinking about whether I could build a low power device to continually collect observations. I had been looking at different hardware to use, but lucked out when I got a Tiny Yeti Locator (TYL) from HackerBox 0119. In the long term I'm aiming to use Rust and [Embassy](embassy.dev/) for this project, but the Rust HAL does not support WiFi and BLE for the ESP32 dev board the TYL uses: ![Screenshot_20251008_153320.png](https://blog.matthewbrunelle.com/content/images/2025/10/Screenshot_20251008_153320.png) So for now I thought I would do the quickest and dirtiest prototype using what I have. The code is available in [this repository](https://github.com/ciferkey/tiny-yeti-locator-beacon-scanner-poc?ref=blog.matthewbrunelle.com). Here is what I ended up doing for the prototype: - Get GNSS updates. - Synchronize clock time with the GPS time. - Scan for nearby BLE devices. - Filter for just BLE Beacons. - Scan for nearby WiFi APs. - Write all observations to the SD card. --- ## Its been a minute since I used C++ and man Arduino is weird. Since I'm not in Rust land for this project I need to shake off some dust. I haven't written C++ since I was in middle school. To add to the confusion Arduino is just *weird*: - As the code base grew I wanted to use multiple files. In Arduino they get [concatenated in alphabetical order](https://arduino.stackexchange.com/questions/60656/split-up-arduino-code-into-multiple-files-using-arduino-ide?ref=blog.matthewbrunelle.com). - I don't see good API docs for standard libs. - No declarative package management or lock files. Instead everything is installed through the UI. - I inconsistently see PascalCase for method names in examples. --- ## A note on AI assistance in this project: I try really hard to make sure everything I post on this blog and represent as my own work *is my own work*. So: 1. This project slams together a lot of example sketches others have written. I make sure to call out the original files when I reference them. 2. Since this project involved exploring a new set of tools I am not familiar with, I bounced ideas off of Claude Code. I had Claude configure the [CLAUDE.md](https://www.anthropic.com/engineering/claude-code-best-practices?ref=blog.matthewbrunelle.com) to explicitly state I only want feedback on questions I ask and not to have Claude modify my code for me: ```markdown ## Working with this Project This is a learning project. When interacting with this codebase: - **Wait for the user to ask** for help, feedback, or explanations - **Do not proactively suggest** code changes or tell the user what to code - **Provide guidance when requested**: Answer questions about the code, explain implementation details, help debug issues, or review code when asked - **Be responsive, not directive**: The user is learning and wants to drive their own development process ``` If you see poor quality code in this project, that's all me. --- ## Starting with the TYL GPS test as a baseline In the [Instrutables for HackerBox 0119](https://www.instructables.com/HackerBox-0119-Geopositioning/?ref=blog.matthewbrunelle.com) they provide a *TYL\_GPS\_Test.ino* example sketch. The file provided examples of two important parts: - Setting up and using the TFT screen on the board, - Setting up and getting the location from the GNSS chip. I moved the GPS related logic into its own file. For each piece of this project I roughly had the following: - A setup method the main setup method would call. - A method to write an observation to the SD card. The GPS logic is in [this file](https://github.com/ciferkey/tiny-yeti-locator-beacon-scanner-poc/blob/main/gps.ino?ref=blog.matthewbrunelle.com). The setup code is very similar to what I covered in HackerBox 0119, so I won't rehash that here. --- ## Adding BLE Beacon Scanning So what are BLE Beacons? I made a whole separate post [Testing Out BLE Beacons With BeaconDB](https://blog.matthewbrunelle.com/testing-out-ble-beacons-with-beacondb/), that covers the topic already. The Arduino IDE includes a number of examples built in. The set of examples changes based on the board module you are using, so they are often hardware specific. Under File -> Examples -> BLE there is the [BeaconScanner](https://github.com/espressif/arduino-esp32/blob/master/libraries/BLE/examples/Beacon%5FScanner/Beacon%5FScanner.ino?ref=blog.matthewbrunelle.com) example I used as a reference. The main parts I learned from the examples are: - Configuring the device for BLE scans. - Creating a [BLEAdvertisedDeviceCallbacks](https://download.coxlab.kr/doc/classBLEAdvertisedDeviceCallbacks.html?ref=blog.matthewbrunelle.com) to handle receiving BLE device advertisements. There are different types of BLE beacons. To tell them apart you need to parse the BLE payload. The beacon scanning example for Arduino was a good reference for telling apart iBeacons, EddystoneURL, and EddystoneTLM beacons. However, I don't actually need the information in the beacon payload. I only needed to know if a BLE device *is* a beacon, so I can filter out the non beacon BLE devices, because the beaconDB API only wants BLE beacons. So I simplified the checks to: ```c++ bool isBeacon(BLEAdvertisedDevice advertisedDevice) { if (advertisedDevice.haveManufacturerData() == true) { return false; } String strManufacturerData = advertisedDevice.getManufacturerData(); // Buffer to store manufacturer data (BLE max is 255 bytes) uint8_t cManufacturerData[255]; size_t dataLength = strManufacturerData.length(); if (dataLength > sizeof(cManufacturerData)) { return false; } memcpy(cManufacturerData, strManufacturerData.c_str(), dataLength); if (dataLength == 25 && cManufacturerData[0] == 0x4C && cManufacturerData[1] == 0x00) { // iBeacon return true; } else if (advertisedDevice.getFrameType() == BLE_EDDYSTONE_URL_FRAME) { return true; } else if (advertisedDevice.getFrameType() == BLE_EDDYSTONE_TLM_FRAME) { return true; } return false; } ``` Similar to handling GPS, I moved the BLE logic into its own file which you can see in [ble.ino](https://github.com/ciferkey/tiny-yeti-locator-beacon-scanner-poc/blob/main/ble.ino?ref=blog.matthewbrunelle.com). --- ## On debugging and serial out All of the Arduino examples do debug logging over hardware USB serial. However the TYL maps the GPS\_RX pin to GPIO1 and GPS\_TX to GPIO3, which is necessary for USB serial to work, so I cannot use the serial monitor for debugging. During testing I had to change all debug lines to printout information on the TFT display. Additionally I wanted to use different areas of the screen for different pieces of information: ```cpp tft.fillRect(0, 100, 320, 100, ILI9341_BLACK); tft.setCursor(0, 100); ``` `fillRect` allowed me to clear parts of the screen while keeping text on others. Then `setCursor` let me print to the specific part of the screen I just cleared. Now ironically, in [HackerBox 1120 Current Affairs](https://blog.matthewbrunelle.com/review-of-hackerbox-0120-current-affairs/) I recently received a USB-to-serial adapter, so I suppose in the future I can learn how to make a software serial work. The TFT setup code is available in [tft.ino](https://github.com/ciferkey/tiny-yeti-locator-beacon-scanner-poc/blob/main/tft.ino?ref=blog.matthewbrunelle.com). --- ## Adding WiFi AP Scanning Next up was adding support for scanning WiFi access points. In the Arduino IDE under File -> Examples -> WiFi there is [WiFiScan.ino](https://github.com/espressif/arduino-esp32/blob/master/libraries/WiFi/examples/WiFiScan/WiFiScan.ino?ref=blog.matthewbrunelle.com). The main parts I learned from the example are: - The ESP32 Arduino WiFi library can [operate in two modes](https://docs.espressif.com/projects/arduino-esp32/en/latest/api/wifi.html?ref=blog.matthewbrunelle.com): working as an access point or working as a station. In my case I wanted to make sure the device was in station mode. - Setup is a simple as a call to `WiFi.STA.begin();`. - To get scan results I had to make a call to `WiFi.scanNetworks();` and then [iterating over the devices](https://github.com/espressif/arduino-esp32/blob/7a9a10ca2cf84451fda1289c6e0a143edd2cee4e/libraries/WiFi/examples/WiFiScan/WiFiScan.ino?ref=blog.matthewbrunelle.com#L26-L50) by index. This small example demonstrates how the iteration over the found networks works: ```cpp void scanWiFi() { int n = WiFi.scanNetworks(); long scanTime = millis(); ... if (n != 0) { for (int i = 0; i < n; ++i) { // access information with WiFi.SSID(i).c_str() } WiFi.scanDelete(); } } ``` Also, the example code uses pre-processor conditions based on `ESP_IDF_VERSION` to handle devices that support both 2.4ghz and 5ghz bands for WiFi. In my case the TYL only supports 2.4ghz so I removed the pre-processor code. I added in some WiFi code and all was good... or not. I started to get the following build error: ``` Sketch uses 1696115 bytes (129%) of program storage space. Maximum is 1310720 bytes. Global variables use 61196 bytes (18%) of dynamic memory, leaving 266484 bytes for local variables. Maximum is 327680 bytes. Sketch too big; see https://support.arduino.cc/hc/en-us/articles/360013825179 for tips on reducing it. text section exceeds available space in board Compilation error: text section exceeds available space in board ``` There is a limited amount of space on the ESP32 for storing programs. It is not uncommon to run out of space [when trying to use both the WiFi and BLE libraries](https://esp32.com/viewtopic.php?t=39502&ref=blog.matthewbrunelle.com). [\[1\]](#fn1) For this prototype I ended up switching the partition scheme to "Huge APP (3MB No OTA/1MB SPIFFS)" in order to fit both libraries. Another interesting thing I learned is that technically the ESP32 doesn't allow WiFi and Bluetooth at the same time. Since both Bluetooth and WiFi [share the same antenna and radio](https://productionesp32.com/posts/lfmm2-ble-wifi-together/?ref=blog.matthewbrunelle.com), there is some multiplexing happening. Thankfully, in my case I am only *scanning* for devices rather than keeping connections open. You can see the full WiFi handling file in [wifi.ino](https://github.com/ciferkey/tiny-yeti-locator-beacon-scanner-poc/blob/main/wifi.ino?ref=blog.matthewbrunelle.com). --- ## Saving CSV files to the SD Card and learning about SPI The examples so far do not include any file I/O. The Instructables also includes a *TYL\_GPS\_Map.ino* example that reads from the SD card, but all of the file logic was abstracted by the Adafruit\_ImageReader library. In my case I need to write to an SD card, so I have the opportunity to learn how that works. The TYL uses a [Adafruit ILI9341](https://github.com/adafruit/Adafruit%5FILI9341?ref=blog.matthewbrunelle.com) based display with a built in SD card slot. [\[2\]](#fn2). The ESP32 communicates with the SD card over an SPI interface. This was my first time using SPI and I found a clear [written guide](https://www.electronicshub.org/basics-serial-peripheral-interface-spi/?ref=blog.matthewbrunelle.com) about how SPI works. Additionally, this is a nice [video comparing UART, I2C and SPI](https://www.youtube.com/watch?v=IyGwvGzrqp8&t=552s&ref=blog.matthewbrunelle.com) which was useful since in HackerBox 0119 [I got to learn about how I2C works](https://blog.matthewbrunelle.com/review-of-hackerbox-0120-current-affairs/#assembling-the-current-affairs-power-monitor). [\[3\]](#fn3) [\[4\]](#fn4) Anyways, the Instructables guide gives the following pinout without explanation: ``` SD_MISO 19 SD_MOSI 23 SD_SCK 18 SD_CS 5 ``` As noted in the written SPI guide, the names for the pins are not universal so you sometimes you need to translate: - Master Output Slave Input (MOSI), allows the main chip to receive data from the connected chips. - Master Input Slave Output (MISO), allows the connected devices to send data to the main chip. - Serial Clock (SCK), the signal used to synchronize the main and connected chips. - Chip select (CS), since multiple devices are connected, this allows you to select which one. SPI is full duplex, so it can send and receive at the same time. That does not really apply in this use case, where I am only writing files, but it makes sense to use full duplex for file IO. Anyways, the pinout the board uses is not the defaults the SD library expects, so I had to do some setup: ```cpp #define SD_MISO 19 #define SD_MOSI 23 #define SD_SCK 18 #define SD_CS 5 ... SPIClass spiSD(VSPI); ... spiSD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS); ... if (SD.begin(SD_CS, spiSD)) { ... ``` Going in order: - We define the pins to use. - The ESP32 [has two SPI bus interfaces](https://randomnerdtutorials.com/esp32-spi-communication-arduino/?ref=blog.matthewbrunelle.com#spi-multiple-bus-hspi-vspi): HSPI and VSPI. For the TYL board we want to create a SPIClass for the VSPI interface. - We call [SPI.begin()](https://docs.arduino.cc/language-reference/en/functions/communication/SPI/begin/?ref=blog.matthewbrunelle.com) to initialize the SPI bus with the correct pins. - We call [SD.begin()](https://docs.arduino.cc/libraries/sd/?ref=blog.matthewbrunelle.com) to initialize the SD library with the right Chip Select pin and the configured SPIClass. - .begin() returns 1 on a success so we can check that everything was correctly setup. This is important because future file operations will just fail silently. Once that is done we can open the files to write to: ```cpp gps_file = SD.open("/gps.csv", FILE_APPEND); ble_file = SD.open("/ble.csv", FILE_APPEND); wifi_file = SD.open("/wifi.csv", FILE_APPEND); ``` In my case I am: - Separating each type of observation into its own file. - Using simple CSV files. [\[5\]](#fn5) - Writing to the files in append mode for a simple resume functionality. Ideally I would make different files for different recording sessions, but for now this is more than enough. Here is an example of how the WiFi AP observations are written: ```cpp wifi_file.print(scanTime); wifi_file.print(","); wifi_file.print(WiFi.SSID(i).c_str()); wifi_file.print(","); wifi_file.print(WiFi.BSSIDstr(i).c_str()); wifi_file.print(","); wifi_file.print(WiFi.RSSI(i)); wifi_file.print(","); wifi_file.print(WiFi.channel(i)); wifi_file.print(","); wifi_file.println(""); wifi_file.flush(); ``` Note the flush at the end to ensure the data actually send to the file system. You can see the full SD card handling file in [record.ino](https://github.com/ciferkey/tiny-yeti-locator-beacon-scanner-poc/blob/main/record.ino?ref=blog.matthewbrunelle.com). --- ## On managing clocks In order for the recording from the device to be useful they need to be stored as time series. This is because I need to correlate the BLE beacons and WiFi AP observations to the most recent GPS recording. [beacon DB geolocate API](https://beacondb.net/?ref=blog.matthewbrunelle.com) has you mark the observations with an age verse the last known position. [\[6\]](#fn6) So, since I am working with time series data, I have to handle time appropriately. Typically, I just assume I have a clock that's set when I program. Whereas when you start up an Arduino device the clock begins at zero. I could use WiFi to [sync the clock over NTP](https://coolplaydev.com/esp32-ntp-time-in-arduino-ide?ref=blog.matthewbrunelle.com), but that wouldn't work if I was turning the device on while away from home. Thankfully, I have a GPS chip which will have the current time beamed right to it! With the help of [this issue](https://github.com/espressif/arduino-esp32/issues/1444?ref=blog.matthewbrunelle.com) I was able to come up with this: ```cpp int gpsTime() { time_t t_of_day; struct tm t; t.tm_year = gps.date.year()-1900; t.tm_mon = gps.date.month()-1; // Month, 0 - jan t.tm_mday = gps.date.day(); // Day of the month t.tm_hour = gps.time.hour(); t.tm_min = gps.time.minute(); t.tm_sec = gps.time.second(); t_of_day = mktime(&t); return t_of_day; } void updateClock() { timeval currentTime = {gpsTime(), 0}; const timeval *tv = ¤tTime; timezone utc = {0,0}; const timezone *tz = &utc; settimeofday(tv, tz); tft.setCursor(0, 0); tft.println(F("Clock updated.")); } ``` On an ESP32 you can use the posix `settimeofday` method to update the system time with a Unix timestamp. The `gps` variable contains the date and time information for the most current reading. So I extracted everything and converted to a timestamp. Once that is done I can call `millis` immediately after getting scan results to mark the time of the reading. --- ## The embedded world vs server land and final thoughts As I said this was a quick and dirty sketch of what I want to do. Here are some of the enhancements I want when I rewrite this in Rust: - Right now the program is not interactive, so I want to use touch screen controls and provide a TUI. - Currently, I have to extract the CSV files and combine them on my computer. Long term I want to do the correlation of the observations on device. - Submit the observations to the beaconDB API from the device when known WiFi is available. - Only record updates when moving and possibly add an accelerometer to avoid always running the GPS. This moves the design closer to what Android currently does with FusedLocationProvider. - Test running everything on off the shelf Meshtastic hardware. The devices basically contain everything I need for this project. - Use a GPS chip that supports bootstrapping for faster startup with A-GPS and an RTC battery backup. Also, as a final thought, I was really struck by how many assumptions I usually make when programming that I cannot when writing embedded code: - I can't just assume I have time. - The size of the compiled binary matters. - Debugging isn't just printing out to the terminal. All in all though, this was really fun and I'm excited to iterate on the design. --- 1. I'm curious to see what the binary size will be for the rust version. [↩︎](#fnref1) 2. I see many devices like this on Adafruit, but they all take microsd cards. So this may be an older revision. [↩︎](#fnref2) 3. Now I suppose I need to find a project that uses UART so I can learn that too. [↩︎](#fnref3) 4. I2C is not to be confused with yet another serial protocol, *I2S* in the audio part of [HackerBox 0118 - More Human](https://blog.matthewbrunelle.com/review-of-hackerbox-0118-more-human/#the-pulse-width-modulated-audio-experiment). [↩︎](#fnref4) 5. [arduino\_json](https://docs.arduino.cc/libraries/arduinojson/?ref=blog.matthewbrunelle.com) exists, but I can't find a streaming or append only file mechanism. [↩︎](#fnref5) 6. In the future I will go through an example dump of recordings to talk about this more. [↩︎](#fnref6) ### Review of HackerBox 0120 - Current Affairs URL: https://blog.matthewbrunelle.com/review-of-hackerbox-0120-current-affairs/ Last updated: 2025-10-29T19:33:35.000Z #### On this page Another month, another post as a part of my [HackerBox review series](https://blog.matthewbrunelle.com/tag/hackerbox/). This time I received [HackerBox #0120 - Current Affairs](https://hackerboxes.com/collections/past-hackerboxes/products/hackerbox-0120-current-affairs?ref=blog.matthewbrunelle.com). Here are the [Instructables instructions](https://www.instructables.com/HackerBox-0120-Current-Affairs/?ref=blog.matthewbrunelle.com) for this box. The write up is shorter this month since I was not able to do all of the steps. --- ## The frontmatter The guide had a nice summary of how voltage, current and resistance are all related. I took an introduction to electronics course last month at [Artists Asylum](https://www.artisansasylum.com/?ref=blog.matthewbrunelle.com), and appreciated a refresher. I never took an electronics class in school, but both the course and this guide used water in pipes as a metaphor, so that must be a pretty common explanation. The guide then talked about using a root mean square [\[1\]](#fn1) to measure AC currents and why RMS is needed to find the average current. Notably though, there is no description of AC vs DC currents. This [brief video](https://www.youtube.com/watch?v=2jqJZxxX6gQ&ref=blog.matthewbrunelle.com) provides a simple overview. The guide also mentioned the board we will use only makes discrete measurements and not continuous measurements, but doesn't go into more details. This [post on the blog Statistics By Jim](https://statisticsbyjim.com/basics/discrete-vs-continuous-data/?ref=blog.matthewbrunelle.com) explains the difference. Then there is an explanation of a how a current transformer works and the first safety warning of the guide. The guide had many safety warnings. The guide also directed me to slice open an extension cord: ![PXL_20251029_152838313.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/PXL_20251029_152838313.jpg) Ironically, I've made sure to replace all my janky, old, ungrounded extension cords with higher quality surge protectors and extension cables. Thankfully, I was able to find in my storage an older style extension cord like the one depicted in the guide for sacrifice. --- ## Assembling the Current Affairs Power Monitor As is tradition I started with testing the ESP8266 D1 Mini Microcontroller by connecting the board [\[2\]](#fn2) and ran the blink sketch: ![ESP8266_blink_sketch.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/ESP8266_blink_sketch.jpg) This time I got to solder: - Two 8-pin headers to the ESP8266 D1. - The ESP8266 D1 to the PCB. [\[3\]](#fn3) - A 10-pin header to the ADS1115 Analog-to-Digital Converter Module. - The ADS1115 to the PCB. - Two PJ-320A 3.5mm Sockets to the PCB. Anyways, alcohol wash, apply resin, solder, done. The tips I learned assembling the Thrifty Yeti Locator in [HackerBox 0119](https://blog.matthewbrunelle.com/review-of-hackerbox-0119-geopositioning/#assembling-the-thrifty-yeti-locator) really helped out. The board has space for additional ADS modules [\[4\]](#fn4) and the Analog-to-Digital Converter uses I2C for communication. This makes sense, since we could have multiple ADS chained together that would all need to communicate with the main chip. I2C is a simple way to handle that. This [video on the I2C protocol](https://www.youtube.com/watch?v=6IAkYpmA1DQ&ref=blog.matthewbrunelle.com) is a great reference and visual aid. Also I should note, I2C is different from the serial protocol *I2S* that I used in the audio part of [HackerBox 0118 - More Human](https://blog.matthewbrunelle.com/review-of-hackerbox-0118-more-human/#the-pulse-width-modulated-audio-experiment). [\[5\]](#fn5) However, since I only had the one ADS, I didn't have to change any jumper configurations: > The top input circuit bank (labeled J1 and J2) does not require an address jumper to be closed. For the ADS1115 chip, I2C address 0x48 is selected by connecting ADDR to GND. Leaving all of the jumpers open accomplishes this as well since ADDR has a pull down resistor on the ADS1115 module. I like your funny words magic man. Trying to discern the transmudane: - input circuit bank - this doesn't seem like a common term, but this [StackOverflow](https://electronics.stackexchange.com/questions/594677/what-is-gpio-bank?ref=blog.matthewbrunelle.com) suggests this is a reference to the pins for digital input on the board. - address jumper - SparkFun and Adafruit saved me here. - a [jumper](https://learn.sparkfun.com/tutorials/how-to-work-with-jumper-pads-and-pcb-traces/what-is-a-jumper?ref=blog.matthewbrunelle.com) is used to physically connect two parts of a board and change the behavior. - In I2C the sub-devices each have an [address](https://www.i2c-bus.org/addressing/?ref=blog.matthewbrunelle.com) that is used by the main chip to direct messages. - So, an [address jumper](https://learn.adafruit.com/adafruit-led-backpack/changing-i2c-address?ref=blog.matthewbrunelle.com#changing-addresses-706265) allows you to change the address the a sub-device will use with I2C. - addr - the address pin. - pull-down resistor - this [tutorial](https://www.electronics-tutorials.ws/logic/pull-up-resistor.html?ref=blog.matthewbrunelle.com) walks through the details, but a pull-down resistor helps bias an input in the correct direction when there is no input. So putting that all together: When the addr pin is connected to ground the I2C address 0x48 is used. You could do that by connecting address jumper, but because there is a pull-down resistor, the addr pin will be grounded by default. Phew. OK, here is a second one: > The ADS1115 ([datasheet](https://www.ti.com/lit/ds/symlink/ads1115.pdf?ref=blog.matthewbrunelle.com)) has an I2C interface and features four single-ended input channels, which can also be configured as two differential input channels. Since our input signals are +/- 1V, we'll be using the input channels in differential mode to capture both the positive and negative portions of the input signal. Adafruit [helped me](https://learn.adafruit.com/adafruit-4-channel-adc-breakouts/signal-connections?ref=blog.matthewbrunelle.com#single-ended-vs-differential-inputs-2974122) with this one: - input channel - used for measuring an analog signal. - single-ended input channel - measures a channel compared to ground. - differential input channel - measure the difference between two input channels. - signals are +/- 1V - the signal is AC and will oscillate above and below 0V. The missing piece here is that since single-ended input channels are compared to ground, they cannot measure negative voltages. So by using the differential mode we can capture both the positive and negative parts of the wave. A final piece to decipher: > The ADS1115 incorporates a programmable gain amplifier (PGA). When the PGA is set to 4X gain, the 16 bit sampling resolution represents 0.03125mV per bit. - a [programmable gain amplifier](https://www.rfwireless-world.com/terminology/programmable-gain-amplifier-basics?ref=blog.matthewbrunelle.com) increases an signal (amplifies). They are programmable because the amount the signal is increase can be controlled. - 4x gain - the voltage is increased by four times. - 16 bit sampling resolution - the amount of information used to store a reading from the input channels. - 0.03125mV per bit - the amount of voltage a single bit in the sample represents. The core part here is the device is taking an analog signal on the input channel, creating a digital reading by sampling. Since the reading is digital, it is discrete and has a fixed number of byte representing the analog value. --- ## Using the Current Affairs Power Monitor With the theory out of the way, I installed the ESP8266 D1 Mini board library in the Arduino IDE and loaded in the *HB0120\_SerialOut.ino* sketch onto the board. The setup code is pretty simple and the main loop has the interesting parts. For each second the following is done repeatedly to sample the current: ```cpp voltage = ads48.readADC_Differential_0_1() * multiplier; current = voltage * factor; sum_ads48_0_1 += sq(current); voltage = ads48.readADC_Differential_2_3() * multiplier; current = voltage * factor; sum_ads48_2_3 += sq(current); ``` The voltage is read from the differential input channel for the positive component of the signal. That voltage is multiplied by the 0.03125mV factor that was mentioned previously to produce the current. The current is then squared and added to a running total to track the sum of the squares. The same is done for the negative component. Additionally, each time a sample is read, a counter is updated. After a second of sampling has passed, the sum of squares is divided by the sample count to produce the mean. Finally the square root of the mean is taken to produce the root mean square: ```cpp I_ads48_0_1 = sqrt(sum_ads48_0_1 / sample_count); I_ads48_2_3 = sqrt(sum_ads48_2_3 / sample_count); ``` That's the theory of how the program works, now I had to actually run this. Before I did I double checked to be *absolutely certain* I had not carved through the shielding of the extension cable. I plugged the split core transformer into the J1 PJ-320A socket on the board. I plugged the extension cord into a surge protector. I plugged a ring light I had into the extension cord. There was no fire or magic smoke! I checked the serial monitor and saw some chatter: ``` J1: 0.001 A (RMS) J2: 0.000 A (RMS) J1: 0.001 A (RMS) J2: 0.000 A (RMS) J1: 0.001 A (RMS) ``` I was getting a small reading on J1 even when the light was off. Vampires are real, as evidenced by the standby power draw. Also, as expected J2 was not reading a current since there was no split core transformer plugged into that socket. When I powered the ring light on, I got a larger reading: ``` J1: 0.026 A (RMS) J2: 0.000 A (RMS) J1: 0.027 A (RMS) J2: 0.000 A (RMS) J1: 0.027 A (RMS) J2: 0.000 A (RMS) J1: 0.027 A (RMS) ``` The ring light defaults to max brightness when unplugged. If I reduce the light down to the minimum brightness the device reads: ``` J1: 0.003 A (RMS) J2: 0.000 A (RMS) J1: 0.002 A (RMS) J2: 0.000 A (RMS) J1: 0.003 A (RMS) J2: 0.000 A (RMS) ``` The ring light can also change from cool to warm light, but that didn't effect the current reading. --- ## The SONOFF Mini R4 Extreme and home power monitoring The next part of the guide involved the SONOFF Mini R4 Extreme. This is an ESP32 powered device that you can connect to using the "eWeLink-Remote" app. The guide provided a number of videos about using [Home Assistant](https://www.home-assistant.io/?ref=blog.matthewbrunelle.com), which I run, and [ESPHome](https://esphome.io/?ref=blog.matthewbrunelle.com), which I am not running yet. [\[6\]](#fn6) Unfortunately, the guides involve wiring into wall switches, and other parts of your mains. I rent and my landlord literally will not even let us touch anything on the toilet that is constantly failing. Alas, I had to skip this part for now, but I will tuck the Mini R4 Extreme away for a future project. --- ## The ESP-01S Wi-Fi Relay Module This section of the guide was extremely brief. The box included a USB to TTL board that can be connected to the ESP8266 ESP-01S Wi-Fi Relay Module by cables. They also provide a relay for connecting to the mains. One project involves making a wired doorbell smart. Unfortunately, the doorbell at my apartment barely works. I will tuck this away as well. --- ## Final thoughts: The guide ends with a remembrance of Thompson, Kernighan, and Ritchie. This was a nice sentiment, but they didn't connect it to anything else from the box. Overall though I think this months box was pretty good. I won't deduct points because I am a renter and not an owner. I also appreciated how different the projects felt this time compared to the other two boxes I had tried. --- 1. RMS is an old friend of mine, since its used when calculating some HRV statistics from inter beat intervals. [↩︎](#fnref1) 2. Everything is USB-C in the future. [↩︎](#fnref2) 3. The ESP8266 came with female to male headers as well. So I could have instead soldered those to the PCB and then slotted the ESP8266 in. However, I don't see myself using that ESP8266 for another project. [↩︎](#fnref3) 4. You have to grab the parts yourself though. [↩︎](#fnref4) 5. So many protocols to learn. [↩︎](#fnref5) 6. In fact I've been thinking about trimming back how many services I self host to reduce the management burden. [↩︎](#fnref6) ### Testing Out BLE Beacons With beaconDB URL: https://blog.matthewbrunelle.com/testing-out-ble-beacons-with-beacondb/ Last updated: 2025-10-25T15:29:37.000Z #### On this page ## What on earth is beaconDB? I've been [using GrapheneOS](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/) for about half a year now. Back in March they [added support](https://github.com/eylenburg/eylenburg.github.io/issues/126?ref=blog.matthewbrunelle.com#issue-2892178110) for [network based location](https://grapheneos.org/features?ref=blog.matthewbrunelle.com#network-location).\[^0\] This means you no longer need to rely on Google's location services. Looking into how the system works sent me down yet another rabbit hole of reading. [\[1\]](#fn1) Anyways, in 2013 Mozilla [launched Mozilla Location Service (MLS)](https://blog.mozilla.org/services/2013/10/28/introducing-the-mozilla-location-service/?ref=blog.matthewbrunelle.com) as a pilot project to provide location lookup using observations of public cell towers, BLE Beacons and WiFi access points. Sadly, in 2024 Mozilla [retired MLS](https://discourse.mozilla.org/t/retiring-the-mozilla-location-service/128693?ref=blog.matthewbrunelle.com). Thankfully, [beaconDB](https://beacondb.net/?ref=blog.matthewbrunelle.com) launched to continue the work! [\[2\]](#fn2) I have been hacking away on a project for contributing observations to beaconDB and I wanted some BLE beacons I could use for testing. This experiment sort of spun off from that work. The plan is simple: - Buy some BLE beacons. - Get their MAC addresses. - Query the beaconDB API to confirm no location is currently associated with the beacons. - Place the beacons in my yard. [\[3\]](#fn3) - Take my dog on a walk around the block while running NeoStumbler. - Re-run the API query to see location estimates. --- ## What on earth are BLE Beacons? I've been writing the phrase BLE beacons a lot without describing what they are. So to disambiguate [\[4\]](#fn4): - Bluetooth - a wireless communication standard. - Bluetooth Low Energy - part of the Bluetooth 4.0 protocol, much lower power consumption, but also reduced transmission rates. - BLE beacons - BLE devices that are primarily transmit only. Stationary BLE beacons are often used to mark locations in places where GPS signals are weak, like inside malls. Also, there is no single BLE beacon standard. Instead we have: iBeacon which was released by Apple in 2013\. Apple generally still supports them. In 2014 Google launched the experimental URIBeacon. Then in 2015 Google replaced that with Eddystone. A one point Google was really into the concept of the [Physical Web](https://google.github.io/physical-web/?ref=blog.matthewbrunelle.com), but thankfully [gave up on](https://android-developers.googleblog.com/2018/10/discontinuing-support-for-android.html?ref=blog.matthewbrunelle.com) spamming users with notifications in 2018\. This effectively reduced Google's involvement with the standard. Eddystone also powers the Waze beacons that [saves me from missing my exit](https://privacysos.org/blog/googles-bluetooth-beacons-massdot-tunnels-privacy/?ref=blog.matthewbrunelle.com) in the Ted Williams Tunnel. AltBeacon was released in 2014 as an open standard. There are also other less used beacon standards out there. Also, since BLE beacons are just BLE devices with extra details attached to the broadcast information, both iPhones and Android devices can scan for any standard. Best of all, most modern beacon devices should support broadcasting multiple beacon types at the same time. In terms of collecting information about all these different types of beacons, Neostumbler uses the [Android Beacon Library](https://altbeacon.github.io/android-beacon-library/?ref=blog.matthewbrunelle.com), which can detect the three main beacon types. Though [they want to](https://github.com/mjaakko/NeoStumbler/issues/478?ref=blog.matthewbrunelle.com) move away from the library so they can support custom scanning intervals. --- ## Which BLE beacons did I choose? When I was surveying the options I saw that many beacons included features like motion detection, lights, buttons, sound, etc. I wanted a stationary beacon with a long battery life, so I tried to avoid extra features if possible to keep the cost down. Additionally there are BLE beacons designed for broadcasting over extra long ranges. However the [Bluetooth / WiFi accuracy](https://ichnaea.readthedocs.io/en/latest/algo/accuracy.html?ref=blog.matthewbrunelle.com#bluetooth-wifi) section of the MLS documentation notes: > Bluetooth and WiFi networks have a fairly limited range. Bluetooth low-energy beacons typically reach just a couple meters and WiFi networks reach up to 100 meters. With obstacles like walls and people in the way, these distances get even lower. > ... > This means position estimates based on WiFi networks are usually accurate to 100 meters. If a lot of networks are available in the area, accuracy tends to increase to about 10 or 20 meters. Bluetooth networks tend to be accurate to about 10 meters. So in my case, I specifically do not want a long range beacon in order to improve location accuracy. In the end I settled on the Feasy FSC-BP104D and bought two of them: ![Feasy FSC-BP104D BLE beacon with banana for scale](https://blog.matthewbrunelle.com/content/images/2025/10/feasy_FSC-BP104D_ble_beacon_with_banana_for_scale.jpg) There is the [FeasyBeacon app](https://play.google.com/store/apps/details?id=com.feasycom.feasybeacon&hl=en%5FUS&ref=blog.matthewbrunelle.com) which leaves a lot to be desired, but is not totally useless. [\[5\]](#fn5) I powered up the two beacons and changed the following settings: - Set a new PIN. - I reduced the broadcast interval time from the default 1300ms to 1000ms. This will increase the batter usage, but allows for more frequent updates. - Changed the name of the beacons to something fun. - Updated the broadcast URL so that I was not advertising a Feasy store page. Sadly, the character limit was not long enough to broadcast my blogs address. So I chose the beaconDB website instead. - Checked for firmware updates. Then I uninstalled the app, hoping to never have to use it again. --- ## Trying out the API and confirming the beacons are not associated with a location beaconDB provides a [geolocate endpoint](https://ichnaea.readthedocs.io/en/latest/api/geolocate.html?ref=blog.matthewbrunelle.com). The old MLS version required an API key, but that is no longer needed: > Instead of using API keys to control access like Mozilla did, beaconDB expects clients to be pre-configured with a reasonable user agent. Ideally this identifies the software the client is using and includes info that can be used to narrow things down in the event a bad configuration or bug causes significant load on the server. As a first step I wanted to confirm I could hit the API and get a valid location as a response. So I threw together a quick Python script: ```python import requests url = "https://api.beaconDB.net/v1/geolocate" headers = {'User-Agent': 'beaconDB test script, blog.matthewbrunelle.com',} body = { "wifiAccessPoints": [{ "macAddress": "01:23:45:67:89:ab", "signalStrength": -51 }, { "macAddress": "01:23:45:67:89:cd" }] } response = requests.post(url, json=body, headers=headers) if response.status_code != 200: print(f"Error: {response.status_code}") else: print(response.json()) ``` Note: The example code for the blog post uses the example MAC addresses from the documentation, not real ones. The API is pretty simple and most fields are optional. The main information you need to provide are MAC addresses and the signal strength for the observation. If I input the MAC addresses for my home access points [\[6\]](#fn6), I get a response like: ```json {'location': {'lat': XX.XXXXXX, 'lng': -XX.XXXXXX}, 'accuracy': 132} ``` Which gives the location of the house directly across the street from me. The accuracy is measured in meters, so the circle with a radius of 132 meters centered on that position does, in fact, contain my apartment. Not bad for locating off a single observation. beaconDB works best when you can query with multiple device observations at once. Next, I wanted to hit the same endpoint, but using my BLE beacons. The app provided the beacons information, but they also had their MAC addresses printed on their side. I changed the body in the script above to: ```python body = { "considerIp": False, "bluetoothBeacons": [{ "macAddress": "ff:23:45:67:89:ab", "age": 2000, "name": "beacon", "signalStrength": -110 }], "fallbacks": { sdf10adfasdfasf "lacf": False, "ipf": False } } ``` Note that here I made sure to set `considerIp` and `fallbacks` to false, so that the API purely relies on the BLE beacon. From the docs: > The `fallbacks` section allows some control over the more coarse grained position sources. If no exact match can be found, these can be used to return a “404 Not Found” rather than a coarse grained estimate with a large accuracy value. As expected, I get a 404 back from the API. --- ## Collecting observations with Neostumbler and testing the API This part was pretty easy. [NeoStumbler](https://github.com/mjaakko/NeoStumbler?ref=blog.matthewbrunelle.com) is a great app for contributing observations to beaconDB. I started recording and took my dog for a walk around the block. At the end of the walk I uploaded my observations. Then I just needed to wait, but not for too long: > note that submissions will take at least 5 minutes to become available in the beaconDB Except... I was still getting 404s for my BLE beacons. Neostumbler has a mechanism [for filtering out moving devices](https://github.com/mjaakko/NeoStumbler/blob/bec73e6a67117e86c4b3d5c495f94ac90db5f509/app/src/main/java/xyz/malkki/neostumbler/scanner/postprocess/AutoDetectingMovingWifiBluetoothFilterer.kt?ref=blog.matthewbrunelle.com#L19), so the next thing I tried was disabling that. However I was still getting 404s... --- ## Directly submitting observations to beaconDB OK, so at this point I do not know if the issue is with Neostumbler submitting my observations, or beaconDB using them. Thankfully, Neostumbler lets you export your observations to csv. ![Neostumbler screenshot export to csv](https://blog.matthewbrunelle.com/content/images/2025/10/Neo-stumbler-screenshot-export-to-csv.png) So I was able to directly submit an observation using the [geosubmit v2 endpoint](https://ichnaea.readthedocs.io/en/latest/api/geosubmit2.html?ref=blog.matthewbrunelle.com). The export contains every piece of information you can submit, except for the GPS heading and the beacon name. ```python import requests url = "https://api.beacondb.net/v2/geosubmit" headers = {'User-Agent': 'BeaconDB test script, blog.matthewbrunelle.com',} {"items": [{ "timestamp": 1405602028568, "position": { "latitude": -22.7539192, "longitude": -43.4371081, "accuracy": 10.0, "age": 1000, "altitude": 100.0, "altitudeAccuracy": 50.0, #"heading": 45.0, "pressure": 1013.25, "speed": 3.6, "source": "gps" }, "bluetoothBeacons": [ { "macAddress": "ff:23:45:67:89:ab", "age": 2000, #"name": "beacon", "signalStrength": -110 } ], }]} response = requests.post(url, json=body, headers=headers) print(f"Status code: {response.status_code}") if response.status_code == 200: print(response.json()) ``` Anyways, after running this script I wait again... and still a 404\. At this point I realized that I had never run a test of a known good BLE beacon against the API to make sure I get a location back. So I dumped a full month of observations which contained a recent road trip I went on [\[7\]](#fn7) and check some of those MAC addresses. Still nothing. --- ## Double checking the beaconDB source and final thoughts So finally, I went to look at the beaconDB source to see what I could find. First I wanted to check if there was a minimum number of observations needed, sort of like the trilateration I learned about in [HackerBox 0119 - Geopositioning](https://www.instructables.com/HackerBox-0119-Geopositioning/?ref=blog.matthewbrunelle.com). A [comment from the repository](https://codeberg.org/beacondb/beacondb/src/branch/main/src/geolocate.rs?ref=blog.matthewbrunelle.com) revealed: > At least two WiFi networks have to been known to accurately determine the position. So I tried making the WiFi query I made before, but adding the BLE beacons to see if the accuracy improved... Nothing, the accuracy was the same. As I searched the codebase I realized the problem: beaconDB currently accepts and stores BLE beacons, but does not use them yet for geolocation. So I made [an issue](https://codeberg.org/beacondb/beacondb/issues/140?ref=blog.matthewbrunelle.com). Not all projects end as expected. At the very least, I learned a lot along the way. [\[8\]](#fn8) My initial question was "how does beaconDB use BLE beacons". I should have probably checked if the answer was "it currently doesn't" before I set everything up. --- 1. Especially since I am a fan of how the fused location provider in Play Services works. [↩︎](#fnref1) 2. There are some pretty graphs [of observations over time](https://beacondb.pngn.cc/?ref=blog.matthewbrunelle.com). [↩︎](#fnref2) 3. I should note that there are several beacons around me already, so this is maybe not the most useful way to spend my time. [↩︎](#fnref3) 4. By mostly looking at the first sentences of pages on Wikipedia. [↩︎](#fnref4) 5. Also amazingly exodus [show no trackers](https://reports.exodus-privacy.eu.org/en/reports/com.feasycom.feasybeacon/latest/?ref=blog.matthewbrunelle.com). [↩︎](#fnref5) 6. I'm currently using two TP-Link EAP670\. [↩︎](#fnref6) 7. Scanning in New Hampshire is interesting compared to MA. I was basically only getting cell tower readings with the occasional WiFi. No BLE beacons. In my neighborhood I get 30-50 WiFi APs at a time. [↩︎](#fnref7) 8. Most importantly, I was able to write an explanation of what BLE beacons are. Now I don't have to cram that into a future post I am working on. [↩︎](#fnref8) ### I never thought I could be a Maker URL: https://blog.matthewbrunelle.com/i-never-thought-i-could-be-a-maker/ Last updated: 2025-10-20T19:07:45.000Z #### On this page ## My maker journey, or lack there of I failed my middle school shop class test four times. Four times. > What are the types of wood drill bits? Spade, spiral and fortsner. Sorry I took so long to learn that Mr Buzz. [\[1\]](#fn1) I grew up loving software. By that I mean in fifth grade I asked my parents to buy me a C++ textbook. Projects in the physical world always seemed harder to me. So, over time I kept doubling down and investing in that craft of code. Now I find myself wishing I had diversified my skills more. I've been reading *Material World* by Ed Conway[\[2\]](#fn2) and in the book he discusses the "ethereal world" we live in most of the time. We are disconnected from the physical processes that transforms raw materials into the products we use. We spend more and more of our time interacting with digital devices. I think I've been trapped in the ethereal world. For a long time I've joked with my friends that working in software and finance means I'm two degrees deep into made up ideas. I suppose three degress when you thrown AI into the mix. Don't get me wrong, ideation and reification are very cool: with software you can go from an idea to a creation using nothing more than a keyboard. However, that lacks the kind of *craft* I want. I remember sitting in my grandfather's living room as a child and disassembling a stereo. My grandfather learned electronics when he was a radar specialist in the airforce. So I imagine he was happy to see me taking an interest. I did not know what any of the parts did, but the *act itself* was fun. After that I remember soldering a couple of times, and maybe doing a breadboard project. I also remember taking a battery, nails, water, and trying to do electrolysis. No reason why, I just read about the concept and thought *let me try that*. I had a couple of small projects like modding a DMG-01 Gameboy with [pro sound](https://chipmusic.org/forums/topic/12195/guide-dmg01-prosound-mod-18-stereo-jack/?ref=blog.matthewbrunelle.com) and a backlight.[\[3\]](#fn3) Then at some point I started to shy away from non-software projects, because they felt outside of my skill-set. I was able to pick up software so much easier, that I started to lean away from the more difficult feeling projects in the material world. What I realize now is the avoidant behavior actually made the situation worse. If those projects were harder, I could have used deliberate practice to try, fail and eventually learn. --- ## Why do I want to learn these skills now? *Solostolgia* is the lived experience of negatively perceived change. My mind is scattered, so this section is as well. Here's a progression of my thoughts over the last year. Google is restricting Android, [requiring](https://android-developers.googleblog.com/2025/08/elevating-android-security.html?m=1&ref=blog.matthewbrunelle.com) all apps to have their developers register with Google in order to run on certified devices. See my related post [I Picked A Really Weird Time to Try Out GrapheneOS](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/). [\[4\]](#fn4) In [Breaking The Creepy AI in Police Cameras](https://www.youtube.com/watch?v=Pp9MwZkHiMQ&ref=blog.matthewbrunelle.com) Benn Jordan talks about AVLR cameras and their ethics[\[5\]](#fn5): > And even after stolen data was used to manipulate and subvert democracy in 2016, another decade passed without any meaningful protection from the federal government. So yeah, I guess we'll just fucking DIY it. A company Enel X Way bought an EV charger startup and didn't want to continue supporting the tethered services the chargers required to run. So they killed the chargers. In [Escaping the chains of tethered products: the Juice Rescue project](https://natematias.com/portfolio/2025-08-22-unchaining-from-broken-software-tethers?ref=blog.matthewbrunelle.com), Dr. J. Nathan Matias talked about the community that removed the restrictions to restore the chargers. My podcast player started showing ads to users that were grandfathered into the premium plan. Well the app is open source so I can [just remove those ads](https://blog.matthewbrunelle.com/podcasts-you-altered-the-deal-so-i-will-alter-your-app/). Subvert... subvert? In high school: - I took some communications classes. I wanted to install plugins for more effects in Final Cut Pro. Back then you could use recovery mode on macs to create admin accounts. So I did. - I had a teacher that wanted to watch sports during their lunch break. I helped them set up a proxy. Helping another person get around restrictions felt good. - A friend ask me to *obtain* a CD of some software. Instead, I made a CD that would auto open Rick Astley's *Never Gonna Give You Up* and a link to Let Me Google That For you. As a kid I owned a print copy of the [Jargon file](http://www.catb.org/jargon/html/H/hacker-ethic.html?ref=blog.matthewbrunelle.com): > 1. The belief that information-sharing is a powerful positive good, and that it is an ethical duty of hackers to share their expertise by writing open-source code and facilitating access to information and to computing resources wherever possible. > 2. The belief that system-cracking for fun and exploration is ethically OK as long as the cracker commits no theft, vandalism, or breach of confidentiality. This is how I've always thought about that ethereal world. Self-hosting has been a form of this [for me](https://blog.matthewbrunelle.com/tag/self-hosting/). A way to exert some small amount of control over my digital life. Why not do the same for the material world? I have a lot of groundwork and learning to do though. --- ## What do I actually want to learn? This year I finished reading Cory Doctorow's book *The Internet Con*. At the end he recommended some other reading material. One was *The Hardware Hacking Handbook*. [\[6\]](#fn6) When I started reading I realized I was in over my head, and should go back to the fundamentals. First I wanted to start with what I already had lying around: - I bought a Pinecil v2 \[^ Everything is USB C in the future.\] when it released in 2022 and never even opened the box. So I picked up some solder, wick, and brass sponge, from Micro Center. See my [HackerBox review series](https://blog.matthewbrunelle.com/tag/hackerbox/). - Last year I wanted to build a [SmartSpin2k](https://www.smartspin2k.com/?ref=blog.matthewbrunelle.com). I got all the parts and they are just sitting there. So I sat down, started printing and assembling. See [3D Printing the Smartspin 2k](https://blog.matthewbrunelle.com/3d-printing-the-smartspin-2k-with-an-ender-3-v2/). Then on my bucket list for what to do next: - I took AutoCAD and Autodesk classes as a kid. Time to restart those ancient neurons. [\[7\]](#fn7) - The workshop [Artist Asylum](https://www.artisansasylum.com/?ref=blog.matthewbrunelle.com) is near me and does a free weekly "Circuit Hacking Night". Time to finish that GBA SP mod project that's been sitting on my desk. They also had an intro to electronics class that I took. - The [MIT Radio Society](https://w1mx.mit.edu/ham-exams/?ref=blog.matthewbrunelle.com) offers a ham Radio license exam. That's a perfect thing to study over winter. There's a great [video series](https://www.youtube.com/watch?v=FktSv3tDwA8&list=PL1KAjn5rGhixvvb%5FjMZFWmbP97-t9Kyxk&index=1&ref=blog.matthewbrunelle.com) practice guide. - I want to build a [Meshtastic](https://meshtastic.org/?ref=blog.matthewbrunelle.com) node. I have friends that own Meshtastic devices and I could possible communicate with. More important than all of that though, is that many projects I currently do feel derivative: assembling and using what others have figured out. I think a core part of the maker mentality is the process of creating or remixing new ideas. So what I really want to do is craft something of my own. I've been particularly feeling the enshitification of fitness wearables. [Strava is suing Garmin](https://www.dcrainmaker.com/2025/10/strava-sues-garmin-demands-stop-selling-devices.html?ref=blog.matthewbrunelle.com) and [Whoop is suing Polar](https://dockets.justia.com/docket/new-york/nyedce/1:2025cv05766/537585?ref=blog.matthewbrunelle.com). I spun my wheels last year trying to make an Android app for capturing fitness data, but I always felt like I was swimming upstream. I want to get closer the hardware now. --- ## I'm not the only one that feels this way I think it's safe to say that there are others that wants to recapture some agency. There is a sense of hope in having the skills to make small change in your life on your own. One example of this I never expected was Justin McElroy. In the episode [How to Get Started in Handheld Gaming with Retro Game Corps](https://podcasts.apple.com/us/podcast/how-to-get-started-in-handheld-gaming-with-retro-game-corps/id505516789?i=1000684353565&ref=blog.matthewbrunelle.com) from the podcast The Besties, he had a chance to talk with Russ about emulation. Then over several episodes he started talking about emulation devices. Then he talked about tinkering with a Raspberry PI. Then in this [episode](https://podcasts.apple.com/us/podcast/herdling-is-beautiful-but-is-it-fun/id505516789?i=1000723998077&ref=blog.matthewbrunelle.com) he said: > That was a trigger moment for me that episode \[talking to Russ\]... after that episode I went like absolutely mad. > > I think in order to keep gaming history preserved, I think we need more people that are fluent in at least the basics of this stuff. You know what I mean? Like how to, to replace a battery, things like that. > > There's an ethos I think that I would love to see that is at play here, and part of it is definitely that 3D printing is open source in a way that I find kind of moving a little bit. It's genuinely a really open thing. I appreciated getting to hear someone talk about their experience on their maker journey[\[8\]](#fn8). Everyone that starts down this path has their own spark in their life that helps them get started. My hope is this blog could do that for others. --- 1. Yes, nominative determinism does exist. [↩︎](#fnref1) 2. Which is available DRM free on Libro.fm. [↩︎](#fnref2) 3. At the time, I thought I wanted to make chiptunes using [LSDR](https://www.littlesounddj.com/lsd/index.php?ref=blog.matthewbrunelle.com). [↩︎](#fnref3) 4. GrapheneOS is not certified, and should be able to skip the runtime check. However, that does not change the fact that this will impact the majority of users that do not run GrapheneOS. See [fdroid's](https://f-droid.org/2025/09/29/google-developer-registration-decree.html?ref=blog.matthewbrunelle.com) excellent write-up for more. [↩︎](#fnref4) 5. Also check out [deflock.me](https://deflock.me/?ref=blog.matthewbrunelle.com). [↩︎](#fnref5) 6. Which you can get DRM free off [No Starch Press](https://nostarch.com/hardwarehacking?ref=blog.matthewbrunelle.com). [↩︎](#fnref6) 7. Heck, there's even open source tooling now. I have a friend that advocates using [FreeCAD](https://www.freecad.org/?ref=blog.matthewbrunelle.com) and [OpenSCAD](https://openscad.org/?ref=blog.matthewbrunelle.com). [↩︎](#fnref7) 8. I should say that Justin has been into other maker crafts like woodworking for a long time. [↩︎](#fnref8) ### Review of HackerBox 0119 - Geopositioning URL: https://blog.matthewbrunelle.com/review-of-hackerbox-0119-geopositioning/ Last updated: 2025-10-16T13:55:54.000Z #### On this page ## Another month, another box My second HackerBox arrived! This is my review of [HackerBox 0119, Geopositioning](https://hackerboxes.com/collections/past-hackerboxes/products/hackerbox-0119-geopositioning?ref=blog.matthewbrunelle.com) which follows my [review of the previous HackerBox 0118, More Human](https://blog.matthewbrunelle.com/review-of-hackerbox-0118-more-human/). Honestly a GNSS based kit could not have come at a better time for me. Like the previous box, there is an official guide on [Instructables](https://www.instructables.com/HackerBox-0119-Geopositioning/?ref=blog.matthewbrunelle.com). In the instructions they note there are free [DEFCON 33 badges](https://hackerboxes.com/products/dc33-mini-badges?ref=blog.matthewbrunelle.com) up for grabs. I've never attended DEFCON, so I didn't get one. They also linked to their [Wireless Workshop](https://hackerboxes.com/products/dc33-wireless-workshop?ref=blog.matthewbrunelle.com). Now that one I may pick up in the future. The front matter also noted that: > Yes, this month marks one full decade of HackerBoxes and tin is the traditional ten year anniversary gift. Congrats to the team! --- ## An introduction to how GNSS works The guide included a [video on how GPS works](https://www.youtube.com/watch?v=AlHPDRQ08jU&source%5Fve%5Fpath=Mjg2NjY&ref=blog.matthewbrunelle.com). I was initially turned off because the voice in the video was generated. Then I realized the author lives in Spain. On their [main channel](https://www.youtube.com/watch?v=7GnLXJiCKUI&ref=blog.matthewbrunelle.com) they voice all their videos themselves in Spanish. They use TTS to provide versions of their videos in English, which I think is a really cool use case. I was too quick to assume the video was AI slop. Also the author does all the 3D animations themselves! Rather than write up a summary, I suggest you go watch the video directly. I took notes as I watched, but the author eventually answered every single question I had. All I will add is that the video mentions a coordinate system WGS 64\. That was explained in a really cool way in Android Developers Backstage [Episode 96: Location: It's Where It's At](https://adbackstage.libsyn.com/episode-96-location-its-where-its-at?ref=blog.matthewbrunelle.com). --- ## Trying out the ESP-WROOM-32 Development Board This was my first time using an ESP32\. I've been wanting to try one for a while. The best part compared to the last box is no more micro-usb [\[1\]](#fn1). The instructions had me set up the board before doing any soldering. Once again they used the Arduino IDE. I'll note that the guide said to use the "COM port that appears when the ESP32 is connected and turned on", however I see the device listed as a USB serial port instead. Once again, the blink sketch was used as a test to verify the board. The instructions said to add `#define LED_BUILTIN 2` to the top with no explanation as to why. The best I could figure out is that the Arduino board variant for the ESP32 does not define the LED, so we have to specify. Also, I liked that this time they directed you to modify and play around with the sketch. --- ## Assembling the Thrifty Yeti Locator This box uses the Thrifty Yeti Locator (TYI), which is similar to the [Cheap Yellow Display (CYD)](github.com/witnessmenow/ESP32-Cheap-Yellow-Display), but with a GPS module. Since the TYI uses the same pin mapping, they provided [HackerBox 0099: Neunundneunzig](https://www.instructables.com/HackerBox-0099-Neunundneunzig/?ref=blog.matthewbrunelle.com) as a reference. The headers are already attached to the board this time, but I still got to practice some soldering. They had me solder: - 14-pin and 4-pin headers on the TFT display. - An optional 12-pin header expansion connection on the edge of the PCB. - The GPS module to the PCB. - The display module to the PCB. The GPS module was a castellated module, which I had never worked with, but they linked to a good [tutorial from SparkFun](https://learn.sparkfun.com/tutorials/how-to-solder-castellated-mounting-holes/all?ref=blog.matthewbrunelle.com). Fortunate timing, because I am going to do some GBA SP mods that uses a castellated module. Also, this time I had picked up some flux [\[2\]](#fn2), and a solvent alcohol dispenser which I learned about from this video on [how to have good solder joints](https://www.youtube.com/watch?v=jz67KgHzXVw&ref=blog.matthewbrunelle.com). Perfect timing since the instructions said flux was *highly recommended* for castellated components. --- ## The GPS test program In my last review I didn't dive into the examples sketches much. This time I wanted to actually sit down and understand what they were doing. I had to install three libraries for this board: - [Adafruit ILI9341](https://github.com/adafruit/Adafruit%5FILI9341?ref=blog.matthewbrunelle.com) \- The TFT display must be sourced from Adafruit, since this library is how the display is driven. - [EspSoftwareSerial](https://github.com/plerup/espsoftwareserial/?ref=blog.matthewbrunelle.com) \- Arduino has a [software implementation](https://docs.arduino.cc/learn/built-in-libraries/software-serial/?ref=blog.matthewbrunelle.com) of serial communication. This library is an implementation for ESP32. - [TinyGPSPlus-ESP32](https://github.com/Tinyu-Zhao/TinyGPSPlus-ESP32?ref=blog.matthewbrunelle.com) \- So, the National Marine Electronics Association has a spec NMEA 0183 for communicating with electronic devices, including GPS. I had looked into this before when I was researching how to use GPS on a future project. There is also this excellent [Hackaday article](%5Bhttps://hackaday.com/2025/09/08/gps-and-its-little-modules/%5D%28https://hackaday.com/2025/09/08/gps-and-its-little-modules/%29) which I saw last month that talks about NMEA. What struck me is that there is no declarative package management with Arduinos. You just install libraries using the UI. I suppose that keeps the barrier to entry down, but it feels really *sketchy*. The testing program is quite simple. Aside from the setup code the only interesting part was: ```c++ EspSoftwareSerial::UART gpsSerial; TinyGPSPlus gps; void loop() { while (gpsSerial.available() > 0){ if (gps.encode(gpsSerial.read())) { DisplayLocation(); delay(1000); } } } ``` So we read from our software serial for the GPS [\[3\]](#fn3) and send the bytes into the NMEA parsing library. What's actually being passed though? Well, NMEA uses "sentences". These are some example sentences I pulled from my device through the serial monitor: [\[4\]](#fn4) ``` $GPRMC,185603.000,A,XXXX.XXXX,N,XXXXX.XXXX,W,0.82,213.46,151025,,,A*7C $GPVTG,213.46,T,,M,0.82,N,1.51,K,A*30 $GPGGA,185604.000,XXXX.XXXX,N,XXXXX.XXXX,W,1,4,2.00,-52.6,M,-33.7,M,,*79 $GPGLL,XXXX.XXXX,N,XXXXX.XXXX,W,185604.000,A,A*4C $GPGSA,A,3,26,31,32,28,,,,,,,,,2.24,2.00,1.00*08 $GPGSV,3,1,09,31,73,317,20,28,61,049,22,26,60,198,18,32,38,126,27*72 $GPGSV,3,2,09,03,37,296,19,29,21,081,17,25,17,045,,04,16,308,*7C $GPGSV,3,3,09,16,,,21*74 $GPRMC,185604.000,A,XXXX.XXXX,N,XXXXX.XXXX,W,0.81,214.66,151025,,,A*77 $GPVTG,214.66,T,,M,0.81,N,1.51,K,A*36 $GPGGA,185605.000,XXXX.XXXX,N,XXXXX.XXXX,W,1,4,2.00,-52.5,M,-33.7,M,,*79 $GPGLL,XXXX.XXXX,N,XXXXX.XXXX,W,185605.000,A,A*4F $GPGSA,A,3,26,31,32,28,,,,,,,,,2.24,2.00,1.00*08 ``` Here is an [in-depth guide](https://aprs.gids.nl/nmea/?ref=blog.matthewbrunelle.com) to the NMEA sentence format. We can see the following sentence types in my snapshot: GPRMC, GPVTG, GPGGA, GPGLL, GPGSA, GPGSV. The main detail I want to add in addition to that guide, is the repeating nature of the sentences. You get one of each message type except for GPGSV. That is the [number of satellites in view](https://receiverhelp.trimble.com/alloy-gnss/en-us/NMEA-0183messages%5FGSV.html?ref=blog.matthewbrunelle.com). In my case there were three, so the second part of the message was "3" and three messages were sent. --- ## The TYL Mapping experiment The kit contains a micro SD card and a SD card adapter. The TFT screen itself actually has the SD cards slot built in. All of the SD file-system interactions are abstracted away through the [Adafruit ImageReader Library](https://github.com/adafruit/Adafruit%5FImageReader?ref=blog.matthewbrunelle.com). The sketch reads two .bmp images saved to the card: a rough map of the US, and a street level map of Gulfgate village which is near the HackerBox headquarters: ```c++ #define NUM_MAPS 3 int bestMap = NUM_MAPS; struct mapData maps[NUM_MAPS]= { {{"/US.bmp"}, 21.45, 54.83, -124.98, -66.80 }, {{"/GulfGate.bmp"}, 27.25560, 27.26195, -82.52425, -82.51471}, {{"/map.bmp"},0,0,0,0} }; ``` The numbers are the latitude and longitude bounds of the maps. I was able to export my local area from OpenStreetMap, edit the image to 320x240, and add a new entry to the mapData struct. The map to use is chosen in the SetBestMap method by seeing which map contains your current position and is tightest: ```c++ if (bestMap < NUM_MAPS){ if ((maps[i].lat2-maps[i].lat1) < (maps[bestMap].lat2-maps[bestMap].lat1)){ //current map (i) is "tighter" (more zoomed in) than the last bestMap bestMap = i; } } ``` GPS acquisition with this chip takes a good bit of time, especially when you are inside. Some GNSS hardware are backed by coin cell batteries, or lets you bootstrap startup by providing nearby satellite information. Neither option is available on this board, so cold starts are slow. However, if you update the sketch and upload without powering down the board, the GPS will not have to reacquire the location! The instructions also point you towards [ESP32\_GPS](https://github.com/aresta/ESP32%5FGPS?ref=blog.matthewbrunelle.com) and [IceNav](https://github.com/jgauchia/IceNav-v3?ref=blog.matthewbrunelle.com) . I didn't have a chance to try them on the board yet. --- ## ESP32 Marauder This part involved trying out a WiFi/Bluetooth toolkit called ESP32 Marauder. [\[5\]](#fn5) I installed it to the TYL using a web flasher built with the [Adafruit WebSerial ESPTool](https://github.com/Fr4nkFletcher/Adafruit%5FWebSerial%5FESPTool?ref=blog.matthewbrunelle.com). Firefox intentionally [does not implement WebSerial](https://mozilla.github.io/standards-positions/?ref=blog.matthewbrunelle.com#webserial) for security reasons, so I had to flash with Chromium. [\[6\]](#fn6) As they noted in the guide, the timing is a bit tricky, but I eventually got my device flashed. The style of the included tutorial video was not my taste, but does go through all the features. Instead I found a [written wiki](https://github.com/justcallmekoko/ESP32Marauder/wiki/getting-started?ref=blog.matthewbrunelle.com) on GitHub is that really detailed. Also, there are no control buttons on the board, but the TFT screen is touch sensitive. Neither of the previous sketches were interactive, but ESP32 Marauder is fully navigable using touch. --- ## Closing thoughts GPS mapping and trying out ESP32 Marauder were absolutely perfect for what I want to do in a future project: Scanning BLE and WiFi APs and correlating them with GPS locations. I would like to do this using [Embassy](https://embassy.dev/?ref=blog.matthewbrunelle.com), but the WiFi and Bluetooth on this particular ESP32 dev board are not supported by the Rust HAL. For now I'll do a quick prototype in Arduino and pick up a different ESP32 in the future. --- 1. Everything is USB-C in the future. [↩︎](#fnref1) 2. They also added the helpful note to "make sure the flux in your solder core matches the liquid flux you are using", which I was not aware of. [↩︎](#fnref2) 3. Amazingly satellites talk at only a 9600 baud rate. [↩︎](#fnref3) 4. Note: the `XXXX.XXXX` parts are redacted longitudes and latitudes, and yes, I made a robot do the redaction. [↩︎](#fnref4) 5. While searching for more information I came across [tindie](https://www.tindie.com/?ref=blog.matthewbrunelle.com), which is a market for makers to sell devices they build! [↩︎](#fnref5) 6. There is a \[polyfill [extension](https://addons.mozilla.org/en-US/firefox/addon/webserial-for-firefox/?ref=blog.matthewbrunelle.com), but it requires a native application and is not available on NixOS. Probably for the best. [↩︎](#fnref6) ### On Practicing Writing URL: https://blog.matthewbrunelle.com/on-practicing-writing/ Last updated: 2026-08-11T19:30:35.000Z #### On this page ## On my current writing process I recently sent one of [my posts](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/) to a friend who was considering trying out GrapheneOS. He asked what my writing process was like and I took some time to summarize my thoughts. I pretty quickly had a brain dump and here's what I said: Yeah the process is pretty different now than when I was younger. As a kid I was into the "manage your online brand" Kool-aid when I thought I wanted to do startups. Then in the second era I was writing posts because I was looking for new work and wanted to link to my blog. See [this post](https://blog.matthewbrunelle.com/blog-paleontology-15-years-of-blogging/). Before my grandfather passed he gave me his favorite books [\[1\]](#fn1) and I earnestly gave them a try. [\[2\]](#fn2) For the first time in my life I started to really enjoy reading. Ever since then I've been gradually building on the habit. Similar to reading, writing was also something that I didn't enjoy growing up. Though I find it much more manageable for me now with ADHD meds. [\[3\]](#fn3) Anyways, my process: - No AI assistance, since that defeats the goal of deliberate practice. - Harper is nice for spell checking though. I will also listen to my writing using TTS to help catch errors. - A lot of my writing is project driven. As I work I quickly dump notes into Obsidian and I can use that as fodder. - If the project kicks off the writing itch I'll start massaging the different pieces into prose. - Some notes and thoughts just never end up fitting. I used to try and cram a paragraph in, but now I'm OK setting them aside. Sometimes I'll pull those unused pieces into a future post. - My goal is to get better at long form writing so I aim for at least 1000 words. I'm trying to be careful not to force that though. Some topics don't need that much. - Recently, when pieces start to get above 1500 words I'll sit with the draft. When it's that long there's usually different but related topics. If I can expand each of them then I can make a series of posts. For example, [these](https://blog.matthewbrunelle.com/tag/framework/) were originally one post. - Often times I have notes I was not interested in writing about. Then something happens in the news that makes me annoyed. I can combine old notes with commentary on the event and make a post out of it. [\[4\]](#fn4) That's why keeping old notes is nice. You never know what you may use. Right now I'm incubating one where I'm lamenting never trying to do maker stuff. - Above all, I need to carve out time for projects or they don't happen, especially when the brain has juice and wants to write. - That also means communication with my partner, like if the dog needs to go out and I'm in a writing groove, I ask if she can walk her. Likewise if my partner is energized to jam on one of her projects, I'll take the dog out. - Finally, I working on accepting that not every idea works out and needs to be published. I still got some practice from writing a draft. - Also, I hate copy editing and will sometimes not publish because I'm afraid of errors. I'm working on finding a middle path of practicing some copy editing, but also knowing the blog is a work in progress and doesn't need to be perfect. Hey look at that 600 words. --- ## On thinking more systematically about how I write Growing up I mostly wrote for assignments in school. I was taught the mechanics of how to write, but never how to *think* about how I write. So, I recently attended a [How to create a writing habit](https://grubstreet.org/seminar/how-to-create-a-writing-habit-degala-fa25-2?ref=blog.matthewbrunelle.com) session. I had never taken a class like that before and I really enjoyed the experience. Some takeaways that really resonated with me: - Do warm up and cool down when you set aside time to write. Even a couple minutes will help mentally prepare you. - Always have a mentor text, and keep one specific element of writing in mind. [\[5\]](#fn5) - Each genre has different strengths and weaknesses. Write outside of your main writing genre to be well rounded. - Read lit mags. My local library gets the usually suspects like Ploughsares and The New Yorker, so I can read them there. - Have a "Go Everywhere Journal" to record inspiration when it happens. [\[6\]](#fn6) - Have intentionality about what kind of writing I will do when I sit down: researching, drafting, editing, etc. [\[7\]](#fn7) The class also ended with a really reassuring thought: > Your writing habit is an organic, ever-evolving thing, since you are an ever evolving thing. Allow it to adapt to its changing environment. Building a long term writing habit is about coming back to the page, time and time again. --- ## On tone and trying new forms of writing: I recently started reading the 13th edition of *Style: Lessons In Clarity And Grace*. I'm only on the first chapter, but I came across these two nuggets: > To accommodate those situations, I renamed Williams’s First Rule of style the “golden rule” of style and introduced a second “silver rule”: do not write to others as you would not have others write to you. If Williams’s golden rule is a principle of empathy, its corollary silver rule is a principle of fairness. > Our own writing always seems clearer to us than it does to our readers because when we read it, we respond less to the words on the page or screen than to the thoughts in our own heads: we read into it what we want them to get out of it. We see what we wanted to say, and we blame our readers for not understanding us as well as we understand (or think we understand) ourselves. These really resonated with me because one [post](https://blog.matthewbrunelle.com/podcasts-you-altered-the-deal-so-i-will-alter-your-app/) of mine about a podcast player recently got some attention. This was a great example of the importance of being conscientious of how you write. I wanted the tone to be "disappointed parent", but one reader online described my writing as "righteous fury". As you can imagine that was not my intention. Thankfully there is another great quote from the book: > As we struggle to master new ideas, most of us write worse than we do when we write about things we understand better. If that sounds familiar, take heart: you will write more clearly when you more fully understand what you are writing about. The class I took said to give yourself the permission to experiment with different writing styles. Here are some new post types I've published over the last couple of months: - [Learning About Municipal Open Data with a Cambridge Workshop](https://blog.matthewbrunelle.com/learning-about-municipal-open-data-with-a-cambridge-workshop/) \- Kind of reporting, but also combined with a summary of the event. - [I'm a laptop weirdo and that's why I like my new Framework 13](https://blog.matthewbrunelle.com/im-a-laptop-weirdo-and-thats-why-i-like-my-new-framework-13/) \- The first time I did a review style post. I enjoyed mixing some personal history and social context in as well. - [What Podcasts Am I Listening To?](https://blog.matthewbrunelle.com/what-podcasts-am-i-listening-to-2025/) \- This post was a survey of my habits and preferences. I'd love to do this for the books I read as well. Additionally I can do yearly updates to see how my preferences have changed. - [Ricoh GRIII in Japan](https://blog.matthewbrunelle.com/ricoh-griii-in-japan/) \- One part travel blog with one part technology thoughts. - [Post Race Report: Boston Run to Remember](https://blog.matthewbrunelle.com/post-race-report-boston-run-to-remember/) \- A personal reflective piece on my experience training. Even though the styles are all different they have a common aspect: sharing what I've learned with others. That is my main through line with writing. --- ## On the benefits of reading and writing Ever since I sent that message to my friend I have been mulling over why I'm practicing all of this. So, I've started collecting links when I encounter an article or story that reinforces the importance of reading and writing. As a parting thought, the links below are to copies of the articles with my Readwise highlights. You can click through to the original articles. From The Economist: - [Is the decline of reading making politics dumber?](https://readwise.io/reader/shared/01k6r6sqc4e2qtya3jqkxpc22z?ref=blog.matthewbrunelle.com) \- the importance of reading for political sophistication and as a means of social mobility. - [Can you make it to the end of this column?](https://readwise.io/reader/shared/01k6r6g28v6hdgc550hdewpfay?ref=blog.matthewbrunelle.com) \- if there is an attention economy, then you can have attention theft. From Cal Newport: [\[8\]](#fn8) - [Why are we getting dumber?](https://www.thedeeplife.com/podcasts/episodes/ep-369-why-are-we-getting-dumber-a-debate/?ref=blog.matthewbrunelle.com) \- around 21:00 Cal talks about some steps to help deal with phone usage so you can recapture your attention. - [Should We Fear Cognitive Debt?](https://www.thedeeplife.com/podcasts/episodes/ep-359-should-we-fear-cognitive-debt/?ref=blog.matthewbrunelle.com) \- around 9:00 he talks about the neurological processes behind writing. How the brain did not evolve for writing the way we evolved for speaking. From Complex Systems: - [Building institutions that bend towards truth, with Clara Collier of Asterisk Magazine](https://readwise.io/reader/shared/01k6r713191sh9qyehbqyvafss?ref=blog.matthewbrunelle.com) \- the guest talked about giving their writing fellows feedback. She mentioned using the concept of reader personas when you are a publication. - [Understanding and wielding power in local government, with Daniel Golliher](https://podcasts.apple.com/us/podcast/understanding-and-wielding-power-in-local-government/id1753399812?i=1000704695007&ref=blog.matthewbrunelle.com) \- on the value of sharing your voice through your own blog. Happy reading and writing! --- 1. The first time I wrote something significant in a long time was when I had the opportunity to write his [obituary](https://www.murphyfuneralhomes.org/obituary/James-Nesto?ref=blog.matthewbrunelle.com). I'm really thankful for all the people that helped me out with that piece. [↩︎](#fnref1) 2. When I asked him what two books I should read he responded: *100 Years of Solitude* and *The Odyssey*. I also kept other books of his including a well loved 1973 box set of *Lord of the Rings* and Carl Sagan's *A Demon Haunted World*. [↩︎](#fnref2) 3. In fact, I would say I even enjoy writing now. [↩︎](#fnref3) 4. See [my OpenTofu post](https://blog.matthewbrunelle.com/on-self-hosting-opentofu-and-formerly-oss-licensed-software/) as an example. I only wrote the post once I thought about combining the project details with discussion on FOSS. [↩︎](#fnref4) 5. Without realizing, I think I've been using [Matt Levine's column Money Stuff](https://www.bloomberg.com/account/newsletters/money-stuff?ref=blog.matthewbrunelle.com) this way. [↩︎](#fnref5) 6. That's how I currently use Obsidian. [↩︎](#fnref6) 7. Don't get stuck researching when you are drafting. You can put a placeholder in brackets to remind yourself to look up the answer later. [↩︎](#fnref7) 8. Readwise barfed trying to parse highlights on the YouTube transcript so I can't share them. [↩︎](#fnref8) ### 3D Printing the Smartspin 2k with an Ender 3 v2 URL: https://blog.matthewbrunelle.com/3d-printing-the-smartspin-2k-with-an-ender-3-v2/ Last updated: 2025-10-10T21:33:33.000Z #### On this page As I mentioned in last years [post race report](https://blog.matthewbrunelle.com/post-race-report-boston-run-to-remember/#training), after I broke my toe by dropping a blender I went out and found a used Schwinn IC4 stationary bike so I could train while my foot was healing. I used [QZ Zwift](https://github.com/cagnulein/qdomyos-zwift?ref=blog.matthewbrunelle.com) with an usb ANT+ adapter to pair my phone to the bike and broadcast my speed and power to my Garmin watch. Unfortunately I still had to manually control the resistance knob on the bike by hand. The [SmartSpin2k](https://www.smartspin2k.com/?ref=blog.matthewbrunelle.com) project lets turns bikes into ERGs on the cheap. Back in November of last year, I bought a [PBC + wiring harness kit](https://www.etsy.com/listing/1447959757/smartspin2k-rev-3-pcb-wiring-harness?ref=blog.matthewbrunelle.com) and then... procrastinated. Once the warm weather came around I was biking and running outside and needed an ERG less. The cold weather is coming though, so I'm finally putting everything together! --- ### The printer, auto leveling, and a new firmware. Back in 2021 I bought a Ender 3 v2 printer from Micro Center. First, I printed the boat benchy. Then I printed this [Mask of the Ancients](https://www.myminifactory.com/object/3d-print-mask-of-the-ancients-201144?ref=blog.matthewbrunelle.com) from FFXIV. \[^ At the time I wanted to export the Dragoon armor mesh from the game and print them, but never got around.\] Then I was distracted and never used the printer again. Recently however, I dusted off my printer, did a test benchy and ... made spaghetti. Seems I had forgotten how to operate the device \[^ Including how to load the filament [https://www.youtube.com/watch?v=bv46Wu87Z5k](https://www.youtube.com/watch?v=bv46Wu87Z5k&ref=blog.matthewbrunelle.com)\], since I didn't remember to level the bed. When searching for a how-to video I found this [guide](https://www.youtube.com/watch?v=ypb1OUII9Nw&ref=blog.matthewbrunelle.com). \[^ That YouTube noted was in my watch history.\], that shows a paper leveling technique using a special [gcode](https://www.chepclub.com/paper-bed-level.html?ref=blog.matthewbrunelle.com). However, a lot has changed with printing since 2021\. \[^ OK, what I'm going to talk about actually first released in 2015, but you get my point.\] Now I can add auto leveling to my printer for pretty cheap. [bltouch](https://www.antclabs.com/bltouch?ref=blog.matthewbrunelle.com) is a probe that mounts on the side of the extruder and uses a hall sensor to measure the bed depth. This can be used to automate leveling the print bed. So long paper! \[^ Actually you will still need paper for setting the z-offset, but the process is much less annoying.\] The device was \~$45 *without* the cable I need, or the printer specific mounting brackets. In comparison, Creality released the CR Touch, with all the necessary parts for \~$39, so I grabbed one. ![IMG_20251003_091123_798.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/IMG_20251003_091123_798.jpg) Some notes: - The included paper instructions were uninformative, but this [video](https://www.youtube.com/watch?v=qvz2gBi1nOE&ref=blog.matthewbrunelle.com) cleared up the details. - Installation requires taking a cover off the bottom of the printer which was a little awkward to do. Each side of the Ender 3 v2 has something jutting out, so I couldn't just lean the printer over. - The kit includes three brackets, but I only need to use the one bracket that matched my printer. \[^ The rest are scrap metal I suppose.\] The stock firmware on the printer does not support the new hardware. Creality [offers](https://www.creality.com/download/ender-3-v2-3d-printer-csco?ref=blog.matthewbrunelle.com) a firmware that supports CR Touch, but the FOSS community has an even better solution. Mriscoc has a [custom firmware](https://github.com/mriscoc/Ender3V2S1?ref=blog.matthewbrunelle.com) for the Ender 3 V2 which has an extensive [feature list](https://github.com/mriscoc/Ender3V2S1/wiki/Firmware-features?ref=blog.matthewbrunelle.com). Make sure to grab the right file from the [release](https://github.com/mriscoc/Ender3V2S1/releases/tag/20250106?ref=blog.matthewbrunelle.com) page. For me this was the "Experimental-Ender3V2-422-BLTUBL-LA-MPC-20250106.bin" because: - `Ender3V2`: my printer model. - `422`: My board firmware was 4.2.2\. \[^They say you can peak through the vent to see the number, but I wasn't able do that.\] - `BLTUBL`: I have auto leveling instead of manual. The release notes break down the file name parts in more detail. Installation is as simple as moving the file to the micro SD card and updating the filename to the current date. Finally, there is a [calibration guide](https://github.com/mriscoc/Ender3V2S1/wiki/Calibration-Guides?ref=blog.matthewbrunelle.com#basic-calibrations). Do not do what I did and skim the guide. The only thing that can be made with impatience is more spaghetti. If you try to auto mesh without tramming first: ![auto_mesh_untrammed.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/auto_mesh_untrammed.jpg) You will get an uneven mesh. Where as if you properly tram first: ![auto-mesh-trammed.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/auto-mesh-trammed.jpg) You will get a much better auto mesh. I was initially confused that you need to both tram and level the bed. In the past I've often seen the terms used interchangeably, but I guess there is a [difference](https://3dinsider.com/bed-tramming/?ref=blog.matthewbrunelle.com) between the two. \[^This is the kind of pedantry I enjoy\]. --- ### Preparing the parts for printing The SmartSpin2k project has a couple of different [versions](https://github.com/doudar/SmartSpin2k/tree/develop/Hardware?ref=blog.matthewbrunelle.com) and [supports](https://docs.smartspin2k.com/compatibility.html?ref=blog.matthewbrunelle.com#supported-bikes) multiple bikes. So I needed to select the right set of parts to print. That meant starting with the [V3 version of the case](https://github.com/doudar/SmartSpin2k/tree/develop/Hardware/V3%20-%20Integrated%20PCB/Case?ref=blog.matthewbrunelle.com): - [Body\_Drawing\_Left.STEP](https://github.com/doudar/SmartSpin2k/blob/develop/Hardware/V3%20-%20Integrated%20PCB/Case/Body%5FDrawing%5FLeft.STEP?ref=blog.matthewbrunelle.com "Body_Drawing_Left.STEP") \[^ There is a no logo version of this part if you prefer.\] - [Body\_Drawing\_Right.STEP](https://github.com/doudar/SmartSpin2k/blob/develop/Hardware/V3%20-%20Integrated%20PCB/Case/Body%5FDrawing%5FRight.STEP?ref=blog.matthewbrunelle.com "Body_Drawing_Right.STEP"). - [Window.STL](https://github.com/doudar/SmartSpin2k/blob/develop/Hardware/V3%20-%20Integrated%20PCB/Case/Window.STL?ref=blog.matthewbrunelle.com). In addition to the v3 parts, I needed to pick the correct versions of the common \[^ Common in this case means shared between V2 and V3, and not common amoung the different bikes.\] assets: - [armWithHook85.STL](https://github.com/doudar/SmartSpin2k/blob/develop/Hardware/Common%20Assets/Arm/armWithHook85.STL?ref=blog.matthewbrunelle.com) for connecting the SmartSpin 2k to the bike mount. There is a [table](https://github.com/doudar/SmartSpin2k/blob/develop/Hardware/Common%20Assets/Arm/readme.md?ref=blog.matthewbrunelle.com) where you can look up the arm length that fits different bikes. - [IC4 Mount.stp](https://github.com/doudar/SmartSpin2k/blob/develop/Hardware/Common%20Assets/Bike%20Mount/IC4%20Mount.stp?ref=blog.matthewbrunelle.com "IC4 Mount.stp") which connects to the head tube on the bike. - Both the [Spur gear 1M 11T.STL](https://github.com/doudar/SmartSpin2k/blob/develop/Hardware/Common%20Assets/Gears/Spur%20gear%201M%2011T.STL?ref=blog.matthewbrunelle.com "Spur gear 1M 11T.STL") and [Spur gear 1M 40T Hex.STL](https://github.com/doudar/SmartSpin2k/blob/develop/Hardware/Common%20Assets/Gears/Spur%20gear%201M%2040T%20Hex.STL?ref=blog.matthewbrunelle.com "Spur gear 1M 40T Hex.STL") as internal gears. They note "If you'd like a metal shaft using a 5/16" bolt, print the 40T HEX, otherwise the normal 40T has a plastic shaft attached to the gear." - [Schwinn\_IC4.STL](https://github.com/doudar/SmartSpin2k/blob/develop/Hardware/Common%20Assets/Inserts/Schwinn%5FIC4.STL?ref=blog.matthewbrunelle.com "Schwinn_IC4.STL") insert, which is an adapter for the knob. - [Knob\_Cup\_Large\_HEX.STEP](https://github.com/doudar/SmartSpin2k/blob/develop/Hardware/Common%20Assets/KnobCups/Knob%5FCup%5FLarge%5FHEX.STEP?ref=blog.matthewbrunelle.com "Knob_Cup_Large_HEX.STEP") for connecting the bike specific insert to the SmartSpin2k - Optionally, [several parts](https://github.com/doudar/SmartSpin2k/tree/develop/Hardware/Common%20Assets/Shifters?ref=blog.matthewbrunelle.com) if you want shifter buttons. I technically do not need them since I will use structed workouts, but my kit included the parts so I will eventually print them. Note that some of these parts are in their original CAD format of `.step` . So I needed to convert them to `.stl`. \[^ There is an open issue for CURA to directly support .step files, but there is no traction yet: [https://github.com/Ultimaker/Cura/issues/15603](https://github.com/Ultimaker/Cura/issues/15603?ref=blog.matthewbrunelle.com). \]. FreeCAD can do the conversion and has [instructions](https://github.com/FreeCAD/FreeCAD-documentation/blob/main/wiki/Export%5Fto%5FSTL%5For%5FOBJ.md?ref=blog.matthewbrunelle.com#export-method-1-using-file--export) that are important to follow to get a good print. I went with 0.01 mm for my maximum mesh deviation. Once I had all the parts as step files, I just needed to slice them. There is [custom startup G code](https://github.com/mriscoc/Ender3V2S1/wiki/Slicer-G-code-Scripts?ref=blog.matthewbrunelle.com) to handle auto leveling that I had to set up in Cura. Aside from that I used all the default [settings for my printer](https://support.ultimaker.com/s/article/1667410775048?ref=blog.matthewbrunelle.com) and sliced in standard quality. \[^ Also, on NixOS, I used the `cura-appimage` and `freecad` packages without any issues.\] --- ### Actually printing the parts For filament, the instructions said to use ABS, ASA, PETG or PA and that the windows should be printed in translucent PETG so you can see the light. \[^ This is a nice guide for comparing the different filament types [https://www.xometry.com/resources/3d-printing/types-of-3d-printer-filaments/](https://www.xometry.com/resources/3d-printing/types-of-3d-printer-filaments/?ref=blog.matthewbrunelle.com).\] I only had PLA+ and was going to try that because PETG prints at a higher temperature. After talking to some friends they assured me I could do PETG safely on my Ender 3 v2 without burning the tubing. So I grabbed some PETG filament. Also, *please* make sure to update the filament type in the extruder settings in Cura: ![Screenshot_20251009_134559.png](https://blog.matthewbrunelle.com/content/images/2025/10/Screenshot_20251009_134559.png) This was my first time printing in a long while, so I started with [cleaning the printer bed](https://3dsolved.com/how-to-clean-a-3d-printer-bed-the-correct-way/?ref=blog.matthewbrunelle.com) since I had previously layered on a lot of hair spray. Then, after finally getting the calibration honed in, the left side printed without issues! The right side gave me more trouble though. I could not get a good adhesion. That was when I remembered another part of the process I had forgotten: ![Screenshot_20251009_104453.png](https://blog.matthewbrunelle.com/content/images/2025/10/Screenshot_20251009_104453.png) By default, Cura orients the part based on the .stl file. However, you can use the [rotation tool](https://support.makerbot.com/s/article/1667410781994?ref=blog.matthewbrunelle.com) to place the part with better contact on the printer bed: ![Screenshot_20251009_104738.png](https://blog.matthewbrunelle.com/content/images/2025/10/Screenshot_20251009_104738.png) ![IMG_20251009_073541_056_edited.jpg](https://blog.matthewbrunelle.com/content/images/2025/10/IMG_20251009_073541_056_edited.jpg) Here are the print times for all the pieces: | Part | Time | | ----------- | ---------------------------------------------------------------------------------------- | | Left Side | 7:27 | | Rigth Side | 7:55 | | Window | \[ ^ I forgot to check, and honestly do not have the constitution to print more parts.\] | | Arm | 0:47 | | Spur 11T | 0:17 | | Spur 40T | 1:00 | | IC4 Mount | 1:54 | | IC4 Adapter | 4:10 | | Knob Cup | 5:52 | --- ### Closing thoughts I really needed to refresh my memory on 3D printing for this project. The upside is I now have a checklist I can run through in the future: - Preparing Parts - \[ \] If needed, convert to .stl with FreeCad using Mesh View, - \[ \] Select correct material type for extruder. - \[ \] Rotate part for better contact with bed. - Preparing Printer - \[ \] If needed, clean bed. - \[ \] Spray bed with hairspray. - \[ \] Make sure printer is fully heated to the correct temperature for the material type. - \[ \] Tram bed. - \[ \] Build bed mesh. - \[ \] Home z-probe. Also, there are upgrades I could do to my printer, but I'm hesitant to do many more considering how old the platform is. The CR Touch seemed like a pretty good cost / benefit ratio. However, if I dump more money into more [upgrades](https://store.creality.com/blogs/all/ender-3-v2-upgrades?ref=blog.matthewbrunelle.com), I start to approach the price of a new printer. There was a time where starting with a cheaper printer and upgrading was the way to go, but I think printers have been commoditized enough that is no longer the case. A modern printer, in the same price range of when I bought my Ender 3 v2, would probably print 2 to 4 times faster. There are also whole bunch of functional improvements I'd get. At the same time I'd like to avoid creating a lot of waste. I think for now my main consideration is how much I will print in the future. After all, there's been a multi-year gap between when I got the printer and I'm using it now. Also, speaking of waste and considering all the ruined builds I had in the beginning of this project, I think I want to set up Octoprint next. You can build one using a RPi, thought [octo4a](https://github.com/feelfreelinux/octo4a?ref=blog.matthewbrunelle.com) lets you reuse an old Android phone! ### Review of HackerBox 0118 - More Human URL: https://blog.matthewbrunelle.com/review-of-hackerbox-0118-more-human/ Last updated: 2025-11-02T15:12:17.000Z #### On this page I want to tackle a couple electronics projects in the future and I need to shake the rust off. Rather than destroy some expensive parts trying to remember how to solder I thought I'd practice a bit first. Get some repetitions in. So I searched around to see if there were any monthly project kits and I found [HackerBoxs](https://hackerboxes.com/?ref=blog.matthewbrunelle.com). Their philosophy seems to match what I am looking for: > The goal is progress, not perfection. You’d better plan on messing some things up. We all do it. The more components that you burn up, short out, or otherwise mutilate, the more you are learning. The path to expertise in electronics is beset with smoking heaps of gear and dreadful tangles of spaghetti code and cables. If such frustrations might tempt you to take up hateful words against, or place blame upon, your humble kit vendor, perhaps a less demanding hobby should be on your horizon. The first subscription box that came in was "HackerBox 0118: More Human". The project is built around working with a Raspberry Pi Pico 2 W and some audio equipment. The instructions for each box are posted on [Instructables](https://www.instructables.com/HackerBox-0118-More-Human/?ref=blog.matthewbrunelle.com). I want to avoid just repeating the instruction, so instead this is a collection of my thoughts on the experience. --- ### Using a Raspberrry Pi Pico 2W as an Arduino Before any of the soldering there was an intro to the RPi. The instructions point you to go through most of this [Random Nerd Tutorial](https://randomnerdtutorials.com/raspberry-pi-pico-2-w-arduino-ide/?ref=blog.matthewbrunelle.com). The instructions talk about working with Arduinos, but the box supplied a Raspberry Pi. I wass confused, because I've worked with RPis before and they are not Arduinos. See: - [BirdNet-Go: But We Have Bird Monitoring At Home](https://blog.matthewbrunelle.com/birdnet-go-but-we-have-bird-monitoring-at-home/) - [Media Center Series Part 2: Wireguard with Docker on Raspberry Pi 4](https://blog.matthewbrunelle.com/media-center-series-part-2-wireguard-with-docker-on-raspberry-pi-4/) - [Media Center Series Part 3: Simpler Raspberry Pi 4](https://blog.matthewbrunelle.com/media-center-series-part-3-simpler-raspberry-pi-4/) That's what I thought at least. However, the [Arduino Pico](https://github.com/earlephilhower/arduino-pico?ref=blog.matthewbrunelle.com) project provides Arduino cores \[^ See [https://thelinuxcode.com/install-arduino-core/](https://thelinuxcode.com/install-arduino-core/?ref=blog.matthewbrunelle.com) for an explanation of what that means.\] for all the Pico boards. The guide they linked was pretty quick. The most useful part was how to configure the Arduino IDE to work with the 3rd party core. Other than that I learned how to put the board into bootloader mode and test out the example blink sketch. After that the instructable led me through running the ScanNetworks and HelloServer sketch examples. \[^ I also found a BLE scan example sketch, which is part of what I want to do in a future project.\] The kit had a USB-C to Micro USB adapter for the RPi. I appreciated they wanted everything included, but I already had several so that felt a bit wasteful. \[^ I understand though that packing different versions of the boxes would increase cost and complexity for them. Hopefully we will one day reach the point where everything is USB-C in the future.\] --- ### Some notes for using the Arduino IDE on NixOS There is the `arduino-ide` package in nixpkgs that you can use, as well as `arduino-cli`. \[^ I also saw a arduino-language-server, so I suppose you could use a different IDE if you wanted.\] Connecting to the board, verifying the sketches, and uploading them all worked without issue. I had trouble with debugging and serial communication though. Debugging would flash the error message: > OpenOCD: GDB Server Quit Unexpectedly. See gdb-server output for more details. The Arduino IDE would log: ``` Waiting for gdb server to start...[2025-09-09T17:42:38.931Z] SERVER CONSOLE DEBUG: onBackendConnect: gdb-server session connected. You can switch to "DEBUG CONSOLE" to see GDB interactions. /home/ciferkey/.arduino15/packages/rp2040/tools/pqt-openocd/4.1.0-1aec55e/bin/openocd -c "gdb_port 50000" -c "tcl_port 50001" -c "telnet_port 50002" -s /tmp/.arduinoIDE-unsaved202589-6015-1te8pwb.6ymo/ScanNetworks -f /nix/store/07imna35mjq5bjwp22rknx9a1amh5ayx-arduino-ide-2.3.6-extracted/resources/app/plugins/cortex-debug/extension/support/openocd-helpers.tcl -f /home/ciferkey/.arduino15/packages/rp2040/hardware/rp2040/5.0.0/lib/rp2350/picoprobe_cmsis_dap.tcl Open On-Chip Debugger 0.12.0+dev-gebec9504d-dirty (2025-05-30-21:52) Licensed under GNU GPL v2 For bug reports, read http://openocd.org/doc/doxygen/bugs.html CDRTOSConfigure Info : Hardware thread awareness created Info : Hardware thread awareness created Info : Hardware thread awareness created Info : Hardware thread awareness created cortex_m reset_config sysresetreq Info : Listening on port 50001 for tcl connections Info : Listening on port 50002 for telnet connections Error: unable to find a matching CMSIS-DAP device [2025-09-09T17:42:38.988Z] SERVER CONSOLE DEBUG: onBackendConnect: gdb-server session closed GDB server session ended. This terminal will be reused, waiting for next session to start... ``` I was not able to resolve this issue yet. I also had trouble connecting to the serial plotter until I added my user [to the dialout group](https://nixos.wiki/wiki/Serial%5FConsole?ref=blog.matthewbrunelle.com#Unprivileged%5Faccess%5Fto%5Fserial%5Fdevice). --- ### Finally some soldering Space is at a premium in my apartment, so I don't have a good work bench yet. In fact, up until recently all I had was a Pinecil v2 I bought years ago and never opened. \[^ I already had an Anker GaN charger that supplied enough power.\] Last week I went to Micro Center to get solder, wick, a brass sponge, wire cutters and a fume extractor. \[^ I already had safety glasses.\] With that I was able to sit down and start soldering. This kit was set up so that you added headers to the components and used jumpers to connect them. I appreciated that, since I could reuse the parts in the future for other projects. The soldering comprised of adding: - Two 20-pin headers on the Raspberry Pi Pico 2 W - One 4-pin header on a TRRS breakout module. - One 5-pin header on a PAM8302A speaker amp. - Strip and connect the speaker wires to the amp. - Two 3-pin headers on an I2S Microphone Sensor Module I was able to do most of them without issue. The instructable stated to "Insert the headers and hold the pins perpendicular to the module PCB" and "Solder the header pins into place". That felt janky, so I went searching and found this video about [soldering Header Pins to Your Raspberry Pi Pico](https://www.youtube.com/watch?v=R11QanPDccs&ref=blog.matthewbrunelle.com) which suggested using a breadboard to hold everything in place. That approach worked really well. The only board that gave me trouble was the microphone. For some reason the solder kept sticking to my Pinecil's tip rather than attaching to the board. Some internet searching suggested this could be from the iron's tip oxidizing, but I still had trouble even after cleaning the tip on the brass sponge. --- ### The pulse-width modulated audio experiment The first experiment was outputting sound. You connect the TRRS breakout board \[^ Also cool, sparkfun has a very similar board with a repo on github [https://github.com/sparkfun/TRRS\_3.5mm\_Jack\_Breakout/](https://github.com/sparkfun/TRRS%5F3.5mm%5FJack%5FBreakout/?ref=blog.matthewbrunelle.com) under Creative Commons!\] to the Pico and plug in headphones. \[^ This was another part that makes sense to include, but felt wasteful since I already had ones I could use.\] Alternatively, you could connect the amp/speaker combto. Then you loaded the PWMAudio/PlayRaw.ino example sketch. \[^ You can see that sketch [here](https://github.com/earlephilhower/arduino-pico/blob/master/libraries/PWMAudio/examples/PlayRaw/PlayRaw.ino?ref=blog.matthewbrunelle.com).\] Shame on me for not reading the sketch first before running, because I blasted my ears with the Macarena. ![IMG_20250925_185029_139.jpg](https://blog.matthewbrunelle.com/content/images/2025/09/IMG_20250925_185029_139.jpg) While the instructions told you what to do, they didn't really talk about why you did anything. For example, they don't say why we wanted to use the tip and ring1 pins on the breakout board. \[^ There was a link with the text "TRRS" but the anchor had no source.\] The wikipedia page for [TRRS](https://en.wikipedia.org/wiki/Phone%5Fconnector%5F%28audio%29?ref=blog.matthewbrunelle.com#TRRS%5Fstandards) gave a good explanation though: "Tip, Ring, Ring, Sleeve" is for a stereo headset with mono microphone. Mentioning that: > Two different forms are frequently found. Both place left audio on the tip and right audio on the first ring, same as stereo connectors. They differ in the placement of the microphone and return contacts. Finally the instructions mention you are using pulse width modulated audio, but doesn't define that. I found this [video](https://www.youtube.com/watch?v=GQLED3gmONg&ref=blog.matthewbrunelle.com) explains PWA clearly. --- ### The audio sampling experiment The next experiment was recording sound from the microphone. They link to the [data sheet](https://invensense.tdk.com/wp-content/uploads/2015/02/INMP441.pdf?ref=blog.matthewbrunelle.com) for the board, but say nothing else. I found this great video on [how to read a datasheet](https://www.youtube.com/watch?v=9r8JMbHc-xQ&ref=blog.matthewbrunelle.com). There are also lots of terms in the section that don't have definitions. The board communicates of I2S, and the instructables says that: > allows the INMP441 to connect directly to digital processors, such as DSPs and microcontrollers, without the need for an audio codec in the system. I've recently started reading The Hardware Hacking Handbook \[^ Which is available DRM free: [https://nostarch.com/hardwarehacking](https://nostarch.com/hardwarehacking?ref=blog.matthewbrunelle.com)\], but paused after realizing I needed to learn some basics first. I remember the book mentioning I2S interfaces, and I wanted to know more. The first part of [this video](https://youtu.be/m-MPBjScNRk?t=87&ref=blog.matthewbrunelle.com) provides a good introduction to how the protocol works. For the first part of the experiment, you load the I2S/I2SInput.ino example sketch \[^ You can see that sketch here [https://github.com/earlephilhower/arduino-pico/blob/master/libraries/I2S/examples/I2SInput/I2SInput.ino](https://github.com/earlephilhower/arduino-pico/blob/master/libraries/I2S/examples/I2SInput/I2SInput.ino?ref=blog.matthewbrunelle.com).\] and use the serial plotter in the IDE to see the audio. ![Screenshot_20250929_132134.png](https://blog.matthewbrunelle.com/content/images/2025/09/Screenshot_20250929_132134.png) Next, you reconnect one of the audio output devices. Then you use a echo.ino sketch they provide which plays back the input from the mic. I got a pretty garbled audio output when I ran this. I'm not sure if that's a limitation of the hardware or not. ![IMG_20250925_191225_723.jpg](https://blog.matthewbrunelle.com/content/images/2025/09/IMG_20250925_191225_723.jpg) The kit also included a USB-C to 3.5mm DAC which you do not use in the projects. Perhaps they included it so you can use the headphones on modern devices? --- ### The Edge AI model and GenAI experiments I guess this is where the "More Human" part comes in. First, the instructable talks a bit about what edge models are. Then, they show you a project on Edge Impulse for audio keyword detection. If you sign up for a free account you can make a build for the Pico 2 W and try [the project](https://studio.edgeimpulse.com/public/500163/latest?ref=blog.matthewbrunelle.com) out. Finally they provide a list of resources for further reading. This part was interesting since you can see the pipeline from start to finish. Finally, they give a short summary of GenAI. There is no project component for this section. To be honest, this section felt a little tacked on. --- ### Final thoughts, followups, and the next box. The box fulfilled my goal to practice and learn about electronics, albeit in an expensive way. Two things I would like to try are: - practice rewriting some sketches in python using [MicroPython](https://www.raspberrypi.com/documentation/microcontrollers/micropython.html?ref=blog.matthewbrunelle.com). - practice rewriting some sketches in rust using [Embassy](https://embassy.dev/?ref=blog.matthewbrunelle.com) After this box I was actually going to cancel the subscription, but I was not quick enough. However, the [next box](https://hackerboxes.com/collections/past-hackerboxes/products/hackerbox-0119-geopositioning?ref=blog.matthewbrunelle.com) that's on the way right now looks to be related to GPS, which is exactly what I want to work with in a future project. So I guess that all worked out for me. I'll try to review that box too. ### Pocket Casts, You Altered The Deal, So I Will Alter Your App URL: https://blog.matthewbrunelle.com/podcasts-you-altered-the-deal-so-i-will-alter-your-app/ Last updated: 2025-09-28T14:52:29.000Z #### On this page ### Update: This post ended up on Hacker News. The CEO of Automattic commented saying that showing ads to champion users was a bug. I have archived the repository, since the intent was to enable grandfathered in users to opt out of new ads. --- ### History so far Pocket Casts, people are not upset you are trying to find revenue sources. People are upset you are reneging on your promise. You were a pay-once app. Released in 2011, pay once each for Android, iOS, and Web and keep for life. In 2018 NPR [acquired you](https://www.npr.org/about-npr/607823388/pocket-cast-acquired?ref=blog.matthewbrunelle.com). Then in 2019 you moved to a subscription for all users, including the paid ones. People were upset so you backtracked and [grandfathered them into](https://blog.pocketcasts.com/2019/09/18/major-new-update/?ref=blog.matthewbrunelle.com) a lifetime plus membership. Sadly, in 2020 NPR lost [$800k from running you](https://current.org/2021/01/public-media-owners-agree-to-sell-pocket-casts-podcast-platform/?ref=blog.matthewbrunelle.com) so you were [sold to Automattic](https://wordpress.com/blog/2021/07/16/popular-podcast-app-pocket-casts-joins-automattic/?ref=blog.matthewbrunelle.com). In 2024, you renamed all "Pocket Casts Lifetime Members" to ["Pocket Casts Champions"](https://support.pocketcasts.com/knowledge-base/lifetime-access-to-pocket-casts-plus/?ref=blog.matthewbrunelle.com) and we knew shenanigans were afoot. This week you flipped the switch to turn on ads in the app: ![1000002462.png](https://blog.matthewbrunelle.com/content/images/2025/09/1000002462.png) However, in 2022 you [made your mobile apps open source](https://blog.pocketcasts.com/2022/10/19/pocket-casts-mobile-apps-are-now-open-source/?ref=blog.matthewbrunelle.com), so there is something that can be done: I [forked](https://github.com/ciferkey/pocket-casts-android?ref=blog.matthewbrunelle.com) the repo. --- ### Exorcising the ads In the app you may be able to see the new nag that says: "Unlock folders, bookmarks, transcripts and more with Pocket Casts Plus" just above the artwork in the player screen. I don't see that exact string in the [GitHub repo](github.com/Automattic/pocket-casts-android), I think that's because of internationalization. However, I found where the [artwork view fragment is](https://github.com/Automattic/pocket-casts-android/blob/f17d2a0408dd299684eb4afa11fb07f4df1fc59e/modules/features/player/src/main/java/au/com/shiftyjelly/pocketcasts/player/view/PlayerHeaderFragment.kt?ref=blog.matthewbrunelle.com#L99) and that it contains `AdAndArtworkHorizontal`. Looking at the changelog I can see that this was added in [Display ads in the player #4086](https://github.com/Automattic/pocket-casts-android/pull/4086?ref=blog.matthewbrunelle.com) which created the flag `Feature.BANNER_ADS`. In the project, a feature flag has the following details: ```kotlin enum class Feature( val key: String, val title: String, val defaultValue: Boolean, val tier: FeatureTier, val hasFirebaseRemoteFlag: Boolean, val hasDevToggle: Boolean, ) ``` With some further updates to the project, the flag is now [broken in two](https://github.com/Automattic/pocket-casts-android/blob/f17d2a0408dd299684eb4afa11fb07f4df1fc59e/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt?ref=blog.matthewbrunelle.com#L125-L140): ```kotlin BANNER_ADS_PLAYER( key = "banner_ad_player", title = "Banner Ads Player", defaultValue = true, tier = FeatureTier.Free, hasFirebaseRemoteFlag = true, hasDevToggle = true, ), BANNER_ADS_PODCASTS( key = "banner_ad_podcasts", title = "Banner Ads Podcasts", defaultValue = true, tier = FeatureTier.Free, hasFirebaseRemoteFlag = true, hasDevToggle = true, ), ``` The flags are ultimately used by [BlazeAdLocation](https://github.com/Automattic/pocket-casts-android/blob/f17d2a0408dd299684eb4afa11fb07f4df1fc59e/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/type/BlazeAdLocation.kt?ref=blog.matthewbrunelle.com#L11). There are a couple of different ways we could slice out the ads. For testing, I went with the simplest approach to start: - Flip `defaultValue` to false. - Flip `hasFirebaseRemoteFlag` to false so the flag will not be remotely overridden. Anyway, based on the readme I just needed to`./gradlew :app:assembleDebugProd` and `./gradlew :app:installDebugProd`. This will install a different debug version of the app: ![1000002460.png](https://blog.matthewbrunelle.com/content/images/2025/09/1000002460.png) Voila, ads gone. Though in the future this process may be tougher because of Google's [impending crackdown on loading APKs](https://android-developers.googleblog.com/2025/08/elevating-android-security.html?ref=blog.matthewbrunelle.com). Now, after doing all that work, guess what I realized? `hasDevToggle` field in the Features class? Well the debug build has a feature toggles section: ![1000002458.png](https://blog.matthewbrunelle.com/content/images/2025/09/1000002458.png) So for now you do not need to modify the project's code. You can get by with just using the debug build and toggling the feature off. Feature flags only exist for testing features though. Eventually the flag will go away and I'll have to disable the ads more directly. --- ### Why did we end up here? Pocket Casts, your current approach to monetization seems extreme. Are you in a similar situation to 2020? Back then the NPR [financial report](https://media.npr.org/documents/about/statements/fy2020/National%20Public%20Radio%20-%20Consolidated%20Financial%20Statements%202020.pdf?ref=blog.matthewbrunelle.com) only had a single line item of $812,129 for "Minus: Share of Podcast Media’s net loss". Why does a podcast service cost so much to run? If you give us more transparency, maybe we will better understand why you need to make changes. Maybe more of the work can be moved on-device. You open-sourced your app. Maybe the community can help you out and contribute those changes? We're not too scary. You extended an olive branch by open-sourcing the project, so let's work together. Hopefully Automattic doesn't pull back on contributions like they [did with WordPress this year](https://automattic.com/2025/01/09/aligning-automattics-sponsored-contributions-to-wordpress/?ref=blog.matthewbrunelle.com). --- ### An entirely different option As I mentioned in my post about [the apps I kept when switching to GrapheneOS](https://blog.matthewbrunelle.com/the-apps-that-i-kept-on-grapheneos-2/#the-media-apps), self-hosting PinePods and using AntennaPod on your phone is a great option! That way you can still have web and desktop sync. I also said in that post: > If I wasn't grandfathered into the free premium tier I would be more tempted to jump ship. The time to jump ship seems to be getting closer. I think Pocket Casts will join a future version of my [apps that I got rid of when switching to GrapheneOS](https://blog.matthewbrunelle.com/the-apps-that-i-got-rid-of-when-trying-out-grapheneos/) post. Oh, and if anyone is looking for new podcasts to listen to, [I have recommendations](https://blog.matthewbrunelle.com/what-podcasts-am-i-listening-to-2025/). ### Learning About Municipal Open Data with a Cambridge Workshop URL: https://blog.matthewbrunelle.com/learning-about-municipal-open-data-with-a-cambridge-workshop/ Last updated: 2025-09-16T23:43:33.000Z A couple months ago I wanted to find parcel datasets for land ownership in Arlington, MA. I found the janky [Property Assessment Data form](https://arlington.patriotproperties.com/default.asp?ref=blog.matthewbrunelle.com), but you can only search for one property at a time. In the process of looking for other sources I found [Open Data for Arlington, MA](https://arlingtonma.info/?ref=blog.matthewbrunelle.com) which notes: > This website is not affiliated with town government in Arlington, MA, and is run by volunteers. We are collaborating with the town to obtain relevant data sets and increase the accessibility of information about local government to facilitate greater citizen engagement. Our long-term hope is that the town will capitalize on this work to build an official open data portal for the future, similar to Cambridge or Somerville. The project is up [on Github](https://github.com/ArlingtonMA/arlingtonma.info?ref=blog.matthewbrunelle.com), and really cool. A bit of a shame though the town doesn't have an official offering. The mention of other towns got me looking though. I found out that the City of Cambridge was offering an [Open Data Workshop](https://www.cambridgema.gov/citycalendar/view.aspx?guid=691ee817b074400a941f2d080841da42&ref=blog.matthewbrunelle.com), hosted in the wonderful Cambridge Public Library. \[^ Public services like libraries are hugely important! Not only did they host the event, but also the library had laptops people could check out to use for the workshop.\] The workshop was designed to help equip a non-technical audience with the skills needed to start working with the data assets the city has opened up. Alex Epstein & Reinhard Engels, both former librarians, ran the session. To paraphrase them: "something about librarians makes them like collecting, categorizing and sharing data." --- ### On Open vs Public Data The presenters did a great job summarizing the difference between public and open data. The idea is that government records should be free and accessible to everyone. Public Data might technically be available, but it could be stored on paper in the basement of some building. Open Data is about making the information as usable as possible. I wanted to know more about this and found [A Guide to the Massachusetts Public Records Law ](https://www.sec.state.ma.us/divisions/public-records/download/guide.pdf?ref=blog.matthewbrunelle.com). Some takeaways I learned from the document: > Every government record in Massachusetts is presumed to be public unless it may be withheld under a specifically stated exemption. > ... each person has a right of access to public information. This right of access includes the right to inspect, copy or have a copy of records provided upon the payment of a reasonable fee, if any. > The Public Records Law broadly defines “public records” to include “all books, papers, maps, photographs, recorded tapes, financial statements, statistical tabulations, or other documentary materials or data, regardless of physical form or characteristics, made or received by any officer or employee” of any Massachusetts governmental entity. > There are strictly and narrowly construed exemptions and common law privileges to the broad definition of “public records.” So by the letter of the law you should be able to access these records, but often the process is not easy. Open data aims to reduce the types of friction you can imagine with access: - Using systems so the data can be provided without cost - Automatically making data assets and updates available. That way you do not have to make requests and wait on receiving the information. - Providing the information in commonly used, standard formats --- ### Working with the property dataset In the workshop we accessed the data assets through the Cambridge [Open Data Portal](https://data.cambridgema.gov/browse?ref=blog.matthewbrunelle.com). The presenters mentioned the portal uses the same vendor that cities like NYC and Chicago use. I'll come back to this topic in a bit. We used the site's interface to work with the data and I think that was the best approach for the wide variety of people that were in the audience. They noted that if you were inclined you could grab the assets as CSVs to work with in a spreadsheet, or you could programmatically access them through an API. One asset we looked at is the [Cambridge Property Dataset](https://data.cambridgema.gov/Assessing/Cambridge-Property-Database-FY2016-FY2025/eey2-rv59?ref=blog.matthewbrunelle.com). Each property is categorized by land use. They are encoded in esoteric abbreviations, but after the session I was able to find a [table of land use categories](https://www.cambridgema.gov/GIS/gisdatadictionary/CDD/CDD%5FLandUse?ref=blog.matthewbrunelle.com). One demo from the class was to: - filter for assessment year of 2025 - filter for `SNGL-FAM-RES` to find single family residential properties - sort by assessed value, descending These are the top rows: | Address | Land Area | Assessed Value | Sale Price | Sale Date | Owner Name | | ------------------ | --------- | -------------- | -------------- | ---------- | ------------------------------------------------- | | 51-61 Highland St | 77,261 | $20,142,300.00 | $0.00 | 07/20/2015 | NOWISZEWSKI, DANIEL & ANNETTE NOWISZEWSKI TRUSTEE | | 168R Brattle St | 71,531 | $18,532,700.00 | $0.00 | 11/07/2006 | FIRST NATIONAL BANK OF BOSTON, TR. | | 157 Brattle St | 17,901 | $16,582,000.00 | $0.00 | 03/15/2021 | QUAGLIAROLI, JAMES J. & KIMBERLY A. HENRY TR. | | 88 Appleton St | 29,649 | $15,208,600.00 | $1.00 | 01/31/2018 | RIVIERE, MATTHIEU R. | | 12 Lakeview Ave | 30,236 | $15,152,500.00 | $15,100,000.00 | 10/31/2018 | CASE, JEFFERSON M. | | 153 Brattle St | 22,505 | $14,904,200.00 | $10,600,000.00 | 07/12/2011 | GINER, A. SILVANA, TRUSTEE OF | | 1 Reservoir St | 30,806 | $14,797,800.00 | $1.00 | 03/19/2020 | DONOHUE, ROBERT TRUSTEE | | 12 Hubbard Park Rd | 14,217 | $14,428,500.00 | $15,000,000.00 | 01/25/2019 | PALANDJIAN, PETER, TRUSTEE | | 163 Brattle St | 26,841 | $14,348,400.00 | $1.00 | 08/05/2008 | MANUS, DEBORAH J. TRUSTEE OF | | 89 Appleton St | 36,412 | $13,388,000.00 | $1.00 | 08/27/2021 | GROSS-LOH DAVID B | | 1 Highland St | 17,856 | $13,075,400.00 | $5,750,000.00 | 09/30/2005 | HIGGINS, ROBERT F. | | 71 Appleton St | 27,712 | $13,029,300.00 | $10.00 | 05/13/2016 | 71 APPLETON LLC | | 70 Sparks St | 31,703 | $12,812,000.00 | $100.00 | 09/05/2018 | MASON, GEOFFREY M., TRUSTEE | Two interesting details the presenters noted about these rows: - You can see the owners name. People are often surprised by that, but the info is on the records and there is no exception in the law to remove them. - Many of the houses have a sale price of $0 or small nominal amount. This is an example of generational wealth: people inheriting the property from their parents. Another fun detail from exploring the dataset is just much much property MIT and Harvard own in Cambridge. In fact if you look for the highest assessed value across all property types you'll find 1341 Mass Ave, priced at $1,604,103,500.00, which is Harvard Yard. Another quirk of the dataset is each row is *per building* so there are actually many entries Harvard Yard since they are all assessed as a block. All in all I really enjoyed these examples since we used a similar dataset to what I was originally looking for in Arlington. --- ### GenAI data exploration demo For all the problems that come with generative AI, in some use cases the tools can be democratizing. The presenters demonstrated how you could attach a CSV of a dataset to a chat and do some data exploration. Most people are not equipped to make visualization or explore trends with datasets and chat tools can help. I also really appreciated how the presenters were *very clear* about the limitations of chat tools and warned to be critical when reviewing their output, noting "this is the fun part of the demo because every month it is different and they always get something wrong". They used an example prompt like "Can you tell me something interesting about this dataset? Any trends, surprises? Please use charts and tables to help me understand." and walked through the output of the model. Taking time to detail what the reasoning section was and noting that "every time we've done one of these there is an inconsistency between the story and the graphs". I think they really struck a nice balance in this section. --- ### The irony of closed monopoly platforms for open data access. Back to the vendor they use. There was an audience member that wanted to know how long of a contract that city had with the vendor. They wanted to integrate with the API, but were worried that if the city changed vendors they would have to change everything they built. The presenters noted that there is not a very lucrative market for this type of product. There are only three contenders, all of which are not great. The pain of switching is very high and until someone makes something better than their current platform, the city is unlikely they would change vendors. During the workshop I had also been trying to better understand who was the vendor. In the portal I found a [developers link](https://dev.socrata.com/?ref=blog.matthewbrunelle.com) to a page on the Socrata website which said: > Socrata was acquired by Tyler Technologies in 2018 and is now the Data and Insights division of Tyler. The platform is still powered by the same software formerly known as Socrata but you will see references to Data & Insights going forward. Per a [Bloomberg](https://archive.ph/gIVH1?ref=blog.matthewbrunelle.com) describing Tyler Technologies as "a libertarian’s brain worm" and a near monopoly on certain types of municipal software. Also that they are [facing](https://www.injusticewatch.org/judges/court-administration/2025/costs-triple-delays-mount-in-tyler-technologies-contracts/?ref=blog.matthewbrunelle.com) multiple lawsuits across the county. Also remember that janky form I found for Arlington? Patriot Properties, Inc created the form though they have since [consolidated](https://catalisgov.com/patriot-LP/?ref=blog.matthewbrunelle.com) into Catalis which was formed through [30 acquisitions](https://www.govtech.com/biz/after-30-acquisitions-government-brands-rebrands-as-catalis?ref=blog.matthewbrunelle.com) of other companies. What really stuck me after reading all of this is how [familiar](https://en.wikipedia.org/wiki/Enshittification?ref=blog.matthewbrunelle.com) the scenario is. There is no sane top down policy to help municipalities handle their data set access. So small private companies stepped into try and create solutions. The market is not profitable so they get rolled up into large companies that leverage their captive audiences to extract as much rent as possible. There are nice projects like non-profit [Code for America](https://codeforamerica.org/?ref=blog.matthewbrunelle.com) aiming to provide civic hacking. \[^ I see they have an event at the end of the month that I'll try and attend: [https://formfest.org/](https://formfest.org/?ref=blog.matthewbrunelle.com) \] I really appreciate them, however I feel like things are broken on a fundamental level: the systems towns use will always be patchwork at best if we never collectively decide to do something better. I haven't had a chance to do this yet, but I've been working to recontextualize what I see in life based on how other countries approach situations. I will often realize that something I assumed was normal is actually *distinctly* American and we can learn a lot from other. --- ### Who decides what data is in the portal? A note on this section: this is based on the conversations between the audience and the presenters. I've cleaned up the dialog into paragraphs, but I don't want to misrepresent this as purely my own thoughts. \[^ I wish I was as eloquent as the speakers.\] When an audience member asked how Cambridge stacks up to other towns the presenters mentioned that while they are not the bleeding edge, they are definitely better than most and they work hard to make new datasets available. However they are not the only team that make the decisions. Other people help clarify what data doesn't go up. Legal, privacy issues, and PII can all prevent records from public access. At the same time the law goes both ways, as we saw with the fact that assessment data with PII *has* to be public by law. Ultimately individual departments are also part of making the decision. There is also a hesitance by some departments that don't want to publish data that makes them look bad. That is understandable from their perspective, but that's not for the greater good. Transparency helps those departments get better. As an example, there was an internal 311 database for tracking if they meet their SLAs. The department set a goal for 80% of the time. While they started off very far off from their goal, after years of getting closer they felt good enough to make the dataset public. Another issue is that if you go far enough back, you get to paper records. While the town has some digitization projects like the GIS department scanning in old maps, those take time and money. They will not reasonably be able to process all the records in storage. The main takeaway for me is that we as citizens can really help out here. The open data team truly strives to make new datasets available, but they can only make requests so many times before other departments stop listening. One thing that we can do ourselves, is to continually to ask departments to release their datasets: > We would love for you to all clamor to the different departments. That makes a difference and they have already heard from us enough". --- ### Other thoughts I think the presenters did a really good job explaining this to true non-technical citizens. That was *super* cool. Also that fact that so many people turned out to fill the workshop was inspiring to see. If you want to learn about the content of the workshop, the [slides](https://www.cambridgema.gov/-/media/Files/informationtechnologydepartment/opendata/trainings/2025/opendataportalpublictraining202509.pdf?ref=blog.matthewbrunelle.com) are available on the website. If you want to do a workshop or learn more, the team has a [newsletter](https://lp.constantcontactpages.com/sl/aiGD8PJ?ref=blog.matthewbrunelle.com). ### Fond memories of a high school personal project - cross country race analysis URL: https://blog.matthewbrunelle.com/fond-memories-of-a-high-school-personal-project-cross-country-race-analysis/ Last updated: 2025-09-09T11:54:38.000Z The project was equal parts fun and cursed. Also wildly important for getting me into software. #### On this page I have been drafting this post for a long time, but kept putting off publishing. Lobsters is [running](https://lobste.rs/s/0nstyk/join%5Flobsters%5Fblog%5Fcarnival?ref=blog.matthewbrunelle.com) a [Blog Carnival](https://indieweb.org/blog%5Fcarnival?ref=blog.matthewbrunelle.com), which felt like a good opportunity to set a deadline that I would have to commit to! Back in high school I turned my personal project into my senior project in order to force me to work on it. In that same spirit I'm making my blog carnival entry so I actually write the post. The theme for the carnival is: > What have you made for yourself? > What is your least favorite/the most “cursed” part about it? --- ### What was the project See, I ran cross country and I had a really dedicated coach. \[^ They recently named a trail after him because he is so awesome [https://www.independentri.com/sports/article\_55eb91f9-283c-4e1e-8abd-1ea38b73305e.html](https://www.independentri.com/sports/article%5F55eb91f9-283c-4e1e-8abd-1ea38b73305e.html?ref=blog.matthewbrunelle.com)\] He used Jack Daniels recommendations from *The Running Formula* to build training plans for us. That involved using our race results to calculate our VDOTs. Those were then used to set our training intensities. Circa 2011 I don't think many high school coaches did that and I really appreciate that he did. One day I realized that all of the race results were posted to the Rhode Island Track and Field Association website. My machinations were as follows: - Somehow get a copy of every race result on the RITCA site. - Somehow pull all the information from the race results. - Somehow analyze all the race results. - Somehow display the analysis. This was the first real project I ever set out on. Prior to this I had only done toy programs and practice problems from books. Part of that was because I was trying to learn C++ and mostly just spent my time battling the linker. Cue [#353](https://xkcd.com/353/?ref=blog.matthewbrunelle.com), I tried out Python and "everything is so simple". One lovely side effect of this project was finding a language I like and continue to use 14 years later. However, that also meant that I was both learning a new language and how to write software in the large at the same time. Truly building the plane while I was flying. I bought a copy of *Dive into Python* and installed Python on Windows. This was 2011, so all my Python code was 2.7.2\. Python 3 existed, but Django still on the [Wall of Shame](https://web.archive.org/web/20111202083204/https://python3wos.appspot.com/). I wrote everything using IDLE, the default IDE installed with Python, because I didn't even think to look for anything else. \[^ Vim didn't enter my life until college. Now a days I'm old, grumpy, and use PyCharm.\] Honestly even then "programming was fun again". --- ### I made a crawler My first goal was to "somehow get a copy of every race result on the RITCA site". I found out about the concept of a crawler and tried to make one. On the website there was a top-level [page](https://www.ritca.com/results-all/?ref=blog.matthewbrunelle.com) with a table: | Year | | | Event | Season | Type | Gender | | Teams | | Download Results | | ---------------------------------------------------------------------------------------------------------------------------------------------- | - | -- | -------------- | --------- | ------------- | ------ | | ----- | -- | ---------------- | | 2011 | 9 | 10 | Marshall Race | 2011-2012 | Cross-Country | Boys | | | no | Results | | 2011 | 9 | 10 | Lowery Race | 2011-2012 | Cross-Country | Boys | | | no | Results | | 2011 | 9 | 10 | Platt III Race | 2011-2012 | Cross-Country | Boys | | | no | Results | | 2011 | 9 | 10 | Lawton Race | 2011-2012 | Cross-Country | Boys | | | no | Results | | 2011 | 9 | 10 | Boys Freshmen | 2011-2012 | Cross-Country | Boys | | | no | Results | | 2011 | 9 | 10 | Roy Race | 2011-2012 | Cross-Country | Girls | | | no | Results | | 2011 | 9 | 10 | Hindley Race | 2011-2012 | Cross-Country | Girls | | | no | Results | | \[^ Interestingly enough the website's design is essentially unchanged since I visited in 2011\. Also, they stopped posting results in 2015.\] | | | | | | | | | | | Each row linked to a [race result](https://www.ritca.com/wp-content/uploads/2016/01/2009-11-08.State-Championship-1.txt?ref=blog.matthewbrunelle.com). There was no pagination for the table which meant I only had to fetch a single page. I knew nothing about frameworks for crawling. I would later learn about Scrapy when I did my internship project. There was a time where you could only have a limited number of private repos on GitHub for free. I eventually removed this project off of GitHub and "backed it up". \[^ Ironically I specifically learned git for this project.\] The project code is of course lost now. However, my senior project binder has printouts of an older version of the code! I made some scans for this post and I was originally going to OCR the text and clean it up, but... attention span. Anyways, the crawler: ![spider.png](https://blog.matthewbrunelle.com/content/images/2025/09/spider.png) The cursed pieces of this 14 year old code by a self taught high school student: - In Python 2.7 [fstrings](https://fstring.help/cheat/?ref=blog.matthewbrunelle.com) did not exist yet, but we did have `.format`. I did not know that and concatenated strings instead. - Requests 0.0.1 was only just [released](https://kennethreitz.org/essays/2011-01-major%5Fprogress%5Ffor%5Frequests?ref=blog.matthewbrunelle.com) at the beginning of 2011, so I used urllib instead. I also mixed usage of urllib 1 and 2. - I used [openanything](https://github.com/4get/diveintopython%5Fexamples/blob/master/openanything.py?ref=blog.matthewbrunelle.com) from the Book *Dive Into Python*. [Beautiful Soup](https://beautiful-soup-4.readthedocs.io/en/latest/?ref=blog.matthewbrunelle.com) existed, but I didn't know. So I taught myself regex and used those to extract the URLs. - In Python 2 strings were not unicode by default, so I had to handle unicode conversion. - Not shown here, but thankfully added later was checking whether I had already downloaded a race result. That way I wouldn't repeatedly hammer the website. All together the crawler was pretty simple. I later took an information retrieval class and UMass and realized how deep the rabbit hole goes. For now though, I had my race results downloaded. --- ### I made a race results "parser" My second goals was to "somehow pull all the information from the race results". I didn't know what a parser was at the time, but if it tokenizes like a parser, its a parser. Some race results were a simple `.txt` file like this: ``` Rhode Island Interscholastic LeagueGER 2:05 PM 11/8/2009 Page 1 RI Cross Country State Championship - 11/8/2009 Ponaganset High School Rankings Event 1 Girls 5k Run CC ======================================================================= Name Year School Finals Points ======================================================================= 1 #63 Molly Keating SO La Salle Acad 18:28.01 1 2 #4 Abigail Jones JR Barrington 18:54.38 2 3 #37 Arianna Colella Cranston West 19:09.34 3 4 #5 Molly Malinowski JR Barrington 19:17.45 4 5 #2 Sarah Barry SR Barrington 19:20.35 5 ``` Except for the fact that there was several race tracking software out there so the format varied. The common pieces the results usually had were: - Header information - Dividers, often formatted differently - Rows for each runner The headers usually provided: - A date which could be formatted in multiple different ways. - A race name like "RI Cross Country State Championship" - An "event" because the software was made to handle track and field which had multiple events. - *Sometimes* there would be the distance of the race. A row typically had: - The finishing place. - *Sometimes* a different number that was the athletes team number. *Sometimes* that was preceded by a "#" - Their grade usually as "FR", "SO", "JR" or "SR", but *sometimes* fully spelled out. - *Sometimes* you got "M", "MJR", "MSO", "W", "WJR" and "WSO" because of course. - *Sometimes* only some of the athletes had grades and some had just spaces. - The team the athlete was on. - *Sometimes* these would be shortened which mean that you would have different strings for the same team across different race results - Their First and Last Name. *Sometimes* names were "Last, First" - Their finishing time. - Optionally, the average mile time. Which of course was hard to disambiguate from the finish time. - Optionally, *sometimes* the points they scored for their team. Oh and the order for these columns was *sometimes* inconsistent. Results would also *sometimes* have team rankings: ``` ================================================================================= Rank Team Total 1 2 3 4 5 *6 *7 *8 *9 ================================================================================= 1 Burrillville 40 2 3 8 12 15 19 23 Total Time: 1:44:15.00 Average: 20:51.00 2 Cumberland 53 4 7 11 14 17 20 22 Total Time: 1:46:46.00 Average: 21:21.20 3 Pilgrim 58 1 6 9 18 24 25 Total Time: 1:49:30.00 Average: 21:54.00 4 Mt. St. Charles 65 5 10 13 16 21 Total Time: 1:48:33.00 Average: 21:42.60 ``` These would *sometimes* randomly appear at *either* the top or the bottom of the results file. *sometimes, sometimes, sometimes* As you can imagine this was all immensely cursed to handle. The parsing was extra cursed because of the existence of [PDFs](https://blog.codinghorror.com/the-trouble-with-pdfs/?ref=blog.matthewbrunelle.com). Some race results came as PDFs and often those PDFs would have a two column format like this: ![Screenshot_20250904_164839.png](https://blog.matthewbrunelle.com/content/images/2025/09/Screenshot_20250904_164839.png) I remember that I converted the PDFs to txt first before processing. I do not remember the specifics, but I do know that the output formatting left a lot to be desired. Anyways, when all you have is a ~~hammer~~ regex, everything is a string: ![santize.png](https://blog.matthewbrunelle.com/content/images/2025/09/santize.png) Some thoughts: - This is an early version of the code. I doesn't cover all the edge cases I eventually got to. You can image the extra layers of nested conditionals that were required. - One thing that would have really made this more manageable are unit/regression tests. I could take a file that demonstrates each edge case and write a test. Of course I did not know about testing at this point. - No type annotations makes this a little harder to read. For example what type is the `source` param? Probably just a string, but who knows what weird stuff I did. - Figuring out what is the first runner row after the header was tricky. ![sanitize2.png](https://blog.matthewbrunelle.com/content/images/2025/09/sanitize2.png) More thoughts: - You can see the point where my brain broke and I began using alpha, beta and gamma as metasyntactic variables. The rest of the pipeline code handled CRUD operations for loading everything into the DB. This was also cursed because I never learned the fundamentals of relational DBs as a kid... so I used MongoDB. I though SQL was hard! Despite choosing to use a non-relational DB, I seemed to have drawn an entity-relationship model without knowing what one was: ![db.png](https://blog.matthewbrunelle.com/content/images/2025/09/db.png) With the pipeline all done I now had a dataset I could work with. --- ### On the power of asking for help Now I needed to "somehow analyze all the race results". As a starting point I recalled that my coach used VDOTs to help us with training. VDOT is a Jack Daniels *Running Method* specific metric. He use the race times and known VO2Max of runners to create regressions. Then he published a table in *Oxygen Power. Performance Tables for Distance Runners* for easily looking up an estimated VO2Max based on race times. \[^ You could to use race times or all out efforts, because the method was based on the assumption that athletes were performing at maximal effort.\] What is VO2Max? I'll defer to others for an explanation. The Tread Lightly podcast has a great practical [episode on vo2max](https://podcasts.apple.com/us/podcast/61-vo2max-workouts-threshold-runs-and-more/id1674913391?i=1000652323085&ref=blog.matthewbrunelle.com) and how it relates to running. \[^ Here are some other good podcasts \] Marco Altini often [writes](https://marcoaltini.substack.com/p/cardiorespiratory-fitness-vo2-max) about the technical details of vo2max and bioinformatics. To this day I really love the topic and do bioinformatic personal projects that never see the light of day. The key piece relating to my first project was that it sounded like I could use a race time to calculate a VDOT. How though? I searched online and found a [vdot calculator](https://web.archive.org/web/20090810011551/http://www.tomfangrow.com/jsvdot.html) \[^ No longer up, but thankfully in the wayback machine\] that used (1) the distance and (2) time to estimate your vdot. I then reached out to the author [Tom Fangrow](http://www.tomfangrow.com/?ref=blog.matthewbrunelle.com) and used the greatest power that a high school student has: showing an earnest interest in actually learning. I got back a very kind and detailed response, including formulas and hand drawn visualizations! Here are the details, with Tom's permission: \[^ Sadly, ghost does not support latex, so no pretty equations here.\] > These are the two equations (where c=oxygen consumption,i=% of maximum effort,v=velocity, d=distance, t=time). > > The first one, c=-4.6+.182258*v+.000104*v\*v, gives oxygen consumption as a function of running velocity. The curve has a parabolic shape: > ![c vs v.jpg](https://blog.matthewbrunelle.com/content/images/2025/09/c-vs-v.jpg) > The second equation,i=.8+.1894393*Math.exp(-.012778*t)+.2989558*Math.exp(-.1932605*t), gives % of maximum effort as a function of running time. The shape of this curve is an exponential decay: ![i vs t.jpg](https://blog.matthewbrunelle.com/content/images/2025/09/i-vs-t.jpg) > For me, the key to understanding the whole thing was to get both equations in the same form, namely oxygen consumption vs time. In the case of the first equation, that means changing v to d/t to get c=-4.6+.182258*d/t+.000104*d\*d/t/t. In order to work with this, d has to be a constant. Then the curve takes the 1/x shape: ![c vs t.jpg](https://blog.matthewbrunelle.com/content/images/2025/09/c-vs-t.jpg) > The second equation is already in terms of t. The key here was to realize that the oxygen consumption is just some constant (in this case VDOT) multiplied by i, the % of maximum effort. > > Now for any distance, both curves can be drawn on the same graph of oxygen consumption vs time. The place where the curves intersect is the time it takes a runner to cover a distance as quickly as possible. When you normalize this (add a constant to make the numbers match the actual measured time), you get a constant (VDOT) that can be used for other distances as well. Since the constant was based on what the runner thought was his maximum effort, it includes everything - not just his fitness level, but also his mechanical efficiency, tolerance for pain, even his level of motivation. I think that’s pretty cool. These guys were really smart for figuring this out! \[^ A kindred spirit! I also think this is immensely cool.\] ![analysis.jpg](https://blog.matthewbrunelle.com/content/images/2025/09/analysis.jpg) > In order to find out where the curves intersect, I used repetitive approximations to converge on the answer. I think it is called Newton’s method, but I’m not sure. All you have to do is come up with a reasonable first guess (I used t=d\*.004 which corresponds to a running speed of 250 meters per minute), then plug this time into both equations and see what the difference is. I called the difference e=absolute value(c-i*vdot), and I stopped the iterations when e became less than .1 ml per minute.* > *\>* > *The next step is to (painstakingly) take the time derivative of each function, then define a correction dt=(c-i*vdot)/(dc-di\*vdot). Set t=t-dt, and start the next iteration. That must have been the way Daniels did it also, because my values match his values to within a couple of seconds for distances ranging from 1 lap around a track all the way to a marathon, and from athletes with VDOT values ranging from 30 to 80. > > I hope you got something out of this explanation. I really want to express how meaningful this was to me as a kid. For someone take the time to lay everything out so clearly. The type of kindness the worlds needs more of. --- ### Thinking through the analysis My binder for the project portfolio had some fun papers. Often when I was in class I would stop taking notes, and start writing ideas for my project. So you would see English notes that transitioned into pseudo code. Obviously the project was more fun to work on then my classes. Also, since you didn't have a computer with you in school in 2011 I couldn't edit my code while ignoring my classes. So like some kind of maniac, I would print out code in the morning before class, editing during school, and then make the updates when I got home: ![hand_code.png](https://blog.matthewbrunelle.com/content/images/2025/09/hand_code.png) Anyway, there is lots of documentation about my thought process. I had a theory that in a race, runners finish times would follow a normal distribution: ![distribution.png](https://blog.matthewbrunelle.com/content/images/2025/09/distribution.png) The truth is that they are actually log-normal due to a right skew. I was still in AP Statistics at the time and I don't think I had gotten to that yet. \[^ As a side note, I had a pretty bad Spanish teacher my junior year. When I found out I was assigned to her again senior yet, I met with my councilor and got approval to drop the class and take AP Statistics instead.\] I also thought that a runner's improvement to their VDOT would follow a logarithmic regression over time: ![season.png](https://blog.matthewbrunelle.com/content/images/2025/09/season.png) I do not know if this has been formally show, but the race results seemed to match. The first version of Python notebooks were only just released in 2011 and I had not heard of them. So I made many one off scripts to test out my ideas. After all the experimentation I had an idea of what to do. --- ### I made a data pipeline I was now armed with ideas and the following code: ![vdot.png](https://blog.matthewbrunelle.com/content/images/2025/09/vdot.png) I had two types of analysis I wanted to do: - Can we predict how a runner will do in the future? - How did a runner do in a particular race? The tricky thing about the first question is that cross-country has a yearly season and many runners do not do indoor / outdoor in the off season. A runner's VDOT would likely drop during the off season and then return to the expected curve in the next season. I decided to rephrase the first question as "can I predict how a runner will improve this current season?" So I needed to segment each athletes races into yearly seasons and perform regressions over each season. I then wanted to see if a prior seasons regression could be used to inform how a current season might progress. I was toying with the idea assuming a runner might follow a similar curve each season, but with a different starting point. That meant I could use the first race or two plus a prior seasons curve as an estimate. I managed to get the following plumbing in place: - For each runner, calculate their VDOTs for every race. - Segment a runner's races into season. - For each season create a logarithmic regression of the VDOTs over time. By the time the project was due I didn't have the final piece in place, so I settled on a simpler approach of just using the races in a current season to make a regression and use that as an estimate. As for the second analysis I did the following: - For every runner in each race, calculate their VDOTs - For each race, calculate the mean and standard deviation of the runners' VDOTs - Us the z-score to explain how the runner did in terms of percentiles. Looking at this now, I could probable have dropped the VDOT in this part and just used the runners finish time since we are only comparing runners withing the same race. I am proud of this analysis work, but also a little disappointed since this is the weakest piece of the project. However, I didn't have many tools in my toolbox yet for this kind of work. I wouldn't take a Knowledge Discovery class and learn more until I attended UMass. --- ### I made a Django Site So far I've laid these sections out like they happened one at a time. In truth I did the scraping, sanitation, analysis and presentation together as a I went. Each piece informing changes to the others. I had read *Rework* and the term "MVP" was echoing in my ears. So armed with my *Django 1.0* book I set out to make a site. I started with some wire framing: ![wireframing.png](https://blog.matthewbrunelle.com/content/images/2025/09/wireframing.png) Design was not, and still is not, my forte. So the site was mostly a one to one mapping of the core data model. The webpages were not backed up in the wayback machine and without the code I can't recreate the web page. However, I printed out some pages for my portfolio. Sadly, I made many updates after these screenshots were taken, but we won't be able to see those changes. The home page: ![home page.png](https://blog.matthewbrunelle.com/content/images/2025/09/home-page.png) \[^ Note that this was all done in an Ubuntu Virtual machine. I had a computer with Linux installed, but I regularly broke the install irreparably. If only NixOS existed then.\] You have to have a home page right? I didn't really know what to do so I put some Lorem ipum up. An example of a race results page: ![race_results.png](https://blog.matthewbrunelle.com/content/images/2025/09/race_results.png) This was the 2009 Girls State Championships for RI. The right side shows the finish time in minutes \[^ Yes I know, I incorrectly labeled the axis as seconds\] versus the the place the runner finished. The school name would link to a page of all the athletes on the team. You could click an athlete's name and go to their profile page. Here is my own profile page: ![runner_page.png](https://blog.matthewbrunelle.com/content/images/2025/09/runner_page.png) You could also hop back to a team or race page from a runners profile. Note how the visualization on the right makes no sense. At later version of the page showed the VDOTs over time instead. Remember how the pipeline stored the data in MongoDB? Circa 2011 there wasn't official support so you had to use the janky [django-mongodb-engine](https://web.archive.org/web/20111127223003/http://django-mongodb.org/) lib instead. Also, I was iterating pretty quickly so my data model would change frequently. In 2011 Django didn't have built in support for migrating models. You had to use South instead. \[^ Eventually in release 1.7 Django would eat South.\] The site was held together with bubblegum and paperclips, but work was finished in time for my project presentation. --- ### The power of asking for free stuff when you are a student At this point I had a front end put together, but no where to host the site. Self-hosting wouldn't come into my life until much later. I did have a powerful option though: asking for free stuff as a student. The AWS free tier released in 2011, but I didn't know at the time. However, this period was also pre-consolidation so there were lots of startups. So I sent out many emails like this: > I'm a senior in high school who is working on a web application for fun. Do you offer any discounts on plans for students? Any help would be greatly appreciated. With that I assembled the following team. **Web Hosting** I used ep.io for hosting the site. They seem to have shutdown in 2012, just after I did my project. \[^ Their going away post is sadly not captured in the wayback machine.\] Allegedly, they announced open sourcing the platform, but I cannot find a repository. Regardless, they were like Heroku, but for Python. Aside from asking for a free account, I also pestered them over email about getting native libraries working on their platform so I could use matplotlib for generating my charts. **Database** Mongolab was a startup that provided MongoDB hosting. In 2016 they changed their name to mLab and in 2018 were acquired by MongoDB Inc. They answered my request very warmly: > Dear Matthew, > > Thank you for your interest in MongoLab. We would be glad to give you a free beta account. > Thanks for telling us a bit about yourself and what you're working on. Psyched to know that high school students are using our service - would love a twitter mention (@mongolab) about your experience with our service if you feel up for it. **Version Control** In 2011 Github only allowed public repositories on free accounts. The student program didn't exist yet, so I filled out their support form. Their response was more terse: > From: Tekkub (Support staff) > Subject: \[Contact\] Student Accounts > > Sure, use this coupon on your account: The coupon was never setup with an expiration date, so I had premium until they retooled how accounts work. **Email** I wanted to allow registration on my site which meant I needed to send verification emails. So I messaged Postageapp for a free student account. They were the most jazzed about my request: > Hey Matthew, > > Thanks for reaching out! > Like I mentioned, we don't have anything formally set up for students who wish to use the program. However, since I joined (back in December) I have been pushing for new initiatives to get some fresh blood in the system. > > Enough of my rambling. We'd be happy to provide you with a free account for your cross country results tracking app. I assume that for low volume, a Carrier Pigeon account would be enough? I chose Postageapp at the time because there was easy integration with `django-postageapp`. I duct tapped all these free services together and got my site up and running. --- ### Ship it Finally my [senior project](https://eli.lipsitz.net/posts/introducing-gamebub/?ref=blog.matthewbrunelle.com) was submitted and review. I went back to make some more updates, but I soon graduated and went off to college. School was exciting and I eventually forgot about the project until recently. Looking back now though I realize how formative the whole experience was. I still hack away at personal projects, but I don't think I've attempted anything of the same scope and scale. I'm probably due for another cursed project, as they are the most fun. ### I appreciate that my high school had a senior project URL: https://blog.matthewbrunelle.com/i-appreciate-that-my-high-school-had-a-senior-project/ Last updated: 2025-10-29T20:15:12.000Z #### On this page When we look back at our lives we can find important inflection points. Moments that change the trajectory of our futures. Many seemly independent choices combine to create meaningful outcomes. Working backwards in time: In 2015, my first job was a software engineer / consultant for investment banks. \[^ See: "Gardening leave" in [https://www.bloomberg.com/opinion/newsletters/2025-04-10/buy-the-truth-sell-the-news](https://www.bloomberg.com/opinion/newsletters/2025-04-10/buy-the-truth-sell-the-news?ref=blog.matthewbrunelle.com) for thoughts on the smart people to finance pipeline.\] I found the company because they happened to be at the UMass CS career fair and their job listing was tagged with the keyword "finance". I searched for finance because of my time in Glasgow. In 2014, I did a semester abroad at the University of Glasgow because I liked Haskell. \[^ See: \] However I didn't realize the university limited the pool of classes exchange students could pick from until I got there. So I took a [course](https://perhammer.com/2021/11/05/asep6.html?ref=blog.matthewbrunelle.com) “Software Engineering for Financial Services” to fulfill my group software engineering require \[^ The narrator: UMass did not accept the class as a substitute.\] We made a position keeping system for fixed income assets and I have the broken kind mind that enjoys projects like that. \[^ The class was actually really well run: all group projects, work was organized in sprints, we learned unit testing, etc\] Another reason I picked the class was because my past internships were related to finance. In 2011, my first internship was at a company that provided a pluggable online account opening site for banks to use. Their CMO wanted help collecting information about what banks and credit unions might be potential customers. Retail banks will often offer higher than average rates when they really want to attract new customers. His is idea was those banks may also be interested in adding online account opening. I was asked to make a crawler that scraped BankRate \[^ which at the time had all their interest rate information just sitting on open web pages\] to help identify potential banks. I got that internship because of my high school senior project. This post is about that project. --- ### The Dreaded Senior Project Waaaaay back in highschool I had to do a senior project that consisted of: - a project proposal - a written research paper - a project - working with an optional mentor - a portfolio documenting the whole projects - a 10 minute presentation to "a panel of faculty and community members" At the time I was a chronic procrastinator. \[^ Now know I had undiagnosed ADHD.\] I had a personal project I was chipping away at, but not making great progress on. However, I also had a school project with a deadline that I needed to motivate myself to finish. So I decided to turn my small side project into the project component of my portfolio. I recently found the old binder my portfolio was in at my parents and decided to on a trip down memory lane. --- ### The paper and the interview The paper I wrote was *called The Agile Approach to Software Development*. Yes, as I high school student I contrasted waterfall vs agile methodology. Even though I did not enjoy writing back then, it turns out if given the opportunity to write about software I was at least interested enough to do that. The paper was surprisingly OK and got a 53/60: "Proficient with Honors". Which is weird considering the audience was non-technical. I think that's the best I ever did on a paper in high school. Even then, we do not need to go over the contents of the paper. Trust me. The big thing is that one of the five sources for our paper needed to be an interview: > The person you choose must be a credible source approved by your teacher. You are required to find an adult, outside of the high school, that is an expert in your area of exploration. So I asked my parents if they knew anyone that worked in software. Their friend was the CMO that eventually offered me the internship. I do not want to diminish the fact that I had a pretty fortunate upbringing and that afforded me connections like this. The CMO put me in touch with someone on their engineering team who could answer my interview questions. The questions I asked were all pretty simple: 1. What company do you work for and what experience do you (and you company) have in the field of software design? 2. You had mentioned that you company now uses Agile. What design methodology did you use before this? 3. How would you characterize Object Oriented Design? 4. How would you characterize Agile Design? 5. In what ways does Agile help you company with increasing: a. Adaptability, b. Feedback (both internal and with you customers), c. Pragmatism Honestly, getting real world answers to include in the paper really helped me out. While re-reading my paper I also found another great reference I used. I cited [What Killed Waterfall could Kill Agile](https://gist.github.com/joshwand/710960?ref=blog.matthewbrunelle.com) which includes this wonderful section about DOD2167: > He began the paper by setting up a straw-man process to knock down. He described this naïve process as “grandiose”. He depicted it with a simple diagram on an early page of his paper. Then the paper methodically tears this “grandiose” process apart. In the end, Royce proposed a far more nuanced and insightful approach, leaving the reader to giggle at the silliness of the “grandiose” model. > > There was just one problem. The process that DOD2167 adopted was Royce’s straw man! Apparently the authors of DOD2167 did not actually read Royce’s paper; because they adopted the “grandiose”, naïve process that Royce’s paper had derided. To his great chagrin, Dr. Winston W. Royce became known as the father of the waterfall. --- ### The Project The project deserves a separate post. In fact [here](https://eli.lipsitz.net/posts/introducing-gamebub/?ref=blog.matthewbrunelle.com) is 4500 words about what I did. The important thing to know is that I wanted to aggregate cross country race results off of the Rhode Island Track and Field Association website. There was no official way to do this programmatically so I had to write a scraper. --- ### Time is a flat circle: So putting the two together, at the end of my senior project I had: - Interviewed someone from my future internship for my paper. - Made a crawler for my project which was something my future internship wanted. After I had submitted my senior project I got this message from the family friend: > What are you up to this summer--have you lined up an internship or > job? I might be able to use some coding help with a few projects. A > few examples: > \-- Building screen-scraping applications to harvest interest rate data > from numerous sites > ... > mentioned that you know Python; what languages are you > comfortable with? My response was: > I do have a resume, but without any real work experience it doesn't say too much about me. I did just throw together a blog post detailing the work I did on a recent project. Its here if you'd like to check it over. > > As far as work, I don't have a job yet but I am definitively getting one for this summer (I need to pay for college books and a macbook pro somehow). I would absolutely love to have an internship though. I suppose I followed [POSSE](https://indieweb.org/POSSE?ref=blog.matthewbrunelle.com) even back then. The restored post is now [here](https://blog.matthewbrunelle.com/work-experience/) and yes, I have essentially written the same story twice now. Regardless, sending over that reply got me this response: > What you're doing with the race data seems very similar to what I'd > like to do with bank rate data. I'm headed out to San Francisco for > the week, but let's find a time to talk about what you might do for us > after that. The rest, as they say, is history. ### I'm a laptop weirdo and that's why I like my new Framework 13 URL: https://blog.matthewbrunelle.com/im-a-laptop-weirdo-and-thats-why-i-like-my-new-framework-13/ Last updated: 2026-02-18T01:10:03.000Z This month I sold my 2021 M1 Max Macbook Pro and bought a Framework 13 DIY Edition laptop. After I got everything setup I sat down to write about the experience. Some \~4500 words later I realized I needed to break my thoughts into multiple posts. See also: - [Framework 13 DIY Edition Hardware Thoughts](https://blog.matthewbrunelle.com/framework-13-diy-edition-hardware-thoughts/) - [Setting up my new Framework Laptop 13 DIY Edition with NixOS](https://blog.matthewbrunelle.com/setting-up-my-new-framework-laptop-13-diy-edition-with-nixos/) #### On this page My new Framework 13 laptop just arrived. After I finally set everything up I started writing a post about the experience. I thought I'd write a *little bit* about my previous laptops, but a lot of fond memories I had forgotten about came flooding back. The tinkerings and many openings of laptops past. If you will indulge me, I've been feeling nostalgic. This is for the other laptop weirdos out there that that feel the same. ### I have a history of doing terrible acts to laptops ![25654_1350884488762_5177004_n.jpg](https://blog.matthewbrunelle.com/content/images/2025/08/25654_1350884488762_5177004_n.jpg) The only image I could find of my NC10 was this blurry, 2011 flip phone photo of me removing the windows sticker. In 2008, I managed to get my hands on a Samsung NC10 Netbook in a fancy metallic blue color. \[^ Back when netbooks where a thing circle 2007-2013\] Prior to this I only had desktops. The specs were pretty humble ([from wikipedia](https://en.wikipedia.org/wiki/Samsung%5FNC10?ref=blog.matthewbrunelle.com)): - A single core 1.6 GHz Intel Atom N270 - Integrated Intel GMA 950 graphics - 1 GB DDR2 RAM - 10.2 inch 1024x600 screen and a *VGA connector* of all things. - 83-key keyboard rather than the usual 87 or 88 keys on a laptop. - A 160 GB HDD Something could be done about that though! You could [upgrade](https://replacethatpart.com/how-to-upgrade-memory-ram-of-the-samsung-nc10-a-complete-guide-2/?ref=blog.matthewbrunelle.com) the RAM to a powerful 2GB. You could replace the slow HDD with an SSD. You could [add a touch screen](https://liliputing.com/touchscreen-kit-for-the-samsung-nc10-and-other-netbooks/?ref=blog.matthewbrunelle.com). You could [make a Hackintosh](https://www.atmasphere.net/archives/2009/01/15/the-samsung-nc10-hackintosh?ref=blog.matthewbrunelle.com) out of it if you replaced the wifi card. If you wanted to, you could do those things and I was a weirdo, so I did! I found a lot of fun in trying to get as much as I could out of that hardware. In fact I'd say the act of doing all that was far more enjoyable than actually using the laptop once the tinkering was done. After the novelty and slowness of a Hackintosh wore off I put Linux on the Netbook. I still sought the thrill of the hunt. I installed a lite weight distro [CrunchBang](https://crunchbang.org/?ref=blog.matthewbrunelle.com) \[^ or just #!\] and messed around. I read more about different minimalist distros and came across two others I could hop to: Arch and Gentoo. This feels like an inflection point in my life, I choose to try Arch since I wouldn't have to compile everything on a single core. \[^ Who know what would have happened if I picked Gentoo. I might have a beard now.\] The screen was small and I wanted to maximize its usefulness so I started trying tiling WMs. Why not [XMonad](https://xmonad.org/?ref=blog.matthewbrunelle.com)? It turns out the GMA950 was undervolted on the NC10\. Someone made a shareware tool called the GMABooster that could restore the max clock rate. The original website [http://www.gmabooster.com/home.htm](http://www.gmabooster.com/home.htm?ref=blog.matthewbrunelle.com) is long toast and not on wayback. This [Arch forum thread](https://bbs.archlinux.org/viewtopic.php?id=81429&ref=blog.matthewbrunelle.com) has details though: > It allows a user, not a manufacturer to choose the desired GMA speed. It combines a sophisticated assembler-level technology and the user-friendly graphic user interface, offering You to near double the GMA core perfomance without even a need to restart a computer.. The package was on AUR so I could squeeze out a little more performance. I could finally watch 480 YouTube videos instead of 360\. At some point, long after I had stopped using the netbook, the AUR package became abandoned. I [adopted it as maintainer](https://web.archive.org/web/20150802113711/https://aur.archlinux.org/packages/gmabooster/) and [mirrored](https://github.com/ciferkey/GMABooster%5FLinux?ref=blog.matthewbrunelle.com) the binary in GitHub. This was the first time I ever was a package maintainer. \[^ I am on a couple random packages in nixpkgs now.\] Nowadays the package is memorialized in the the [AUR archive](https://github.com/aur-archive/gmabooster?ref=blog.matthewbrunelle.com). I had a device that I could repeatedly break and remake. Did I do anything productive or meaningful with it? Absolutely not. Did I learn a lot in the process? I'd say so! --- ### In the past you could do terrible things to Macbooks too When I went to College I got a 2011 Macbook Pro. The kind that would overheat and desolder the GPU. \[^ Some clever people have found hardware hacks to repair the problem [https://www.jeffgeerling.com/blog/2017/fixing-2011-macbook-pro-booting-grey-screen-amd-radeon-video-glitch](https://www.jeffgeerling.com/blog/2017/fixing-2011-macbook-pro-booting-grey-screen-amd-radeon-video-glitch?ref=blog.matthewbrunelle.com)\] Mine managed to last a long time and didn't need replacing until 2019\. The RAM was not built-in yet on Macbooks. Apple said the model could only support up to 8GB total RAM, but you [could actually](https://apple.stackexchange.com/a/259351?ref=blog.matthewbrunelle.com) get 16GB to work. Also, this was back when Macbooks had CD drives. I replaced the my drive with an [Other World Computing DIY Optical Drive to HDD Upgrade Kit](https://eshop.macsales.com/item/Western%20Digital/DDMB5KT1.0/?ref=blog.matthewbrunelle.com). \[^ And you could put the drive into an "OWC SuperSlim" enclosure to turn it into a USB CD drive.\] and installed SSDs in both slots. With two drives I was able to install rEFInd as a boot manager and triple boot: - OSX as a stable install for my course work - Windows for games - Linux so I could break my install repeatedly I iterated on my Arch install so many times that I started to keep a checklist about my setup process to help me remember everything. Certain stylistic choices were set and still used to this day. \[^ This is when I started using Inconsolata for a monospace font and Zenburn for a color scheme.\] I couldn't change quite as many things about this laptop, but I still made an effort to change what I could. --- ### As laptops grew thinner they grew more boring When it came time for a new laptop I was not looking at Macbook Pros anymore. Apple had made changes, like the touch bar and removing magsafe, that felt like they were targeting a different audience. So instead I had been eyeing a ThinkPad. \[^ It's almost cliche to buy one and install Linux.\] The prices on the Lenovo store are mostly made up and constantly discounted. My housemate had access to a corpo portal for Lenovo that let me get one at a heavily reduced price. The cost of 3 year service coverage was also discounted so I got some figuring it could help to cover cost of parts if if something failed. So I bought a Gen 7 X1 Carbon and... I just used it. No mods were possible on this laptop. When I had an SSD failure I asked Lenovo if they could mail me the drive so I could do the install. They said they had to send someone to confirm the issue. So a technician came out and replaced the drive. --- ### The gift and curse of a free Macbook Pro Finally in 2023 I was [laid off by HubSpot](https://www.hubspot.com/company-news/a-message-from-hubspot-ceo-yamini-rangan?ref=blog.matthewbrunelle.com). Part of severance was the following: > Laptops & WFH Set-Up: Impacted employees may keep their HubSpot laptops (it will be cleaned of any company data remotely), as well as any work from home gear like monitors and keyboards. Thus a pretty high spec 2021 M1 Max Macbook Pro fell into my lap. I gave my X1 Carbon to a friend to avoid creating yet more ewaste that sits in my closet. The 2021 version was a bit of return to form: touch bar was gone, magsafe was back, etc. However even the [iFixit review](https://www.ifixit.com/News/54122/macbook-pro-2021-teardown?utm%5Fsource=M1MBPTD&utm%5Fmedium=Annotation&utm%5Fcampaign=YouTube) said the "design represents a major move in the right direction" but still only rated the laptop a 4/10 for repairability. \[^ The score was eventually updated to a 5/10 when Apple later released a service manual and access to parts.\] I felt some dissonance though. If I was looking to buy a laptop, I wouldn't have picked this one. macOS was getting less enjoyable to use with each update. Likewise the Linux Desktop experience was really coming into its own. \[^ By 2023 essentially all my games were playable!\] However I felt bad about buying a new laptop when I now had a perfectly good one. So I held onto it and once again, no mods were done or could be done with this laptop. --- ### Finally buying a Framework 13 I had waited on getting a Framework laptop because I wanted to see them go through a couple iterations. I wanted to see if the promise of repairing, replacing and upgrading actually came true. From what I read it mostly has! \[^ People with Framework 15 do seem to be waiting though.\] What changed the decision for me was the following: - Lugging around a powerful 16 inch laptop was a drag. Having a laptop when traveling is nice if I need to hurriedly rebook something. Mobile sites and apps tend to restrict you in weird ways. - Despite being a couple years old, the laptop was still worth a lot. People probably want Macbooks for local LLM inference. So I felt pretty good a buyer will actually use the laptop. - The Framework release a refresh of the 13 with the new AMD chips. Then I had a friend get Laptop 13 and attest to liking it. That was the last push I needed to finally buy one. Now I can be a laptop weirdo again. You can't change the RAM on laptops now. You can't change the SSD on laptops now. You can't easily repair the screen on laptops now. You can do all that and more with a Framework laptop. You can be a laptop weirdo with a Framework laptop. Weirdo typically has two interpretations: > A possibly dangerous person. > A strange, odd, eccentric person. To both of those I say: all us laptop weirdos can now put a [snack drawer](https://community.frame.work/t/the-snack-drawer-store-now-made-with-real-snacks/43101?ref=blog.matthewbrunelle.com) in our laptops. You cannot stop us. ### Setting up my new Framework Laptop 13 DIY Edition with NixOS URL: https://blog.matthewbrunelle.com/setting-up-my-new-framework-laptop-13-diy-edition-with-nixos/ Last updated: 2025-09-02T21:39:56.000Z #### On this page I've used NixOS for year, yet I've never written about the experience before. This month I sold my 2021 M1 Max Macbook Pro and bought a Framework 13 DIY Edition laptop. After I got everything setup I sat down to write about the experience. Some \~4500 words later I realized I needed to break my thoughts into multiple posts. This one covers my perspective on setting up the software side. See also: - [Framework 13 DIY Edition Hardware Thoughts](https://blog.matthewbrunelle.com/framework-13-diy-edition-hardware-thoughts/) - [I'm a laptop weirdo and that's why I like my new Framework 13](https://blog.matthewbrunelle.com/im-a-laptop-weirdo-and-thats-why-i-like-my-new-framework-13/) --- This post is not: - a general NixOS install guide. Please use the [NixOS manual](https://nixos.org/manual/nixos/stable/?ref=blog.matthewbrunelle.com#sec-installation). - a guide for installing NixOS on Framework laptops. NixOS is a [community supported](https://discourse.nixos.org/t/exciting-partnership-announcement-framework-community-nixos-communities-join-forces/44640?ref=blog.matthewbrunelle.com) distro for NixOS. So please use Framework's [NixOS on the Framework Laptop 13](https://guides.frame.work/Guide/NixOS+on+the+Framework+Laptop+13/400?ref=blog.matthewbrunelle.com) guide and the [NixOS wiki](https://wiki.nixos.org/wiki/Hardware/Framework/Laptop%5F13?ref=blog.matthewbrunelle.com). - a Framework 13 DIY Edition setup guide. Please use the [Framework Laptop 13 (AMD Ryzen™ AI 300 Series) DIY Edition Quick Start Guide](https://guides.frame.work/Guide/Framework+Laptop+13+%28AMD+Ryzen%E2%84%A2+AI+300+Series%29+DIY+Edition+Quick+Start+Guide/404?ref=blog.matthewbrunelle.com). What this post is: - I haven't had a Linux laptop for a bit and wanted to share some laptop specific configuration changes I made to my setup. - I tried out some new things and wanted to share the solutions to stumbling blocks I found. - Talking a bit about having a single NixOS config for multiple machines. --- ### Prepping the live install usb and booting up I usually use Balena Etcher to setup a live USB for installs. The stable version of the NixOS manual even [suggests](https://nixos.org/manual/nixos/stable/?ref=blog.matthewbrunelle.com#sec-booting-from-usb) using Etcher. However, based on this [issue](https://github.com/NixOS/nixpkgs/issues/153537?ref=blog.matthewbrunelle.com#issuecomment-2461136259), the program has not been included in nixpkgs for a while due to using an out of date Electron version. The issue suggests using Popsicle instead, but you could also just use dd and the manual suggests. To boot from a live USB for install you need to [disable secure boot](https://github.com/FrameworkComputer/linux-docs/blob/main/misc/secure-boot.md?ref=blog.matthewbrunelle.com#how-to-disable-it-if-you-choose-to) first. Don't worry, you can re-enable secure boot later. I also had an issue with the laptop not detecting my USB. The Framework laptops use [expansion cards](https://knowledgebase.frame.work/what-is-an-expansion-card-SkdG4Hw%5Fu?ref=blog.matthewbrunelle.com) for the ports. I removed and reinserted my USB A card and suddenly my USB drive was detected. So perhaps the expansion card was not detected. When I finally booted from live USB, I couldn't authenticate to my home wifi. I had a usb to ethernet adapter I used instead. Weirdly enough after installation I've had no issue with wifi. I was tempted to try out [nixos-anywhere](https://github.com/nix-community/nixos-anywhere?ref=blog.matthewbrunelle.com) this time, but I was very excited and just dove in instead. --- ### Partitioning with Disko, finally quick BTRFS on LUKS I usually am pretty boring when I partition my desktop. Just a simple EFI partition, a single ext4 partition, and a swap partition for hibernate. Simple is easy to setup and diagnose if something goes wrong. However, I've wanted to try [Disko](https://github.com/nix-community/disko?ref=blog.matthewbrunelle.com) out for a while now. Not only is a declarative parition description appealing, but also I can be lazy and avoid typing all the commands to setup my disk. In fact I felt empowered to try a new partition scheme thanks to Disko. The project has a whole folder of [example](https://github.com/nix-community/disko/tree/master/example?ref=blog.matthewbrunelle.com) configurations. I chose to start with the [luks-btrfs-subvolumes.nix](https://github.com/nix-community/disko/blob/master/example/luks-btrfs-subvolumes.nix?ref=blog.matthewbrunelle.com) example. The only changes I had to make where: 1. Updating `devices` to match my nvme drive 2. Changing the swapfile size to match my ram size. That way I can use hibernate. 3. Removing the `settings.keyFile` as noted by a comment in the file. I want to use an interactive password instead. Thats everything! Basically in five minutes with no prior experience I was able to use the [quickstart guide](https://github.com/nix-community/disko/blob/master/docs/quickstart.md?ref=blog.matthewbrunelle.com) and have BTRSFS on LUKS setup. Also, don't forget to swapon the swap file before you run `nixos-generate-config` so your configuration includes the swap device. --- ### Installing NixOS and handling multiple machines in a single configuration The install process was pretty standard. I did not use the GUI installer and instead used the [manual](https://nixos.org/manual/nixos/stable/?ref=blog.matthewbrunelle.com#sec-installation). Also, I think I just totally missed this addition, but the `nixos-install` command now has a `--flake` option! I've been installing without and then converting to flake based installs this whole time... Once the install command finished I rebooted. Then I just needed a quick git pull to get my configuration, a rebuild and then everything felt like home. My current [current iteration](https://github.com/ciferkey/NixOS-Config?ref=blog.matthewbrunelle.com) configuration is based off [nix-starter-configs](https://github.com/Misterio77/nix-starter-configs?ref=blog.matthewbrunelle.com). The only host I had configured was my desktop. However if you add a new `nixosConfigurations.` entry in your `flake.nix` you can have different configurations for each host. I'm due for a revamp and cleanup to my current configuration. For now I extracted the couple of desktop specific configuration details into their own `desktop.nix` file that is only imported by my desktop configuration. I then duplicated all the general configuration under a directory for my laptop's hostname. Going forward I'll work on pulling common pieces out into their own modules. \[^ I don't want to go too crazy with that though. I did that in the past and ended up with too many modules which felt cumbersome.\] I also realized that there is now an [issue](https://github.com/Misterio77/nix-starter-configs/issues/86?ref=blog.matthewbrunelle.com) in the nix-starter-configs repo that the project needs a rework. So I may try to migrate off of to something simpler. --- ### Hibernation using a swapfile with BTRFS on LUKS Remember when I mentioned I usually do the boring thing for disk partitions? Hibernate is a great reason why. OK, I'm being a little hyperbolic, but hibernation is something I'd really like on a laptop for powersavings and LUKS complicates the process. If I had used a swap partition I would just need to point `swapDevices` to the partition. Instead we have a swapfile on BTRFS *inside LUKS*. The wiki page on [hibernation](https://nixos.wiki/wiki/Hibernation?ref=blog.matthewbrunelle.com) recommends the following: ```nix boot.kernelParams = ["resume_offset="]; boot.resumeDevice = "/dev/disk/by-uuid/"; powerManagement.enable = true; swapDevices = [ { device = "/var/lib/swapfile"; size = 32 * 1024; # 32GB in MB } ]; ``` Where you need to figure out the offset for your swap file using `filefrag`. However based on a conversation in the [forums](https://discourse.nixos.org/t/is-it-possible-to-hibernate-with-swap-file/2852/7?ref=blog.matthewbrunelle.com), since release 24.05 you only need to enable initrd systemd and set the swap device: ```nix boot.initrd.systemd.enable = true; swapDevices = [{ device = "/var/lib/swapfile"; size = 32 * 1024; }]; ``` Systemd determines and handles the offset for you. What's more `powerManagement.enable` [defaults](https://search.nixos.org/options?channel=unstable&show=powerManagement.enable&query=powerManagement.enable&ref=blog.matthewbrunelle.com) to true and can be omitted. --- ### I was afraid of trying secure boot, but that was unfounded I have never set up secure boot on Linux before, and I'm happy to report the process was painless! I found this post [Framework and NixOS - Secure Boot (Day Three)](https://0xda.de/blog/2024/06/framework-and-nixos-secure-boot-day-three/?ref=blog.matthewbrunelle.com) which pointed me towards Lanzaboote. Then I found this FOSDOM 2023 presentation [Towards Secure Boot for NixOS](https://archive.fosdem.org/2023/schedule/event/nix%5Fand%5Fnixos%5Ftowards%5Fsecure%5Fboot/?ref=blog.matthewbrunelle.com) which explains how Lanzaboote works. Some notes from the talk: - automatically handles signing everything when you when you do a nixos rebuild - provides a stub to help prevent the EFI parition from running out of space when you have many generations - you have to handle key generations and rerollment once Also this lovely quote: > I've heard that it works for other people... I have also heard it did not work for other people. Thankfully in the time since then the project has improved. I simply followed the [quick start guide](https://github.com/nix-community/lanzaboote/blob/master/docs/QUICK%5FSTART.md?ref=blog.matthewbrunelle.com) as prescribed. The guide even includes specific details for Framework Laptop users. Also note this warning: > To be able to setup Secure Boot on your device, NixOS needs to be installed in UEFI mode and [systemd-boot](https://www.freedesktop.org/wiki/Software/systemd/systemd-boot/?ref=blog.matthewbrunelle.com) must be used as a boot loader. This means if you wish to install lanzaboote on a new machine, you need to follow the install instruction for systemd-boot and then switch to lanzaboote after the first boot. I used the flakes option when installing rather than the niv option. Also, make sure to [set your UEFI password](https://knowledgebase.frame.work/setting-a-bios-password-SkEuDB7Blxk?ref=blog.matthewbrunelle.com). ![image.png](https://blog.matthewbrunelle.com/content/images/2025/08/image.png) --- ### Linux on Laptops seems simpler now In the past I feel like I had a whole laundry list of items to attend to when installing Linux on a Laptop. Now in 2025 there really isn't that much tweaking to do compared to a desktop. No mucking about with TLP or anything. The Framework knowledge base [recommends](https://knowledgebase.frame.work/en%5Fus/optimizing-ubuntu-battery-life-Sye%5F48Lg3?ref=blog.matthewbrunelle.com): > For Framework Laptop 13/16 AMD Ryzen™ 7040 Series configurations, you will absolutely want to use power-profiles-daemon for the absolute best experience. **Do NOT use TLP**. Without getting too detailed, there are things happening behind the scenes that require PPD for the best experience for our Linux customers. \[^ They have not said if this applies for the AMD 300 Strix AI series, but in absence of clarification I'm avoiding TLP.\] The whole experience is.... peaceful? I still get to tweak a lot of things. Just less of the fiddly bits. I dig it. I've literally never done a ~~neofetch~~ fastfetch before, but I'd like to end on a cliche: ![fastfetch.png](https://blog.matthewbrunelle.com/content/images/2025/08/fastfetch.png) ### Framework 13 DIY Edition Hardware Thoughts URL: https://blog.matthewbrunelle.com/framework-13-diy-edition-hardware-thoughts/ Last updated: 2025-09-02T21:40:08.000Z #### On this page This month I sold my 2021 M1 Max Macbook Pro and bought a Framework 13 DIY Edition laptop. After I got everything setup I sat down to write about the experience. Some \~4500 words later I realized I needed to break my thoughts into multiple posts. This one covers my perspective on the hardware experience. See also: - [Setting up my new Framework Laptop 13 DIY Edition with NixOS](https://blog.matthewbrunelle.com/setting-up-my-new-framework-laptop-13-diy-edition-with-nixos/) - \[I'm a laptop weirdo and that's why I like my new Framework 13\]( --- ### I really appreciated the packaging ![1755789859000_R0001572.JPG](https://blog.matthewbrunelle.com/content/images/2025/08/1755789859000_R0001572.JPG) \[^ Yes, I used my GRIII for these photos. See: No, I didn't mean to make the lighting moody, that was an accident.\] I'm not a fan of unpacking videos and the like so I'll keep this part short. I mostly want to highlight Framework's effort to ship the device with 100% recyclable materials. They actually managed to make almost everything cardboard! There is one piece of plastic to protect the screen and Framework made sure to include the message: > This protective cover is made of post-consumer recycled PET and is highly recyclable. We recommend this can be disposed of with other household plastic recycling. Quite likely some diligent person at Framework worked hard to get rid of that plastic, but had to keep the cover in the end. The message feels like and admission of guilt and I want you to know including the plastic was OK. We understand! ![1755790087000_R0001573.JPG](https://blog.matthewbrunelle.com/content/images/2025/08/1755790087000_R0001573.JPG) Some other nice aspects of the packaging are: - They did not include instructions. Instead you scan a QR code to living [documentation](https://frame.work/laptop13?tab=resources-and-guides&ref=blog.matthewbrunelle.com) that they update. Plus you can comment on individual sections of the instructions with questions or to give feedback. - They gave me a [combination screwdriver pry tool](https://guides.frame.work/Item/Framework%5FScrewdriver?ref=blog.matthewbrunelle.com), which is nice but I already had both of those. Would be nice to see a way to opt out of that. ![1755790827000_R0001574.JPG](https://blog.matthewbrunelle.com/content/images/2025/08/1755790827000_R0001574.JPG) Framework also has a page [*We are not sustainable*](https://perfect-hiring-822947.framer.app/sustainability?ref=blog.matthewbrunelle.com) where they lay out their ethos: > And neither is any other device maker. This industry is full of “feel good” messaging, but generates 50 million metric tons of e-waste each year. We believe the best way to reduce environmental impact is to create products that last longer, meaning fewer new ones need to be made. Instead of operating on feels, we operate on data and actions. The page also has a lifecycle analysis report with more details. --- ### Build quality There are people far better at reviews like [Phoronix](https://www.phoronix.com/review/framework-13-amd-strix-point?ref=blog.matthewbrunelle.com), so I will defer to them. However, I will note a couple thoughts: **Keyboard**: The Laptop 13 now ship with the [2nd generation keyboard](https://frame.work/products/keyboard?v=FRANRP00BG&ref=blog.matthewbrunelle.com) and I enjoy the typing experience. My only complaint is the function key placement to the right of left control key trips me up. You can swap ctrl and fn in the UEFI settings, but you are advised not to pull the key caps. \[^ Also kind of crazy but the framework 16 had a dedicated keyboard chip that runs QMK! Not on the 13 though.\] **Battery**: Likewise the 13 also ship with the [61w battery](https://frame.work/products/battery?v=FRANGWAT01&ref=blog.matthewbrunelle.com) that came out in 2024 instead of the older 55w battery. **Touchpad**: I've been spoiled by Macbook touchpads in the past. The touchpad on the Framework 13 is nice! Not a Macbook nice, but still nice. Good news is in the recent [Framework Laptop 16 - Prototypes and Scrapped Ideas](https://youtu.be/0RzUBqtgODM?t=290&ref=blog.matthewbrunelle.com) video the CEO Nirav talked about their experiments on a haptic touchpad! Sounds like Framework still want to figure out how to ship one. Also, the touchpad is the one spot where I noticed a build quality issue: the left edge of the touch pad does not sit flush with the case. There is a small gap between the two. However, you can [fully replace](https://guides.frame.work/Guide/Touchpad+Replacement+Guide/90?lang=en&ref=blog.matthewbrunelle.com) the touchpad if you need to, so I should be able to change the position of the pad. **Screen**: No stuck pixels! The 3:2 aspect ratio is nice, but takes some getting used too. I updated to the 2.8k screen and the was able to enable the variable refresh rate in KDE. Also, the upgraded screen has rounded corners. As far as I can tell KDE connot be configured for this, but the rounding is so slight I haven't had anything cut off yet. The extra 100 nits and matte is appreciated for avoid glare in natural light. As I write, I'm sitting next to a window and typing under full natural light at 45% brightness. Also, there is no auto-brightness, but from a [tracking thread](https://community.frame.work/t/tracking-auto-brightness-aka-ambient-light-sensor-in-linux-details/16746/59?u=rokejulianlockhart&ref=blog.matthewbrunelle.com) I see a couple options I may try out in the future. **Firmware**: I only had two updates: "Goodix Fingerprint Sensor" and "UEFI dbx": ![Screenshot_20250825_151603.png](https://blog.matthewbrunelle.com/content/images/2025/08/Screenshot_20250825_151603.png) --- ### Thoughts on the DIY Edition vs prebuilt I don't think Framework needed to offer a DIY Edition of the Laptop 13, but I'm happy they did. As a laptop weirdo, I enjoyed putting everything together. I couldn't find a summary of the differences between the DIY Edition and the prebuild version that I can link to. So I had a look over the product listings and as far as I can tell, the main differences for the DIY Edition are: **Windows license**: On the DIY Edition you can skip paying for a Windows 11 license. We love this. **Spec combinations**: You can pick any combination of specs. This includes the display choice. **Memory and storage**: You can bring your own RAM and NVME SSD instead of buying from Framework. Framework has a [supported RAM](https://knowledgebase.frame.work/en%5Fus/what-dram-memory-is-supported-by-framework-laptop-13-amd-ryzen-ai-300-series-BkY0ZG0ike?ref=blog.matthewbrunelle.com) guide, but I went with a different option: my friend got a Laptop 13 before me and chanced some 3rd party RAM he heard worked. Thanks buddy for taking the leap! You should be able to find the same memory if you search for Crucial 32GB DDR5 RAM Kit (2x16GB) CT2K16G56C46S5. For the SSD I went with a WD\_BLACK 2TB SN7100\. That drive is an [officially supported](https://knowledgebase.frame.work/what-storage-ssd-parts-are-compatible-with-the-framework-laptop-rJOOeHU0%5F?ref=blog.matthewbrunelle.com) option and available as a choice when configuration a DIY Edition, but is available for cheaper if you buy separately. **Keyboard**: There are waaay more keyboard options. Like 22 options. I went with boring US English. **Bezel**: You can customize the color of the bezel. They have a clear one, but they use a different plastic that is a little more brittle. As far as I can tell, there is no first or third party aluminum bezel, so I went with the black one. Smaller things: - I had the option not buy a power adapter. Which is great because everything is USB C in the future and I have plenty. - There is only the 1-year warrany, no 3-year extended. - You save a bit of money. Overall, I really think the DIY Edition is worth it. ### On Self-hosting, OpenTofu, and Formerly OSS Licensed Software URL: https://blog.matthewbrunelle.com/on-self-hosting-opentofu-and-formerly-oss-licensed-software/ Last updated: 2026-02-16T15:36:38.000Z #### On this page Self hosting is a wonderful hobby, but for me, it's still just a *hobby*. If start to feel like I'm become a full time SRE or Infra Engineer then I lost my way somewhere. So a large part of self-hosting for me is balancing the automation to avoid drudgery while not going crazy trying to be *enterprise ready*. \[^ Good ol [#1205](https://xkcd.com/1205/?ref=blog.matthewbrunelle.com) and [#1319](https://xkcd.com/1319/?ref=blog.matthewbrunelle.com). \] However on the flip-side, sometimes when I want to spin up another service there are certain kinds of friction make me procrastinate. One of those is provisioning a new DB for a new service. The task is not particularly hard, but also not shiny and new. We love to chase the shiny and new. So anyways, I wanted to spin up [Endurain](https://github.com/joaovitoriasilva/endurain?ref=blog.matthewbrunelle.com) one weekend to try it out and that meant setting up Postgres for it. To make the experience shiny and new enough for me I decided to give ~~Terraform~~ OpenTofu a whirl. --- ### I Got Really Distracted In the Process I had not looked at Terraform for a couple years and didn't realized they changed their license in 2023\. The re-license was very similar to ones I had heard of in the past and that got me in a mood to survey some history... Soapbox time: there is a trend for successful open-source projects to spawn for profit companies behind their development. OSS is hard to monetize by design. Sometimes the for profit side of a project will start providing paid cloud options for funding. The problem is that the large cloud providers Microsoft, Amazon, and Google can easily offer their version of the project as a paid service. Even when a project is [AGPLv3](https://opensource.org/license/agpl-v3?ref=blog.matthewbrunelle.com) licensed. \[^ I'd like to be clear to: I'm not throwing my support behind these companies.\] So then the temptation sets in: the for profit company could re-license the project to try and prevent the cloud providers from offering their version. They need to strike a balance though: they still want people to run the project in private cloud so they can sell consulting services and they also want to accept code contributions for the project. So the project becomes *source-available* under a custom license. You could view the code and offer a change, but the project is not Free and Open Source Software. > "There's a reason that old FLOSS hands react with instant derision whenever someone proposes making up a new software license. It's the same reason cryptographers are so hostile to the idea of people rolling their own ciphers: no matter how smart and well-intentioned you are, there's a high likelihood that you will screw up and irrevocably place innocent people at risk." > \~ Cory Doctorow, [Semantic drift versus ethical drift](https://pluralistic.net/2025/07/14/pole-star/?ref=blog.matthewbrunelle.com#gnus-not-utilitarian) --- ### A High Likelihood That You Screw Up MongoDB is the first example of this that I recall. In 2018 they [created their own](https://techcrunch.com/2018/10/16/mongodb-switches-up-its-open-source-license/?ref=blog.matthewbrunelle.com) Server Side Public License as a reaction to cloud providers hosting MongoDB as a service. I'll defer to the OSI's explaination for why [the SSPL is Not an Open Source License](https://opensource.org/blog/the-sspl-is-not-an-open-source-license?ref=blog.matthewbrunelle.com). There were attempts to fork the project and continue under an open license but it looks like none of those projects survived. Ironically the re-license didn't stop Amazon since they created a protocol compatible service called DocumentDB. Elastic was the next time I heard about this happening. The company started putting important features for security and operations under their non-open source paid component which was called the "X-pack". Then in 2021 Elasticsearch [re-licensed](https://www.elastic.co/blog/licensing-change?ref=blog.matthewbrunelle.com) using SSPL and their Elastic License 2.0\. So AWS [forked](https://aws.amazon.com/blogs/opensource/stepping-up-for-a-truly-open-source-elasticsearch/?ref=blog.matthewbrunelle.com) the project OpenSearch. One upside of this fork is that the paid only features like SSO and RBAC were implemented under the core project and available to everyone. Thankfully in 2024 Elastic switched back to an [open source license again](https://www.elastic.co/blog/elasticsearch-is-open-source-again?ref=blog.matthewbrunelle.com) under AGPL. In 2024 Redis [re-licensed](https://redis.io/blog/redis-adopts-dual-source-available-licensing/?ref=blog.matthewbrunelle.com) \[^ With really spicy [pr comments](https://github.com/redis/redis/pull/13157?ref=blog.matthewbrunelle.com).\] under SSPL and their Redis Source Available License. The Linux Foundation [forked](https://valkey.io/blog/hello-world/?ref=blog.matthewbrunelle.com) Valkey and Redis attempted to set a narrative with a [blog post](https://redis.io/blog/what-is-valkey/?ref=blog.matthewbrunelle.com) about "What is Valkey". Despite that a year later Redis attempted to [reverse course](https://redis.io/blog/agplv3/?ref=blog.matthewbrunelle.com) and dual-license under AGPL again. as people and distributions were jumping ship to Valkey. Somehow I missed that in 2023 Terraform had [re-licensed](https://www.hashicorp.com/en/blog/hashicorp-adopts-business-source-license?ref=blog.matthewbrunelle.com) from the Mozilla Public License v2.0 to the Business Source License. The Linux Foundation [explains](https://www.linuxfoundation.org/blog/how-open-source-foundations-protect-the-licensing-integrity-of-open-source-projects?ref=blog.matthewbrunelle.com) the license well: > It’s important to note that many or all of these BUSL relicensed projects have OSI-approved license projects in their dependency trees. Many of the relicensed projects rely on thousands of packages and modules under OSI-approved open source licenses. The companies relicensing often cite the motivation as the need to prevent tech companies from taking all their business. However, there doesn’t seem to be a problem building their BUSL products on Linux and thousands of open source projects they depend on in their codebase. Then the community [forked ](https://opentofu.org/blog/opentofu-announces-fork-of-terraform/?ref=blog.matthewbrunelle.com) OpenTofu and applied to become part of the Linux Foundation. So after a good couple hours of reading about the past I remembered what I originally set out to do. Ongoing Updates: I'm going to add new examples of OSS projects relicensing as I find them. - In 2009 [Sentry](https://web.archive.org/web/20250925130834/https://blog.sentry.io/introducing-the-functional-source-license-freedom-without-free-riding/) relicensed from BSD-3 to BSL, then in 2023 created their [Functional Source License](https://web.archive.org/web/20250925130834/https://blog.sentry.io/introducing-the-functional-source-license-freedom-without-free-riding/). - In 2014 Gitlab [relicensed](https://about.gitlab.com/blog/gitlab-ee-license-change/?ref=blog.matthewbrunelle.com) under their own [GitLab Enterprise Edition (EE)](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/LICENSE?ref=blog.matthewbrunelle.com) - In 2018 Emby [tried](https://web.archive.org/web/20181225073326/https://github.com/MediaBrowser/Emby/issues/3075) to make the GPL code closed source. - In 2025 Pangolin [relicensed](https://github.com/fosrl/pangolin/issues/1620?ref=blog.matthewbrunelle.com) to their own [Fossorial Commercial License](https://digpangolin.com/fcl.html?ref=blog.matthewbrunelle.com). The upside is we got the wonderful [Jellyfin](https://jellyfin.org/?ref=blog.matthewbrunelle.com) fork. - In 2025 Liquibase [relicensed](https://github.com/liquibase/liquibase/issues/7374?ref=blog.matthewbrunelle.com) from Apache License to FSL. - In 2026 Mattermost's [confusing license situation was clarified](https://github.com/awesome-selfhosted/awesome-selfhosted-data/issues/1997?ref=blog.matthewbrunelle.com). --- ### Back to the Self-hosting Stuff ~~Terraform~~ OpenTofu is appealing because it helps me declaratively write out my DB config in case I need to migrate later. \[^ Also I appreciate not having to run all the commands by hand against the DB.\] Especially since [Unraid keeps upping its pricing](https://unraid.net/blog/new-pricing?ref=blog.matthewbrunelle.com) and TrueNAS finally landed [RAIDZ expansion](https://www.truenas.com/blog/electric-eel-openzfs-23/?ref=blog.matthewbrunelle.com) last year. At work my infra team handles Terraform so this is basically all net new for me. The first part I looked into were [Providers](https://opentofu.org/docs/language/providers/?ref=blog.matthewbrunelle.com), which are "plugins... to interact with cloud providers, SaaS providers, and other APIs". You must declare which providers you are using. Then OpenTofu will handle installing them for you. I used the [cyrilgdn/postgresql](https://github.com/cyrilgdn/terraform-provider-postgresql?ref=blog.matthewbrunelle.com) provider to handle configuring Postgres. Note that this does not handle installing Postgres itself. Here is a minimal example. Define a [.tf](https://opentofu.org/docs/language/files/?ref=blog.matthewbrunelle.com#file-extension) file: ```yaml terraform { required_providers { postgresql = { source = "cyrilgdn/postgresql" version = "1.25.0" } } } provider "postgresql" { host = "postgres" port = 5432 database = "postgres" username = "postgres" password = var.postgres_password sslmode = "disable" } resource "postgresql_database" "endurain" { name = "endurain" owner = postgresql_role.endurain.name } resource "postgresql_role" "endurain" { name = "endurain" login = true password = var.endurain_password } resource "postgresql_grant" "endurain_database" { database = postgresql_database.endurain.name role = postgresql_role.endurain.name schema = "public" object_type = "database" privileges = ["ALL"] } ``` The `terraform` block is where we declare that we are using the Postgres provider. Then in the `provider` block we provide connection information. Note the `var.postgres_password`. I defined another file `variables.tf`: ``` variable "postgres_password" { description = "Password for PostgreSQL admin user" type = string sensitive = true } variable "endurain_password" { description = "Password for endurain database user" type = string sensitive = true } ``` This uses a [Variable](https://developer.hashicorp.com/terraform/language/values/variables?ref=blog.matthewbrunelle.com) block to define an input variable for the database password. The remaining blocks are [Resource](https://opentofu.org/docs/language/resources/?ref=blog.matthewbrunelle.com) blocks. These are really the core of a ~~Terraform~~ OpenTofu configuration. They define *something* that we want to have configured *someway*. More on how resources are used in a bit. For now the example defines three resources: 1. A `postgresql_database` named "endurain" which will create the database on the connection we configured. 2. A `postgresql_role` that handles creating a role on the database using the input variable. 3. A `postgresql_grant` which provides the role all privileges on the database. With these resources defined you can run `tofu plan`. This is where the *something* will happen to the resources. You can think of the process almost like a diff: - The providers will be used to check the real world state. - That state will be compared to the state described in your configuration - When the two differ OpenTofu will figure out how to change the real world to match your configuration. The result of the process is then displayed for you to see. Once you have the plan you can run `tofo apply` to... well *apply* the configuration. This is obviously a very simple example that we could extend in many ways, but is a good starting point. One is example is using [Modules](https://opentofu.org/docs/language/modules/?ref=blog.matthewbrunelle.com) which are a mechanism to help reduce duplication. That way when you want to set up additional databases for more services you are not repeating the configuration. Another option is to leverage [Credential Helps](https://opentofu.org/docs/internals/credentials-helpers/?ref=blog.matthewbrunelle.com) for managing secrets. For now I'm keeping my configuration pretty simple. Everything is under version control, but not hooked up to to any CI yet since I have some other changes in the works. ### Blog Paleontology - 15 years of Blogging URL: https://blog.matthewbrunelle.com/blog-paleontology-15-years-of-blogging/ Last updated: 2025-08-18T17:45:45.000Z #### On this page As noted in my post [All Personal Blogs Die, But Mine Came Back As a Ghost](https://blog.matthewbrunelle.com/all-personal-blogs-die-but-mine-came-back-as-a-ghost/) my blog has gone through many iterations over the years. When I recreated the site after letting it lapse I noticed that I didn't have my full collection of posts. At some point I had removed older posts from when I was young. This is understandable, as it can be uncomfortable to read one's old writing. Now though, I think there's value in remembering what I once wrote. So I've done my best to restore the old pages. --- ### A look through the post timeline Thankfully my old blog was kept under version control! So I could scrub through the commit log to find what was missing. The posts span 2011 to 2013\. Some interesting stats show a trend: - 15 posts in 2011 - 2 posts in 2012 - 3 posts in 2013 This corresponds to my senior year of high school and first two years of college. After that I didn't post again until 2018 when wrote about how I made [Soup Bot](https://blog.matthewbrunelle.com/soup-bot-a-slack-bot-that-scrapes-specials-menus-from-instagram/) for my first job. \[^ I was very excited by the idea of Soup Bot.\] After another burst of posts I only wrote a couple posts each year until 2021 when I stopped: - 5 posts in 2018 - 3 posts in 2019 - 2 posts in 2020 - 2 posts in 2021 2021 lines up with when I changed jobs and was a lot busier. In general I seem to write a burst of posts and then take some time off. --- ### Forgotten Things Some other fun details I found while going through the commit log: - I created pages for me to share recommended books, programs, rss, etc. Those pages were never filled out... This is shocking, but not surprising given the fact that I was planning to make those pages on the new version of the blog, but have not gotten around to it. Some habits never change. - One stylistic difference I've noticed: I used to stuff extra thoughts into parenthesis, whereas now I try to use footnotes instead. \[^ See like this.\] - I used to have a +1 button, which was Google's attempt at a like button for the now defunct Google+ - For a while I had a "Fork me on Github" ribbon on the site. - I had forgotten I joined the internet blackout for [SOPA](https://www.eff.org/issues/coica-internet-censorship-and-copyright-bill?ref=blog.matthewbrunelle.com) on January 18, 2012. --- ### Have a Read The restored posts can be found under the new [paleontology](https://blog.matthewbrunelle.com/tag/paleontology/) tag. Instead of just posting them as is, I've added a little bit of commentary and context at the top of the post. I also tried to clean up spelling, formatting, and dead links when possible. Another detail I noticed was that I initially started the blog because of the trend to "manage your online brand". I was not writing because I enjoyed the act, nor because I wanted to share things with the world. Looking back the reason was more selfish: to get my name out there. I think this explains why the writing was never lasted more than a year or two. Now I write for the sake of writing and I hope blogging will be a sustainable habit. ### Ghost 6.0 - ActivityPub Is a Go URL: https://blog.matthewbrunelle.com/ghost-6-0-activitypub-is-a-go/ Last updated: 2025-08-18T15:29:14.000Z #### On this page When I first switched to Ghost I wrote about [how ActivityPub integration was a main reason I chose it](https://blog.matthewbrunelle.com/all-personal-blogs-die-but-mine-came-back-as-a-ghost/). More recently had written about how [excited](https://blog.matthewbrunelle.com/i-dont-want-to-play-the-seo-game/) I was to get away from the modern SEO trap and hopefully find something like the old blogosphere. Well the time is finally here. I've been haunting the GitHub notifications for changes to the project, watching the preview releases trickle by. As of today Ghost 6.0 is launched! The [changelog](https://ghost.org/changelog/6/?ref=blog.matthewbrunelle.com) is beefy, so I'm not looking to enumerate everything in it. The new ActivityPub integration is what I am most excited to write about. --- ### Changes to Self-Hosting Ghost The first thing to note is that setting up ActivityPub on a self-hosted instance of Ghost can be done to two degrees: 1. You can run Ghost, but use their ActivityPub infrastructure at ap.ghost.org. This is subject to [usage limits](https://docs.ghost.org/install/docker?ref=blog.matthewbrunelle.com#hosted-activitypub-usage-limits). 2. You can run ghost *and* the ActivityPub server all on your own infrastructure. I have currently achieved #1 but I am not yet at #2\. This is because the self-hosted ActivityPub is still in a preview state. Setting everything up [involves](https://docs.ghost.org/install/docker?ref=blog.matthewbrunelle.com#enabling-social-web-activitypub) quite a bit of work and assumes you are installing on a dedicated server. Right now you can't just pull Docker images and go. You have to checkout a [git repo](https://github.com/TryGhost/ghost-docker?ref=blog.matthewbrunelle.com) and Docker Compose from there. To use their own words: > Ghost 6.0 represents the beginning of a major improvement to how Ghost is deployed. Up until now, Ghost has been installed with Ghost-CLI as a single app that runs on a VPS with one MySQL database, but our new social web and analytics features have been built as independent services that run alongside Ghost. > > So, we're moving toward **Docker Compose** as the officially supported way to install, run and update Ghost. This is now available as a developer preview, and will become the default from Ghost 7.0 onward. So I'm excited to see the project moving towards proper containerization, but am holding off since support is still in an early state. What's more I'm currently using the Unraid Community App for Ghost which will not work with this design. That's OK! The growing pains are worth the change. Also, the docs are a little scattered right now: - There is this [Docker guide](https://docs.ghost.org/install/docker?ref=blog.matthewbrunelle.com) on the official site, but its not complete yet. - There is a guide on [Notion](www.notion.so/ghost/Ghost-6-0-Self-Hosting-Guide-20751439c030802aab77e7da43e0eb43) that may have been a draft version of the docs. - [People](https://forum.ghost.org/t/happy-ghost-6-0-day-quick-question/59214/7?ref=blog.matthewbrunelle.com) on [the forums](https://forum.ghost.org/t/upgrade-from-5-to-6-using-portainer/59226?ref=blog.matthewbrunelle.com) are [also](https://forum.ghost.org/t/guide-community-overview-of-ghost-v6-0-for-self-hosted-installations/59236?ref=blog.matthewbrunelle.com) foaming at the mouths to figure out how to set ActivityPub up for themselves. Regardless I was able to make #1 work. --- ### Getting My Self-Hosted Instance to Work With Ghost's Hopefully this goes without saying, remember to have a backup before you upgrade. \[^ You should actually always have backup, but in this case especially.\] First I did a Docker update to pull in the 6.0 version of the container. Then I saw the new "Network" section in the admin panel. However is was non-functional. The logs for Ghost revealed a number of 404s on pages starting with `.ghost`. So I did some snooping around. I noticed the new guide has language around Caddy, which I use, and not just nginx. We love this. I found the docs source in Github and did a blame to find the [commit](https://github.com/TryGhost/Docs/commit/c647ce10c9273e33a075eaede1b261c038c0fc5c?ref=blog.matthewbrunelle.com) with all the Caddy additions. This was a useful starting point, but not the whole picture. The repo for the Docker Compose based installation contains the actual snippets to use in a Caddyfile. In particular there was an [ActivityPub caddyfile snippet](https://github.com/TryGhost/ghost-docker/blob/main/caddy/snippets/ActivityPub?ref=blog.matthewbrunelle.com) that had `handle` sections for redirecting the `.ghost` endpoints to an ActivityPub Server. The snippet uses a `ACTIVITYPUB_TARGET` variable that I [realized](https://github.com/search?q=repo%3ATryGhost%2Fghost-docker%20ACTIVITYPUB%5FTARGET&type=code&ref=blog.matthewbrunelle.com) defaults to `ap.ghost.org` when not overridden with a local server. So I updated my caddyfile to have an entry similar to: ```yaml https://blog.matthewbrunelle.com { handle /.ghost/activitypub/* { reverse_proxy https://ap.ghost.org } handle /.well-known/webfinger { reverse_proxy https://ap.ghost.org } handle /.well-known/nodeinfo { reverse_proxy https://ap.ghost.org } reverse_proxy ghost:2368 { header_up Host {host} header_up X-Real-IP {remote_ip} header_up X-Forwarded-For {remote_ip} header_up X-Forwarded-Proto {scheme} } } ``` With that, a restart of Caddy and Ghost, and the Network integration was live! You can see my blog's profile [now](https://blog.matthewbrunelle.com/ghost/#/activitypub/profile). One Side note about the Ghost network. The admin settings on my blog had this new notice: > Share growth data to rank higher? > Enabling this will use your revenue/member growth data to rank your site more highly on Ghost Explore. Total member count will be displayed publicly, other data will be kept private. I've left this toggled off for now, until I better understand just what is shared. --- ### Whats This Tinybird Thing? OK, I'll talk about one other new feature, but only because I also spent a lot time poking around. In 6.0 the internal site analytics were overhauled. This [leverages](https://ghost.org/integrations/tinybird?ref=blog.matthewbrunelle.com) an analytics platform called Tinybird. Some thoughts from when I explored this today: - A built in analytics solution is appealing because I could avoid injecting a script. - You can't really self-host Tinybird. To use their phrasing you can only ["self manage"](https://www.tinybird.co/blog-posts/tb-infra?ref=blog.matthewbrunelle.com) the workspace. That keeps the data on your machine, but requires you to still go through their cloud platform. - The analytics do not seem to have every detail that I'd like. Mainly, details like referrer information. Because of this I think for now I'm going to stick with Umami. Especially since the upcoming [3.0 release](https://umami.is/blog/what-is-coming-in-umami-v3?ref=blog.matthewbrunelle.com) should improve the ergonomics. --- ### So Whats Next? My plan going forwards is to spin up a test instance using the new Docker Compose based setup. Then I'll test the self-hosted ActivityPub server. If that goes well I can import everything into the new instance and cut-over. Hopefully as that happens the documentation will be clarified too. Anyways, I'm excited to finally see this all coming together and looking forward to see how we all build on this foundation! ### It's Weirdly Hard to Buy Music Now URL: https://blog.matthewbrunelle.com/its-weirdly-hard-to-buy-music-now/ Last updated: 2025-08-18T15:29:01.000Z #### On this page Yesterday, the new Hilltop hoods new album [Fall From the Light](https://hilltophoods.com/?ref=blog.matthewbrunelle.com) dropped! I don't really listen to hip-hop much but brother did growing up and like most younger brothers I was impressionable. I'm happy I started listening to them because I've had the chance to come see them every time they come to the US. Usually it corresponds with a new album release so fingers crossed they come by next year. It is my understanding that in the age of streaming concerts are now a really important revenue source for artists. \[^ I had a really hard time finding primary sources for numbers on this topic. It's not my area of expertise, so I'll stick to generalities.\] So support them if you can! Second to that is directly buying their music, since it's digital the margins are pretty good. \[^ Merch actually seems a really big component as well.\] --- ### The Quest So the question is: In the year 2025, how hard is it to buy and download a DRM free lossless version of an album? I want to directly buy music to support an artist I care about. One option is to buy a CD, but that involves shipping, packaging and ultimately waste because I would just rip the music and then the disc would sit around. Don't get me wrong, the backup of a disk is wonderful and rare these days. However, I'm personally trying to fight my urge to buy more physical goods. So instead I wanted to figure out where in this blighted hellscape I could digitally purchase the album. My requirements are: 1. DRM Free, that way I can plop it on any device I want. 2. Lossless quality. If I actually bought a CD I would rip the songs in FLAC. So if a digital download is roughly the cost of a CD, then I better be able to download the music as something better than a lossy mp3. This is because I self-host my music streaming. [Navidrome](https://www.navidrome.org/?ref=blog.matthewbrunelle.com) running on my server and [Symfonium](https://symfonium.app/?ref=blog.matthewbrunelle.com) on my phone (which I've [written about](https://blog.matthewbrunelle.com/the-apps-that-i-kept-on-grapheneos-2/#the-media-apps) before). --- ### The Options Back in the day I used to check Bandcamp first when I wanted to buy an album. \[^ For example the soundtrack to any games by Toby Fox.\] Then Epic Games bought them in 2022\. Then the employees tried to form a union. Then [every member of the union bargaining team was laid off](https://www.sfgate.com/tech/article/bandcamp-bargaining-union-layoffs-songtradr-18432047.php?ref=blog.matthewbrunelle.com) and they were sold off to Songtradr. So, Bandcamp is really an option now... Anyways, the Hilltop Hoods website has a [short link](https://hilltophoods.lnk.to/FFTL?ref=blog.matthewbrunelle.com) with a list of places to get the album. In order we have: **The Official store**: Listed as the "Limited Edition Vinyl, Merch & CD" in the shortlist. The idea scenario for me is a digital download direct from the artist. That way a 3rd party is not taking a cut of the purchase, and what luck, the store also has a digital download listing! That seemed promising but, the page has the ominous paragraph: > Digital album will be delivered via email upon purchase. Please note: Digital downloads will only be available to Australian customers & are delivered as MP3 44.1KHZ audio files. So not only is this limited to Australia, but also the music is not lossless. **Apple Music, Spotify, Youtube, Youtube Music, Amazon Music, Deezer, SoundClou:** all listed as "Play" I have been able to download albums from Apple Music in the past but do not see the option here. Regardless, all of these look to be streaming only. **JB Hi-Fi, Sanity, The Sound of Vinyl:** Listed as "Limited Edition Vinyl & CD" Weirdly two of the options are just listed as a vague "Go To". They only provides physical purchases. **Amazon:** Listed as "Vinyl & CD" We don't want to support them. **iTunes Store:** The link just takes me to the same Apple Music page as before and there is no option to buy. **Tidal & Qobuz**: listed as "Play" Finally towards the bottom of the list we have two sites that actually offer a buy option! Even though they are listed as just "play". Whats more they both offer lossless versions of the music. Huzzah. --- ### The Purchase I flipped a coin and went with Qobuz. After the purchase they wanted me to use their downloader to get the music. However they only support MacOS and Windows, not Linux. Thankfully they also had the option to directly download the songs... manually one at a time: ![Screenshot 2025-08-01 at 2.47.47 PM.png](https://blog.matthewbrunelle.com/content/images/2025/08/Screenshot-2025-08-01-at-2.47.47---PM.png) The download page also had the delightful message: > Certain works may be withdrawn from the service for legal reasons, such as a revocation of rights. The titles purchased will then no longer be available for re-download. **Download your purchases quickly.** (Emphasis mine) This is weird considering the download page also had the line "Own your music with the new Qobuz Downloader". It almost feels like even Qobuz doesn't trust that they can keep this model going. So yes, it was still possible to buy the album, but I think the whole process took 40 minutes all together for me to figure out. Which reminds me of the infamous [interview with Gabe Newell](https://www.escapistmagazine.com/valves-gabe-newell-says-piracy-is-a-service-problem/?ref=blog.matthewbrunelle.com) that is now almost 13 years old but still as true as ever: > “We think there is a fundamental misconception about piracy. Piracy is almost always a service problem and not a pricing problem,” he said. “If a pirate offers a product anywhere in the world, 24 x 7, purchasable from the convenience of your personal computer, and the legal provider says the product is region-locked, will come to your country 3 months after the US release, and can only be purchased at a brick and mortar store, then the pirate’s service is more valuable.” - Gabe Newell Well put Gabe, well put. ### What Podcasts Am I Listening To? URL: https://blog.matthewbrunelle.com/what-podcasts-am-i-listening-to-2025/ Last updated: 2025-07-20T14:40:31.000Z #### On this page ### On the Topic of Podcasting: It is crazy how far podcast have come. I remember the days of loading a feed into iTunes and downloading episodes onto my iPod. In my mind podcasting a great example of how well open federated systems can work. Somehow, against all odds, the podcasting ecosystem continues to push back against corporate capture. That's not to say it isn't happening in some places. For example, I loved the podcast *How to Save the Planet*. The content was thoughtful and each episode ended with a practical call to action. Then Spotify bought it and then Spotify [killed it](https://techcrunch.com/2022/10/06/spotify-cancels-11-original-podcasts-lays-off-under-5-of-staff/?ref=blog.matthewbrunelle.com). \[^ One of the hosts Ayana Elizabeth Johnson wrote a wonderful book *What If We Get It Right?* and that is something at least that Spotify can't kill.\] This means that over time podcasts come and go. The feed is always in flux. This is meant to be a little snapshot in time. --- ### On the News and How Much to Consume: Even though I'm practicing reading long form content, I primarily get my news through audio. In part this is because I can split my attention while listening and do chores or exercise. That is not something I want to do when engaged with a good book. The other reason is there is high signal-to-noise sources for news in audio form. Almost too many good sources. I used to have a maximalist approach to listening to the news. I wanted to be well informed so I could talk about issues. However that also pays a high mental toll. A lot of what you hear is emotionally heavy. Also you can only consume so much content and retain it well. So I have shifted listening habits to find a balance between being well informed, and not over consuming the news. \[^ I had been trying this in a scattered approach but Cal Newport's *Digital Minimalism* really helped me solidify this.\] The main way I realized this was changing from daily news to slow journalism. I used to focus on multiple short daily news sources: NPR's *Up First*, *PBS News Hour*, *The Daily* \[^ I still keep The Daily in my feed. I've been listening since it started. The difference now is I no longer listen to every episode. Instead I try to focus on episodes that provide reporting on gaps in my understanding.\], etc. \[ ^ In fact way back before podcasting took off, NPR's News Now was just and RSS feed of supercut news clips: no intro, no outro, no ads. *chefs kiss*\]. Now I try to focus more on weekly news. The benefits are: - Tranquility. I am not getting a glucocorticord stim constantly from fast news designed to drive engagement through outrage. For me *boring* news is what I want. - Journalists can provide more details and better coverage when they have more time to work on a piece and wait for details to emerge. - If someone asks me about a recent event that I haven't heard about, I am still informed enough to participate in the conversation. As an aside, in 2014 I did a semester at the University of Glasgow. I got to take [Economic & Social History 1B: Themes in Globalisation, 1914-c2008](https://www.gla.ac.uk/coursecatalogue/course/?code=ESH1002&ref=blog.matthewbrunelle.com). The course covered both World Wars and it was really eye opening to see how other countries, even allies perceive the US. Since then I try to re-contextual events in my life: is this universal or just something we do here? So I also try to include news from outside the US in my media diet. Some slower news I really appreciates: - *Inside Europe* by DW - Deutsche Welle is a German public broadcaster and Inside Europe is specifically designed as a source of news for people outside the EU to know whats going on in the EU. Its weekly and the content is typically in-depth. - *The Economist Weekly Edition* \- Originally recommended to me by a coworker. I was surprised, but it's a pretty balanced and nuanced perspective. Also, as I mentioned in my post about [what apps I use on GrapheneOS](https://blog.matthewbrunelle.com/the-apps-that-i-kept-on-grapheneos-2/), you no longer need to use their app and can subscribe by RSS in your podcast player of choice. - *The NPR Politics Podcast* \- I don't listen to the weekday content outside of their Friday weekly wrap up. Also with NPR+ they occasionally do in-depth episodes on specific topics. Anyways, speaking of public news... --- ### On the Topic of Public Broadcasting: The House has [approved a plan](https://www.npr.org/2025/07/18/nx-s1-5469912/npr-congress-rescission-funding-trump?ref=blog.matthewbrunelle.com) to rescind $1.1 billion for the Corporation for Public Broadcasting. If you are in a spot to, please support your local stations since they are most likely to be impacted! I'm pretty lucky that WGBH is both and NPR and PBS. So I can support both at the same time and get NPR+ content. Also consider if your work has matching donation offers at certain times of the year! --- ### Now the Fun Stuff: When it comes to entertainment podcasts, the McElroy family has an outside footprint in my feed. \[^ I own both the [Bean Juice](https://www.youtube.com/watch?v=lIwC0YPDeJs&ref=blog.matthewbrunelle.com) and Bean Juice 2.0 mugs.\] This is mostly because they were very early to the podcasting scene \[^ They even wrote a book *Everybody Has a Podcast (Except You)*.\] It also doesn't hurt that they come to Boston frequently and I get to see the live shows. From their media empire I listen to: - *The Adventure Zone* \- this is the first TTRPG podcast I ever listened to. I don't always listen to every season, but the recent TAZ vs Dracula was the kind of chaos I crave. - *The Besties* (+ Patreon) - technically only two of the brothers, but this is a really solid games journalism podcast and I often pick up games they recommend before hearing other places hyping them up. - *The Empty Bowl* \- A "meditative podcast on cereal". I don't really eat cereal, but it's kind of hypnotizing. It's also fun to share weirdly strong opinions about cereal, regardless of whether people asked for your opinion. - *My Brother, My Brother and Me* \- Funny, relaxing and nostalgic. Other notable podcasts are: - *The Glass Cannon Network* (Subsciption) - I have been listening to it from the beginning and see their live shows in Boston when they come by. The main feed show has been having some difficulties but the Patreon show *Blood of the Wild* is really good. I appreciate that its a more rules and mechanics heavy show, but still manages to have a strong narrative. I think I'm going to take a break from subscribing for the first time though while they retool all their programming. - *Hello From the Magic Tavern* (Patreon) - The show has been going for 10 years strong! It really is some fun improv and I cannot recommend it enough. - *Not Another D&D Podcast* \- A counterpoint to GCN, this is much more narratively heavy and lots of fun. Always a good laugh and the side content like Dungeon Court, 8-bit Book Club and Tortle Tank and much appreciated. - *RPGBOT.podcast* \- Excellent TTRPG munchkin energy. I follow their RSS feed online, and add episodes selectively. Usually just the PF2e content. --- ### Now For the Interesting Stuff: I want to elevate one podcast above the others because I truly appreciate it: *Gastropod*. The podcast "looks at food through the lens of science and history". I am genuinely excited when I see there's a new episode and usually bump it to the front of my listening queue. I read *Freakanomics* and *SuperFreakanomics* in high school and adored them. So the *Freakonomics Radio* podcast has been a long time listen for me. The network has a couple other podcasts, but of them I've only really listened to two: - *No Stupid Questions*: I'm not currently listening but really enjoyed it when it came out. This is mostly because one of the original hosts Stephen moved off the podcast. I mostly wanted to mention it because on the last episode that Stephen did, they answered my own listener submitted question: [Do You Have a Scarcity Mindset or an Abundance Mindset?](https://freakonomics.com/podcast/do-you-have-a-scarcity-mindset-or-an-abundance-mindset/?ref=blog.matthewbrunelle.com) - *People I (Mostly) Admire*: it's been really cool to see Steve move away from academia, develop as a speaker, and try to advocate for causes he really cares about. For the remaining podcasts I'm going to recommend some episodes to try out. Think of them as a little sampler: - *Android Developers Backstage* \- its really interesting to hear about all the internals of Android. Sample episodes: [bluetooth](https://adbackstage.libsyn.com/episode-97-bluetooth?ref=blog.matthewbrunelle.com) helped me to understand why it can be so flaky sometimes and this episode on the [fused location services](https://adbackstage.libsyn.com/episode-96-location-its-where-its-at?ref=blog.matthewbrunelle.com) was a treat. - *Complex Systems* \- Sample episodes: [credit cards, airlines, and hotel loyalty programs work](https://www.complexsystemspodcast.com/episodes/gary-leff-frequent-flier-programs/%7D?ref=blog.matthewbrunelle.com) and [Understanding and wielding power in local government, with Daniel Golliher](https://www.complexsystemspodcast.com/episodes/understanding-and-wielding-power-in-local-government-with-daniel-golliher/?ref=blog.matthewbrunelle.com). - Data Skeptic - Sample episode: [found of iNaturalist](https://dataskeptic.com/blog/episodes/2024/inaturalist?ref=blog.matthewbrunelle.com). Did I mentioned I think [birds are neat](https://blog.matthewbrunelle.com/birdnet-go-but-we-have-bird-monitoring-at-home/)? - *Drilled* \- Sample episode: [S3, Ep7 | John Hill and the Tobacco-Oil-Plastic Triangle](https://drilled.media/podcasts/drilled/3/drilleds03-e07?ref=blog.matthewbrunelle.com). - Lexicon Valley - Sample episode [order that we automatically put adjectives in](https://lexiconvalley.supportingcast.fm/listen/lexicon-valley-from-booksmart-studios-1/age-color-origin-material?ref=blog.matthewbrunelle.com). - *Money Stuff* \- I'm a long time reader of the [free newsletter](https://www.bloomberg.com/account/newsletters/money-stuff?ref=blog.matthewbrunelle.com). The podcast is less information, more fun and flavor around what Matt wrote about that week. Good vibes for a Friday. \[^ Also before they launched the podcast, they sent out a survey to see if people were interested in one. I got to respond that [I had a literally TTS automation pipeline](https://blog.matthewbrunelle.com/automatic-conversion-of-article-html-to-ssml-for-text-to-speech/) for the weekly newsletter so I could listen to it.\] - *The new Bazaar* \- Sample episode: [Shattering immigration myths](https://eig.org/newbazaar/shattering-immigration-myths/?ref=blog.matthewbrunelle.com). - *Practical AI* \- Sample episodes: [Federated Learning in Production](https://practicalai.fm/314?ref=blog.matthewbrunelle.com). - *Signals and Threads* \- Sample episode: [From the Lab to the Trading Floor with Erin Murphy](https://signalsandthreads.com/from-the-lab-to-the-trading-floor/?ref=blog.matthewbrunelle.com). - *Statecraft*, a recent addition - Sample episode: [How to Salvage a Transit Project](https://www.statecraft.pub/p/how-to-salvage-a-transit-project?ref=blog.matthewbrunelle.com) - *Tread Lightly Running Podcast* \- Sample episode: [Why You Should Warm Up Before Runs - and How to Do It](https://podcasts.apple.com/us/podcast/96-why-you-should-warm-up-before-runs-and-how-to-do-it/id1674913391?i=1000680341013&ref=blog.matthewbrunelle.com) - *Whats your problem* \- I *really* like how much he researches the topic and person ahead of time, plus asks good thoughtful questions. Sample episode: [How the Sun Won (The Solar Era, Part 1)](https://www.pushkin.fm/podcasts/whats-your-problem/how-the-sun-won-the-solar-era-part-1?ref=blog.matthewbrunelle.com). Happy listening! ### I Don't Want to Play the SEO Game URL: https://blog.matthewbrunelle.com/i-dont-want-to-play-the-seo-game/ Last updated: 2025-07-31T15:23:08.000Z #### On this page This blog is mostly a writing exercise for me. However I hope that what I write is also useful for others. If the content is useful, then I want the right people to be able to find it. I don't want to spam links to my blog. I remember back in the aughts being excited about generating a sitemap for the first time. It felt like all I had to do was hand it off to Google's Webmaster Tools, as it was called then, and people could see what I wrote. Now I go to the Google Search Console: > Crawled - currently not indexed No pages are indexed. You are directed to [documentation](https://support.google.com/webmasters/answer/7440203?ref=blog.matthewbrunelle.com#crawled) telling you to play the SEO game. I decided to try out Bing Webmaster Tools because why not: > "Your site lacks inbound links from high-quality domains." I get it, there's so much AI slop to fight now a days that its hard to sift through and know what is the good human written content. However I feel like with all these defenses in place we *still* don't see the good human written content. \[^ Not to mention that the Google and Reddit partnership has [further eroded trust](https://housefresh.com/beware-of-the-google-ai-salesman/?ref=blog.matthewbrunelle.com). \] --- ### Some Things Work At Least I usually use Kagi for search, so I thought "is there a webmaster tool for Kagi?" The answer is no and I love that. Thankfully Kagi doesn't want to leave bloggers hanging. They are a big fan of the [small web](https://blog.kagi.com/small-web?ref=blog.matthewbrunelle.com) and all I had to do was [open a PR](https://github.com/kagisearch/smallweb/pull/421?ref=blog.matthewbrunelle.com). \[^ You can't just add your own site though. Theres a minimum of three new sites, so try to add your friends as well!\] Likewise, way back in the day I added my site to [Personalsit.es](https://personalsit.es/?ref=blog.matthewbrunelle.com). The [PR](https://github.com/xdesro/personalsit.es/blob/1960265e8b0e744ed481c44a592f397c72c9e50e/sites/blog.matthewbrunelle.com.md?plain=1&ref=blog.matthewbrunelle.com#L4) tells me that was 6 years ago. The problem with all of these approach though is how you have to (1) know that they exist and (2) take action to opt into them. In an ideal world it would be nice if all I had to do was: - Write a reasonable title. Hopefully a meaningful one rather than a clickbait title. - Add a useful, short text description for the meta field. - Focus on 100% human written long form content, grammar mistakes and all. \[^ and some form of lightweight analytics. I don't need targeted advertising, I'm not trying to earn revenue, and I have nothing to sell. I do however want to know if certain articles are being read beacuse thats a sign they were of some use. Umami hosted in the EU has been pretty good so far.\] Then I'd want something magic to happen: somehow all our blogs link arms and talk to each other. I suppose thats just a dream... --- ### We Can Do Better Though Anyways, I've [mentioned](https://blog.matthewbrunelle.com/all-personal-blogs-die-but-mine-came-back-as-a-ghost/) that I am patiently awaiting for the ActivityPub integration in Ghost 6.0 to release. Yesterday I was looking at their [dev blog post](https://activitypub.ghost.org/?ref=blog.matthewbrunelle.com) that pointed me towards an [episode of the Dot Social Podcast](https://dot-social.simplecast.com/episodes/longformers?ref=blog.matthewbrunelle.com). I hadn't listened to the podcast before but the specific episode had the leads of Ghost and WordPress chatting about the history of blogging, their future hopes. Some of it really hit home for me: (GenAI note: these quotes are pulled from the YouTube transcript. I assume they were auto-generated with TTS. I've also clean them up a little, because they were generated after all.) Why blogging felt different from social media: > Facebook was already around at the time, but it was for people whom you knew, and Blogs were for people who you didn't yet know, but who you shared something in common with. And Twitter kind of lowered the barrier to entry for that, lowered the barrier to entry for how much content you need to make and take off. > What they all benefited from was that it was easy to get started. It was free typically and it had this network built in where the social aspects that used to be in the blogosphere were like on steroids. You know, now you had likes and replies and two-way Interactions and notifications. It was a step above RSS in every way, in a way that allowed communities to form around topics. So for those of us on WordPress, on Ghost, on our own websites, that kept publishing outside of those networks, the web started to become more and more lonely. You would hit publish, and then the comments wouldn’t really roll in anymore the way they used to. The discoverability problem I'm feeling: > From an SEO point of view, if someone's searching for, you know, content about Tesla, articles about Tesla, the chances of you ever even seeing that link in Google search results are now increasingly lower because you've got these AI summaries up at the top of the Google search results. Right, so there are increasingly fewer and fewer ways for people to discover this writing, you know, out on these other platforms. How writers supported other writers: > Just everyone has relied on Google or social media apps, and there are so many other ways for discovery to happen. One of the things you mentioned was the concept of a blogosphere where people were linking to other people, right? And you know, I talked about this with Molly White at South by Southwest recently... I asked her, "How did you get discovered as a new writer? How did you get discovered?" Her answer was that her other writers, her heroes, respected her writing and linked to her writing from their blogs. Finally their vision for what the future can be like: > It's time to revisit this blogosphere concept, but do it with a new web standard that makes it not just something that would work Ghost to Ghost, but something that works Ghost to WordPress, Ghost to Threads, Ghost to Flipboards, and have everything combined with each other. This dovetails nicely into a book by Cory Doctorow I've just finished up reading, The *Internet Con:How to Seize the Means of Computation*. \[^ You can, amazingly get a DRM free epub through [Verso Books](https://www.versobooks.com/products/3035-the-internet-con?ref=blog.matthewbrunelle.com).\] I'm not going to pull direct quotes from the book because I think it's worth the read. Many of the topics Cory hits on though mirror what ActivityPub support in writing platforms like Ghost, WordPress, and WriteFreely are trying to achieve: - Ideally automatic: you just choose to federate and off you go. - Interoperability: no one platform controls all the users and content. You can move when you want to. You can even self-host it yourself! - Network effects: it's not necessary for each platform to build their own user base, all platforms that federate get an audience immediately. - Niche communities: You can build smaller, more focused conversation that can moderate with a contextual understanding. There's going to be lots of growing pains, but I think it's an exciting time to see how this all shakes out. Just today the Ghost GitHub tagged the [v6.0.0-alpha.1](https://github.com/TryGhost/Ghost/releases/tag/v6.0.0-alpha.1?ref=blog.matthewbrunelle.com) release! \[^ Though the ActivityPub code lives in its own [repo](https://github.com/TryGhost/ActivityPub?ref=blog.matthewbrunelle.com) and I'm not sure its integration will release on a different timeline.\] --- ### I'm Not Totally Without Fear Most of what I have talked about so far is the *technical perspective* on this view. I have a fear though: I don't use social media because of its capacity to ensorcell you. Over the last decade I've made explicit choices not to use social media. In the last couple years I've worked on consuming more long form textual content rather than short form content. More recently I've been trying to get back into writing again. Reading and writing are skill, things you can practice and develop through craft. ActivityPub will allow authors to openly interact with readerships again, but it does that by layering follows, likes, shares, etc on top. It is not bringing back the way things used to be. In the internet of today interactions have been replaced with *engagement*. So I do have the fear that we may head off with the best of intentions, but end up with the same problems from modern social media that I'm trying to avoid. I'm still hopeful though. ### Self Hosting a Dog Camera for Away From Home Monitoring URL: https://blog.matthewbrunelle.com/self-hosting-a-dog-camera-for-away-from-home-monitoring/ Last updated: 2025-07-15T22:46:00.000Z #### On this page > GenAI note: While I'm practicing my long form writing and do not use any GenAI tooling for writing posts, sometimes I use GenAI on side projects for learning about and setting up new tools. In this post I prompted Claude when I had issues with my configuration, so the full example config at the end is not 100% written by me. My partner and I adopted an elderly dog this past week. She is a wonderful bean! I have friends that use a camera to check in on their dog when they are away. That would be convenient, but I feel uneasy though adding video surveillance to the *inside* of my own house. So part of the challenge with this project was to see if I could: 1. Use an affordable camera. 2. Block the cloud integration and telemetry that makes it affordable. 3. Self host my own alternative. 4. Make sure the cameras are not active when I am home. 5. Continue to keep my total power usage down. \[^ This has been an overarching goal for me with self-hosting.\] When it comes to self-hosting video cameras, Frigate is the big name in town. It's a really cool and flexible project, but due to how much it can be customized, setting up the configuration can be intimidating. My recommendation, and the docs, is to start small and build up. So I'm going to try to simulate how I did that in this post as an example for others to learn from. --- ### Installing Frigate: Frigate can be installed as a Home Assistant OS addon, however the [docs state](https://docs.frigate.video/frigate/installation/?ref=blog.matthewbrunelle.com): > Frigate runs best with Docker installed on bare metal Debian-based distributions. For ideal performance, Frigate needs low overhead access to underlying hardware for the Coral and GPU devices. Running Frigate in a VM on top of Proxmox, ESXi, Virtualbox, etc. is not recommended I currently have HaasOS installed in a VM in Unraid, so I did not use the addon. Instead I used the Unraid community app. When you start it up the first time it will generate an example `config.yml` that will not work because, well, its just an example. Even though I didn't use the addon, I still plan to integrate with Home Assistant. So I had to do the following: - Install the [MQTT addon](https://www.home-assistant.io/integrations/mqtt/?ref=blog.matthewbrunelle.com). This handles both setting up an MQTT instance for you and integrating MQTT into Home Assistant. - Create a dedicated user in Home Assistant just for MQTT as the docs recommend. - Install the [Frigate Integration for Home Assistant](https://github.com/blakeblackshear/frigate-hass-integration?ref=blog.matthewbrunelle.com). This also requires [HACS](https://www.hacs.xyz/?ref=blog.matthewbrunelle.com) to be installed. Once I had the pieces in place I could start setting up Frigate! --- ### Minimum Configuration For Frigate: Before my cameras or the Coral arrived I was eager to set up something while I waited. When you start up Frigate for the first time it will generate an example `config.yml` that will not work because, well, its just an example. So I ignored its contents and started fresh. Based on what I could figure out, at a minimum the config that works needs two things: - An MQTT entry with the connection information - A camera entry. This can contain an empty collection of cameras though. Bare minimum example: ```yaml mqtt: host: user: "{FRIGATE_MQTT_USER}" password: "{FRIGATE_MQTT_PASSWORD}" cameras: {} ``` Note: You can provide [environment variable substitution](https://docs.frigate.video/configuration/?ref=blog.matthewbrunelle.com#environment-variable-substitution) for *some* but not all values in our configs. The docs say to use the [reference config](https://docs.frigate.video/configuration/reference/?ref=blog.matthewbrunelle.com) as an example to know which ones. With this configuration I was able to start up the Frigate container without any errors. After you run Frigate for the first time you will see that it appends something like `version: 0.15-1` to the end of your configuration. It seems this is added to handle any migrations that are needed in the future. --- ### Adding In a Camera: There are *lots* of options for cameras, especially if you want to spend money. Luckily I only need to monitor a small space and its inside so the lighting is pretty controlled. If I needed a camera for outside and operating at a distance it would be a totally different story. The documentation has a [recommended camera hardware section](https://docs.frigate.video/frigate/hardware/?ref=blog.matthewbrunelle.com#cameras) if you want a perfect setup. I did not, I went for the cheap option. I grabbed two TP-Link Tapo C120 \[^ One downside of the camera is that the LED is not hardwired to forcibly turn on when the camera is recording.\] cameras because: - They did not cost much. - You can set up a local account on them that does not need the cloud account to log in. - Even though they are not wired, my APs setup can handle the additional load. Note: The Github readme for Frigate [lists](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file&ref=blog.matthewbrunelle.com#source-tapo) the video sources that the project support. Frigate can use the proprietary Tapo camera protocol instead of RTSP. In fact this is the only way to use the 2-way audio feature the camera has. However this requires using the *cloud account* and not the "camera" account so its a no-go for me. Anyways, once I physically set the cameras up in my house, did the app onboarding, updated the firmware, etc, I changed the following in the app: - Set a name for the camera. - Under Advanced Settings for each camera I [added a "camera account"](https://www.tapo.com/us/faq/76/?ref=blog.matthewbrunelle.com) with a username and password. - Under Network Settings I enabled a static IP address. - Disabled all the built in detection and alerts. \[^ I'm not sure how much power those features consume, but I won't be using them anyways.\] Once that was done I started working on configuring Frigate to work with a single camera. The new pieces I added to my config were: - A camera entry under `cameras` with an name of your choice like `tp_c120_0`. - `Detect` only dogs. - Use different streams from the camera for detection and recording. ```yaml mqtt: host: user: "{FRIGATE_MQTT_USER}" password: "{FRIGATE_MQTT_PASSWORD}" cameras: tp_c120_0: ffmpeg: inputs: - path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@:554/stream1 roles: - record - path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@:554/stream2 roles: - detect detect: width: 640 height: 360 fps: 5 objects: filters: dog: min_area: 1000 max_area: 100000 threshold: 0.7 ``` Now when you add this you will see in your logs that its using a CPU based detector which is only for testing, but we can remedy that. --- ### Hardware Accelerated Object Detection: There are lots of options available for object detection devices. The [recommended hardware](https://docs.frigate.video/frigate/hardware/?ref=blog.matthewbrunelle.com) page provides some opinions. It also has this nice explanation: > A detector is a device which is optimized for running inferences efficiently to detect objects. Using a recommended detector means there will be less latency between detections and more detections can be run per second. Frigate is designed around the expectation that a detector is used to achieve very low inference speeds. Offloading TensorFlow to a detector is an order of magnitude faster and will reduce your CPU load dramatically. Essentially Frigate has two passes where: - First it looks for motion in a frame. - If there is motion then the frames with motion are passed off to the hardware for object detection. This means that Frigate does not need to do object detection on every single frame from all the cameras. In terms of hardware for speeding up detection you could: - Use the integrated graphics, but that tends to put high load on your CPU. \[^ In fact before I setup up hardware for this the Frigate dashboard gave me a warning about high CPU usage.\] - Use a dedicated Nvidia graphics card, but that goes against my goal of keeping the power draw on my self-hosted hardware down. - Use specialized hardware that is really power efficient. For special hardware I started off looking at the [Hailo accelerators](https://hailo.ai/products/ai-accelerators/?ref=blog.matthewbrunelle.com), but they are *quite* pricey. I initially looked at Halio over Google's Coral hardware because Google has not refreshed them for several years. However the Frigate documentation strongly encourages them since they are cheap if you can still find them. \[^ Mouser actually had a bunch of stock when I looked!\] I went with the Coral [Dual Edge TPU](https://coral.ai/products/m2-accelerator-dual-edgetpu/?ref=blog.matthewbrunelle.com) which was only \~40 plus tax, shipping, and *tariffs*. The Dual Edge version is, as expected, is two TPUs slapped together. \[\[^ This [squared the output of a single GN Drive](https://gundam.fandom.com/wiki/Twin%5FDrive%5FSystem?ref=blog.matthewbrunelle.com)\] It uses the M.2 E key slot that a Wi-Fi card would normally go in. On my board the E key slot is sadly under an NVME SSD, which I don't really like from a thermals perspective but we will see. Once I inserted the card I just needed to install the Unraid "Coral Accelerator Module Drivers" in the Apps center and update the Frigate container to run as privileged so it has the proper hardware access. Once that was done I followed the docs on [configuring a detector](https://docs.frigate.video/configuration/object%5Fdetectors/?ref=blog.matthewbrunelle.com#single-pciem2-coral): - Add a `detector` entry which describes the Coral hardware to use. - Enable `ffmpeg` hardware acceleration. \[^ I'm on an Intel CPU so I'm using the quick sync option.\] - Tell Frigate how to encode the video we want to `detect` from. - Tell Frigate what kind of `objects` we want to track. ```yaml mqtt: host: user: "{FRIGATE_MQTT_USER}" password: "{FRIGATE_MQTT_PASSWORD}" detectors: coral: type: edgetpu device: pci:0 ffmpeg: hwaccel_args: preset-intel-qsv-h264 detect: width: 640 height: 360 fps: 5 objects: track: - dog cameras: tp_c120_0: ffmpeg: inputs: - path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@:554/stream1 roles: - record - path: rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@:554/stream2 roles: - detect ``` Notice I moved `detect` and `objects` to the top level. You can put them under each camera if you want them to have different settings but in my case I do not. Defining them globally reduces duplication. --- ### What resolution to stream? You'll notice that for the detection process I only have 640x360 for the resolution. This is plenty for the detector and will improve the rate the detector can run. However that resolution leaves a lot to be desired for the live feed. Luckily Frigate [bundles](https://docs.frigate.video/guides/configuring%5Fgo2rtc/?ref=blog.matthewbrunelle.com) go2rtc which helps us get around this. The documentation for [setting-stream-for-live-ui](https://docs.frigate.video/configuration/live/?ref=blog.matthewbrunelle.com#setting-stream-for-live-ui) was helpful for this. Essentially I had to: - Add a new `go2rtc` section with the rtsp connection strings. - Update the camera entry path to use the new go2rtc connection information. - Mark the camera inputs with `input_args: preset-rtsp-restream`. - Add a `live` section to the camera telling it to use go2rtc. ```yaml mqtt: host: user: "{FRIGATE_MQTT_USER}" password: "{FRIGATE_MQTT_PASSWORD}" detectors: coral: type: edgetpu device: pci:0 detect: width: 640 height: 360 fps: 5 ffmpeg: hwaccel_args: preset-intel-qsv-h264 objects: track: - dog go2rtc: streams: tp_c120_0: - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@:554/stream1 tp_c120_0_sub: - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@:554/stream2 cameras: tp_c120_0: ffmpeg: inputs: - path: rtsp://127.0.0.1:8554/tp_c120_0 input_args: preset-rtsp-restream roles: - record - path: rtsp://127.0.0.1:8554/tp_c120_0_sub input_args: preset-rtsp-restream roles: - detect live: stream_name: tp_c120_0 ``` --- ### Adding a Second Camera, Detection, Alerts, and Recording Finally on the Frigate config side there are two pieces left: - Enable recording, alerts, and detections with a `record` section. - Make sure we only run detection [when motion is detected](https://docs.frigate.video/configuration/record/?ref=blog.matthewbrunelle.com#reduced-storage-only-saving-video-when-motion-is-detected). - Add a second camera to the configuration. So the final configuration looks like: ```yaml mqtt: host: user: "{FRIGATE_MQTT_USER}" password: "{FRIGATE_MQTT_PASSWORD}" detectors: coral: type: edgetpu device: pci:0 detect: width: 640 height: 360 fps: 5 ffmpeg: hwaccel_args: preset-intel-qsv-h264 objects: track: - dog record: enabled: True retain: days: 3 mode: motion alerts: retain: days: 30 mode: motion detections: retain: days: 30 mode: motion go2rtc: streams: tp_c120_0: - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@:554/stream1 tp_c120_0_sub: - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@:554/stream2 tp_c120_1: - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@:554/stream1 tp_c120_1_sub: - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@:554/stream2 cameras: tp_c120_0: ffmpeg: inputs: - path: rtsp://127.0.0.1:8554/tp_c120_0 input_args: preset-rtsp-restream roles: - record - path: rtsp://127.0.0.1:8554/tp_c120_0_sub input_args: preset-rtsp-restream roles: - detect live: stream_name: tp_c120_0 tp_c120_1: ffmpeg: inputs: - path: rtsp://127.0.0.1:8554/tp_c120_1 input_args: preset-rtsp-restream roles: - record - path: rtsp://127.0.0.1:8554/tp_c120_1_sub input_args: preset-rtsp-restream roles: - detect live: stream_name: tp_c120_1 ``` With that the cameras are all set up and working! Though this is really only scratching the surface of what Frigate offers. There are features like [semantic search](https://docs.frigate.video/configuration/semantic%5Fsearch/?ref=blog.matthewbrunelle.com) that I haven't even tried out yet. --- ### Securing the Cameras: OK, so the cameras were working and all wired up to Frigate. Now I just needed to sever them from the cloud. I couldn't use the [unbind functionality](https://www.tp-link.com/us/support/faq/1351/?ref=blog.matthewbrunelle.com) in the app because: > "Once the device is removed from the app, the device would be reset to the factory default settings by default, and you need to reconfigure it from the very beginning if you want to control the device on the app again." Instead I had to block the cameras from accessing the internet. In an ideal world I would setup VLANs to help me with this. Unfortunately I own have unmanaged switches \[^ What can I say they use less power than managed switches.\] so I can't use VLANS. I do have an Opnsense router though! So I blocked the cameras from accessing everything on WAN: - I made an alias in Opnsense with the camera IP addresses - Made two new Firewall LAN rules: (1) A block action with the new alias as the source and "any" as the destination. \[^ Optionally I could make another rule above this one to allow UDP port 123 for NTP.\] (2) the same but with the source and destination flipped. - Made sure the new rules were before the "Default allow any to..." rules. - Cleared the firewall state for the cameras' IP addresses And with that the Tapo app can't access my cameras but Frigate can! --- ### So That's It? No I still have some work to do but that's enough setup for me to view the cameras in Home Assistant and check in on the dog when I'm away from home. One of my requirements is missing though! That's because the ability to dynamically enable and disable cameras is coming in the [next release](https://github.com/blakeblackshear/frigate/releases/tag/v0.16.0-beta2?ref=blog.matthewbrunelle.com), 1.6.0\. Right now I can only toggle the detection and recording in HA, not the actual video stream. Once that release drops I can have the final piece I'm looking for! ### 100 Weeks of Pokemon Sleep is Probably Enough URL: https://blog.matthewbrunelle.com/100-weeks-of-pokemon-sleep-is-probably-enough/ Last updated: 2025-07-15T19:29:34.000Z #### On this page Note: Initially this was just long form writing practice for me and I was not going to post it. However I realized it fit with my recent post themes: thinking about how I interact with my phone, enshittification, and how the decline of services can get us to re-evaluate why we use them. So I have decided to share it. --- A couple weeks ago I saw that I had played Pokémon Sleep for 100 weeks. The game shouts at you the number of weeks you've played like some kind of grim warning. The 2 year anniversary is coming up on July 20th. I wanted to look back and hopefully avoid fully grasping how much time has been spent in this game. You see I am the Venn diagram of people this game was designed for. I am the target audience: the game is a spreadsheet with a thin veneer of Pokémon applied, combined with a health tracking app. There is a BLE sleep tracking accessory for goodness sake. There is a problem though that is helping to shake me from my stupor. --- ### The Change In Development of Pokémon Sleep Pokémon Sleep released under the direction of a the company Select Button. The game was pretty bare bones at launch but the core loop was fun and they did a good job of continuously improving it over time. In November last year they [announced](https://www.serebii.net/news/2024/12-November-2024.shtml?ref=blog.matthewbrunelle.com) development would shift from Select Button to The Pokémon Works. The [subreddit lamented](https://old.reddit.com/r/PokemonSleep/comments/1gphqhj/the%5Fpok%C3%A9mon%5Fcompany%5Fhave%5Fannounced%5Fa%5Fchange%5Fin/?ref=blog.matthewbrunelle.com) and there was a general sense of worry that rent extraction would begin. For a while it seemed OK, but game development takes a time and the releases we were seeing after the announcement where likely still developed under Select Button. Recently, a Reddit user post a [summary of the changes](https://old.reddit.com/r/PokemonSleep/comments/1lr47vm/whos%5Freally%5Fmaking%5Fpok%C3%A9mon%5Fsleep%5Fnow%5Fnew%5Fstaff/?ref=blog.matthewbrunelle.com) to the game credits over time. They did the work so go there to see the details, but the important part to highlight is that The Pokémon Works is now officially in a leadership role for the game. You can tell too from the recent game changes \[^ I don't think its worth enumerating all of them but the implementation of the Darkrai even is a great example\] and [the vibes are off](https://old.reddit.com/r/PokemonSleep/comments/1lqmag5/oh%5Fno%5Fmain%5Fskill%5Fseed%5Fclarification/?ref=blog.matthewbrunelle.com). --- ### The Game Loop Why did I play a mobile game in the first place? That is not something I would usually allow myself to do. \[^ I especially do not let myself play incremental games. I have the exact kind of brain they scratch *too* well.\] I want to go over the core of the game because I think it will help me understand why I enjoyed it. The gameplay loop exists on three time horizons: daily, weekly, and long term. The daily cycle is where the bulk of the mechanics exist. There are two distinct phases to it: daytime and night time. The normal day to day focuses on taking care of a Snorlax: - You have a team of 5 helper Pokémon \[^ If you own the Pokemon Go Plus+ you also have a "Nightcap Pikachu" that is worth roughly a third of a Pokémon. I really like weird BLE wearable devices and so I think it is neat.\] that you stock from your Pokémon box. **This is where the majority of the choice framework in the game comes from: Picking who you want to use on your team.** - The Pokémon on your team will toil away while your phone is off. At fixed intervals they roll to do one of the following: - Berries: find find berries that can be fed to Snorlax. The type of the berry depends on the Pokémon's type. - Ingredients: find ingredients that can be used to cook meals for Snorlax. - Skills: Store a trigger of their skill. There are many skills in the game but each Pokémon line has one specific skill. - Each Pokémon has an inventory. When they gather a berry or ingredient it takes up slots in the inventory. - Each Pokémon has a role that matches one of the three actions. While all Pokémon do all three actions, they will do much better at the one that matches their role. - **You have to take your phone out of your pocket to interact with your at some point.** This involves tapping each Pokémon in your party to collect the items from their inventory. - Three times a day you are able to cook a meal for Snorlax that will provide a significant about of XP towards their Snorlax Rank. - You can unlock new recipes by knowing the correct combination of ingredients to put into the cooking pot. - Your Pokémon have stamina that drains as the day progresses. The lower their stamina the less efficient they are at performing the three actions. The night half: - In the evening you go to bed and track your sleep. - When you wake up, you check your phone. If Pokémon Sleep detects less than eight and a half hours of sleep you will only get partial credit. \[^ The sleep detection built into the app is exceedingly flakey. The Pokemon Go Plus+ helps mitigate this somewhat.\] This is your "Sleep Score" which is from 0 to 100. - You will get "Sleep Points" equal to your Sleep Score. This is a game currency that can be spent on items. - Depending on how much you move at night and the will of the gods, the game will decide on one of four "Sleep Types" to assign that night of sleep. - You get "Dream Shards" based on the rarity of the Pokémon that appeared. This is a game currency that is used when leveling up Pokémon. \[^ along with Candies which is a whole other thing.\] - Your "Drowsy Power" for the night is equal to your Snorlax's Strength X your Sleep Score. - The Drowsy Power is used to decide what Pokémon show up overnight. The Raenonx site has [details on rolling logic](https://pks.raenonx.cc/en/docs/view/help/sleep-styles?ref=blog.matthewbrunelle.com#sleep-style-rolling-logic). - Also Pokémon have different Sleep Types and under normal circumstances only appear when they match the Sleep Types you got that night. - Each Pokémon has four different "Sleep Styles", they could appear in. When you see a new style for a Pokémon you get a new entry in your Sleepdex. - You then stuff some of the Pokémon with biscuits to fill up a bar and catch them. You get one free biscuit a day. If you want any more you need to spend sleep points on them. - You will get Research XP based on the rarity of the Pokémon that appeared. This will increases your Research Rank. - You will return to the day part of the cycle and all your Pokémon will have their stamina refilled. The weekly cycle is the main progression reset mechanic in game: - There are different islands you can visit and you start with one island unlocked. - Each island has a different type theme that favors three specific types. Berries gathered by Pokémon matching these types will get a bonus. - At the beginning of a week you pick what island you want to go to and start fresh with a Rank 1 Snorlax. - The goal of the game is to stuff this boy with as much food as you can to increase his Snorlax Strength. - There are also three types of meals in the game: curry, salads and desserts. Each week you can only cook recipes from one of these categories. - So each week you have to balance the types of the Pokémon that match the islands type, with the Pokémon that can gather the right ingredients for the kind of dishes you are making that week. - You repeat all of daily loop each day until the end of the week and you get a grade. - You start all over next week. The long term progression is layered on top of the weekly resets so you feel like there is momentum in the game: - The max level for Pokémon is gated by your Research Rank. - Access to new islands is gated by the number of Sleep Styles in your Sleepdex. \[^ It also gates other progression like the size of your cooking pot which impacts how much your meals will increase your Snorlax Strength\] - At the end of each week you get a small percent multiplier added to the island you visited. \[^ or if you are at the cap its now spread out over the other islands that are not capped yet.\] This is the big flywheel mechanic: the higher the multiplier the higher your Snorlax Rank. That means you will see more Sleep Styles and get more Sleep XP. --- ### Did I enjoy my time play Pokémon Sleep? Believe it or not but the section above is the *short* explanation for the game's mechanics. There are plenty more layered on top. For me, the main properties of the loops that are nice are: - Pokémon inventory size is pretty generous. - There is a "Sneaky Snacking" \[^ Here is [lots of math](https://pks.raenonx.cc/en/docs/view/calc/sneaky-snacking?ref=blog.matthewbrunelle.com) if you are also a fan of games that are actually just spreadsheets\] mechanic where if a Pokémon's inventory is full, berries they collect will go directly to Snorlax. - You only can feed Snorlax a meal three times a day. - An update made it so skill specialist Pokémon can store a second proc of their skill. - You can only get new Pokémon once a day. All of this together means you do not have to open the game up too frequently. The amount of time you had to spend in the game is fairly limited \[^ Now having said that the number of load screens an interacts the game requires is really tiring and unnecessary.\] compared to other mobile games that optimize for engagement. This is the part that made the game have positive utility for me. --- ### So Why Are the Vibes Off? I see friends complain about not liking games they used to love. These are games they have played for *many* years. I think it's unlikely that even with solid stewardship a game can provide stimulus forever. This game is no exception. That core game loop has been refined over many updates but the day to day of the game is essentially unchanged. Also, based on the odds around rolling for stats and skills on Pokémon, the following two things will happen over the long run: 1. You will not get great rolls on most Pokémon, so you probably won't use them because... 2. You will eventually get amazing rolls on some Pokémon. They will be so good that you might as well never not use them. This means that you no longer really have to make choices to do well enough in the game. With the choice framework gone there isn't much left. This is a mobile game, its always been a designed to manipulate our Skinner Box reactions to random events. Previously the balance was that the amount of fun I got from the game was worth the amount of time I spent in it. The new game play changes from the developers reduced the amount of fun. Actually if I'm being honest the game play changes actually just removed the facade around the social contract of this type of game: Developers make a game, some users hopefully give them money if they can't control themselves, and all users get some enjoyment out of the experience. The recently changes just make it much clearer that the new developers care about changing "some users" to "as many users as possible". --- ### A final thought I was talking recently with a co-worker about the game Blue Prince. No spoilers here I promise. \[^ but also please go play that game.\] They mentioned they no longer play games that do not have an end to them. I really like that mentality, it's an interesting and explicit choice to think about how we interact with this form of entertainment. I would like to try and follow that more myself. ### The Apps That I Kept On GrapheneOS URL: https://blog.matthewbrunelle.com/the-apps-that-i-kept-on-grapheneos-2/ Last updated: 2025-09-18T19:45:59.000Z #### On this page In a [previous post](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/) I talked about my experience trying out GrapheneOS.Then in [another post](https://blog.matthewbrunelle.com/the-apps-that-i-got-rid-of-when-trying-out-grapheneos/) I spoke more specifically about which apps I've decided to part ways with. This post in contrast covers the apps I chose to reinstall: some because I love them and some because I just can't quite get rid of them yet. --- ### The Google Apps *Three*. I'm pretty happy to see that this is a list of only three apps. **Maps:** The true faustian bargain. I *love* (lowercase) maps and this is a good map. I especially like that when the Map is wrong or missing a detail I can correct that. I once Google Mapped so hard that Google sent me a free pair of socks... Anyways in my mind this is one of Google's best products, and that means it one of the hardest to break away from. Not to mention that *some* metropolitan areas are chronically under construction and navigating them in comparable to the Labyrinth of Crete. Google literally puts [BLE beacons in tunnels](https://support.google.com/waze/partners/answer/7298984?ref=blog.matthewbrunelle.com) where GPS reception is poor. They put 850 of them up in [MA in 2017](https://www.wcvb.com/article/new-gadgets-make-it-easier-to-find-your-waze-through-bostons-tunnels/10317921?ref=blog.matthewbrunelle.com). Think of the people trying to get to Boston Logan Airport through the Ted Williams Tunnel that have been saved by this. I am one of those people. But man Maps knows *a lot* about you. I have countless favorites and lists in Google maps. I also used to have an extensive day-by-day timeline of where I went because of the location history. That is until I got an email in March saying: > We briefly experienced a technical issue that caused the deletion of Timeline data for some people. We're reaching out as your account may have been impacted. Thank you Google for doing to me what I would not do myself: deleting location history Replacing Google Maps is still a hard sell though. The two big contenders for alternates are OsmAnd and ~~Organic Maps~~ [Comaps](https://www.comaps.app/news/2025-05-12/3/?ref=blog.matthewbrunelle.com). OsmAnd is packed with features \[^ There is a paid Osmad+ with even more features but if you install a non-play build you can get them for free\], but the usability is *rough*. Comaps is more focused on usability. But these apps are not Google Maps. If I'm under time constraints when driving where I need congestion \[^ There is an open [issue](https://github.com/osmandapp/Osmand/issues/6878?ref=blog.matthewbrunelle.com) for Osamd but I don't think it will go anywhere\] information and incident reporting. If I'm trying to get around in Greece or Japan \[^ Actually for Japan, please use Tabelog for resturant reviews instead of Maps.\] I'm going to still rely on Google Maps. So what to do? At least GrapheneOS's sandboxing and choosing to limit the data I send can help a bit. I can be active on [StreetComplete](https://streetcomplete.app/?lang=en&ref=blog.matthewbrunelle.com) to help contribute to OpenStreetmap. Google owns me here though for the forseable future. **Messages:** I have family members with iPhones in iOS 18 Apple finally started supporting RCS. Currently, if you want to use RCS on Android you are stuck with Google Messages. \[^ the [issues](https://github.com/FossifyOrg/Messages/discussions/34?ref=blog.matthewbrunelle.com#discussioncomment-13409403) don't make me hopeful for an alternative any time soon.\] Also of note, I could only get RCS to function when Messages was installed in the primary user on my phone. Oddly enough there was no backup of message history, but that is probably for the best. **Android Auto:** On GrapheneOS there is a a special installation process through the "Apps" app. Similar to Messages it seems to only work when installed in the primary user. Android Auto slurps up a lot of information and once again sandboxed Google Play helps here *a little bit*.\[^ You can even restrict it to work only over a wired connection\] Automotive telemetry in general for every car brand is [pretty terrifying](https://www.mozillafoundation.org/en/blog/privacy-nightmare-on-wheels-every-car-brand-reviewed-by-mozilla-including-ford-volkswagen-and-toyota-flunks-privacy-test/?ref=blog.matthewbrunelle.com)\[^ Subaru has the DCM which is like a little cellphone attached to the car. I am not a car guy, but [other people](https://www.gr86.org/threads/dcm-data-collection-and-privacy-addtional-info-for-those-interested.12054/?ref=blog.matthewbrunelle.com) are passionate about getting around this and I'd like to look into it more.\] --- ### The "Essential" Apps These are apps that feel like a part of my daily life. The title is in quotes because often they are not actually that essential. Here are the apps I've kept from this bucket: - **Bitwarden & Ente Auth:** my go to password management and 2fa apps. Bitwarden works as my passkey provider but that requires Play Services to be installed - **Breezy Weather:** if you install from Github and not fdroid you get more weather providers. For example you can get pollen count from AccuWeather. - **Droidify & Obtanium:** for installing FOSS apps - **Futo Keyboard:** Previously I was testing HeliBoard but I am now mostly using Futo. I like that they omit the internet permission compared to Google's keyboard, but that means I need to find a dedicated FOSS GIF search app. - **KDE Connect:** for syncing with my computers. - **Nextcloud:** only occasionally using right now. - **Obsidian:** on mobile I mostly look up things in my vault since the ergonomics for writing are not great. Occasionally I will jot down notes though. Obsidian is closed source but also provides APKs [through github](https://github.com/obsidianmd/obsidian-releases?ref=blog.matthewbrunelle.com) that work with Obtanium - **Readwise Reader:** Requires a subscription but its really worth it for me. \[^ Once again it helps to be grandfathered into a lower price.\] It helps that I'm grandfathered into a lower price. The TTS on Mobile is nice for listening to longform content on the go. \[^ This is the main way I consume Matt Levine's [Money Stuff Newsletter](https://www.bloomberg.com/account/newsletters/money-stuff?ref=blog.matthewbrunelle.com). I no longer have a [custom service](https://blog.matthewbrunelle.com/ghost/#/editor/post/6828b774e0bad00001528cdb) for it.\] Linkwarden is a potential self-hosted alternative but web pages are epubs are all so chaotically varied. The Readwise team has always been really responsive when I reach out for support on parse failures. - **Tailscale:** You can only have one app using the VPN interface on Android at a time \[^ The restriction actually seems to be *per user* to be specific.\]. I need tailscale to access my self-hosted services which means I cannot also have an adblocking app like Adguard. So instead I use Tailscale [with NextDNS](https://tailscale.com/kb/1218/nextdns?ref=blog.matthewbrunelle.com). \[^ I initially created my Tailscale account with Google as the SSO option. I was able to add a passkey based user to my tailnet and log in with that instead! \] - \*\*Thunderbird (Previously k-9): \*\* I use this to access my gmail account. I'm in the longtail of moving my logins out of gmail and frankly I'll probably never completely escape it. - **Tuta Mail**: I have been trying out Tuta for a couple months now. They implemented their own encryption so can only use their mobile app. I like how they [handle catch all emails and email aliases](https://tuta.com/blog/release-notes-unlimited-aliases?ref=blog.matthewbrunelle.com) though! I'm patiently awaiting for Thunderbird Pro [beta access](https://thundermail.com/?ref=blog.matthewbrunelle.com) so I can jump ship that. Outside of these apps I consider everything else here "nice to have". --- ### The Launchers: For the longest time I used Nova launcher but then they [were acquired](https://www.androidpolice.com/nova-launcher-acquired-by-branch/?ref=blog.matthewbrunelle.com) in 2022 by a shady ad company so I switched off. \[^ Also, sadly then in 2024 everyone but the main developer was [laid off](https://www.theverge.com/2024/8/9/24217077/nova-launcher-layoffs-only-original-developer-remaining?ref=blog.matthewbrunelle.com).\] After Nova I tried Lawnchair but it never seemed to merge in [foldable support](https://github.com/LawnchairLauncher/lawnchair/issues/3517?ref=blog.matthewbrunelle.com#issuecomment-2334772470) so I moved to Niagra Launcher. Nova and Lawnchair were really good at helping me open the app I wanted quickly. Niagra requires a couple extra steps but I was actually looking for that. Its a feature, not a bug that I had to be more intentional about choosing what I was launching on my phone. Also while Niagra launcher is closed source, they put the releases on github so you can install outside of the play store. In 2022 they even [made it](https://medium.com/niagara-launcher/going-global-6bb58c2d6bb7?ref=blog.matthewbrunelle.com) possible to use Niagra without Play Services! Huge kudos to them. Having said that I have been trying out a FOSS launcher Kvaesitso since switching to GrapheneOS. Last time I tried it, its still needed some more work but it feels fully featured now. Also, Kvaesitso integrates with both Breezy Weather and Tasks.org which I both use. --- ### The Communication Apps > "what is the trigger in my life that gets me to open this app and start using it? So, for example, I don't know, take Airbnb. It's the trigger is like, ah, you want to travel and then and then you, you do that and then you open up the app. Apps that do not have this already existing natural trigger in your life, it's very difficult for a consumer app to then get the user to open the app again. You need a hook. Yeah. There's basically only one app. One super successful app that has been able to do that without this natural trigger, and that is Duolingo... So they, I mean, they gamified the shit super successful, super beautiful. They are the GOATs in this arena" - Latent Space podcast, [episode "Building Snipd"](https://www.latent.space/p/snipd?ref=blog.matthewbrunelle.com) \[^ In this episode the speaks *admired* these apps rather than the abject horror I felt hearing that discussed.\] Outside of Google Messages this are the only other messaging apps I have installed. A main rule I have with all of them is to figure out how to limit the notifications they send me so my phone is not pulling my attention. **Discord:** The network effect really gets me with this one. Its slowly enshittifying but not enough yet for us to start jumping ship. I keep all server channels muted but allow DM messages through. **Line:** I meet with friends roughly once a week to get food after work. For some reason we only allow coordination for this event though Line. **Signal:** I've had it on my phone since it was TextSecure! Still hard to convince people to switch over though. **Snapchat:** the only social media I am on, thought I only use it for message and not grazing the feed. The rent extractions really shows on this app. I turned notifications off long ago and recently they send me a *text* saying I had unread snaps. --- ### The Browsers Apps: The GrapheneOS [docs](https://grapheneos.org/usage?ref=blog.matthewbrunelle.com#web-browsing) have a pretty detailed paragraph about why they recommend avoiding Gecko-based browsers. Having read that, I still really appreciate all the work the Mozilla foundation does, even if Mozilla Corporation gets a lot of flack now a days. So for daily driving I am using Firefox on my phone, but Vanadium's web view still has my back everywhere else. --- ### The Media Apps: **Audiobooks:** - Audiobookshelf / Libro.fm: Libro.fm is like Audible but it helps support your local bookstore instead of trying to destroy them. Also the books can be downloaded DRM free so you can load them into Audiobookshelf which has more controls and options in the app. - Libby: Please go get a library card! Enough said. **Podcasts:** Podcasts combine two of my favorite things: RSS feeds and getting to listen to content at 2x speed. - ~~The Economist:~~ On recommendation from a co-worker I gave a one year subscription a try. There is an Audio Edition where all of the articles are read out loud. When I signed up I was bummed because they had recently killed their downloadable version of the Audio Edition. As I was writing this post I realized that they [released](https://myaccount.economist.com/s/article/Audio-edition?ref=blog.matthewbrunelle.com) a new RSS feed for the Audio Edition! The episodes are fully chaptered too! Just deleted this app off my phone. - Pocket Casts: While the service is paid \[^ and they got a lot of flack when they initially did a poor job adding a subscription cost after promising lifetime support\] the client apps are all open source. The APKs are installable with Obtanium. For me the big feature is seamless cross device syncing. I can listen on my commute then sit down at my computer and pick up where I left off. Self-hosted Pinepods for desktop listening, antennepod for phone listen and gpodder/nextcloud for syncing is an option for an alternative but higher friction. If I wasn't grandfathered into the free premium tier I would be more tempted to jump ship. **Music:** - Symphonium: a shining example of an app I will pay for: One time purchase with a developer that is constantly adding updates. I self-host Navidrome and Symphonium integrates with it smoothly. **Videos:** - Tubular: This is a fork of NewPipe that include SponsorBlock. I don't graze youtube videos from my phone, but its nice to open a link in the app when I want to watch a specific video. - Jellyfin / Findroid: I mostly stream my Jellyfin server to my TV but its nice to have it on my phone when I'm on a trip. Sadly while the official Android app does not support caching videos for offline Findroid does. **Images:** - Immich: I said that Google Maps was my favorite Google product. Google Photos is my second favorite. I luckily I am able to escape it because the open source software community has essentially perfectly recreated it. I've fully swapped over to using it instead of Google Photos. Only downside is the network effect where sending / sharing with people also on Google Photos gets a little harder. --- ### The Nature Apps If I had a nickle for every nature ID app I had on my phone I'd have two. Which is not a lot, but still. Neither are open source, but they are both citizen science projects which I love. - **Merlin Bird ID:** The ability to pretty reliably identify birds by sound is really fun. \[^ Though it varies considerable by the country you are in.\] Their data policy seems reasonable and you can contribute your bird IDs back. Also see my [post](https://blog.matthewbrunelle.com/birdnet-go-but-we-have-bird-monitoring-at-home/) about self-hosting a bird monitoring station - **Seek by iNaturalist:** Similar to Merlin but image based instead of accoustic. I love it for IDing plants, bugs and animals. --- ### The Fitness Apps: I love collecting data to help me with my training. It was really useful to look back on when writing my last [post race report](https://blog.matthewbrunelle.com/post-race-report-boston-run-to-remember/). For now I'm in walled gardens, but I've been trying out Endurian \[^ and I'm looking to make contributions\] so hopefully I can escape in the future. - **Garmin (Connect):** For syncing my runs from my watch. - **Health Sync:** handles pushing data from Garmin Connect to Health Connect and Strava. Last month Google [announced](https://www.androidcentral.com/wearables/garmin/garmin-io-2025-health-connect-support-details?ref=blog.matthewbrunelle.com) Garmin would finally add Health Connect support. So if its a total replacement I can drop this soon. \[^ Though once nice feature of Health Sync is Strava usually wont keep a workout's original name and will generate their own but with Health Sync you can force Strava to keep the original name.\] - **KeyLifts:** I bounce around between different apps for tracking my lifting. Usually do some Wendler 5/3/1 variation and KeyLifts supports that well. - **TrainingPeaks:** I have all my training plans on TrainingPeaks and the mobile app is a nice way to check and re-arrange things when needed. - **Yoga (Downdog):** really nice company and the app is low friction so I will actually do my yoga regularly instead of just thinking about doing it. --- ### The Self Management Apps: In the past I used TickTick for managing todos, time-blocking and habit tracking. Its the only app that had worked well for me because it balanced providing enough without letting me spin my wheels in pseudo productively land. I think it was worth paying a subscription for. But the siren call of self-hosting is strong so I've duct-taped a simulacrum of TickTick together using FOSS pieces: - **DAVx5, ICSx5:** \- For synchronizing tasks and calendar events. Both are paid apps on Google Play as one time purchases. Free if you install through Droidify / Obtanium. Ironically because they offered a free option I decided to make a one time donation. If you sponsor through their GitHub they get the full amount of the donation compared to buying it on Google Play. - **Tasks.org:** for task management and quickly capturing tasks to be done. Also free if installed through Droidify / Obtanium. - **Fossify Calendar:** for managing all my events. - **NextCloud Deck:** for workload management on my projects I've committed to. \[^ I'm also trying out Vikunja but it doesn't sync well with other apps and I like to have unified views.\] The piece I most the miss with my current approach is easily mapping tasks to actual time blocks on my calendar. I like to sit down at the start of the day and block things off. That is really the only way I've found to prevent myself from overcommitting. I'd like to write about this more in the future, so stay tuned! --- **The Random Other Apps:** These are from the "Nice to Have" bucket but do not fit into any of the themes I have above: - **Image Sync:** For GPS location syncing and photo transfers. See my [post on the GRIII](https://blog.matthewbrunelle.com/ricoh-griii-in-japan/) for more. - **Pathbuilder2e:** another shining example of a paid app. Reasonable 1 time payment and the developer is always on top of adding newly released PF2e content. - **Pix Material You Light/Dark:** I've had a couple different icon packs over the years. This is the current one I am using and it has a wide coverage of apps it includes. - **Pokemon Sleep** \- The only game on my phone and I could write a whole post on this one. Stay tuned. Update: [here's the post](https://blog.matthewbrunelle.com/100-weeks-of-pokemon-sleep-is-probably-enough/). --- Once again I've gone and wrote way to many words about how I am using my phone while also talking about trying to be more thoughtful about how I use my phone. C'est la vie. ### The Apps That I Got Rid of When Trying Out GrapheneOS URL: https://blog.matthewbrunelle.com/the-apps-that-i-got-rid-of-when-trying-out-grapheneos/ Last updated: 2025-07-15T15:16:29.000Z #### On this page > The web is open; apps are closed. The majority of web users have installed an ad blocker, which is also a privacy blocker. But no one has ever installed an ad blocker for their apps because it's a felony to make and distribute that tool. Because you have to reverse engineer the app's DRM to make it. An app is just a website that we have wrapped in the correct DRM, making it a felony to protect your privacy while you use it. > \~ [Cory Doctorow](https://youtu.be/ydVmzg%5FSJLw?t=1760&ref=blog.matthewbrunelle.com) ### Getting Rid of Apps In my [previous post](https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/) about testing out GrapheneOS I mentioned I would follow up with details about the specific apps I am using. In the past every time I got a new phone I used the transfer tool to move all my apps over. I never really took a deep look at what I was using and how I spent my time on my phone. So the move to GrapheneOS felt like a nice opportunity to be more intentional with my phone usage. A lot of articles about making changes to a persons phone usage focus on just the main apps that they use. They talk about substituting the big services with alternates. I think that makes the process seem easier than it is in practice and I hope I can present a more realistic view. Hopefully this captures examples of the smaller apps we have cluttering our phones and how we can shed many of them. Before I installed GrapheneOS I went through every app I had installed tried to put them into five different buckets: - "Essential" - Nice to have - Google - Work - Private Space Then I installed GrapheneOS and started fresh without any apps. Every time I reached for something that was not installed I tried to think about whether I needed it or not. I kept notes of what apps I installed as I and I've compared those notes to what I previously used. I don't want this to just be a dry, uninformative list. So I've tried to group related items together into sections to talk about them more generally. I'll work from the least desirable apps that I have dropped to what I have decided to keep. So I am going to break this into two posts: everything I dropped and everything I kept. --- ### On the Topic of Social Media One other note, I don't really use social media much, so you will not see that as a category to remove or include. I know this is an important category for many people who are trying to evaluate their social media usage in the context of all the information the apps collect. See the recent [Disclosure: Covert Web-to-App Tracking via Localhost on Android](https://localmess.github.io/?ref=blog.matthewbrunelle.com) for a great example. One option is to use [Hermit](https://hermit.chimbori.com/?ref=blog.matthewbrunelle.com) \[^ Though they have a really [weird stance](https://chimbori.com/help/source-code?ref=blog.matthewbrunelle.com) towards OSS\] or the FOSS [Native Alpha](https://github.com/cylonid/NativeAlphaForAndroid?ref=blog.matthewbrunelle.com). They let you wrap sites into little web apps you can use instead of installing the full app with all the permissions they request. However, many apps like Instagram restrict the mobile sites functionality to push people onto the main app. This could be a boon for some since it can encourage *less* usage of the app, but might not work for all. Also remember, if privacy is your main concern rather than the mental well being aspect then data minimization is your best approach: the easiest way to avoid having your data captured is to not produce the data in the first place. And yes, I understand the irony of writing that while in a spurt of writing *a lot* about myself in long form content online. --- ### App Overlays / Custom Link Views Android has the nice feature called [deep links](https://developer.android.com/training/app-links/deep-linking?ref=blog.matthewbrunelle.com) where a link on a webpage or in an app can directly open the content in another related app. The link will also open as an overlay on top of the app you were originally using such that back navigation all works intuitively! For example if you are in a RSS reader and click a link to HN that link will open with a native app like [Harmonic](https://github.com/SimonHalvdansson/Harmonic-HN?ref=blog.matthewbrunelle.com) view for reading the content. In the past I took a maximalist approach to leverage this feature. I installed several apps that I didn't use directly but liked for opening links with a better view. Wikipedia, GitHub, IMBD, whatever, I would put the app on. Then a couple years ago I pivoted to make my phone *less* appealing for consuming content so I removed all these apps. So those kinds of apps are not in my list of removed apps because they were already removed some time ago. Now having said that I still use some of them in a limited context. I have an Android e-ink tablet that all my RSS and book consumption happens on. That helps me contextualize the purpose of my devices and prevents be from getting sucked into content apps. --- ### The Default Apps Default apps are in the weird space where you don't install them but also you don't not install them. I can't really *choose* whether to include them. So instead I just want to highlight them as a category and my usage of them. **Clock, Calculator, Contacts, File Manager, Gallery:** GrapheneOS offers there own alternatives for these apps. Also the [Fossify Apps](https://www.fossify.org/apps/?ref=blog.matthewbrunelle.com) are a great replacement two. I'm using a mix of those. I used to use FX File Manger with the pro license. Not for any particular reason, just because it is what I started with. The default apps are good enough for me needs now a days. Partially because I don't root my phones anymore and partial because post Android 11 file managers are more restricted due to scoped storage changes. **The email and CalDAV gang:** Calendar, Contacts, Gmail, Tasks, and Notes - I was not heavily using these since I had moved to other options. So I did not reinstall them and I'll cover the alternatives in the future post. **Camera:** GrapheneOS's default camera app is built off the CameraX library and takes good quality photos. Note that by default the app does not save location info so if you would like that you need to enable it. Also read about my usage of the [Ricoh GRIII](https://blog.matthewbrunelle.com/ricoh-griii-in-japan/) since I'm trying to shift my photography off my camera. --- ### The Google Apps Now for the trendy category since lots of people are talking about DeGoogling now-a-days. A distinction I want to draw here is not just replacing apps but considering if a type of app is needed could be totally dropped! In my previous post I had discussed how this exercise helped me untangle what apps were a part of (1) Android Open Source Project, (2) Google's apps that can be used on any phone supporting Google Play and (3) Pixel Exclusive Features. I've bundled all of (2) and (3) into this section. Also note, there are some apps that come pre installed like AICore, Speach Recognition and Synthesis, and Google Play Services for AR. They do not show up in your launcher but are visible in your apps list in the system settings. Many times Google apps will work without them but certain app *features* won't work. One example is Google Maps requires AICore in order to support the Live View feature. So anyways, the Google apps I've gotten rid of or replaced: - **Chat:** I have exactly two friends I talk to through Google chat. I've decided not to install the app and instead only response through the gmail webview on desktop. - **Chrome:** I didn't use it before and GrapheneOS provides Vanadium. See the section in my previous post for more. - **Digital Wellbeing** \- I talked a bit about this in my previous post. There are some features from this I miss, but not so much that I feel the need to install it and give it access to !! find what permissions it needs **Docs and Drive:** My notes live in Obsidian now mostly. For non-document file storage I've been testing out NextCloud but I need to get a stronger backup solution in place. - **Find Hub** \- You can't really have something like this without a central coordinator. Also, the network of phones participating isn't great right now \[^ Google just recently [made](https://www.androidauthority.com/google-find-hub-setup-screen-3570944/?ref=blog.matthewbrunelle.com) a change now asked if you want to enroll during sign up and is likely going to auto enroll users that enable location history.\]. I *do* have a well established history of losing my keys and wallets though so I was excited to see Google roll this out. However I think for now I'm going to try and go without it. - **Fitbit:** I've had many Android Wear / Wear OS watches over the years. Fitbit leaves a lot to be desired but I was stuck with it when I was testing out the Pixel Watch 3\. More details in my next post where I will talk about health apps. **Gemini:** I already use Kagi search so I'm trying their Assistant for now. **Google TV:** I don't actually use the app on my phone so this one can go. **Google Opinion Rewards:** I was using this for fun-money to use on In App Purchases. I've now decided I should just stop using it and either: set a small "allowance" or even better stop those IAPs all together. **Home**: Already in the process of moving my home automation home assistant. **Health Studies:** This was actually really fun to try it out but they haven't run a study in a long time and I stopped testing out the Pixel Watch 3 anyways so I've dropped it. **Now Playing::** I'm not sure what this lives inside but its gone now. I've lost the cool party trick that it is but I think I'll be OK. **Phone:** Google's Phone app is **really** good and I will miss it. **Photos:** Another *really* good product but I've already transitioned to Immich. Immich manages to do an fantastic job replacing this. **Pixel Stand:** I don't actually use any of the smart features of the stand. I just like that it can negotiate faster Qi charging with Pixel phones \[^ The Pixel 9 Pro Fold does not align properly with the stand, but you can [3d print a spacer](https://www.printables.com/model/995882-pixel-9-pro-fold-pixel-stand-2-spacer-alignment?ref=blog.matthewbrunelle.com) that makes work\]. So the app can go! **Translate:** Another *really* well executed app and it requires AI Core to work. It was really useful on trips out of the country, but I don't travel that often. I may selectively install it in another GrapheneOS user during my trips. Youtube - I didn't even have regular Youtube app enabled since it hijacks links when it is. I was already using Tubular instead. I try really hard not to graze on Youtube videos, but its nice to have an app when someone sends you a link. Outside of that I subscribe to a couple channels via our beloved RSS and will decide to play videos on Android TV sometimes using SmartTube. --- ### The Banking, Payments, Etc Apps **Wallet:**\[^ This is a Google app but I've moved it here since its thematically appropriate\] Very convenient, but not usable on GrapheneOS. Garmin watches can do tap to pay but have a limited set of banks they support. I keep a card on it as a back-up and just use my cards directly now. The biggest problem is I have a virtual card from work that gets lunch credit once a week that I cannot use anymore since Garmin does not support it. The only workaround I have so far is I can get gift cards for a local ice cream shop. **Loyalty & Membership Cards**: [Catima](https://github.com/CatimaLoyalty/Android?ref=blog.matthewbrunelle.com) is awesome, though I need to think of a backup strategy for it. I use it for the library, gym, etc. Re-evaluating my app usage had led me to stop using loyalty memberships however. Yes I loose out on a couple dollars but I think the privacy trade-off is worth it. **Venmo:** I don't like it! They are intentionally frictive to prevent you from moving money out of the app so they can earn interest. Also a payments app does not need social features. Poor payment apps are a very American problem due to how our transfer rails work. \[^ "Patrick McKenzie has some great [longform content](https://www.bitsaboutmoney.com/archive/the-business-of-check-cashing/?ref=blog.matthewbrunelle.com) and [podcast episodes](https://www.complexsystemspodcast.com/episodes/money-movement-erik-torenberg/?ref=blog.matthewbrunelle.com) about US payments." \] Out in the world I might need Venmo but I am trying to be more mindful about keeping some cash on my again or going to an ATM when needed. The web version is highly restrictive, you cannot do any transactions or transfers on it. So we can't use that instead. There is some lock in due to friends that use Venmo but I have been bullying them into paying me over Zelle instead. Most US banks support Zelle now and have it built into their mobile app. Which leads me to... **Banking apps:** I had half a dozen apps installed for the various financial institutions I use. Note: Some banks use obsolete APIs to check if they can run on a device. See the [Attestation compatibility guide](https://grapheneos.org/articles/attestation-compatibility-guide?ref=blog.matthewbrunelle.com) and [Banking Apps](https://grapheneos.org/usage?ref=blog.matthewbrunelle.com#banking-apps) on the official GrapheneOS docs for the details. The good news is GrapheneOS has made it really easy for banking apps to fix their API usage by providing a detailed guide! There is a crowd sourced [compatibility report](https://privsec.dev/posts/android/banking-applications-compatibility-with-grapheneos/?ref=blog.matthewbrunelle.com) and somehow against all expectations many banking apps have been fixed through community outreach. Reports are handled through issues on GitHub if you want to check if your banking apps work. My main bank is thankfully supported by GrapheneOS. Currently it is the only financial institution app I have installed and thats mostly so I can make Zelle payments. One downside to note, is many financial institutions *still* do not support proper 2fa but will do one time codes with their mobile apps. So you lose that if you do not use them. Somewhat related but Splitwise is steadily enshittifying. They only let you do certain things from the app, and now limit how many transactions you can add each day. So I'm looking for a FOSS app or self-hosted alternative. --- ### The Nice to Have Apps The next category of apps are the ones I use pretty regularly, but I don't feel as attached to. Since some of these reflect personal purchasing preferences I will use generic names rather than the specific app names. The apps that I do not plan to reinstall are: - **Amazon:** The only reason I had the app was so I could quickly add refills to my cart when I noticed I needed them. The web page is more than sufficient as a replacement. Also hopefully this will encourage me to use Amazon less if possible. - **Battle.net:** I don't play many games on the platform anymore. - **My local bike share app:** I was able to buy a hardware key I can use at the bike stands now! - **Hidrate Spark:** Stay tuned, I'm trying to see if I can implement GadgetBridge support for this. - **My Smartbulb app** \- everything is migrated to Home Assistant now.\[^ "Also more hardware buttons helps reduce the need for an app."\] I previously had trouble with it when running it through a container, but since transitioning to HA OS in a VM its been smooth sailing. - **My local ice cream stores rewards app:** gone since no more loyalty programs! - **My doctors office health app:** The app had *convenience* features like automatic check-in using your location. Thankfully the mobile website is feature-full and I can use that instead. - **My car's control app:** sadly the only way to remote start. Also the app is not even supposed to function in my state due to the enhanced vehicle telematics privacy law and right to repair law. Its probably for the best that I dropped this app. - **The *four* different parking apps I need for my city:** Half of them will not even run since they use the deprecated integrity checking API. I am OK with this and can walk to a payment station to handle my parking. - **Quicken Simplify:** I used Mint from the when I graduated college to when CreditKarma [ate them](https://www.creditkarma.com/about/releases/intuit-credit-karma-welcomes-all-minters?ref=blog.matthewbrunelle.com) in 2023\. Since then I've been using Simplify and despite the crazy amount of data I shared over the years they are genuinely useful budgeting tools.\[^ "Also at least its a paid service instead of a free one selling off my data to sell me on financial services."\] I've been trying out a [self-hosted option](https://github.com/actualbudget/actual?ref=blog.matthewbrunelle.com) but hasn't quite stuck yet. I still have some time before my subscription lapses to figure this out. At the very least I can drop the mobile app. - **Smart Contact Reminder:** really useful for me and I need to figure out a good replacement that isn't a overly complicated CRM. - **Steam:** I would love if I could just use regular 2fa with steam. For now I'm trying to go without using the app. - **Trakt:** I can just use the site for now. - **Strava:** I don't use it to track my exercise so I'm using the website instead. This is nice since it *is* social media and that means I interact with it less. The apps that I have not reinstalled but still might: - **AnkiDroid:** A good FOSS app that pairs well with this [obsidian plug-in](https://github.com/ObsidianToAnki/Obsidian%5Fto%5FAnki?ref=blog.matthewbrunelle.com) for generating flash cards. I'm not taking any courses right now so I have not installed it yet. - **ANT Radio Service / ANT USB Service / ANT+ Plugin Service / qdomyos-zwift:** I could write a whole post on this... but with a cheap ANT+ dongle you can pull live data from stationary bikes and send them to other devices like Garmin watches. If I finish building my [SmarkSpin2k](https://www.smartspin2k.com/?ref=blog.matthewbrunelle.com) or if I get a Victory Jetblack I will not need this anymore. Either way it is summer right now so I'm mostly biking outside anyways. - **Busuu:** Haven't managed to fit in time for daily language learning recently. - **Cartogram:** Really beautiful, cool, and reasonably priced live wall paper. Tracking policy looks OK but I want to see if there is a nice FOSS live wallpaper I can use instead. - **CellMapper:** Basically all carrier coverage maps overstate their coverage, but Cellmapper shows actual coverage contributed by the community. Also if you're a big dork that likes to see the roll-out of new cell frequencies you can even view the individual towers and what frequencies they support. - **My wifi AP control app:** I mostly manage my APs through a self-hosted controller so I haven't needed this. - **My sous vide device:** Requires an app because of course. There is no alternative I can see right now. This might end up in the Private Space jail. - **My headphones app:** \- I don't really want an app for my headphones but I might need to install it temporarily on occasion for firmware updates. ### Work Apps My employer uses Google Workspace and I had a couple apps in a work profile. For GrapheneOS I have instead made a work user to isolate the apps. **Authenticators:** There are [ways](https://gist.github.com/chowder/2ead734d60d84d4d15034fcce81aaaf9?ref=blog.matthewbrunelle.com) to pry 2Fa codes from apps, but mostly I just setup my hardware token instead. So I was able to skip installing these. **Calendar / Contacts / Docs / Drive**: Device Policy setup fails on GrapheneOS, so no Google apps will work. I can however visiting them in the browser if I need to. --- ### The "Sealed" apps *Hic svnt leones* I previously use the Private Space feature introduced in Android 14 for putting sealing wards on junk apps that I only used occasionally and expected to gather lots of data. These include all airline, rideshare, and restaurant app. When the Private Space is not on the apps are all frozen and cannot work in the background. The downside is you can't do this by app, its all or nothing. Most of these apps are loyalty program adjacent so I'm just not using them for now. I very rarely use ride sharing and can install / uninstall as needed. --- ### Why Would I Do This To Myself? Its fun! I enjoyed the process and I think its useful to think about how I interact with my phone in the year 2025\. Yes its ironic to spend so much time doing this \[^ "plus more time writing a very long article about it"\] while also talking about wanting to reduce how much I use my phone. But hey this is also a hobby, a hyper-fixation, and above all a small form of empowerment and nonconformity. ### BirdNet-Go: But We Have Bird Monitoring At Home URL: https://blog.matthewbrunelle.com/birdnet-go-but-we-have-bird-monitoring-at-home/ Last updated: 2025-07-15T15:17:22.000Z [BirdNet](https://birdnet.cornell.edu/?ref=blog.matthewbrunelle.com) is "a research platform that aims at recognizing birds by sound at scale" out of the Cornell Lab of Ornithology. [BirdNet-Go](https://github.com/tphakala/birdnet-go?ref=blog.matthewbrunelle.com) bundles a model from the BirdNet project into an easy to use platform for bird monitoring. For me this was one of those projects where I had the parts sitting around for a while but only just got around to actually putting it all together... Luckily setting up BirdNet-Go was really easy once I finally tried to do it. As far as getting all the parts there is a [recommended hardware](https://github.com/tphakala/birdnet-go/blob/main/doc/wiki/hardware.md?ref=blog.matthewbrunelle.com) page in the repo. Based off there advice I went with: - An old unused Raspberry Pi 4 I had. - A 400gb micro-sd card I already had in the RPi. Some people set up NAS shares for archiving recordings. I personally don't care about the raw samples so I'm not going to retain them long term. - A UGREEN USB to Audio Jack Sound Card Adapter. It was pretty cheap, under $15. - The Clippy EM272 Mono Microphone they recommended on the wiki. ### Installation I grabbed the [Raspberry Pi Imager](https://www.raspberrypi.com/software/?ref=blog.matthewbrunelle.com) and selected the Raspberry Pi OS Lite 64bit as the image option. The Lite option is recommended by their docs since the unit will run headless. In the installer I made sure to set the following: - hostname, username and password - Wireless connection, I use Omada APs and one is already set so the back porch can get good coverage. - SSH enabled, password based Once its done flashing and the device was booted up all I had to do was get BirdNet-Go on it. Usually I reach for Docker when I can but this device is only going to host BirdNet-Go and I have a limited amount of time for this project. So instead I went with their [installation script](https://github.com/tphakala/birdnet-go/blob/main/doc/wiki/installation.md?ref=blog.matthewbrunelle.com#recommended-method-installsh-linux). Its simple and interactive. They really did a nice job. After BirdNet-Go was installed I just had to get the device into position. I am fortunate to have plenty of birds near my porch so I was able to keep my RPi inside while running the mic cable outside, but under a covered roof. Otherwise I would have to handle weather proofing, running power, longer range internet, etc. I was not sure if I we get could sound reception next to the house but the mic is doing well so far! ### Customization and Birdweather Support Once the device is setup you can go into the dashboard (on port 8080 by default) to customize the installation. Most options that you need to set are configured during the guided installation in the script. Make sure to enable authentication since its off by default. Also please don't expose the device to the internet. Thankfully you an instead set up integration with [Birdweather](https://www.birdweather.com/?ref=blog.matthewbrunelle.com). Birdweather sells their own hardware but they are also a citizen science initiative to collaboratively monitor birds. They have [documentation](https://www.birdweather.com/birdnetpi?ref=blog.matthewbrunelle.com) you can follow to make an account and get an API key for the BirdNet-Go dashboard. There is a [live map](https://app.birdweather.com/?ref=blog.matthewbrunelle.com) you can view to see if anyone monitoring already in your area! Now to be very clear: this involves adding a station marker to the public map. If you are running the station at your home you are effectively marking your house. The station information on Birdweather does not contain any personally identifying information or even linkage to a username but its still something important to consider. Especially if someone knows you have a station and there are no stations in your area it would be easier to figure out your house. ### Closing thoughts > "I love birds" \~ [Usidore, the Blue](http://magictavern.wikidot.com/usidore?ref=blog.matthewbrunelle.com) I am excited to look back a year from now and see what birds have been identified. The dashboard lets you see aggregate species information over different time spans. Also if you want to ID birds on the go there are [BirdNet](https://app.birdweather.com/?ref=blog.matthewbrunelle.com) and [Merlin](https://merlin.allaboutbirds.org/?ref=blog.matthewbrunelle.com) android apps. Both are developed by the Cornell lab. I believe Merlin uses a different model that is more focused on offline on-device identification but I can't find the specifics on it. Merlin is the app I usually use. ### I Picked A Really Weird Time to Try Out GrapheneOS URL: https://blog.matthewbrunelle.com/i-picked-a-really-weird-time-to-try-out-grapheneos/ Last updated: 2025-07-25T18:25:48.000Z #### On this page Update: - Part 2 here [The Apps That I Kept On GrapheneOS](https://blog.matthewbrunelle.com/the-apps-that-i-kept-on-grapheneos-2/) - Part 3 here [The Apps That I Got Rid of When Trying Out GrapheneOS](https://blog.matthewbrunelle.com/the-apps-that-i-got-rid-of-when-trying-out-grapheneos/) I used to really enjoy custom ROMs back in the day. Over time Android integrated just about everything that custom ROMs had that I wanted, so I stopped using them. Recently though I've been wanting to try out GrapheneOS. I've been self-hosting for a while now and have been substituting FOSS apps. Switching to GrapheneOS is sort of the logical extreme of this and fun exercise. My hope was it would force me to be more intentional about what apps and services I use. Or thats what the plan was for the last couple of weeks. Then this happened today: > [ Post by @GrapheneOS@grapheneos.social View on Mastodon ](https://grapheneos.social/@GrapheneOS/114665558894105287?ref=blog.matthewbrunelle.com) I had a draft post with my notes from trying out GrapheneOS ready, and while I was going to test for another week now seems like a good time to talk about it. Also to have a chat about GrapheneOS going forward and if I'll use it. When possible in this post I will try to defer to the official documentation. Things go fast in this space and any details here will go stale. ### Before Installing GrapheneOS and Device Support Before even attempting the installation I wanted to survey the applications I was currently using. So I went through each one and tried to put them into five different buckets: - "Essential" apps that feel like a part of my daily life. In quotes because often they are not actually essential. - Nice to have - apps I use pretty regularly, but I don't feel as attached to. - Google - Google's various services - Work - My employer uses Google Workspace and I have a couple apps in a work profile - Private Space - the "Jail". Junk apps that I only need on occasion like airline, rideshare, and restaurant. When private space is not on the apps are all frozen. My current device is a Pixel 9 Pro Fold. Here of the official docs on [supported devices](https://grapheneos.org/faq?ref=blog.matthewbrunelle.com#supported-devices) but the short of it is Google Pixel devices 6 or newer are supported. This is because they are the only phones currently where you can install a custom ROM and relock the bootloader while still keeping a verified boot with custom keys. I don't really see a single section in the docs summarizing this but the whole general [security and privacy](https://grapheneos.org/faq?ref=blog.matthewbrunelle.com#security-and-privacy) section covers this in detail. ### Different Users and Profiles with GrapheneOS You'll notice I mentioned using work profiles before I installed GrapheneOS. Android supports having multiple users on one device. You have to use a user switch button in the pull down shade to change between them. However there is also support for ["enterprise work profiles"](https://www.android.com/enterprise/work-profile/?ref=blog.matthewbrunelle.com) that sit on top of a user which I believed arrived in Lolipop (5.0). The pitch is you get isolation for your work apps from your personal apps without having to flip between users. The mechanisms for this sits on both sides of Android Open Source Project (AOSP) and Google Play Framework. More or less: - `DevicePolicyManager` is built into the OS - All the pieces for creating and managing the user profiles is built into Play Services. Based on what I could find out this mostly because Google wanted to integrate it with the Play Store for managing the installation and management of work apps. So in GrapheneOS we have the `DevicePolicyManager` but not the Play Services piece. Even if you install Play Services you wont get this because of the sandbox GrapheneOS implemented (more on this later). This means you can't use a work profile like on stock. So you lose the ability for a single user for personal and work apps. There are open source apps like [Shelter](https://github.com/PeterCxy/Shelter?ref=blog.matthewbrunelle.com), [Island](https://github.com/oasisfeng/island?ref=blog.matthewbrunelle.com) and [Insular ](https://github.com/proletarius101/Insular?ref=blog.matthewbrunelle.com) that interact with `DevicePolicyManager` to isolate apps into profiles in a similar way you would use work profiles. Privacy Guides's [writup](https://www.privacyguides.org/en/android/general-apps/?ref=blog.matthewbrunelle.com#shelter) on why not to use those apps explains it well so I'll point you there to decide if you want to use them or not. For my testing with GrapheneOS I decided not to use those apps and see how far along I could get with just the [Private Space](https://source.android.com/docs/security/features/private-space?ref=blog.matthewbrunelle.com) feature that was added in Android 15\. This is thankfully a part of AOSP and is now integrated into more and more launchers. I also wanted to test out having just a work user. So yes back to users. Here are the official docs on [improved user profiles](https://grapheneos.org/features?ref=blog.matthewbrunelle.com#improved-user-profiles) in GrapheneOS. There are a lot of features they implemented so go read the primary source. The big one for me is [notification forwarding](https://grapheneos.org/features?ref=blog.matthewbrunelle.com#notification-forwarding). This means that if one user is in the background and gets a notification you can still see it on your main user. If you are logged in and the main Owner user you can go into the system settings for users and choose which get to run in the background so this is also configurable. For example if you wanted to get notifications from work related apps like calendar reminders. Speaking of... ### Work User, Web View, Vanadium Slack usually requires Chrome for the Google login flow to work. Every time I've used it in a work profile I had to add Chrome into the work profile. GrapheneOS already a custom Chromium fork call Vanadium that is also used as the system webview in place of Chrome. Its a very nifty project. See the official docs for [more details](https://grapheneos.org/features?ref=blog.matthewbrunelle.com#vanadium). When I tried to use Google to log into Slack on GrapheneOS though the login flow was broken. So I tested out installing Chrome on my Work User. That cause something called the "Trichrome library" to prompt for installation. This [seems](https://discuss.grapheneos.org/d/3350-trichrome-library?ref=blog.matthewbrunelle.com) to be the default system webview on Android. That attempt however didn't work, which is probably good since we don't want to circumvent Vanadium. What does work is use the option in Slack to log in with my workspace's name. That takes you to a page in the Vanadium web view that you can then use to log in with your work Google account. Work profiles require the Device Profile app to be installed. The app is supposed to make sure the device follows the security policies they set. Unfortunetely I could not get it to work which meant that apps like Google Calendar and Gmail did not work. This [seems](https://discuss.grapheneos.org/d/1662-work-profile-sandboxed-google-play-services-android-device-policy-help/3?ref=blog.matthewbrunelle.com) to be because privileged permissions are required. There is an [issue](https://github.com/GrapheneOS/platform%5Fframeworks%5Fbase/pull/4?ref=blog.matthewbrunelle.com) covering this but it is marked closed. The good news is there a recent and very active [PR](https://github.com/GrapheneOS/platform%5Fframeworks%5Fbase/pull/147?ref=blog.matthewbrunelle.com) working on support! One downside vs stock is that stock also has [Digital Wellbeing](https://www.android.com/digital-wellbeing/?ref=blog.matthewbrunelle.com) which lets you set work hours where the work profile will automatically turn off. That is not a part of AOSP so we lose it. Luckily users on GrapheneOS have an "end session" button on the lock screen you can use at the end of the day to turn the work user off. ### The Play Services Sandbox Here are the official docs on [sandboxed google play](https://grapheneos.org/usage?ref=blog.matthewbrunelle.com#sandboxed-google-play). Once again please read the official source because this is extremely interesting. The sandbox does provided an explicit choice for you to make with your device: by default the Owner user and any new users you make will not have Play Services installed. You have to decide to install Play Services through the "Apps" app that GrapheneOS provides. ### Installing GrapheneOS and apps Installing was actually pretty painless. I just followed the steps on the [web installer](https://grapheneos.org/install/web?ref=blog.matthewbrunelle.com) in Chrome. Firefox did not work, I believe its related to WebUSB. Much easier than the days of flashing with ADB commands. Please read this though: installing GrapheneOS involves temporarily unlocking the bootloader. This means totally wiping you device. Please make sure everything is backed up. Part of why I went through all my apps and thought about them was to make sure I had a backup and restore strategy for any if needed. One other note: GrapheneOS disables just in time compilation and instead only uses ahead of time compilation. This means app installation will often take longer compared to stock android. ### Lessons From Daily Driving So now we are finally here. Talking about the actual apps getting installed. Initially I thought of listing out all my apps, talking about FOSS alternatives, etc but that seems out of theme with what I've written so far so I'll punt that to another post. What I do want to say is that I initially set up my device like this: - Owner user, no play services. Installed all important FOSS apps through Obtanium. - "Google" user, play services installed. Installed all apps that are on the Play Store only. I figured this setup would not work well and people advised against but I wanted to see just how bad it feels. So here's some examples why this was tough: - I couldn't listen to podcast/music and use Maps at same time. My music is all self hosted and Pocket Casts is open source so they are on the Owner profile while maps is on the Google profile. - Visual voicemail for phone calls didn't seem to work unless it was on the Owner profile. However looking over the forums visual voicemail seems fickle in general. See [Carrier functionality](https://grapheneos.org/usage?ref=blog.matthewbrunelle.com#carrier-functionality) in the docs for more. - Android auto seems like it needs to be in the Owner profile to work though I haven't been able to confirm this. See the [usage guide](https://grapheneos.org/usage?ref=blog.matthewbrunelle.com#android-auto) on the official docs. GrapheneOS does some deep magic to make it work with the Sandboxed Google Play. - The only way to use RCS right now is through Google Messages. See [this](https://github.com/FossifyOrg/Messages/discussions/34?ref=blog.matthewbrunelle.com) long issue thread on the Fossify Messages repo for why that is the case. I tried installing messages in the Google user and the app claimed RCS was enabled but it did not work. RCS does work when it is in the main profile. What I settled on for now is this: - Owner user with play services installed. FOSS apps and essential Google apps. - "Friction" user with all the other Play apps I'm still evaluating. I called it Friction because the act of changing users create a tiny bit of intentionality where I get to think about if I want to use those apps. ### Categories of features Another part of this experiment is getting a better understanding of what features come from where. My current mental model is there are three buckets: - AOSP, as mentioned these are things in the base open source Android OS. All Android phones should have these. - Google, things related to Google Play Services and the Play Stores. Not all Android phones will have these but many will. Google has to approve devices for manufacturers to get Play services. For example Amazon's Android devices do not have Play Services. I also put in this bucket random Google owned apps like Speech Synthesis Services or AICore. - Pixel, which are only on Google's own phones. These are the what they talk up in the "Pixel Feature Drops". I've noticed over the last couple years that Google's own language around these buckets is kind of blurry. I've been on Nexus/Pixel devices for a long time too so for me especially the features are all mushed together. So anyways here's an unordered list of other things I've noticed these past couple of weeks relating to features that change with GrapheneOS. I've tagged them with the bucket I think they belong to when I can: - (Pixel) No Now Playing ❌ - (AOSP since 14) Health Connect ✅ - (Google) Digital Wellbeing ❌ I think I could install this from the Play Store but I'm choosing not to. This means I lose things like Flip to Shh, Bedtime Mode, and Work Hours - (AOSP) Night Light ✅ where the screen shifts towards red tones. - (Google) WiFi password syncing ❌ where Google saves and sync WiFi passwords between devices. My guess is this lives in Play Services but the sandbox protects us from this. - (Google) Wallet NFC payments ❌ This is a big an of worms I won't open here but basically Google prevents it. I believe passes might work but I have not tried. - (Google) Passkey login to apps using a third-party password manager does not work unless Play Servics is installed. According to this [issue](https://github.com/GrapheneOS/os-issue-tracker/issues/2073?ref=blog.matthewbrunelle.com#issuecomment-2128632596) they do not plan to support that. - (AOSP) ❌ The network connectiom symbols are the default generic names rather than the carrier branded versions. For example I see "5g+" instead of "5g UC". Because OSS is awesome there is already an [issue](https://github.com/GrapheneOS/os-issue-tracker/issues/3451?ref=blog.matthewbrunelle.com) covering this. ### Final Thoughts This has already been quite a long post. So huzzah for more long form writing but I'll wrap up for now and talk more about app specifics later. ``` At the top though I mentioned the uncertain future of GrapheneOS and what my future plans are. Prior to today I felt I wanted to continue using GrapheneOS and thought the trade-offs where worth it. After today? Honestly I'm only more invigorated. This action from Google makes projects like GrapheneOS more import. Hopefully Graphene can get their own device in the future like they want and keep us free from enshitification. ``` Oh also I'm going to [donate](https://grapheneos.org/donate?ref=blog.matthewbrunelle.com). If you give GrapheneOS a try, like it and are in a position to help out then I hope you do too! ### All Personal Blogs Die, But Mine Came Back As a Ghost URL: https://blog.matthewbrunelle.com/all-personal-blogs-die-but-mine-came-back-as-a-ghost/ Last updated: 2025-07-15T15:20:33.000Z ### Blog Archeology For a decade (!) from 2011 to 2021 I hosted my personal blog on Github Pages with Jekyll. I had forgotten much of the history behind my blog but thankfully it was just a git [repo](https://github.com/ciferkey/ciferkey.github.com?ref=blog.matthewbrunelle.com)! We can look back in time through the changelog. The first post was 2011-03-15 and titled "Cold Boot": > I'd like to give a special thanks to [James Hunt](https://web.archive.org/web/20140901204212/http://ohthatjames.github.io/) who's blog I forked (its amazing how you can actually fork a blog). As well as the [inspiration](https://web.archive.org/web/20130220102955/http://chrismdp.com/2009/12/jekyll) to do so. > I used to have a blog at blog.overflowbit.com however it ended up becoming malnourished and so I decided to shut it down. One day I would like to set up a portfolio site at matthewbrunelle.com, however that would require having material to put in my portfolio. As of now I'm a high school senior for Rhode Island. I'm applying for the [Google Summer of Code 2001](http://www.google-melange.com/?ref=blog.matthewbrunelle.com), specifically with the [Python Software Foundation](http://www.python.org/psf/?ref=blog.matthewbrunelle.com). As such I needed a blog to post to and rather than set up a self hosted word-press install again I opted to try out [Jekyll](https://github.com/ciferkey/ciferkey.github.com?ref=blog.matthewbrunelle.com). > I can't wait to dig in to some real development! > \~Matt (Links have been updated to wayback machine archive links since all personal blogs die.) How ironic that what I thought was the start of my blog was the announcement of the death of my old blog. `blog.overflowbit.com` is sadly not in the wayback machine, so it is truly lost to time. I think I used WordPress at the time hosted on some forgotten VPS but we will never know. In fact I let the registration slip for a while but it seems no one sniped it before I rebought the domain recently. ### The Value of Static Sites The fact that I can figure any of this out though is a testament to the power of the git repo + markdown + static site generation style of blogs. Markdown is a tried and tested format for content. When I wanted to stand up my blog again I was able to just copy the markdown out of the repo and load it into Ghost. There were some custom tags I had added and needed to be updated but all the text formatting was portable. Speaking for portability back when I started blogging I loved that all I really needed to do was generate an RSS feed and I could serve it as a static file. Honestly I still love that aspect of RSS and RSS in general. RSS is unencumbered content distribution at its finiest. I consume RSS feeds daily and I like the idea of giving back content over RSS. Somewhat related, Kagi is [throwing their support](https://blog.kagi.com/small-web?ref=blog.matthewbrunelle.com) behind Small Web. It turns out it was very easy to [add my site to their repo](https://github.com/kagisearch/smallweb/pull/421?ref=blog.matthewbrunelle.com) for inclusion. ### So why Ghost? So flash forward to now. It turns out my blog had been rotting for years and at some point that Github Pages had just stopped serving the site. I realized this last month at PyCon and I got the itch to start writing again. Cory Doctorow gave the keynote speech and I was reminded about how the web can be free and open. I should probably take the time to read through *The Internet Con:How to Seize the Means of Computation* At the same time my goals with writing are a little different now. Now I am looking for the following: - Attention sinks pull me in so many different directions. While I love to tinker setting up software, I currently want to focus more on writing. - I still want to write my posts in Markdown - ActivityPub integration. I'm not active on any social media so this sounds weird but I like the idea of contributing long-form content to the fediverse. I guess its called Macroblogging retroactively now that microblogging exists. Plus I've played around with implementing the protocol before and its really nifty. ActivityPub support rules out static site generation. I had seen some approaches to shoehorn in support but they didn't feel great. The extra steps required to publish each post also goes against the low friction writing goal. I had looked into using [Writefreely](https://writefreely.org/?ref=blog.matthewbrunelle.com) instead but I kept hitting snags with self-hosting it. Then I found Ghost: - Open source and easily self-hostable. Its already bundled in the Unraid Community Apps. I just had to put it behind Caddy and set up the DNS on Cloudflare. - ActivityPub integration is currently in beta for paid hosted users. Ghost recently [posted](https://activitypub.ghost.org/ramping-up-for-expansion/?ref=selfh.st) about the 6.0 release is on its way soon and will include ActivityPub. In its current private form it sounds like it was deployed as a side car with its own nginx so I'm curious if thats what will ship in the next release. - Newsletters? I don't think I will use this feature to be honest. It could be fun to do a private one for my family members though. ### What I Will Miss My old blog started by forking someone else's blog. Thats so neat! I tried to pay that forward and keep my own blog available as a repository. I will miss that now that I'm using Ghost. But you know, at the end of the day my posts are still just Markdown text. I can yank them out and rebuild if I need to again. As I said, all blogs die, so in a decade I suppose I'll do some more blog necromancy. ### Ricoh GRIII in Japan URL: https://blog.matthewbrunelle.com/ricoh-griii-in-japan/ Last updated: 2025-07-15T15:22:41.000Z In March/April I traveled to Japan. My friends had been planning the trip since the end of 2019, but you know... things happened the next year. Then life events happened, and we kept pushing the date back, but this year we finally made it! Before the trip, I had lucked into a gift certificate for a RISD adult continuing education class, and they were offering remote options. Since I knew I was going to Japan, I took the [1647 - Photography: Introduction](https://cereg.risd.edu/search/publicCourseSearchDetails.do?method=load&courseId=1029293&ref=blog.matthewbrunelle.com) course. This was my first time doing a remote class, and I was unsure how that would go, but it ended up being a really nice experience. They covered all the fundamentals about manual mode camera operation, the theory behind it, and above all the tempo of regular course work meant I actually get hands on practice! For the course, I was fortunate to borrow my father's old Canon camera. Having multiple lenses, batteries, straps, memory cards, etc. was a boon during the course, but I didn't want to lug all of that around on my trip to Japan. I ruminated on what new camera to get for way too long, but I eventually settled on the Ricoh GRIII. My thoughts were: - I wanted something truly pocketable. - I wanted something I could use to quickly grab shots as we went so I could stay present. I'm traveling in a beautiful country and don't want the photography to take me out of the moment. - Most pocketable point and shoot cameras are beaten nowadays by cellphone cameras. Especially now that cell phones are packing multiple lenses and can take long shots. However, I wanted to stay off my phone as much as possible on the trip. The Ricoh GRIII makes a lot of trade-offs to be so small: - Prime lens - the lens cannot zoom, there is only one focal length you can shoot at - Fixed lens - the lens is built into the camera, you cannot swap it for other lenses. This also has implications for cleaning if dust gets inside, and it's prone to that. - Video quality is so poor you shouldn't use the device for video - Battery is a bit short, but a second battery is cheap, compact, and easy to swap to. But in return you get: - One of the smallest packages for an APS-C sensor. - [Snap Focus](https://www.nonshape.com/blog/2024/9/1/ricoh-gr-iii-snap-focus?ref=blog.matthewbrunelle.com) is awesome for taking quick photos on the go. There's a reason street photographers love this camera. - Really crisp in camera processing. I shot full quality JPEG + raw, but honestly the raws were only a backup. - Prime lenses are really cool for flexing your creativity. As they say, "zoom with your feet". > When shooting a prime lens, you often can’t shoot the subject the way you envision it. So you have to get creative. Limiting yourself to a focal length you're not used to makes you experiment with new ways of composing. ​Primes might not make your photos better, but it will exercise your ability to improvise. - [Cody Mitchell](https://cody-mitchell.kit.com/tips?ref=blog.matthewbrunelle.com) ### GRIII Recommendations: There is a [Rioch 101](https://www.youtube.com/watch?v=-h3w6KjXzvA&list=PLhSx6aZzGgaKO0a3QHbh4K1TeT1z107DR&ref=blog.matthewbrunelle.com) tutorial series on YouTube that is really useful for learning the specific of the device. The guide covers all the buttons, customizing the layout and how the snap focus mode works. I highly recommend giving the series a watch and trying out the recommended customizations. The official Ricoh [Image Sync](https://play.google.com/store/apps/details?id=com.ricohimaging.imagesync&hl=en&ref=blog.matthewbrunelle.com) app has a really low score, but actually worked fairly reliably for me. I really liked that I could have location data on my photos. The main things I had to do with the app to keep it happy were: - Remembering to turn the app on before taking photos so that the camera would receive the GPS info. For periods where I was taking lots of photos, I would leave the app on. This would draw extra power though, so a phone battery was a must. - The app can connect to the camera over either Bluetooth or Wi-Fi. Connecting over Wi-Fi was much reliable for me. - The Wi-Fi connection required me to turn off my VPN connection to Tailscale. Similar to Fujifilm cameras, you can apply color recipes. My favorite color recipe is the [Reggies Color Negative](https://reggiebphotography.com/blog/The-Most-Versatile-Ricoh-GR-III-GR-IIIx-Film-Simulation-Recipe-Reggies-Color-Negative?ref=blog.matthewbrunelle.com). Unlike Fujifilm cameras, you can't use an app to swap them out. Here's a guide on [how to](https://ricohrecipes.com/2021/08/30/how-to-add-recipes-to-your-ricoh-gr-camera/?ref=blog.matthewbrunelle.com) add recipes to the GRIII. ### Upcoming GRIV Before I bought the GRIII I thought long and hard about waiting on a GRIV announcement. The GRIII had been out for a while, and a refresh was overdue. I knew that if I got the camera for the trip, Ricoh would definitely announce the GRIV for this year. So anyway, Ricoh [announced](https://ricohgr.eu/blogs/news/22-05-2025-development-anouncement-of-ricoh-gr-iv?ref=blog.matthewbrunelle.com) the GRIV two weeks ago. This sacrifice was made for all of you. I am pretty happy with the GRIII, so I don't think I'll upgrade immediately unless the reviewers come back saying they think the camera is a significant upgrade. My main thoughts based on what has been revealed so far: - It looks like the GRIV has the same aperture range of F2.8 to F16 that the GRIII has. A lower end focal length closer to F2 like the Fujifilm x100 line would be appreciated. Though once again the GR line is significantly more compact, so there is less room to work with and trade-offs must be made. Some positives change I see are: - Ricoh is releasing a new "GR WORLD" app. Fingers crossed the app performs better than Image Sync. The announcement also mentions the app will work with the GRIII line, which is appreciated. - The GRIII has double max ISO and Ricoh claims to have improved the stabilization. The GRIII can struggle in low light situations, so fingers crossed these changes help! Anyway without further ado here are some favorite shot I captured on the trip: ![](https://blog.matthewbrunelle.com/content/images/2025/06/R0000224.JPG) ![](https://blog.matthewbrunelle.com/content/images/2025/06/R0000328.JPG) ![](https://blog.matthewbrunelle.com/content/images/2025/06/R0000544.JPG) ![](https://blog.matthewbrunelle.com/content/images/2025/06/R0000592.JPG) ![](https://blog.matthewbrunelle.com/content/images/2025/06/R0000928.JPG) ![](https://blog.matthewbrunelle.com/content/images/2025/06/R0000968.JPG) ![](https://blog.matthewbrunelle.com/content/images/2025/06/R0001076.JPG) ![](https://blog.matthewbrunelle.com/content/images/2025/06/R0001176.JPG) ![](https://blog.matthewbrunelle.com/content/images/2025/06/R0001294.JPG) ### Post Race Report: Boston Run to Remember URL: https://blog.matthewbrunelle.com/post-race-report-boston-run-to-remember/ Last updated: 2025-07-15T15:27:17.000Z I want to start writing race reports to reflect on my running. That way I can look back on all my achievements and learn from my effort. They also help me with my goal to practice long form writing. - [Background](#background) - [Planning My Race Season](#planning-my-race-season) - [Training](#training) - [Pre-race](#pre-race) - [Race](#race) - [What's Next?](#whats-next) ### Background: My grandfather [passed away](https://www.murphyfuneralhomes.org/obituary/James-Nesto?ref=blog.matthewbrunelle.com) in December 2023\. He was the most formative person in my life and the one who got me into running. When he passed, I had taken time off from running to try out other activities, but I figured that if he could [run a sub four marathon](https://www.athlinks.com/event/36996/results/Event/104288/Course/145340/Bib/573?source=internal) at the age of 60 I should give a marathon a shot. So last year I ran the same race he did at 60: the Ocean State Marathon. It went really well and while I didn't write up a race report after, it did set me up to think more seriously about running this year. ### Planning My Race Season: In December, I finally sat down to plan my training and race schedule for 2025\. I had the following goals based on my experience with the marathon: 1. Instead of one marathon I wanted to target two half-marathons. That way I could see what it was like to have training cycles build on top of each other. 2. I pushed a lot of other things in life aside while marathon training and I wanted to be more present and balanced this year. 3. I couldn't sustain strength training and running with the workload of the marathon training. Strength training is so important for health and I wanted to integrate back into my schedule this year. I saw there was enough time to do two full training cycles with a period in between to recover. I purchased my entrance to two races: - [Boston Run to Remember](https://www.bostonsruntoremember.org/?ref=blog.matthewbrunelle.com) \- Sounded like a very fun race, and it is local to me so no traveling. It's in late May, so there's plenty of time to train in warm weather, but it's also before the heat of summer. - Amica Newport Marathon - I read it was a very scenic run and it's near my family which would make traveling for it easier. The race is in October, so I had time to rest between cycles. Last year I used Matt Fitzgerald's 80/20 structured running plan to train for the marathon, and it worked well for me. So this year I wanted to use an 80/20 plan again for the Half Marathons. I take a stimulant medication and like how the plans are conscientious of heart rate. Another nice aspect of the plans is there are different versions depending on experience level. For the marathon I used the level 0 total beginner plan. For the half-marathon this year I used the level 1 plan. The main difference between level 0 and level 1 is they increase the weekly running time by having only one day off rather than 2 days off. When I ran the marathon last year I felt like I barely had enough time to recover and I was often pushing aside other aspects of my life to do my training. My main goal for this year was to find a better balance between my training and everything else in life! I figured that having an additional training day gave me a little extra wiggle room if I needed to skip a day here or there. ### Training The race date was May 25th, so with a 15-week training plan that I would start training on Feb 11\. That meant I could do some general preparation through December and January... then I dropped a blender bottle on my toe: > "Nondisplaced slightly comminuted fracture of the distal phalanx of the fourth toe, with intra-articular extension." The doctor said recovery would take 6-8 weeks. Luckily that was the exact amount of time I had before training started! Thankfully I was cleared to start some biking after only two weeks and that led to some fun projects. I grabbed a used Schwinn IC4 stationary bike for a good price and slapped some Garmin speed and cadence sensors on. The bike also does a *very* rough estimate of power that it broadcasts over bluetooth but not in any kind of standardized way. Thankfully I could then use qz zwift [GitHub](https://github.com/cagnulein/qdomyos-zwift?ref=blog.matthewbrunelle.com) with a usb-ant+ stick to collect and rebroadcast the power information to my Garmin watch. The bike can also be converted into an ERG trainer with [SmartSpin2k](https://www.smartspin2k.com/?ref=blog.matthewbrunelle.com). I bought all the parts and as ready to do it but then... I was healed and able to run again. Will definitely revisit this project. Another positive that came out of this experience was being reminded about biking and how it can be mixed into my training. I had read a couple of Matt's books, and they talk about the benefits of cross-training. I do not have a background in sports medicine, so I'll defer to experts but cross-training is a nice way to lower impact/injury while still keeping volume up even if you lose a bit on the specificity side. The training plan [documentation](https://www.8020endurance.com/understanding-your-8020-run-plan/?ref=blog.matthewbrunelle.com) has guidance on cross-training: > There are two basic approaches to replacing runs with cross-training: the programmatic approach and the as-needed approach. In the programmatic approach, you replace certain runs with cross-training sessions routinely. For example, you might replace your first Foundation Run of each week with an elliptical workout. In the as-needed approach, you replace runs with cross-training only when soreness or fatigue from prior running makes running again seem inadvisable. Note that these two approaches are not mutually exclusive, and indeed you should cross-train instead of running anytime it seems risky to run, regardless of whether you also practice the programmatic approach. I subbed out 14 runs over the course of the 15 weeks with bikes rides. Some other numbers: I went to Japan and skipped 13 days of training during weeks 6-8\. Outside of that I only chose to skip 5 other training days as a part of balancing life. I'm very happy with that over all, I gave myself the grace to skip running, so I wouldn't miss out on important events, but I was also judicious with those decisions. Another aspect that was different this year was weight lifting. Before my marathon last year I was lifting pretty frequently. I had been doing Jim Wendler's 531 framework and after a life of running I was starting to really enjoy 4 days a week in the gym. However, when I did my marathon training I did not have a principled process for strength training while running. Even though I knew strength training was essential I had enough difficulty recovering from the running that I basically dropped all lifting. Matt Fitzgerald's books had some really nice framing about strength training. You really can only have one primary thing you are training for at any time, and you can lift but if you are training for a race the lifting is in support of your training, not the goal. So this season I aimed for only two days a week in the gym. That is the bare minimum that you can really do to make progress, but I wanted to have a realistic goal based on last season. Additionally, I changed my strength training off of a split workout like 531 where certain days would focus the lower body to full body strength days. It made the logistics for how to line up strength days with running workout days easier and that planning flexibility was really useful. So the numbers for lifting: 6 weeks where I lifted once, 5 where I lifted twice. Not quite my goal but certainly better than last year. Also, two weeks were missed due to my trip so that's 11 of 13 weeks where I went to the gym at least once. As a side note I use TrainingPeaks for my structured workout planning. Typically, I use it to look forward into the future, but I'm realizing now it's nice for reviewing all these details! Gear: - 1 pair of the now discontinued Brooks Ghost Max I retired with 320 miles on them. - 1 pair of Brooks Ghost Max 2 that I got just before the race and put \~20 miles on. - Garmin Fenix 7 Pro + HRM Pro Plus chest strap - A Whoop, a Pixel Watch 3, Polar Verity Sense, Polar 360, Scosche Rhythm24, etc. I've been tinkering with lots of random wearables to better understand the technology, but they are not an active part of my training. ### Pre-race: For the marathon I used my training to estimate a race pace and decided to aim for 10:15 pace. That was a very conservative pace because my main goal was to complete the race without injury or needing to take a long time off from training after the race. I got the course map into Garmin and generated a PacePro Plan with a small negative split. For average time I ended up pretty much right on with 10:11, but my minute splits were all over the place. I had hoped to run with a pacer, but they ended up not being able to make it to that race. For my half-marathon I went with a 9:05 pace seemed pretty reasonable given my training. Since the distance was so much shorter than a marathon I felt I could push the pace more without the injury risk. After seeing the magic of a taper for the first time with my marathon I was excited to see it again. Once again it delivered, and I felt ready to race just as I got to race day. ### Race: So here are the official [results](https://results.raceroster.com/v2/en-US/results/36vs6pxhw573fv4c/detail/23d4swq3hqfr4gv4?ref=blog.matthewbrunelle.com) and my splits for the race: | | | | | | --- | ------ | --------------- | ------------ | | Lap | Time | Cumulative Time | Average Pace | | 1 | 9:03.7 | 9:03.7 | 9:04 | | 2 | 8:48.8 | 17:52 | 8:49 | | 3 | 8:37.7 | 26:30:00 | 8:38 | | 4 | 8:39.4 | 35:10:00 | 8:39 | | 5 | 8:37.1 | 43:47:00 | 8:37 | | 6 | 8:25.7 | 52:12:00 | 8:26 | | 7 | 8:29.3 | 1:00:42 | 8:29 | | 8 | 8:24.7 | 1:09:06 | 8:25 | | 9 | 8:22.7 | 1:17:29 | 8:23 | | 10 | 8:28.6 | 1:25:58 | 8:29 | | 11 | 8:05.5 | 1:34:03 | 8:05 | | 12 | 8:03.8 | 1:42:07 | 8:04 | | 13 | 7:44.7 | 1:49:52 | 7:45 | | 14 | 1:24.0 | 1:51:16 | 5:54 | Some things to note: - I did not follow my pacing plan - I was able to go much faster than my plan - I had a nice crisp negative split! How it happened was weird though. There were \~8000 people in the race so it was a packed start. I began the race with my pacer, but they were going a little faster than my target pace. Around mile 4 they signaled to everyone to bathroom break if needed at an aid station. I didn't need to, so I decided to go ahead and regroup later. Then I just felt like running. As I continued to feel good I continued to push the pace more. By the time I got to the end of the race I still had some gas in the tank and my old XC sprint to the finish ethos kicked in. In fact after the race a random person came up and said "you're the one that sprinted at the finish aren't you?". Recording of me at the finish line: ### Post-race: So in retrospect I could have aimed for a much faster pace, kept a negative split, but not had as much wasted energy at the end. I was honestly surprised by how good the race felt. When I ran the marathon I fueled constantly during the race with Cliff Bloks, a bit of Gu, Skratch and more Bloks. So many Bloks. For the half-marathon I had my running candy at a slightly faster rate, and I'm curious if that helped. There also the fact that they contain a lot of caffeine and I *never* have caffeine outside of races and a couple long runs before races to get my stomach used to it. Caffeinated candy is magic. ### What's next? I will rest this week, and then I have a DEXA scan and VO2 max test scheduled for a week after the race. I've always wanted to try those, and I'm curious how the VO2 max estimate from various apps match up to reality. After that I have a second half-marathon scheduled, but I might push that race to next year. My friend was encouraged by his doctor to take his health more seriously, and he is aiming to do a tri-sprint this year. He invited me to join him, so I'm going to do the [Hyannis 2 Sprint Triathlon](https://neeevents.com/hyannis2-sprint-triathlon/?ref=blog.matthewbrunelle.com). I'm pretty excited about it. My time off from running before my marathon taught me how nice it is to try out new types of physical activity. A tri-sprint will help me dip my toe into both biking and swimming. Also, there is an 80/20 training plan for tri-sprints that has a cool structure to it: - running workouts are HR based - biking workouts are power based - swimming workouts are pace based It sounds like a lot of new fun things to learn about! Then when winter comes I'm looking forward to having a period where lifting can be my primary focus. And for next season? I kind of want to try a 10K and a 5K as primary races rather than supporting races. That way I will have run the full breath of race distances so I can experience them all. Long term my goal is health running is a significant part of that, but you can also run too much and sacrifice other important contributors to good health. I shouldn't plan too far out though because I could always have another blender related accident that leads to another epiphany. ### PyCon 2025 Saturday - Talks URL: https://blog.matthewbrunelle.com/pycon-2025-saturday-talks/ Last updated: 2025-07-15T15:30:04.000Z Saturday was another day of talks. At this point in a conference its not unreasonable to start feeling a bit burnt out. When I visited EMNLP last year my coworker gave me some wonderful advice: > "pace yourself. It's tempting to go to everything but it's not sustainable. Inevitably every breakfast, lunch, and dinner will be with colleagues (or folks you know from past schools and companies), which is draining" I really appreciated that lesson and made sure to share it with coworkers this year. Especially when you are sent by your employer you can self impose a pressure to always be "on" at a conference but thats not possible. Verbally let your coworkers know to be kind to themselves so you can all collectively create that culture. You might not attend as many sessions but you will find that you are more present during the ones you do attend! --- ### [Writing Extension Modules To Be Interruptible](https://us.pycon.org/2025/schedule/presentation/39/?ref=blog.matthewbrunelle.com): I knew this would be an interesting talk when early on was the phrase "There is no magic fix, this is inherent to how it works". The talk covers that common scenario where you try to kill a python program and it.... just hangs. One part I really appreciated was that they showed how it could be avoided but pointed out how even well intentioned people trying to do their best can miss the part in the docs or miss understand it because its confusing. ### [Scaling the Mountain: A Framework for Tackling Large-Scale Tech Debt](https://us.pycon.org/2025/schedule/presentation/2/?ref=blog.matthewbrunelle.com): This was interesting because it covered both the technical and corpo-politics aspects that can make tackling tech debt harder than it needs to be. That potent combination of lack of visibility meets no clear accountability. They have a [repo](https://github.com/ZipHQ/bar-raiser?ref=blog.matthewbrunelle.com) for the project available. ### [GPU Programming in Pure Python](https://us.pycon.org/2025/schedule/presentation/111/?ref=blog.matthewbrunelle.com): The speaker talked about wanting to change the perspective that parallel is not default and shouldn't be used cautiously because GPUs are not exotic anymore. I liked how that sentiment was tempted with the idea that you need a workload that works for parallelism, and overcomes the fixed cost: > "If you're doing linear amount of compute like summing you need gbs of data for it to payoff... If you're sorting or doing matrix multiplications then you need less data than that" ### [The Zen of Polymorphism: Choosing between isinstance(), methods, and @singledispatch](https://us.pycon.org/2025/schedule/presentation/99/?ref=blog.matthewbrunelle.com): I appreciated this talk and will avoid the temptation to just dump a bunch of my notes here. The recording will do better justice to the idea. The example use case was and AST for simple calculator. We walked through four techniques for evaluating the nodes: 1. one big function w/ `isinstance` 2. object oriented programming 3. dynamic Dispatch using `@singledispatch` 4. catamorphism with `@traverse.register` Brett was very fair comparing the trade-offs on the different approaches and when to pick one over the other. Especially if you are more worried about jumping around *when writing the implementation* vs jumping around *when debugging*. Plus I love getting to hear fun language theory phases like catamorphism. ### [Elastic Generics: Flexible Static Typing with TypeVarTuple and Unpack](https://us.pycon.org/2025/schedule/presentation/83/?ref=blog.matthewbrunelle.com): The actually use case in this talk felt like a stretch but I enjoyed learning about `TypeVarTuple` and unpacking. I know I just said that I love language theory but I feel like Python is not really the language to go super deep into it. A lot of the typing feels bolted on. ### [Notes, Groups and Stars: exceptional new features of Python 3.11!](https://us.pycon.org/2025/schedule/presentation/96/?ref=blog.matthewbrunelle.com): My brain was starting to turn into goo at this talk so I want to revisit it but the presenter was very fun: "I'm sad when I look at this code, and I wrote it." ### [What they don't tell you about building a JIT compiler for CPython](https://us.pycon.org/2025/schedule/presentation/5/?ref=blog.matthewbrunelle.com): The speaker commented that they gave a talk last year with the same title so this one hould have a red asterisks next to its title. I had heard about the Fast Python initiative but hadn't really dug into this. This talk was a nice overview of what had be done so far and where the JIT pieces are going. Also the fun line: "If you're a rust programmer this is what we call unsafe code" ### PyCon 2025 Friday - Keynote and Talks URL: https://blog.matthewbrunelle.com/pycon-2025-friday-keynote-and-talks/ Last updated: 2025-07-15T15:31:06.000Z Friday at PyCon 2025 brought the first keynote speaker and many talks! Talks run concurrently in four rooms so you do not get to see everything. In general for a conference like this I try to read the talks description and the speaker bios when deciding which sessions to go to. Its tough to really know what a talk will be about though until you go into it. Give yourself permission to walk away from a talk if you're getting anything form it. At the same time try and give talks a chance because they might suprise you! Here are some highlights. ### [Cory Doctorow Keynote](https://us.pycon.org/2025/about/keynote-speakers/?ref=blog.matthewbrunelle.com#cory-doctorow): Absolutely amazing. Cannot wait for the recording to go online so I can share it with everyone. Update: recording is live! ### [Diving into the Deep End: A Python Journey in Water Management](https://us.pycon.org/2025/schedule/presentation/104/?ref=blog.matthewbrunelle.com): I was looking forward to learning more about water management as a part of this. It was mostly a lessons learned presentation, but I got quite a bit out of it. The presenter Jack had a really cool life story and it was nice to see someone on stage talking about navigating ADHD as a part of running a software company. ### [The PyArrow revolution in Pandas](https://us.pycon.org/2025/schedule/presentation/69/?ref=blog.matthewbrunelle.com): This struck a good balance of how deep into the weeds to go. Started with a nice history of how Pandas was built on top of numpy since numpy provided many things Pandas needed. Then went on to nicely summarize why numpy's design is not aligned with the type of workloads Pandas is used in. Finally they dug into the details of how Apache Arrow fits their usecase better and how they are replacing numpy bit by bit Ship of Theseus style. ### [Processing Large Geospatial Datasets with Dask & Xarray](https://us.pycon.org/2025/schedule/presentation/86/?ref=blog.matthewbrunelle.com): Similar to the Water Management talk I was hoping to learn a bit about geospacial datasets along the way on this talk. The presentation was framed around an example 252 TiB data set from NOAA. However the only details about the dataset that the speaker went into was describing the shape of it: time series + coordinates + measurement. That did nicely setup the discussion to show how xarray's data labeling is useful. ### [503 days working full-time on FOSS: lessons learned](https://us.pycon.org/2025/schedule/presentation/164/?ref=blog.matthewbrunelle.com): There is a recording for the talk [here](https://www.youtube.com/watch?v=iURLDirfmno&ref=blog.matthewbrunelle.com). I *really* enjoyed this talk. You could tell Rodrigo is passionate about this topic and able to explain his thoughts clearly. I'm going to include a summary of my favorite takeaways but when the video goes online I really encourage giving it a watch. Interacting Online: - Everything you do online is publicity. Its there and others may find it. - Give the *first* reply to a issue quickly. The first interaction is important for making someone feel like their contribution is being considered. - If you cannot get to the issue soon let them know! "Hey thanks you for submitting this, I will take a look as soon as I can" - Give very clear instruction to bug reporters. Ideally written down or baked into the bug reporting form. - If they are missing details let them know at the start. You can point to the written guidance. - If you have to close a PR, take anything you can salvage, put it in a new PR and *mark them as a co author* so they don't feel like you are stealing their contributions. Contribution Guides: - If you run an open source project CREATE A CONTRIBUTING GUIDE. - People will not read your guide but the doc will have your back. - You can point contributors towards your guide. Then people are less likely to view feedback as a personal attack. - Include a style guide in your guide, then its just a thing the project does and not something to be argued. ### [Unlocking Python's Power: A Practical Guide to Metaprogramming with Decorators, Metaclasses, and Dynamic Code Generation](https://us.pycon.org/2025/schedule/presentation/102/?ref=blog.matthewbrunelle.com): I left five minutes into this talk... ### [Talks Detecting Honeybee Swarms Using the Integration of OpenCV, Pandas, AI, and PyTorch](https://us.pycon.org/2025/schedule/presentation/20/?ref=blog.matthewbrunelle.com): ... and walked across the hall to this one! The speaker is very passionate about beekeeping and it turns out bees will seasonally leave the hive in mass. The talk focused on using consumer hardware and a model to detect when bees are swarming. ### PyCon 2025 Thursday - Tutorial Sessions URL: https://blog.matthewbrunelle.com/pycon-2025-thursday-tutorial-sessions/ Last updated: 2025-07-15T15:28:14.000Z When I went to EMNLP last year a coworker who attends conferences frequently gave me some advice: "TAKE NOTES! There will be so much content you will not remember it all". A nice upside of this is I now have notes a can share here. --- I attended PyCon 2025 in Pittsburgh this year since my employer was a sponsor. Thursday was a tutorial day and I got to attend two different sessions. The first session I sat in on was [Event Sourcing From The Ground Up](https://us.pycon.org/2025/schedule/presentation/60/?ref=blog.matthewbrunelle.com). The code for the tutorial is available in a public [repository](https://github.com/gtm19/event-sourcing-tutorial-pycon2025?ref=blog.matthewbrunelle.com). Overall I really enjoyed the structure: we had alternating periods of lecture with individual hands on time to solve problems. Sebastiaan also provided code checkpoints so that after each section you could build off their example code rather than continuing with your own. Event sourcing is tricky though. The happy path scenarios are all very encouraging. However when you dig in more you see the complexity unfold. Unfortunately there is only enough time in a tutorial to cover basic pieces so you don't get to see how complex it would be in practice. I should say to that I went into the tutorial with some bias against event sourcing due to having seen it misapplied at my first job with investment banks. The phrase "turn the database inside out" still haunts me. Regardless I'm still happy I attended, it was fun! Also Sebastiaan recommended the book Architecture Patterns with Python and I'm keen to give it a read. If you are interested in the topic the repository is set up in a way that you could follow without an instructor. The final tutorial of the day that I attended was [Handcrafting Your Own Language Processor: The Art of Writing Recursive Descent Parsers](https://us.pycon.org/2025/schedule/presentation/65/?ref=blog.matthewbrunelle.com). This session had a [repository](https://github.com/ariel-ortiz/pycon2025?ref=blog.matthewbrunelle.com) however it started off empty. Instead Ariel periodically pushed changes to the repository as a reference. Additionally there was a [site](https://arielortiz.info/pycon2024/?ref=blog.matthewbrunelle.com) with instructions that we all followed as we went. I think I preferred the style from the previous session where we had individual time rather than following the instructor as they went. I still got a lot out of it though. If you wanted to try this out the repository would be very useful but the site is very well put together and should be more than enough. Finally I really wanted to go to [Polars: Faster, Simpler, Smarter Python Analytics](https://us.pycon.org/2025/schedule/presentation/107/?ref=blog.matthewbrunelle.com) but it reached capacity. I had a coworker that went and says it was quite good. There isn't a public repository or site for the session but the speak is the author of Effective Polars and has a code example [repository](https://github.com/mattharrison/effective%5Fpolars%5Fbook?ref=blog.matthewbrunelle.com). ### Automatic Conversion of Article HTML to SSML For Text To Speech URL: https://blog.matthewbrunelle.com/automatic-conversion-of-article-html-to-ssml-for-text-to-speech/ Last updated: 2025-05-17T16:22:47.000Z As a side project I'm creating a small web application that takes RSS feeds of long form articles, performs text-to-speech on them and then exposes the audio as a podcast RSS feed. This involves a fairly hairy multi-step process. This post covers an improvement I recently made to enhance one step of the flow. The application uses [Google Cloud's Text to Speech](https://cloud.google.com/text-to-speech?ref=blog.matthewbrunelle.com) to convert the text extracted from articles into audio. GCP TTS accepts two different ways to encode the text: 1. You can provide it with raw text. 2. You can mark up the text with [Speech Synthesis Markup Language (SSML)](https://cloud.google.com/text-to-speech/docs/ssml?ref=blog.matthewbrunelle.com) SSML lets you add context to the text like pauses, emphasis, and boundaries around sections like paragraphs and sentences. This is particularly useful for manually annotating written text to provide context. I wanted to investigate automatically wrapping the text in SSML tags using the semantics of the html tags. ## Current state of the project The previous flow for the project looked like this: 1. Article links from the website's RSS are collected and saved regularly as the feed is refreshed. 2. Each article becomes an episode in the podcast RSS feed. Audio for each episode is generated on demand as it is requested. 3. Many sites with articles try to prevent bots from scraping their pages. One shortcut to get around this is to use the Wayback Machine's copy of the article. I use the [Wayback Machine's API](https://archive.org/help/wayback%5Fapi.php) to get the link for the most recent crawl of the page. 4. Postlight used to offer an API for extracting Article text from a webpage but they shut the API down. It is now available as an [open source project](https://postlight.com/insights/mercury-goes-open-source?ref=blog.matthewbrunelle.com). Additionally the community has bundled this up into a [Docker container](https://hub.docker.com/r/wangqiru/mercury-parser-api?ref=blog.matthewbrunelle.com) which I deploy onto [Google Cloud Run](https://cloud.google.com/run?ref=blog.matthewbrunelle.com). 5. The article text returned from Mercury retains some of the markup from the webpage. I use jsoup to [extract the readable text](https://jsoup.org/cookbook/extracting-data/attributes-text-html?ref=blog.matthewbrunelle.com) from the html fragment. 6. GCP TTS has a [limit](https://cloud.google.com/text-to-speech/quotas?ref=blog.matthewbrunelle.com) of 5000 total characters per request (include SSML tags). This means the text has to be divided into sections under that limit. We could naively divide the text at exactly 5000 characters but that could mean splitting the text mid sentence or even mid word. This would lead to less natural sounding audio. Instead I want to split the text on word boundaries. Currently I'm using Apache OpenNLP's [sentence detector](https://opennlp.apache.org/docs/1.7.1/apidocs/opennlp-tools/opennlp/tools/sentdetect/SentenceDetectorME.html?ref=blog.matthewbrunelle.com) to handle splitting the sentences. I then group sentences into sections of less then 5000 characters. 7. Each section is submitted to GCP TTS. The audio for all the sections are then merged into a single audio file and finally uploaded to GCP Storage where they can be served to podcast players. ### A note on GCP TTS vs other options Amazon Web Services' competitor to GCP TTS is AWS Polly. Its text length limit is smaller than GCP TTS. However it also started offering a [Asynchronous Synthesis](https://aws.amazon.com/blogs/aws/amazon-polly-update-time-driven-prosody-and-asynchronous-synthesis/?ref=blog.matthewbrunelle.com#Asynchronous%20Synthesis) option in 2018\. You can submit text up to 100,000 characters and an additional 100,000 characters in SSML markup. The catch is the the output will be automatically stored in S3 rather than returned by the API call. This is not a drop in replacement for all TTS applications but is actually perfect for my needs since I would be serving the audio out of S3 anyways. A simpler implementation and lower network costs sounds like a win-win. Why am I not using it? Basically its because Google's WaveNet voices sound *really* good and they added [WaveNet voices](https://cloud.google.com/blog/products/ai-machine-learning/cloud-text-to-speech-expands-its-number-of-voices-now-covering-33-languages-and-variants?ref=blog.matthewbrunelle.com) to GCP TTS in 2019\. So the tradeoff I'm making is added complexity and costs in order to improve the listening experience. Since right now I'm implementing this for my own needs and not as a product to sell I'm OK with this tradeoff. If I were expand this as a service to others I might move over to AWS since it seems all around cheaper for my needs. ## What changed The steps above that need to change are 6 and 7 where I remove the tags from the article text and split the text on sentence boundaries. Instead of stripping all the tags I want to use them to generate SSML for the text. Lets take this [article](https://www.bloomberg.com/opinion/articles/2021-04-08/archegos-got-too-big-for-its-banks?ref=blog.matthewbrunelle.com) from Matt Levine's weekday Money Stuff\* Opinion Column as an example. Here is some of the HTML fragment that Mercury parser sends back: ```html

Programming note: Money Stuff will be off tomorrow, back on Monday.

Archegos fallout

Generally speaking, in the U.S., if you want to borrow money from your broker to buy stocks, you are capped at 2-to-1 leverage. If you have $100, you can buy $200 worth of stock. Back in the olden days, you could have bought $300 or $500 or $1,000 of stock with your $100, borrowing the rest from your broker, but then a Great Depression happened and regulators clamped down on margin lending.

``` Some tags provide useful information. For example the strong and em tags are highlighting important details. We may decide that we want to convert those into the SSML [emphasis tag](https://cloud.google.com/text-to-speech/docs/ssml?ref=blog.matthewbrunelle.com#emphasis). Some tags like anchors don't convey information about how the text should be said. They do however have text embedded in them that we need to extract. For these tags we emit plain text. Lastly we need to handle block tags. Divs can be discarded. P tags can be retained and mapped into SSML [p tags](https://cloud.google.com/text-to-speech/docs/ssml?ref=blog.matthewbrunelle.com#p,s) as they both represent paragraphs. Here is some example SSML output for the HTML fragment: ```xml

Programming note: Money Stuff will be off tomorrow, back on Monday.

Archegos fallout

Generally speaking, in the U.S., if you want to borrow money from your broker to buy stocks, you are capped at 2-to-1 leverage. If you have $100, you can buy $200 worth of stock. Back in the olden days, you could have bought $300 or $500 or $1,000 of stock with your $100, borrowing the rest from your broker, but then a Great Depression happened and regulators clamped down on margin lending.

``` \* Money Stuff is actually the main reason I started this project! I really enjoy Matt Levine's writing. ## Implementation At a high level the plan is to walk the DOM of the parsed HTML fragment. As its traversed we need to emit SSML tags and text. When you parse an HTML fragment with jsoup you can get the parsed HTML as an [Element](https://jsoup.org/apidocs/org/jsoup/nodes/Element.html?ref=blog.matthewbrunelle.com) object. Element has a traverse() method that lets you walk the tree depth first. The traverse() method takes a [NodeVisitor](https://jsoup.org/apidocs/org/jsoup/select/class-use/NodeVisitor.html?ref=blog.matthewbrunelle.com) that defines two methods: - head​(Node node, int depth): is called when a node is first visited. - tail(Node node, int depth): is called when a node is last visited after all its descendants have been visited. One distinction to make is that the NodeVisitor works on Nodes not Elements. Elements are tags with their attributes and are a type of Nodes. There are other types of nodes like TextNode which represent just text. Here is some output demonstrating the order the nodes are visited in the HTML fragment from above: ```bash Starting Starting
Found TextNode: End TextNode Starting

Starting Found TextNode: Programming note: End TextNode Ending Found TextNode: End TextNode Starting Found TextNode: Money Stuff will be off tomorrow, back on Monday. End TextNode Ending Ending

Starting

Found TextNode: Archegos fallout End TextNode Ending

Found TextNode: End TextNode Starting

Found TextNode: Generally speaking, in the U.S., if you want to End TextNode Starting Found TextNode: End TextNode Starting Found Element with unhandled tag: meta Ending Found Element with unhandled tag: Found TextNode: borrow money from your broker to buy stocks End TextNode Ending Found TextNode: , you are capped at 2-to-1 leverage. If you have $100, you can buy $200 worth of stock. Back in the olden days, you could have bought $300 or $500 or $1,000 of stock with your $100, borrowing the rest from your broker, but then a Great Depression happened and regulators clamped down on margin lending. End TextNode Ending

Found TextNode: End TextNode Starting ``` You will notice that TextNodes are visited after we first enter an Element and before we leave the eElement. This means that we can: - Emit the start of an XML node when we enter an Element that maps to a SSML tag. For example creating a p tag when we enter a p tag. - Write out any text we encounter in a text node. - Emit the end of an XML node when we leave an Element that we emitted an XML start for. There are many ways to work with XML on the JVM. The standard library alone has four different ways. I'll defer to [Baeldung's comparison](https://www.baeldung.com/java-xml-libraries?ref=blog.matthewbrunelle.com) of the libraries. I went with the streaming approach to XML [StAX](https://docs.oracle.com/javase/tutorial/jaxp/stax/why.html?ref=blog.matthewbrunelle.com). It lacks some features of the other libraries but those features are mainly on the XML parsing side and I'm only concerned with XML generation. The following is a simplified implementation of the NodeVisitor. It only handles the tags in the example HTML fragment above. It also creates the log seen previously: ```kotlin class Visitor : NodeVisitor { private val logger = LoggerFactory.getLogger(javaClass) private val xmlStreamWriter: XMLStreamWriter private val stringWriter: StringWriter = StringWriter() private val noOpTags = setOf("a", "body", "div") private val emphasisTags = setOf("em", "h2", "strong") init { val xMLOutputFactory = XMLOutputFactory.newInstance() xmlStreamWriter = xMLOutputFactory.createXMLStreamWriter(stringWriter) xmlStreamWriter.writeStartElement("speak") } override fun head(node: Node?, depth: Int) { val indent = "\t".repeat(depth) when (node) { is Element -> { logger.info(indent + "Starting <${node.tagName()}>") when (node.tagName()) { in noOpTags -> { // Do Nothing } in emphasisTags -> { xmlStreamWriter.writeStartElement("emphasis") xmlStreamWriter.writeAttribute("level", "moderate") } "p" -> { xmlStreamWriter.writeStartElement("p") } else -> { logger.error(indent + "Found Element with unhandled tag: ${node.tagName()}") } } } is TextNode -> { logger.error(indent + "Found TextNode: ${node.text()}") xmlStreamWriter.writeCharacters(node.text()) } else -> { logger.error(indent + "Found node with unhandled type: ${node?.javaClass?.simpleName}") } } } override fun tail(node: Node?, depth: Int) { val indent = "\t".repeat(depth) when (node) { is Element -> { logger.info(indent + "Ending ") when (node.tagName()) { in noOpTags -> { // Do Nothing } in emphasisTags -> { xmlStreamWriter.writeEndElement() } "p" -> { xmlStreamWriter.writeEndElement() } else -> { logger.error(indent+ "Found Element with unhandled tag: <${node.tagName()}>") } } } is TextNode -> { logger.info(indent + "End TextNode") } else -> { logger.error(indent + "Found node with unhandled type: ${node?.javaClass?.simpleName}") } } } fun finalize(): String { xmlStreamWriter.writeEndElement() xmlStreamWriter.flush() xmlStreamWriter.close() val xmlString: String = stringWriter.buffer.toString() stringWriter.close() return xmlString } } ``` Invoking the NodeVisitor is as simple as: ```kotlin fun convertToSsml(rawHtml: String): String { val soup = Jsoup.parseBodyFragment(rawHtml) val visitor = Visitor() soup.body().traverse(visitor) return visitor.finalize() } ``` ## Next steps Now this only handles step 6 from the outline above where the HTML fragment is converted into an input for GCP TTS. It does not however take into account how to split the text so its less then the 5000 character limit. One approach to this would be to inspect the size of the internal string buffer before adding a node. If its larger then the limit you would write it out and start a new SSML document. The trick parts to this are: - You still want to split on sentence boundaries to keep the audio sounding natural. When writing out a TextNode that runs over the limit you can try to split it on sentences. - If you run out of space in the middle of a TextNode and that text sits inside something like emphasis you want to make sure the text that goes into the next section is also inside an emphasis. - When calculating whether adding the next node would put you over the limit you need to consider the length of the tags for the SSML node. - You need to reduce the limit by the end "speak" tag so you have enough space to include it. Implementing all of this correctly takes a lot of care and will be the next phase of the work before I can deploy this live. I will likely implement this as a second pass over the generated XML that splits it as needed rather than working with raw strings. Should be fun. ### Google Cloud Platform with Gradle Kotlin DSL URL: https://blog.matthewbrunelle.com/google-cloud-platform-with-gradle-kotlin-dsl/ Last updated: 2025-05-17T16:20:36.000Z I was trying to deploy a Spring application to App Engine [as per the documentation](https://cloud.google.com/community/tutorials/kotlin-springboot-app-engine-java8?ref=blog.matthewbrunelle.com) and realized that while the GCP documentation [covers the Groovy based DSL for Gradle](https://cloud.google.com/appengine/docs/standard/java/using-gradle?ref=blog.matthewbrunelle.com) it doesn't have any official documentation for the newer Kotlin DSL. Bits and pieces were available online but it took some time to piece it all together. I'm sharing it her in hopes it can save others from having to figure it out again. For those that just want to grab the Kotlin DSL version is here: ```kotlin import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import com.google.cloud.tools.gradle.appengine.standard.AppEngineStandardExtension buildscript { repositories { jcenter() mavenCentral() } dependencies { classpath("com.google.cloud.tools:appengine-gradle-plugin:2.4.1") } } plugins { war kotlin("jvm") version "1.4.21" kotlin("plugin.spring") version "1.4.21" kotlin("plugin.jpa") version "1.4.21" } apply(plugin = "com.google.cloud.tools.appengine") group = "com.example" version = "0.0.1-SNAPSHOT" java.sourceCompatibility = JavaVersion.VERSION_11 repositories { maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") } mavenCentral() jcenter() } extra["springCloudGcpVersion"] = "2.0.0" extra["springCloudVersion"] = "2020.0.0" dependencies { implementation("com.google.appengine:appengine-api-1.0-sdk:+") // Latest App Engine Api's providedCompile("javax.servlet:javax.servlet-api:4.0.1") implementation("jstl:jstl:1.2") // Add your dependencies here. // Testing testImplementation("junit:junit:4.12") testImplementation("com.google.truth:truth:1.1") testImplementation("org.mockito:mockito-all:1.10.19") testImplementation("com.google.appengine:appengine-testing:+") testImplementation("com.google.appengine:appengine-api-stubs:+") testImplementation("com.google.appengine:appengine-tools-sdk:+") } dependencyManagement { imports { mavenBom("com.google.cloud:spring-cloud-gcp-dependencies:${property("springCloudGcpVersion")}") } } tasks.withType { dependsOn("test") } tasks.withType { dependsOn("test") } tasks.withType { kotlinOptions { freeCompilerArgs = listOf("-Xjsr305=strict") jvmTarget = "11" } } tasks { test { useJUnitPlatform() testLogging.showStandardStreams = true beforeTest(closureOf { logger.lifecycle("test: $this Running") }) onOutput(KotlinClosure2({descriptor, event -> logger.lifecycle("test: " + descriptor + ": " + event.message ) Unit })) afterTest(KotlinClosure2({descriptor, result -> logger.lifecycle("test: $descriptor: $result") Unit })) } } the().apply { deploy { // deploy configuration projectId = System.getenv('GOOGLE_CLOUD_PROJECT') version = "1" } } ``` # Figuring this all out ## Buildscript ```groovy buildscript { // Configuration for building repositories { jcenter() // Bintray's repository - a fast Maven Central mirror & more mavenCentral() } dependencies { classpath 'com.google.cloud.tools:appengine-gradle-plugin:2.2.0' // If a newer version is available, use it } } ``` There are some general Groovy vs Kotlin syntax changes we will need to handle here and in the rest of the code: - single quotes need to become double quotes - methods like classpath, implementation, testImplementation need parenthesis around them. ```kotlin buildscript { repositories { jcenter() mavenCentral() } dependencies { classpath("com.google.cloud.tools:appengine-gradle-plugin:2.4.1") } } ``` ## Plugins ```groovy apply plugin: 'java' // standard Java tasks apply plugin: 'war' // standard Web Archive plugin apply plugin: 'com.google.cloud.tools.appengine' // App Engine tasks ``` Originally I had wanted to use the new "plugins" syntax for adding the appengine plugin but it seems that [it only works if the plugin is available in the Gradle Plugins Repository](https://stackoverflow.com/a/32353244/564606?ref=blog.matthewbrunelle.com). So we have to use the older "apply" syntax instead. ```kotlin plugins { war kotlin("jvm") version "1.4.21" kotlin("plugin.spring") version "1.4.21" kotlin("plugin.jpa") version "1.4.21" } apply(plugin = "com.google.cloud.tools.appengine") ``` ## Dependencies ```groovy dependencies { compile 'com.google.appengine:appengine-api-1.0-sdk:+' // Latest App Engine Api's providedCompile 'javax.servlet:javax.servlet-api:3.1.0' compile 'jstl:jstl:1.2' // Add your dependencies here. // compile 'com.google.cloud:google-cloud:+' // Latest Cloud API's http://googlecloudplatform.github.io/google-cloud-java testCompile 'junit:junit:4.12' testCompile 'com.google.truth:truth:0.33' testCompile 'org.mockito:mockito-all:1.10.19' testCompile 'com.google.appengine:appengine-testing:+' testCompile 'com.google.appengine:appengine-api-stubs:+' testCompile 'com.google.appengine:appengine-tools-sdk:+' } ``` In the Groovy DSL you can use deprecated "compile" syntax for adding dependencies as well as the newer "implementation" syntax. We can only use the later in the Kotlin DSL. I also updated the plugin version as the comment in the original example suggests. ```kotlin dependencies { implementation("com.google.appengine:appengine-api-1.0-sdk:+") // Latest App Engine Api's providedCompile("javax.servlet:javax.servlet-api:4.0.1") implementation("jstl:jstl:1.2") // Add your dependencies here. // Testing testImplementation("junit:junit:4.12") testImplementation("com.google.truth:truth:1.1") testImplementation("org.mockito:mockito-all:1.10.19") testImplementation("com.google.appengine:appengine-testing:+") testImplementation("com.google.appengine:appengine-api-stubs:+") testImplementation("com.google.appengine:appengine-tools-sdk:+") } ``` ## Deploy and stageStandard tasks depend on the test task ```groovy // Always run unit tests appengineDeploy.dependsOn test appengineStage.dependsOn test ``` [This answer](https://stackoverflow.com/a/60225081/564606?ref=blog.matthewbrunelle.com) on Stack Overflow explains the syntax I used for getting the plugin's deploy task and stageStandard task to depend on the test task. One tricky part is figuring out the correct type to supply to withType. The good news is if you get the type wrong gradle will complain with something like: The task 'appengineDeploy' (com.google.cloud.tools.gradle.appengine.core.DeployTask) is not a subclass of the given type (org.gradle.api.tasks.compile.JavaCompile). So you can figure it out through trial and error... ```kotlin tasks.withType { dependsOn("test") } tasks.withType { dependsOn("test") } ``` ## Deploy block ```groovy appengine { // App Engine tasks configuration deploy { // deploy configuration projectId = System.getenv('GOOGLE_CLOUD_PROJECT') version = '1' } } ``` [This issue](https://github.com/GoogleCloudPlatform/app-gradle-plugin/issues/191?ref=blog.matthewbrunelle.com#issuecomment-462404681) on Github explains the necessary syntax for the final deploy block. ```kotlin the().apply { deploy { // deploy configuration projectId = System.getenv('GOOGLE_CLOUD_PROJECT') version = "1" } } ``` ## The tests block ```groovy test { useJUnit() testLogging.showStandardStreams = true beforeTest { descriptor -> logger.lifecycle("test: " + descriptor + " Running") } onOutput { descriptor, event -> logger.lifecycle("test: " + descriptor + ": " + event.message ) } afterTest { descriptor, result -> logger.lifecycle("test: " + descriptor + ": " + result ) } } ``` First change we need to do is wrap the block in a tasks block. This conversion is also tricky because the Groovy DSL expects Groovy closures and we need to convert them to Kotlin closures. There is a [small example on Github](https://github.com/passsy/kotlin-dsl/blob/master/doc/getting-started/Closures.md?ref=blog.matthewbrunelle.com) which covers how to use this "closureOf" method to handle the conversion. Note that when dealing with a closure that takes two arguments we need to use "KotlinClosure2" instead which I figured out from [this blog](https://nwillc.wordpress.com/2019/01/08/gradle-kotlin-dsl-closures/?ref=blog.matthewbrunelle.com). An additional tricky part with this is we need to explicitly provide the type of the arguement(s) to closureOf and KotlinClosure2\. In my case I was able to figure out the type by looking at the [documentation for the test tasks](https://docs.gradle.org/current/dsl/org.gradle.api.tasks.testing.Test.html?ref=blog.matthewbrunelle.com) which includes the full method signatures with types. This let me understand that "beforeTest" needs a TestDescriptor, "onOutput" needs TestDescriptor and TestOutputEvent, and "afterTest" needs TestDescriptor,TestResult. ```kotlin tasks { test { useJUnitPlatform() testLogging.showStandardStreams = true beforeTest(closureOf { logger.lifecycle("test: $this Running") }) onOutput(KotlinClosure2({descriptor, event -> logger.lifecycle("test: " + descriptor + ": " + event.message ) Unit })) afterTest(KotlinClosure2({descriptor, result -> logger.lifecycle("test: $descriptor: $result") Unit })) } } ``` And its as "simple" as that! ### Labeling Web Page Category with Mechanical Turk URL: https://blog.matthewbrunelle.com/labeling-web-page-category-with-mechanical-turk/ Last updated: 2025-05-17T16:17:12.000Z # Why use Mechanical Turk? I wanted to experiment with classifying whether a web page is an article or not. The categories I want to label are: - News Article - Blog Post - Press Release - Other My goal is too specific to find an existing public dataset I could use. This meant that I would have to construct my own. One option is collecting unlabeled data and hand labeling with one of the [many data labeling tools](https://github.com/heartexlabs/awesome-data-labeling?ref=blog.matthewbrunelle.com) available. However all of the labels would be categorized based on my own personal perspective. To get around this problem I would need many people to all work together labeling the data. It would be even better if each individual person only labeled a small number of pages. This is where services like [Mechanical Turk](https://www.mturk.com/?ref=blog.matthewbrunelle.com) are helpful. They allow you to create tasks that a large number of people can be paid to complete. # Structuring a Mechanical Turk HIT for web page categorization In order to use MT you need to structure your problem in the context of [MT's concepts](https://docs.aws.amazon.com/AWSMechTurk/latest/RequesterUI/mechanical-turk-concepts.html?ref=blog.matthewbrunelle.com). At a high level you have: ## Human Intelligence Tasks (HITs) > "a single, self-contained task a Requester creates on Mechanical Turk". My HIT is to label a single URL as one of the categories. ## Assignments > "You can assign many Workers to work on the same HIT, which is an effective way to achieve consensus on a subject when many workers provide the same answer." While assigning more workers per HIT can improve the quality of your results it also increases your costs. See the cost calculation below for more information. I chose to go with three assignees per HIT. ## Qualification type > "It is important to note that anyone can register to work in the Mechanical Turk Marketplace. To control who can work on your HITs, you can require that Workers have specific qualifications before they can work on your HITs." For this first pass I did not add any qualification requirements. I may add them in the future depending on the quality of the results. For example if I am working with only English web pages I may want to require the workers know English. # Creating the project in Mechanical Turk The AWS Mechanical Turk documentation points towards a guide for [categorizing web pages](https://blog.mturk.com/tutorial-categorizing-names-with-the-requester-website-eef620ae8aa5?ref=blog.matthewbrunelle.com) that I used as a base. I set the categories that the worker selects by updating the crowd-classifier tag: ```xml ``` And I provided context for the HIT by filling out a short and long description: ```xml Choose the appropriate category that best suits the page:
  • A 'New Article' about recent news.
  • A 'Blog Post' is a written piece but may not be about recent news.
  • A 'PR Piece' is a news piece that is a Press Release written for a company.
  • 'Other' is anything else. This includes media like pages where the content is primarily video.

Read the website carefully. Not all pages on a site with articles are articles.

Choose the appropriate category that best suits the particular page.

Pages where the content is primarily media like a video should be labeled as other.

Examples:

``` Here is what the HIT will look like in Mechanical Turk: ![Preview of Mechanical Turk HIT](https://blog.matthewbrunelle.com/images/mechanical-turk-hit-preview.png) # Running a batch Once the project is configured you need to create a batch of data to submit. I pulled URLs from my crawler's database and dumped them into a CSV. The CSV needs to have one column for every variable your HIT uses. My template on Mechanical Turk has only the URL variable so the input CSV only needs a single column: ```csv url https://www.theguardian.com/us-news/2020/jun/09/protests-us-america-cars-weapons https://www.cnn.com/2020/06/09/us/dancing-black-man-police-trnd/index.html https://www.nbcnews.com/news/us-news/body-cam-footage-shows-officer-punching-alabama-store-owner-who-n1228006 https://abcnews.go.com/US/virginia-indiana-joining-taking-confederate-monuments/story?id=71066712 ... ``` I started off with a first batch of 500 HITS and submitted it to Mechanical Turk for completion. # Cost and time You can use a [Mechanical Turk Cost Calculator](https://morninj.github.io/mechanical-turk-cost-calculator/?ref=blog.matthewbrunelle.com) before running a batch to figure out how much it will cost. In my case a batch of 500 HITs at 1 cent per HIT and 3 assignees per HIT comes out to $30\. Note that half of that cost is fees to Mechanical Turk. The workers only get $15. Batches are completed pretty quickly. Mine took about 2 hours and 15 minutes. I could even watch the progress in real time: ![Mechanical Turk Batch Progress](https://blog.matthewbrunelle.com/images/mechanical-turk-batch-progress.png) There was an interesting long tail where it went quite fast in the beginning and then slowed as the completion percentage increased. I had 90% complete after 30 minutes so the remaining 10% took up the remaining 105 minutes. This makes sense as workers can accept HITs and not complete them. When that happens the HITs get kicked back into the system after a max time so other workers can complete them. Mechanical Turk recommends giving workers a generous amount of time to complete HITs. I went with the default max time of one hour. Its also interesting to note that average time per assignment started at 15 seconds and ended up at 1 minutes 36 seconds. # Results of batch The Mechanical Turk documentation has a guide on [getting results](https://blog.mturk.com/tutorial-managing-results-with-the-requester-website-219f6c809e47?ref=blog.matthewbrunelle.com) from the requester website. In the end you have a CSV file of the results. Each row will represent an *assignment* so you will have multiple rows per HIT if you have multiple assignments per HIT. Each row will contain information about the worker, the inputs to the HIT and the answers the worker chose. Here are some of the columns: ![Mechanical Turk Results](https://blog.matthewbrunelle.com/images/mechanical-turk-results.png) In my case I had 505 HITs in the batch so there are 1,515 assignment results. The results I got were about what I expected. There are plenty of mislabeled pages which is mostly due to how I structured the problem. I think better clarity with the categories would improve the results. For example workers seemed to use the PR category frequently despite it having a low occurrence. As a quick experiment I pulled the results into Python. For each URL I found the proportion of labels assigned to that HIT. For example: The url "[https://www.npr.org/2020/06/12/876351501/zoom-acknowledges-it-suspended-activists-accounts-at-china-s-request](https://www.npr.org/2020/06/12/876351501/zoom-acknowledges-it-suspended-activists-accounts-at-china-s-request?ref=blog.matthewbrunelle.com)" was given labels \['Other', 'News Article', 'News Article'\] So the proportions for each label are {'Other': 0.3333333333333333, 'News Article': 0.6666666666666666}. I then averaged the proportions to get a rough idea for the relative agreement workers had for each category: - PR Piece: 38% - News Article: 64% - Blog Post: 44% - Other: 47% We can see that PR Pieces had the lowest agreement as expected but the average agreement for other categories are also quite low. When result quality is low you have a chance to approve/reject the individual results of the batch. If you do not manually approve them they will auto approve after the approval interval you set with creating the project (which defaults to 3 days). However its important to note tat rejecting results will have an impact on both *your* Requester score and the worker score. # Closing thoughts This really highlights (1) how it important it is to correctly structure your HITs to make them easy and clear to answer and (2) labeling is an inherently noisy task. I plan to continue with future batches in Mechanical Turk after I clean up my HITs. I may also experiment with other category types. I expect things will go better with binary classification (Article or not) rather than multicategory classification. ### Media Center Series Part 3: Simpler Raspberry Pi 4 URL: https://blog.matthewbrunelle.com/media-center-series-part-3-simpler-raspberry-pi-4/ Last updated: 2025-05-17T16:14:11.000Z In a previous post I discussed [using Ansible to install Docker and Docker Compose on Debian](https://blog.matthewbrunelle.com/media-center-series-part-1-install-docker-with-ansible/). Since then I've taken this and made some updates as I've gone and modified for use on a Raspberry Pi 4 instead of just testing on a Linode instance. # Changes ## Docker Installation As fun (complicated?) as it was manually doing all the steps to install Docker, there is a nice [first party install script](https://github.com/docker/docker-install?ref=blog.matthewbrunelle.com) you can curl and run instead. It was not usable on the RPi 4 previously which is why I had not originally used it but it has since been updated and works perfectly on Raspbian. Another upside of this change is it means you don't have to manually check and set the architecture and release when you set up the repos. All that is handled by the script. ## Docker Compose Installation Docker Compose is now [installed using Pip](https://docs.docker.com/compose/install/?ref=blog.matthewbrunelle.com#install-using-pip) rather than curling the binary and dropping it on the box. The main downside is this take makes longer to run, especially since the pip install has native code that needs to be compiled and its happening on the RPi itself. The main upside is... well I guess I'd just rather use a package manager in this case? Perhaps the rationale for this change is not as strong as the other one. Also note that Docker Compose seems to have problems with Python 2's backported libraries on Debian. There are currently two that gave me problems on Raspbian: - python-backports.ssl-match-hostname - python-backports.shutil\_get\_terminal\_size You can either (1) uninstall them with pip and then reinstall them with apt or (2) install and use Python 3 instead. Note that I also made the choice to install into the system Python rather than a virtual environment for simplicity. ## Running the Playbook Final change is the playbook now uses a ssh connection rather than running locally on the RPi so it must now be provided an inventory when run. # The updated playbook Here is the updated playbook: ```yaml --- - name: Docker connection: ssh gather_facts: yes hosts: rpi vars_prompt: - name: password prompt: "Password for docker user" private: yes encrypt: "sha512_crypt" confirm: yes salt_size: 7 become_user: root become: yes tasks: - name: Download Docker Install Script get_url: url: https://get.docker.com dest: /tmp/docker_install.sh - name: Run the Docker Install Script shell: sh /tmp/docker_install.sh - name: Create docker user user: name: docker password: "{{password}}" update_password: on_create groups: - sudo - render - video - docker shell: /bin/bash state: present - name: Set Docker to Run on Startup systemd: state: started name: docker enabled: true - name: Docker Compose Dependencies Workaround # See https://stackoverflow.com/a/51071841 pip: name: - backports.ssl-match-hostname - backports.shutil_get_terminal_size state: absent - name: Install Docker Compose Dependencies apt: pkg: - python-backports.ssl-match-hostname - python-backports.shutil_get_terminal_size - python-pip - python-dev - libffi-dev - libssl-dev - build-essential update_cache: yes - name: Install Docker Compose pip: name: docker-compose ``` ### Media Center Series Part 2: Wireguard with Docker on Raspberry Pi 4 URL: https://blog.matthewbrunelle.com/media-center-series-part-2-wireguard-with-docker-on-raspberry-pi-4/ Last updated: 2025-07-20T14:45:38.000Z Originally my media center was a docker containers with OpenVPN to handle VPN traffic. Since they I have updated my setup to use [Wireguard](https://www.wireguard.com/?ref=blog.matthewbrunelle.com) instead and I'm documenting the resources I found useful in case anyone wants to do the same. Two main reasons drove me to move from OpenVPN to Wireguard: The first reason is that my old provider Private Internet Access was [bought out by Kape](https://torrentfreak.com/private-internet-access-to-be-acquired-by-kape/?ref=blog.matthewbrunelle.com). There haven't been any changes to PIA yet but the new owner is a bit worrying considering their track record. So I have changed to [Mullvad](https://mullvad.net/en/?ref=blog.matthewbrunelle.com) as my new provider and they have very good Wireguard support. The second reason is that Wireguard offers some compelling improvements over OpenVPN: - OpenVPN is very resource demanding on the RPi. The Model 4 Raspberry Pi has a new I/O architecture which can properly use Gigabit LAN. Unfortunately the CPU overhead for OpenVPN was high enough that it limited throughput. My Pi would regularly have a core pinned near 100% utilization from OpenVpn. In comparison [Wireguard's performance](https://www.wireguard.com/performance/?ref=blog.matthewbrunelle.com#performance-roadmap) is considerably lighter leading to better throughput. - OpenVPN runs in user space where as Wireguard will be included in the kernel [eventually](https://www.phoronix.com/scan.php?page=news%5Fitem&px=WireGuard-Net-Next-Lands&ref=blog.matthewbrunelle.com). For now this complicates things a bit since it has to be installed separately but once the changes land it will make setup much easier. *Update 1/29/2020:* Phoronix reports that [Wireguard was just merged into the 5.6 kernel](https://www.phoronix.com/scan.php?page=news%5Fitem&px=Net-Next-For-Linux-5.6&ref=blog.matthewbrunelle.com)! However it will be a while before 5.6 is the stable kernel for many distrobutions so the installation instructions below will still be needed for a while. *Update 7/24/2020:* While Debian 11 Bullseye isn't releasing until 2021 its features are already being backported to Buster. Wireguard can now be pulled in from the buster-backports repo rather than having to build it! In terms of researching how to set this up three articles were particularly useful: ## Installing Wireguard on Debian First I wanted general information about installing the Wireguard kernel modules and user space tools. The [Debian wiki](https://wiki.debian.org/Wireguard?ref=blog.matthewbrunelle.com#Installation) generally outlines the process but leaves some details to the reader in terms of file permissions. [Linode's](https://www.linode.com/docs/networking/vpn/set-up-wireguard-vpn-on-debian/?ref=blog.matthewbrunelle.com) covered those in better details. I only care about the client configuration details since I am not running a server. Additionally instead of writing my own configuration file I used the [Mullvad Wireguard configuration tools](https://mullvad.net/en/blog/2018/5/14/wireguard-configuration-tool-has-new-function-download-all/?ref=blog.matthewbrunelle.com) to generate one. I also needed to install the following to follow the setup: ```bash sudo apt install openresolv resolvconf ``` ## Using Wireguard with Docker There's a blog post that describes two solutions for using Wireguard with docker. The first involves creating a docker container that runs the Wireguard client. I went with the [second approach](https://nbsoftsolutions.com/blog/routing-select-docker-containers-through-wireguard-vpn?ref=blog.matthewbrunelle.com#solution-2) instead which covers setting up a Wireguard interface on the host and using in a Docker network. ## Raspberry Pi Specific Changes for Installation Finally I want to do this on a Rapsberry Pi 4 which requires [additional steps](https://github.com/adrianmihalko/raspberrypiwireguard?ref=blog.matthewbrunelle.com) to install raspberrypi-kernel-headers and get Wireguard to run. # Putting It All Together With Ansible ## Handling the Mullvad config file The playbook assumes there is a file called "mullvad.conf" in the same directory as playbook which is the configuration file downloaded from the Mullvad configuration generation tool and the vars section uses the [ini plugin for lookups ](https://docs.ansible.com/ansible/latest/plugins/lookup/ini.html?ref=blog.matthewbrunelle.com) to read the server Address and DNS information from config file: ```yaml --- - name: Wireguard connection: ssh become_user: root become: yes hosts: rpi vars: address: "{{ lookup('ini', 'Address section=Interface file=mullvad.conf').split(',')[0] }}" dns: "{{ lookup('ini', 'DNS section=Interface file=mullvad.conf') }}" subnet_ip: "10.193.0.0/16" tasks: - name: Print Mullavd Address debug: msg: "Address is: {{ address }}" - name: Print Mullvad DNS debug: msg: "DNS is: {{ dns }}" ``` Note the we have to split the address on "," since the INI entry contains both the ipv4 and ipv6 address and we only want the first half ## Installing Wireguard *Update:* Wireguard has been backported from Bullseye to buster and can be [installed on a Pi](https://raspberrypi.stackexchange.com/questions/109452/wireguard-installation/109521?ref=blog.matthewbrunelle.com#109521): ```bash sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 04EE7237B7D453EC 648ACFD622F3D138 echo 'deb http://httpredir.debian.org/debian buster-backports main contrib non-free' | sudo tee -a /etc/apt/sources.list.d/debian-backports.list sudo apt update sudo apt install wireguard ``` We can achieve this in Ansible with the following: ```yaml - name: Add first key apt_key: keyserver: keyserver.ubuntu.com id: 04EE7237B7D453EC - name: Add second key apt_key: keyserver: keyserver.ubuntu.com id: 648ACFD622F3D138 - name: enable unstable lineinfile: path: /etc/apt/sources.list.d/debian-backports.list create: yes line: deb http://httpredir.debian.org/debian buster-backports main contrib non-free - name: Install deps apt: pkg: - wireguard - wireguard-tools - resolvconf update_cache: yes ``` *Original Version:* The installation process is basically a combination of the two posts on installing, but replacing command with ansible tasks to clean it up: ```yaml - name: enable unstable lineinfile: path: /etc/apt/sources.list.d/unstable-wireguard.list create: yes line: deb http://deb.debian.org/debian/ unstable main - name: enable wireguard repo blockinfile: path: /etc/apt/preferences.d/limit-unstable create: yes block: | Package: * Pin: release a=unstable Pin-Priority: 150 - name: Add first key apt_key: keyserver: keyserver.ubuntu.com id: 8B48AD6246925553 - name: Add second key apt_key: keyserver: keyserver.ubuntu.com id: 7638D0442B90D010 - name: Add third key apt_key: keyserver: keyserver.ubuntu.com id: 04EE7237B7D453EC - name: Install deps apt: pkg: - raspberrypi-kernel-headers - dirmngr - wireguard-dkms - wireguard-tools - resolvconf update_cache: yes ``` ## Configuring the Wireguard Interface Finally we want to set up the wireguard interface that docker will use. We do this by first copying the Mullvad config over to the machine. Then as noted by the Wireguard on Docker article we remove the "Address" and "DNS" options from the config file since we have to manually configure the interface instead of using the wg-quick command. Then we are free to setup up the interface and configure it: ```yaml - name: Copy config to server synchronize: src: mullvad.conf dest: /etc/wireguard/wg1.conf - name: remove Address entry ini_file: path: /etc/wireguard/wg1.conf section: Interface option: Address state: absent - name: remove DNS entry ini_file: path: /etc/wireguard/wg1.conf section: Interface option: DNS state: absent - name: add wireguard interface command: "ip link add dev wg1 type wireguard" - name: set wireguard config command: "wg setconf wg1 /etc/wireguard/wg1.conf" - name: set wireguard address command: "ip address add {{ address }} dev wg1" - name: put interface up command: "ip link set up dev wg1" - name: set nameserver command: "printf 'nameserver %s\n' '{{ dns }}' | resolvconf -a tun.wg1 -m 0 -x" - name: handle reverse path filtering command: "sysctl -w net.ipv4.conf.all.rp_filter=2" ``` ## Creating the Docker Network Finally all that is left is to create the docker network and setup the firewall to route it through wireguard: ```yaml - name: create docker network docker_network: name: docker-vpn0 ipam_config: - subnet: "{{ subnet_ip }}" - name: add firewall rule command: "ip rule add from {{ subnet_ip }} table 200" - name: add firewall route command: "ip route add default via {{ address.split('/')[0]}} table 200" ``` ### Media Center Series Part 1: Install Docker With Ansible URL: https://blog.matthewbrunelle.com/media-center-series-part-1-install-docker-with-ansible/ Last updated: 2025-05-17T15:59:23.000Z This series is a collection of notes from my experience setting up a media server. This first post covers using Ansible to setup [Docker](https://www.docker.com/?ref=blog.matthewbrunelle.com) and [Docker Compose](https://docs.docker.com/compose/?ref=blog.matthewbrunelle.com) on the server. # Ansible The [full Ansible playbook](https://github.com/ciferkey/media/blob/master/ansible.yml?ref=blog.matthewbrunelle.com) is available but I'm going to breakdown what went in to it. The file is designed so that you can drop it on to a box as a fresh root user and it will handle everything for you. I'm not coordinating multiple machines so the script will be run locally on the box you want to configure using the default localhost configuration: ```yaml - name: Docker connection: local gather_facts: yes hosts: localhost vars_prompt: - name: password prompt: "Password for docker user" private: yes encrypt: "sha512_crypt" confirm: yes salt_size: 7 ``` I've configured the playbook to [prompt](https://docs.ansible.com/ansible/latest/user%5Fguide/playbooks%5Fprompts.html?ref=blog.matthewbrunelle.com) for a password to use with for the docker user since there is no secretes management tool. That could be a nice to have but would be overkill for such a *simple demo application*. ## Docker User First we need a non root user to run the containers with. Instead of running these commands: ```bash adduser docker usermod -aG sudo docker ``` We can use the [user module](https://docs.ansible.com/ansible/latest/modules/user%5Fmodule.html?ref=blog.matthewbrunelle.com): ```yaml - name: Create docker user user: name: docker password: "{{password}}" update_password: on_create groups: - sudo - render - video shell: /bin/bash state: present ``` The docker user will also be added to the render and video groups to enable access to hardware transcoding. ## Install Docker The Docker [documentation recommends](https://docs.docker.com/install/linux/docker-ce/debian/?ref=blog.matthewbrunelle.com) the following installation process: ```bash sudo apt-get update sudo apt-get install \ apt-transport-https \ ca-certificates \ curl \ gnupg2 \ software-properties-common curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add - sudo add-apt-repository \ "deb [arch=amd64] https://download.docker.com/linux/debian \ $(lsb_release -cs) \ stable" sudo apt-get update sudo apt-get install docker-ce docker-ce-cli containerd.io ``` This involves installing some dependencies, adding a key and a new repository to the package manager and installing docker. Ansible has built in support for many of these operations which simplifies tasks. The [apt module](https://docs.ansible.com/ansible/latest/modules/apt%5Fmodule.html?ref=blog.matthewbrunelle.com) allows us to install dependencies. By using "update\_cache: yes" we can skip having a separate update step: ```yaml - name: Install Docker Deps apt: name: - apt-transport-https - ca-certificates - curl - gnupg2 - software-properties-common update_cache: yes ``` The [apt\_key module](https://manpages.ubuntu.com/manpages/bionic/man8/apt-key.8.html?ref=blog.matthewbrunelle.com) lets us pull in a new key from a URL: ```yaml - name: Install Docker Repository Key apt_key: url: https://download.docker.com/linux/debian/gpg ``` The [apt\_repository module](https://docs.ansible.com/ansible/latest/modules/apt%5Frepository%5Fmodule.html?ref=blog.matthewbrunelle.com) lets us add a new repository. Since I specified ["gather\_facts: yes"](https://docs.ansible.com/ansible/latest/modules/gather%5Ffacts%5Fmodule.html?ref=blog.matthewbrunelle.com) at the start of the playbook Ansible collected information about the machine before it started. This includes a "ansible\_distribution\_release" value that lets us use the correct repository for our Debian release: ```yaml - name: Add Docker Respository apt_repository: repo: "deb [arch=amd64] https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable" ``` Then we can install Docker from the new repository: ```yaml - name: Install Docker apt: name: - docker-ce - docker-ce-cli - containerd.io update_cache: yes ``` Finally instead having to do this to enable the docker service to run on startup: ```bash sudo systemctl enable docker sudo systemctl start docker ``` The [systemd module](https://docs.ansible.com/ansible/latest/modules/systemd%5Fmodule.html?ref=blog.matthewbrunelle.com) can be used instead: ```yaml - name: Set Docker to Run on Startup systemd: state: started name: docker enabled: true ``` ## Install Docker Compose The Docker Compose [documentation recommends](https://docs.docker.com/compose/install/?ref=blog.matthewbrunelle.com) the following installation process: ```bash sudo curl -L "https://github.com/docker/compose/releases/download/1.24.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose ``` This pulls a binary from Github, saves the binary under /usr/local/bin and sets the appropriate permissions for it. The [get\_url module](https://docs.ansible.com/ansible/latest/modules/get%5Furl%5Fmodule.html?ref=blog.matthewbrunelle.com) lets us fetch the binary off of GitHub. We use "ansible\_system" and "ansible\_architecture" from the gathered facts to generate the URL as opposed to [uname](http://man7.org/linux/man-pages/man2/uname.2.html?ref=blog.matthewbrunelle.com). As a part of getting the binary we can set the mode to 755 for the docker user to avoid doing this in a second step. Also as per the docs "You must either add a leading zero so that Ansible's YAML parser knows it is an octal number (like 0644 or 01777) or quote it (like '644' or '1777') so Ansible receives a string and can do its own conversion from string into number." ```yaml - name: Install Docker Compose get_url: url: https://github.com/docker/compose/releases/download/1.24.1/docker-compose-{{ ansible_system }}-{{ ansible_architecture }} dest: /usr/local/bin/docker-compose mode: '755' owner: docker ``` # Running the Playbook At this point you can curl the playbook to a fresh Debian machine and run the following: ```bash ansible-playbook ansible.yml ``` Now you will have Docker and Docker Compose ready to use on your machine. ### Glassistant Google Assistant on Google Glass Part I URL: https://blog.matthewbrunelle.com/glassistant-google-assistant-on-google-glass-part-i/ Last updated: 2025-05-17T15:46:13.000Z *Note: This port isn't really complete but its been sitting on the backburner for over a year now so I'm pushing it out as a draft.* Update 2019-12-07: Google has [announced a final software update for the explorer edition](https://support.google.com/glass/answer/9649198?hl=en&ref%5Ftopic=3063354&ref=blog.matthewbrunelle.com) that "removes the need and ability to use your Google account on Glass". I'll likely not come back to finish this experiment at this point. Google announced [Enterprise Edition 2](https://www.blog.google/products/hardware/glass-enterprise-edition-2/?ref=blog.matthewbrunelle.com) of Google Glass. I'm very jealous I cannot buy one but that is probably for the best considering I bought a used Google Glass XE-C (the last Glass revision available to consumers) off of Ebay back in February of 2018 and never finished my project with it... The goal of the project was to add Google assistant connectivity to the Google Glass. At the time (end of 2017) Google had just [announced](https://developers.googleblog.com/2017/12/the-google-assistant-sdk-new-languages.html?ref=blog.matthewbrunelle.com) updates to the Google Assistant SDK that allowed additional ways to call the assistant API. My plan was to adapt the examples given for running assistant on Android Things to instead run on the Glass. I decided to call the project Glassistant because it simultaneously breaks the branding guidelines both [Glass](https://developers.google.com/glass/distribute/branding-guidelines?ref=blog.matthewbrunelle.com) and [Assistant](https://developers.google.com/actions/policies/branding-policies?ref=blog.matthewbrunelle.com). ### Previous Implementation Back when I had implemented the project in 2018 I was able to issue Assistant requests over the API: ![First successful assistant SDK request from glass](https://blog.matthewbrunelle.com/images/first-request.png) However the project was lacking in a couple areas and Glass runs an older version of Android (4.4 KitKat) which complicates matters. The primary areas I wanted to address this time are: - There was a [bug in GRPC](https://github.com/grpc/grpc-java/pull/3971?ref=blog.matthewbrunelle.com) that prevented it from using the correct Security Provider. I had to depend on a custom build of GRPC while waiting for the changes to be released. - There was no pure voice driven invocation like on normal android devices. You had to first say "Ok Glass" to bring up the voice triggers menu and then say "ask Assistant....". - There was essentially no user interface. It would display the response from Assistant on a Glass Card but there was no clear flow and no way to make multiple requests without restarting. ### On the problem with Security Providers in GRPC: Android 4.4 KitKat does not come with newer Java security providers required for GRPC. Normally the recommended way to [update security providers](https://developer.android.com/training/articles/security-gms-provider?ref=blog.matthewbrunelle.com) on Android is through Google Play Services. Unfortunately play services is not available for Glass. Instead GRPC [recommends using](https://github.com/grpc/grpc-java/blob/master/SECURITY.md?ref=blog.matthewbrunelle.com#bundling-conscrypt) the conscript library by Google and manually registering the new security provider. Recent versions of GRPC-Java will now use conscrypt and you no longer need a custom build. ### On the problem of voice invocation and cleaner UX This is still a work in progress and enough work that I'd like to provide more detail in a future post. ### Porting Process The Glass SDK is still available for use in Android Studio. In the Android SDK section of the settings insure you have installed the 4.4 (KitKat) SDK Platform. It doesn't mention Glass but if you check the "Show Package Details" checkbox you will see it includes "Glass Development Kit Preview". ![Android Studio SDK settings for glass](https://blog.matthewbrunelle.com/images/sdk-settings.png) Next I created a new project starting with the Glass Immersion Activity as the base. ![Android Studio SDK settings for glass](https://blog.matthewbrunelle.com/images/glass-immersion-activity.png) To issue unregistered Glass voice commands during development you need to [add a permission](https://developers.google.com/glass/develop/gdk/voice?ref=blog.matthewbrunelle.com#unlisted%5Fcommands) to the manifest. The assistant integration is derived from [sample Google Assistant project for Android Things](https://github.com/androidthings/sample-googleassistant?ref=blog.matthewbrunelle.com). I first started by [migrating the GRPC module](https://github.com/ciferkey/glassistant/commit/23ad68bb16ef7f0a2f3ae849e41e1e33fdc58f63?ref=blog.matthewbrunelle.com) over into the base Glass project. I also had to [move to a newer version](https://github.com/ciferkey/glassistant/commit/0b71f58d572de31077f05dc15121fdf3a45f3d60?ref=blog.matthewbrunelle.com#diff-5b3066d7378782045d33568340c02ebb) of GRPC to fix the security provider bug. Next step was adapting the SDK integration they had to work on Glass. The [EmbeddedAssistant](https://github.com/androidthings/sample-googleassistant/blob/master/app/src/main/java/com/example/androidthings/assistant/EmbeddedAssistant.java?ref=blog.matthewbrunelle.com) class provides most of the heavy lifting but there are some Android Things related pieces that need to be excised since the demo can invoke assistant with a button press. Additionally since the Glass is running 4.4 there are are number of audio api updates (AudioDeviceInfo, AudioTrack, etc) that are not available and need to be replaced with calls for the older API. ### Testing There is no way to emulate Glass you can only test by running directly on the device. You also need to [enable debug](https://stackoverflow.com/questions/21542577/how-can-we-enable-debugging-mode-on-google-glass-for-testing-an-android-app-on-g?ref=blog.matthewbrunelle.com) on your Glass. Also since I was developing on Windows 10 I needed to [perform a fix](https://stackoverflow.com/questions/20435778/google-glass-is-not-listed-as-android-device-by-adb/42312419?ref=blog.matthewbrunelle.com#42312419) for adb to pick up Glass. ### KPOI A Kotlin DSL for working with Apache POI URL: https://blog.matthewbrunelle.com/kpoi-a-kotlin-dsl-for-working-with-apache-poi/ Last updated: 2025-05-17T15:44:56.000Z Sometimes in my line of work I have the pleasure of trying to programmatically generate spreadsheets or get to work on someone else's code which generates spreadsheets. In Java land the go to library for doing this is [Apache POI](https://poi.apache.org/?ref=blog.matthewbrunelle.com). The API for generating spreadsheets is very imperative and side effect based. I could not help be curious about what it might look like if the API was re-imagined with a [Domain Specific Language](https://martinfowler.com/books/dsl.html?ref=blog.matthewbrunelle.com) written in [Kotlin](https://kotlinlang.org/?ref=blog.matthewbrunelle.com). So like most normal humans I decided would to spend a couple weekends writing code that helps generate spreadsheets. ### What is a DSL? For those that not familiar with the concept, a Domain Specific Language is a "computer language specialized to a particular application domain". For example SQL (when you exclude the many extensions that have been made to it over the years) is a DSL specifically for querying databases. Contrast this to general purpose programming languages like Java, Python, Kotlin, and Haskell which are designed to cover a wide variety of problems. Some languages provide the ability to extend the syntax of the language to create DSLs *within* the language itself. This is fairly common in the ML (thats "Meta Language" not "Machine Learning") family of languages (Standard ML, OCaml, F Sharp) and languages that take inspiration from ML to varying degrees (Haskell, Scala, Rust) as well as modern languages not directly related to ML (Kotlin, Swift, Groovy). An example of a DSL you might have within a language is a [declarative way of generating HTML](https://kotlinlang.org/docs/reference/type-safe-builders.html?ref=blog.matthewbrunelle.com). This post is designed to: 1. Highlight the language features in Kotlin which make wrapping/calling existing Java code a pleasant experience. 2. Giving a medium scale example creating a Kotlin DSL. Some great additional guides on creating DSLs are also available: - [Writing DSLs in Kotlin (part 1)](https://proandroiddev.com/writing-dsls-in-kotlin-part-1-7f5d2193f277?ref=blog.matthewbrunelle.com) - [Kotlin DSLs: The Basics](https://dzone.com/articles/kotlin-dsl-basics?ref=blog.matthewbrunelle.com) - [Kotlin DSL: From Theory to Practice](https://dzone.com/articles/kotlin-dsl-from-theory-to-practice?ref=blog.matthewbrunelle.com) - [Create a DSL in Kotlin](https://kotlinexpertise.com/create-dsl-with-kotlin/?ref=blog.matthewbrunelle.com) At the end of this article we can take Apache POI code in Java which looks like this: And change it to the Kotlin DSL which looks like: ### Designing the Apache POI DSL: The design goals for the DSL were born from real world usage of POI: - Focus the DSL on generating workbooks rather than parsing existing workbooks - POI provides multiple spreadsheet [implementations](https://poi.apache.org/components/spreadsheet/?ref=blog.matthewbrunelle.com) with different version compatibility and performance characteristics. I wanted to build a single DSL for all for all the implementations that allows the underlying sheet type to be changed as needed. - It should provide defaults for common operations but allow the defaults to be easily overridden. For example you will not need to supply a row number when writing contiguous rows but you can set a specific row number if you wish. In order to build off Apache POI we need to understand the existing API. It is built off a hierarchy of concepts: - You start off by creating a *Workbook* - You can create *Sheets* from a Workbook (these are the tabs you would see at the bottom in Excel). After creating you can set Sheet level configuration. - You can create *Rows* from a Sheet. After creating you can set Row level configuration. - You can create *Cells* from a Row. After creating you set the value of a Cell and can set various styling information You can see the POI API is very *imperative*. You create each component from a previous component and then after creation you modify it is as needed. I want the DSL to instead allow you to define the workbook *declaratively* by creating a nested structure that mirrors the relation between the classes. ### Implementing the DSL: So how do we go about implementing a DSL to wrap an existing Java library? Well Kotlin provides some nice language features that we can build off of. #### Higher Order Functions, Lambdas as Blocks, Lambda as last parameter One set of fundamental language features are [lambdas and higher order functions](http://kotlinlang.org/docs/reference/lambdas.html?ref=blog.matthewbrunelle.com#higher-order-functions). These concepts will be familiar for those who have worked in Java 8\. The basic idea is that functions are "first class" can be passed as parameters to *other* functions. In our case lets say we want a method called "workbook" which will create a new Workbook for us. The method is passed a lambda which modifies the workbook and then returns the new workbook. In Java this would look like: Where as in Kotlin this would look like: So what on earth just happen in that last example method call? Basically if you have a method that only takes a single parameter which is a lambda then you can drop a lot of the syntax you would normally have to type out in Java. This is key to making a DSL! If you look closely at the final example you can see that it looks just like we are using a *expression* in the language such as the "if" expression. **This is why an internal DSL is often described as extending the language**. We are able to create new syntax which feels like its a part of the language itself! #### Receiver Types A second piece of syntax we can clean up in the DSL is the explicit reference to the Workbook. In the previous example we skipped naming the parameter we passed into the lambda and instead opted to use the default parameter name of "it". We can further simplify things by making use of [receiver types](https://kotlinlang.org/docs/reference/lambdas.html?ref=blog.matthewbrunelle.com#function-types). A receiver type lets you make the *explicit* workbook *implicit* so you can call the methods on it directly without having to reference the workbook by name. This means we can update the previous example as follows: Now we can call setHidden and setSelectedTab directly without referring to "it". #### Property Access Syntax Why do we use getters and setters in Java? One reason is that accessors enable encapsulation: if the underlying implementation of a class changes and we are directly referring to fields on a class then we must update all the places were we access fields that have changed. If we use accessor methods then we can change the underling implementation, update the methods and the external code can stay the same. Additionally getters and setters allow us to do things like check if the underlying value is initialized or do some form of type conversion. However these examples typically are the *exception* rather than the *norm*. Unless you are writing a library that you are exposing to others then you have the ability to refactor both the class and the code that calls a class. In fact the change can be easy and comprehensive when done with a modern IDE. It would be preferable then to instead allow access to a field directly but to provide a way to override the accessor's behavior if you encounter a circumstance when you need to. This is what language like C# and Kotlin allow you to do. In Kotlin you directly refer to a field on a class. Then if you need to you can define a getter or setter. However instead of explicitly referring to the accessor method, the method will instead be implicitly used when accessing the field: Additionally since Kotlin aims for a pleasant Java interop experience you can use Kotlin's property access syntax on Java classes that have a getter. Kotlin will handle the conversion for you. So if you have a Plain Old Java Object with getters and setters you can use it in Kotlin as if its a Kotlin class in most cases. This means we can update our previous example to: #### Default/Optional Parameters In Kotlin you can set default values for method parameters. This makes the parameters optional. In the previous examples we have been hard-coding the type of Workbook used as HSSFWorkbook (this an Excel '97 compatible workbook, .xls). What if a user wants to use a XSSFWorkbook (Excel 2007, .xlsx) instead? It would be nice if a user could provide the workbook to use if they wanted to. With a default parameter value we can enable this behavior: #### Extension Methods So after all that work we now have a nice way to create and modify Workbooks. What about Sheets, Rows and Cells though? For Workbooks we were able to create a default Workbook or use a provided Workbook. Will that work for the other parts? Remember that with Apache POI you need a parent object to create a child object: Lets think back to the example from the beginning we would like to create: You will notice that inside the lambda block passed to the "workbook" expression we want to have a similar looking "sheet" expression (starting on line 2). If we think back to the explanation of how the "workbook" expression works we realize that the "sheet" *expression* is actually a call to a method named "sheet" on the implicit Workbook object. However Workbook is a class from Apache POI that we do not have control over and it does not have any method like that! So what we need is a clean mechanism to enhance the existing Java classes from the API. If we were working in Java some options to achieve this are: - Use inheritance to extend the classes in the library to add new functionality. In this case it would generally be frowned upon. Item 18 in Effective Java (Favor composition over inheritance) makes the important distinction between using inheritance within your own projects vs across project boundaries: > It is safe to use inheritance within a package, where the subclass and the superclass implementations are under the control of the same programmers. It is also safe to use inheritance when extending classes specifically designed and documented for extension (Item 19). Inheriting from ordinary concrete classes across package boundaries, however, is dangerous ... Unlike method invocation, inheritance violates encapsulation \[Snyder86\]. In other words, a subclass depends on the implementation details of its superclass for its proper function. The superclass’s implementation may change from release to release, and if it does, the subclass may break, even though its code has not been touched. As a consequence, a subclass must evolve in tandem with its superclass, unless the superclass’s authors have designed and documented it specifically for the purpose of being extended. - Use composition to create a new object that wraps the original object and adds new functionality. Its perfectly acceptable to create the wrapper object but it can add extra syntax to the DSL since you will need to reach into the wrapper to get the inner class (littering the code with "wrapper.innerObject" references). Alternatively you could write the wrapper so that it has methods that cover every use case for the inner object but that would tightly couple the wrapper to the inner object and break encapsulation. - Use a static utility class to contain the new functionality. An example of this idea would be [Apache Commons](https://commons.apache.org/?ref=blog.matthewbrunelle.com) which provides many utility classes. For example the StringUtils class contains static methods like [containsIgnoreCase](https://commons.apache.org/proper/commons-lang/apidocs/org/apache/commons/lang3/StringUtils.html?ref=blog.matthewbrunelle.com#containsIgnoreCase-java.lang.CharSequence-java.lang.CharSequence-) which evaluates whether a given string contains a specific string while ignoring case. Static utility classes provides a clean separation of the original class (String) and the new functionality (the static methods) but adds noise to the syntax as you must now write something like StringUtils.containsIgnoreCase(bookText, searchPhrase) (or containsIgnoreCase(bookText, searchPhrase) if you use a static import). This unfortunately is not the clean call to just "sheet" that we want. It would be nice if there was a way to enhance the original class with new functionality similar to a static utility class (to avoid breaking encapsulation) but be able to retain the calling conventions we would get if we extended the class. Born from this need Kotlin provides [extension methods](https://kotlinlang.org/docs/reference/extensions.html?ref=blog.matthewbrunelle.com). Under the hood extensions methods basically generate a static utility class for you while allowing you too call the methods as if they were a true method belonging to the class. For example, in Apache POI you can merge cells in a sheet by defining the region that bounds the cells to merge and then saying you want to merge that region: In Kotline we can create an extension method called "merge" which directly takes the bounded region and handles creating the CellRangeAddress for you: Here you will noticed we defined the method as *Sheet.merge*. This is the special syntax for an extension method. It says we are defining the "merge" method on the existing "Sheet" type from Apache POI. Extension methods are a powerful basic language feature that we need to use to build a DSL for an existing library. Going back to the previous question of how to implement the "sheet" method we can do something like this: This leverages extensions methods as well as all the techniques from the previous sections. I even threw in some other Kotlin features *on top of that* (sorry not sorry). Breaking it down: > Workbook.sheet( - This is an extension method adding "sheet" to the Workbook class > name: String? = null - You can optionally pass a first parameter "name" - Why is the parameters type "String?" and not "String"? This is a very important Kotlin feature that I'm glossing over for now. Basically the type "String" can never be null. "String?" is a "String" that can also be null. This means null safety can be *checked and enforced at compile time* forcing authors to think about how to handle nulls. > block: Sheet.() -> Unit = {} - You can optionally pass a parameter "block". If you don't supply it then a lambda that does nothing is used. The block also uses a receiver type of Sheet. > return if (name != null) { > createSheet(name) > } else { > createSheet() > } - In Kotlin if statements with else branches are actually *expressions*. This means the statement will be equal to the final value of the branch that is evaluated (similar to the ternary operator "?:" in Java). In this example if name is not null it will create a sheet with that name. Otherwise it will create a sheet using the default name. Kotlin actually has a lot of very cool operators to handle [null safety](https://kotlinlang.org/docs/reference/null-safety.html?ref=blog.matthewbrunelle.com) but I opted not to use them here for clarity. > .apply(block) - In Java we have Object that all objects inherit from. It gives us sad methods likes the default equals and default toString. In Kotlin we have Any which classes inherit from and gives us a bunch of [useful methods](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-any/index.html?ref=blog.matthewbrunelle.com) like apply. Apply will call a supplied method on an object and then return the object. This is great for modifying a value and then passing it on. By using apply hear we avoid needing to make the block method return its input so you don't have to worry about returning anything when you write the block to pass into this method! Putting that all together, the sheet method essentially adds a new method to Workbook which creates a new Sheet from the workbook, applies the given lambda to it and then returns the Sheet. Whew... wait we still need row and cell. They leverage the same design more or less: There are some more Kotlin features here that could use more complete explanations but I'm glossing over since this is about DSLs and not a full Kotlin language tutorial: - What are the "rowNumber ?: physicalNumberOfRows" and "column ?: physicalNumberOfCells" doing? Well remember those null operators I mentioned? "?:" is the "elvis operator". If the first value is null then the operator evaluates to second value instead. - What is that "when" part? That is a ["When Expression"](https://kotlinlang.org/docs/reference/control-flow.html?ref=blog.matthewbrunelle.com#when-expression). Think of it like a better and safer switch statement. There are actually a couple different ways to use it. In this scenario the cell method is optionally taking a "value" parameter which can be any of the different types that Apache POI supports for cell values. The Cell class has overloaded the setCellValue method with a different implementation for each of the types it supports. This unfortunately breaks the automatic property access syntax so we use a When Expression to handle it. The branches in the When Expression matches the different types for "value" using the "is" keyword (similar to instanceOf in Java). Then we can call "cell.setCellValue(value)". Note that we do not need to explicitly cast the type of value to the type we matched. This is because Kotlin has [smart casts](https://kotlinlang.org/docs/reference/typecasts.html?ref=blog.matthewbrunelle.com#smart-casts). Since the branch already checks the type the compiler knows its correct and does not require us to specify it. Features like this are how you know that Kotlin was written by people who have spent many days writing in Java and wishing for something basically the same but less soul crushing. With that we now can use the example that was given in the beginning! Along with the extension methods above I have also implemented some additional extension methods for common scenarios. They can all be viewed in the [repository](https://github.com/ciferkey/kpoi/tree/master/src/main/kotlin/com/matthewbrunelle?ref=blog.matthewbrunelle.com) ### Testing Realistic Use Cases for kpoi While the documentation for Apache POI can sometimes be sparse there does exist the great [Busy Developers' Guide to HSSF and XSSF Features](https://poi.apache.org/components/spreadsheet/quick-guide.html?ref=blog.matthewbrunelle.com). It contains a series of code examples for common use cases and problems. To build out the tests for the DSL I took the example Java code from the guide and use it to generate workbooks. Then I create the equivalent workbooks using the Kotlin DSL and [compare the two](https://github.com/ciferkey/kpoi/blob/master/src/test/kotlin/com/matthewbrunelle/DevelopersGuide.kt?ref=blog.matthewbrunelle.com). This helps provide some test cases that are modeled after real world usage. An unfortunate aspect of Apache POI is that none of the classes implement Equals() due to the authors deciding there was not a clear concept for "equivalent workbooks". Because of this I had to resort to using reflection based comparison for the workbooks. My personal preference would be to use AssertJ but there is currently [ongoing work](https://github.com/joel-costigliola/assertj-core/issues/1002?ref=blog.matthewbrunelle.com) around the recursive comparison API so instead I opted to use Unitil's [assertReflectionEquals](http://www.unitils.org/apidocs/org/unitils/reflectionassert/ReflectionAssert.html?ref=blog.matthewbrunelle.com#assertReflectionEquals%28java.lang.Object,%20java.lang.Object,%20org.unitils.reflectionassert.ReflectionComparatorMode...%29) which recursively uses reflection to compare objects field by field. ### Potential future work Note that this DSL is specifically designed to cover declaratively creating workbooks. It has nothing to do with reading in notebooks and manipulating which is another use case for Apache POI. There still is interesting work that could be done around the read side to make it more pleasant. ### Migrating from a RDS Postgres Instance to Aurora Serverless URL: https://blog.matthewbrunelle.com/migrating-from-a-rds-postgres-instance-to-aurora-serverless/ Last updated: 2025-07-02T14:27:36.000Z Amazon has now opened up [Serverless Aurora](https://aws.amazon.com/rds/aurora/serverless/?ref=blog.matthewbrunelle.com) publicly. This means [Soup Bot](https://blog.matthewbrunelle.com/soup-bot-a-slack-bot-that-scrapes-specials-menus-from-instagram/) can follow the trend of serverless culture and free itself from the shackles of its [AWS Lambda function to handle starting and stopping an RDS instance](https://github.com/ciferkey/soup-bot/blob/master/provision.py?ref=blog.matthewbrunelle.com) and instead use the new on demand Aurora cluster. This is an especially timely change since Daves Fresh Pasta has stopped serving soup recently so now Soup Bot is not only serverless but also "soupless". The big promise of Serverless Aurora is that it will only charge you for on-demand usage and handle spinning up and down the compute resources for you. There are some cost differences however which may not make this an ideal choice if you are on the super low usage side like Soup Bot (running for only roughly five hours each week). Some differences that stood out to me: - RDS instances must provision a minimum of 20gb of storage even if you usage is much lower. This currently accounts for most of my current costs for running Soup Bot. Aurora Serverless does not have provisioned space, only used space so this is nice for a small databases. Thus the "$0.115 per GB-month of provisioned GP2 storage" for the RDS instance becomes "$0.10 per GB-month of consumed storage". - RDS instances take a longer time to start up (on the order of a couple minutes). Thus means the provisioning script is set to run 15 minutes before Soup Bot starts up. Aurora Serverless obviates this. However query time can be longer with Aurora Serverless if the cluster is cold and needs to spin up computer resources. - RDS instances can be setup with 1 vCPU/1 GiB memory ($0.018 per RDS db.t2.micro instance hour). Aurora Serverless allows a minimum capacity of 2 "capacity units" (sometimes they also seem to talk about "Aurora Compute Units" or "ACU") where each compute unit is 2gb of ram ($0.12 per compute hour, $0.10 per GB-month or storage). This means that I will need to use more computer power than I need. - Aurora Serverless does not allow public access for the database so I had to [use butt 9](https://aws.amazon.com/getting-started/tutorials/configure-connect-serverless-mysql-database-aurora/?ref=blog.matthewbrunelle.com) if I wanted to query the database. - Aurora Serverless does not allow creating a database during cluster setup like you would with a regular RDS instance. Instead it can be created by using the command line in butt9 or programatically ([sqlalchemy-utils](https://sqlalchemy-utils.readthedocs.io/en/latest/?ref=blog.matthewbrunelle.com) provides a create\_database method) I would also like to note the change is not a completely perfect comparison since Soup Bot was previously using a Postgres instance on RDS and Aurora Serverless currently only supports MySQL (Postgres support is coming later). This mean I had to [switch the engine](http://docs.sqlalchemy.org/en/latest/core/engines.html?ref=blog.matthewbrunelle.com#mysql) to mysql-connector-python and handle some differences between Postgres and MySQL (mainly the fact that I could not use the URL as as primary key due requirements around max length of text columns). The only difficulty I encountered was that I needed to make sure the Aurora instance was created in the same security group as the lambda function, and that I needed to updated the inbound rules on the security group to allow the correct ports for accessing MySQL (since it had previously been configured to only allow Postgres through). Once those changes were made the lambda was able to access the new database just the same as the old database. I'll be updating this space with a final cost comparison after it has had some time to run. ### Kotlin Bencode Decoding and Encoding URL: https://blog.matthewbrunelle.com/kotlin-bencode-decoding-and-encoding/ Last updated: 2025-05-17T15:30:38.000Z As a fun weekend project I tried my hand at rolling an encoder/decoder for [Bencode](https://en.wikipedia.org/wiki/Bencode?ref=blog.matthewbrunelle.com) (the encoding format used in [torrent files](https://en.wikipedia.org/wiki/Torrent%5Ffile?ref=blog.matthewbrunelle.com)). Code for the project is [available](https://github.com/ciferkey/kotlin-bencode?ref=blog.matthewbrunelle.com). The specification for how Bencoding works is defined in [The BitTorrent Protocol Specification v2](http://www.bittorrent.org/beps/bep%5F0052.html?ref=blog.matthewbrunelle.com) (which supersedes the [v1 specification](http://www.bittorrent.org/beps/bep%5F0003.html?ref=blog.matthewbrunelle.com)). The section defining bencoding states: ### Decoding > - Strings are length-prefixed base ten followed by a colon and the string. For example 4:spam corresponds to 'spam'. > - Integers are represented by an 'i' followed by the number in base 10 followed by an 'e'. For example i3e corresponds to 3 and i-3e corresponds to -3\. Integers have no size limitation. i-0e is invalid. All encodings with a leading zero, such as i03e, are invalid, other than i0e, which of course corresponds to 0. > - Lists are encoded as an 'l' followed by their elements (also bencoded) followed by an 'e'. For example l4:spam4:eggse corresponds to \['spam', 'eggs'\]. > - Dictionaries are encoded as a 'd' followed by a list of alternating keys and their corresponding values followed by an 'e'. For example, d3:cow3:moo4:spam4:eggse corresponds to {'cow': 'moo', 'spam': 'eggs'} and d4:spaml1:a1:bee corresponds to {'spam': \['a', 'b'\]}. Keys must be strings and appear in sorted order (sorted as raw strings, not alphanumerics). This relatively straight forward specification lends itself to a simple decoding process: - read the next character to parse and see if it matches one of the markers in the specification: - The digits 0 through nine mark a bencoded string - i marks a bencoded integer - l marks a bencoded listed - d marks a bencoded dictionary - try to decode the next part of the sequence based on the rules for that particular marker, recursing for lists and dictionaries It is a sequence of transformations over the Result type. Each of these steps could possibly fail: - read digits off the iterator. This could fail if the iterator does not have a next value. - convert them to an integer (representing the string's length). This could result in a parse failure. - try to consume the ':' separator. - try to read the correct number of characters from the iterator The upside of this approach is it requires no backtracking and only needs to look ahead a single character at a time. I chose to use [Guava's PeekingIterator](https://github.com/google/guava/wiki/CollectionHelpersExplained?ref=blog.matthewbrunelle.com#peekingiterator) to assist this. It allows you to peek at the next value in an iterator without actually removing it (the same way one might peek at the top value in a stack). Additionally I implemented some helper [extension methods](https://kotlinlang.org/docs/reference/extensions.html?ref=blog.matthewbrunelle.com) over PeekingIterator for common operations I was using to read characters off the iterator. For example reading characters while a condition is true: ### Internal Representation Internally I chose to represent the becoded information as an ADT using a [sealed class](https://kotlinlang.org/docs/reference/sealed-classes.html?ref=blog.matthewbrunelle.com). Here is a simplified version of it (omitting methods): Additionally I wanted to try and produce meaningful error information when decoding failed. I enjoyed using [Results in Rust](https://doc.rust-lang.org/book/first-edition/error-handling.html?ref=blog.matthewbrunelle.com#the-result-type) to do this before and wanted to see how it could work in Kotlin. I found a [Result library](https://github.com/kittinunf/Result?ref=blog.matthewbrunelle.com) and refactored the decoder to use it. On the whole I found it easier to propagate error messages but I'm not sure if it created idiomatic Kotlin code. For example the Bencoded String parsing looks like: ### Encoding In comparison to decoding the encoding process is even simpler: - for a string prepend the length on the string with a ':' separating them - for a integer wrap the base 10 string representation of the number in 'i' and 'e' - for a list recursively encode the list elements, concatenate them and wrap them in an 'l' and 'e' - for a dictionary recursively encode the key value pairs, concatenate them and wrap them in an 'd' and 'e' ### Testing As far as testing the decoding and encoding I found example torrent files from the Webtorrent [Testing Fixtures](https://github.com/webtorrent/webtorrent-fixtures?ref=blog.matthewbrunelle.com) project which are available for use under Creative Commons. This was a great way to test real world example of bencoded data. On large file in particular even revealed a performance problem around bencode string parsing. ### Spring Fu Test Project: Link Unfurling Service URL: https://blog.matthewbrunelle.com/spring-fu-test-project-link-unfurling-service/ Last updated: 2025-05-17T15:27:29.000Z > Every programmer occasionally, when nobody’s home, turns off the lights, pours a glass of scotch, puts on some light German electronica, and opens up a file on their computer. It’s a different file for every programmer. Sometimes they wrote it, sometimes they found it and knew they had to save it. They read over the lines, and weep at their beauty, then the tears turn bitter as they remember the rest of the files and the inevitable collapse of all that is good and true in the world. > From [Programming Sucks](https://www.stilldrinking.org/programming-sucks?ref=blog.matthewbrunelle.com) ### Spring Fu I have been messing around with a new Spring project called [Spring Fu](https://github.com/spring-projects/spring-fu?ref=blog.matthewbrunelle.com). Pivotal has a nice [introduction video](https://spring.io/blog/2018/06/13/spring-tips-spring-fu?ref=blog.matthewbrunelle.com) for the project. The key takeaways about Spring Fu are: - Its an experimental test bed for new Spring Boot features. The project is currently pre 0.0.1 so many things are still in flux. - Takes Spring 5 as a base (See [Whats new in Spring 5](https://spring.io/blog/2016/09/22/new-in-spring-5-functional-web-framework?ref=blog.matthewbrunelle.com) if you haven't looked yet at what has changed) - The project is designed to be a lightweight micro-framework with low resource usage and fast startup. - Its written in Kotlin, the language made by JetBrains which has some [nice improvements over Java](https://kotlinlang.org/docs/reference/comparison-to-java.html?ref=blog.matthewbrunelle.com). - Provides a DSL leveraging [Functional Configuration](http://www.baeldung.com/spring-5-functional-beans?ref=blog.matthewbrunelle.com) which helps reduce the amount of reflection used. - Built off of [Webflux](https://docs.spring.io/spring/docs/current/spring-framework-reference/web-reactive.html?ref=blog.matthewbrunelle.com) for reactive programming and there is work in progress to adapt this to work with Kotlin's [coroutines](https://kotlinlang.org/docs/reference/coroutines.html?ref=blog.matthewbrunelle.com). The project builds off the minimal reactive example from the github project. I had briefly touched Kotlin and Gradle prior to this project when on an Android project but this was my first time working with the [Kotlin Gradle DSL](https://blog.gradle.org/kotlin-meets-gradle?ref=blog.matthewbrunelle.com). As always, [the code](https://gitlab.com/ciferkey/fu-unfurl?ref=blog.matthewbrunelle.com) is available for viewing. ### Link Unfurling Example Project I wanted a small and focused idea to test Spring Fu with. I decided to create a simple API for [unfurling links](https://medium.com/slack-developer-blog/everything-you-ever-wanted-to-know-about-unfurling-but-were-afraid-to-ask-or-how-to-make-your-e64b4bb9254?ref=blog.matthewbrunelle.com). The high level overview of the unfurling logic is: 1. Fetch the requested page. This is the only step that involves I/O and it should be done reactively so its non blocking. 2. If metadata in the head section contains tags for [OpenGraph](http://ogp.me/?ref=blog.matthewbrunelle.com) or [Twitter Cards](https://developer.twitter.com/en/docs/tweets/optimize-with-cards/overview/markup.html?ref=blog.matthewbrunelle.com) use that information 3. Otherwise use default unfurler which uses the first paragraph as the description and the first image as the thumbnail 4. Return JSON response containing title, thumbnail, and description For example if you requested to unfurl "[http://www.baeldung.com/spring-reactor](http://www.baeldung.com/spring-reactor?ref=blog.matthewbrunelle.com)" you would get back the following response: In this case there was OpenGraph meta data that was used to build the response. The application configuration ended up very similar to the sample configuration for the project except that I configure beans specific to my handler and I also add Jackson as a codec since I'm producing a JSON response: This sample project is small so there is only one API endpoint I have setup "/unfurl/{url}". The sample code had inlined the routing logic into the main configuration but I chose to separate it like you would in a larger project. Additionally the routes do not sit inside a class since Kotlin allows top level methods. Handlers in Webflux take a ServerRequest which contains all the request information and the handlers generate a [Mono](https://projectreactor.io/docs/core/release/api/reactor/core/publisher/Mono.html?ref=blog.matthewbrunelle.com). Internally the handler is generating a Mono of the fetched document using [Spring Webclient](https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-webclient.html?ref=blog.matthewbrunelle.com) which is the reactive equivalent of the traditional RestTemplate. (As a side note, if I had needed to call another API rather than just fetch a page I would be tempted to use [Feign](https://github.com/OpenFeign/feign?ref=blog.matthewbrunelle.com). It seems like there is current [work in progress](https://github.com/spring-cloud/spring-cloud-openfeign/pull/11?ref=blog.matthewbrunelle.com) to create a reactive version of Feign which I definitely would like to try out at some point in the future). Finally I've omitted the logic for [extracting the document information](https://github.com/ciferkey/spring-fu-unfurl/blob/master/src/main/kotlin/com/matthewbrunelle/blog/Extractors.kt?ref=blog.matthewbrunelle.com) since it is not Spring Fu specific. ### Interacting with the developers behind the project As I had mentioned Spring Fu is still very new and there were some bugs I encountered but the [Kotlin Slack](http://slack.kotlinlang.org/?ref=blog.matthewbrunelle.com) was super helpful for resolving through them: - I couldn't import webflux-jackson to handle configuring json decoding/encoding. [I got a response](https://kotlinlang.slack.com/archives/C0B8ZTWE4/p1531774520000016?thread%5Fts=1531767215.000097&cid=C0B8ZTWE4&ref=blog.matthewbrunelle.com) to my question in the #spring channel and then a swift [fix](https://github.com/spring-projects/spring-fu/commit/798b9dfd347d18e4c7683c86575183fbb708d0b1?ref=blog.matthewbrunelle.com) in the project repository. - There was a bug around having a separate class for handling routing and it seems its [due to a recent refactor](https://kotlinlang.slack.com/archives/C0B8ZTWE4/p1532095680000287?ref=blog.matthewbrunelle.com), but I [got a response](https://kotlinlang.slack.com/archives/C0B8ZTWE4/p1532104961000327?ref=blog.matthewbrunelle.com) and a [fix](https://github.com/spring-projects/spring-fu/commit/f7309ac0c11659fc3acd16081ba841a78ae398a6?ref=blog.matthewbrunelle.com) was made. Overall I enjoyed the experience and am excited to try out another project once 0.0.1 lands! ### Soup Bot: A Slack Bot That Scrapes Specials Menus From Instagram URL: https://blog.matthewbrunelle.com/soup-bot-a-slack-bot-that-scrapes-specials-menus-from-instagram/ Last updated: 2025-07-02T14:29:27.000Z There is a sandwich place near my work that I visit frequently for lunch. They have an [Instagram](https://www.instagram.com/davesfreshpasta/?ref=blog.matthewbrunelle.com) where they post their daily specials menu which is convenient since I can see what the specials are before heading over. However in some form of twisted sloath I wanted to be directly told whenever there was a special menu update. Thus Soup Bot was born. [Source is available](https://gitlab.com/ciferkey/soup-bot?ref=blog.matthewbrunelle.com) The goal was simple: check for new posts on Instagram and if the new post is a specials board (and not some other photo) send a message over Slack. The anticipated plan was: 1. Scrape all the posted images from the Instagram profile @davesfreshpasta and seperate images of the specials board from the rest. 2. Retrain a Tensorflow image classifier to learn the new categories of Soup and Not Soup. 3. Create a script that will - Check for new posts and classify them. - If the new post is Soup, send a message to slack 4. Find a cheap and simple way to deploy the whole thing (I ended up going with AWS Lambda) 5. Get soup on the days where there are specials that I like. ## Scraping the Training Corpus In the beginning I was just toying with the idea of soup bot so rather than script the process of downloading all the images I used a [browser extension](https://chrome.google.com/webstore/detail/downloader-for-instagram/olkpikmlhoaojbbmmpejnimiglejmboe?hl=en&ref=blog.matthewbrunelle.com) to download all \~7500 images from their Instagram profile and then sorted them by hand. This seemed reasonable at the time since it was a one time process. Classification results for this initial corpus have seemed good enough so far that I'm not sure if I will ever end up downloading new images and retraining. Here is an example of an image that I want to categorize as a specials board: > [Wednesday treatz!](https://www.instagram.com/p/Bij6ISCAHOi/?ref=blog.matthewbrunelle.com) > > A post shared by [ DFP](https://www.instagram.com/davesfreshpasta/?ref=blog.matthewbrunelle.com) (@davesfreshpasta) on May 9, 2018 at 8:05am PDT Here is an example of an image that should be the "not soup" category: > [We only pack the finest watermelon slices🍉🍭💁‍♀️](https://www.instagram.com/p/BiZuIl1hbD2/?ref=blog.matthewbrunelle.com) > > A post shared by [ DFP](https://www.instagram.com/davesfreshpasta/?ref=blog.matthewbrunelle.com) (@davesfreshpasta) on May 5, 2018 at 9:07am PDT ## Classification In order to tell which photos were specials boards and which were not I need to train an image classifier. Thankfully Tensorflow has a [retraining script](https://www.tensorflow.org/tutorials/image%5Fretraining?ref=blog.matthewbrunelle.com) with great documentation for retraining an existing ImageNet classier. After an original test with the default retraining setting I switched from using the regular ImageNet classifier to a MobileNet classifier to cut operating costs. I dubbed it the the Mobile Soup classifier. It resulted in a cool savings of: - \~10x reduction model size. 85mb to 8.9mb (important since AWS Lambda constrains the size of your deployment bundle) - \~2x increase run time speed. cProfile in Pycharm showed a \~6800ms down to \~3100ms for the total runtime of the tensorflow method calls. - \~3x reduction memory usage (this metric is mostly eyeballed...) - a relatively small loss in accuracy (that I have not put the time in to quantify). Luckily I only need to differentiate between two classes of images so accuracy has been very good (positive classifications are typically greater than 98%). ## The Script Oddly enough two weeks before I started development Instagram [deprecated the official APIs methods](https://www.instagram.com/developer/changelog/?ref=blog.matthewbrunelle.com) I could have used. Luckily nature finds a way and there are existing libraries like [Instalooter](https://github.com/althonos/InstaLooter?ref=blog.matthewbrunelle.com) that work off scraping pages rather than using the official APIs. The library makes its easy to fetch posts over a specific time range. I keep track of the fetched images and the classifications using a standard setup with [SQLAlchemy](https://www.sqlalchemy.org/?ref=blog.matthewbrunelle.com). Local testing runs against a SQLite database. The Lambda methods on AWS uses a PostgreSQL RDS instance (the tiniest one they have). The final part was Slack integration. Originally I was going to go with a whole subscription model where people could ask to receive updates and then be notified with private messages but a coworker pointed out I could just have a channel that the updates get posted to which people could subscribe (and thats much easier to do). I used the library [Slacker](https://github.com/os/slacker?ref=blog.matthewbrunelle.com) to handle the integration. ## Deploying To save time with deploying I went with [Serverless](https://serverless.com/?ref=blog.matthewbrunelle.com) to ease configuring AWS lambda. Additionally since the dependencies are quite large (we need to include tensorflow and all its dependencies) and they need to be included in the deployed bundle I used the [Serverless Python Requirements](https://github.com/UnitedIncome/serverless-python-requirements?ref=blog.matthewbrunelle.com) plugin to help with bundling and compressing the dependencies (especially useful for getting the correct tensorflow/numpy binaries in the bundle). The main complications I ran into when setting up AWS were around VPC configuration but others before me have [resolved the issues before](https://gist.github.com/reggi/dc5f2620b7b4f515e68e46255ac042a7?ref=blog.matthewbrunelle.com). Finally CloudWatch was used to schedule events to kick off the lambda function, and to [start and stop the RDS instance](https://www.codeproject.com/Articles/1190194/Start-Stop-RDS-instances-on-schedule?ref=blog.matthewbrunelle.com) in order to reduce costs. **Update 6/3/2018:** So it turns out NAT Gateways are quite pricey (\~$30 a month). It seems [other people have come to this realization as well](https://hackernoon.com/dealing-with-an-aws-billing-surprise-beware-the-defaults-d8a95f6635a2?ref=blog.matthewbrunelle.com). As an alternative I've replaced the NAT Gateway with a scheduled NAT Instance on EC2 using the images provided by AWS. Finally for additional cost savings I'm using [another set of scheduled lambdas](https://aws.amazon.com/premiumsupport/knowledge-center/start-stop-lambda-cloudwatch/?ref=blog.matthewbrunelle.com) to start and stop the EC2 instance. **Update 6/30/2018:** Another caveat I found while monitoring the billing dashboard: AWS will not charge you for an Elastic IP associated with a running EC2 instance. However if you keep an EIP associated with a stopped EC2 instance then you will be charged (around $3 a month) to incentive people not to horde IP addresses. [Details on EIP billing works](https://aws.amazon.com/premiumsupport/knowledge-center/elastic-ip-charges/?ref=blog.matthewbrunelle.com). Rather than creating a third set of lambdas to handle the allocation/association and deallocation/disassociation I instead dropped the original lambdas and created a [new single Python 3.6 lambda](https://gitlab.com/ciferkey/soup-bot/blob/master/provision.py?ref=blog.matthewbrunelle.com) to handle starting up and shutting down. Additionally I started the process of moving all the informal AWS configuration I had manually configured into the serverless configuration. **Update 8/16/2018** I've migrated Soup Bot to use Aurora Serverless. Details are available in [another post](https://blog.matthewbrunelle.com/migrating-from-a-rds-postgres-instance-to-aurora-serverless/). ## Conclusion So in the end was [it](https://xkcd.com/1205/?ref=blog.matthewbrunelle.com) [worth](https://xkcd.com/1319/?ref=blog.matthewbrunelle.com) [it](https://xkcd.com/1445/?ref=blog.matthewbrunelle.com)? Probably not. Was it enjoyable? Certainly. ### H320 Research Paper Response: Understanding Regression Failures Through Test-Passing and Test-Failing Code Changes URL: https://blog.matthewbrunelle.com/h320-research-paper-response-understanding-regression-failures-through-test-passing-and-test-failing-code-changes/ Last updated: 2025-08-13T00:05:22.000Z This post is a part of my [blog paleontology series](https://blog.matthewbrunelle.com/tag/paleontology/). I dug up and restored this post on 2025-08-12\. I made corrections to a *number* of spelling mistakes. I've also updated broken links where necessary. When I studied CS at UMass Amherst I took [CS 320](https://people.cs.umass.edu/~brun/class/2013Spring/CS320/?ref=blog.matthewbrunelle.com) Introduction to Software Engineering. There was a 1 credit addon honors students could take. That was my first exposure to reading research papers and was a nice introduction into how to approach them. --- This semester I am currently enrolled in a one credit honors extension to my SE class at the University of Massachusetts Amherst. We recently read this paper [paper](http://people.cs.umass.edu/~brun/pubs/pubs/Sukkerd13icse-nier.pdf?ref=blog.matthewbrunelle.com) which my Professor [Yuriy Brun](http://people.cs.umass.edu/~brun/?ref=blog.matthewbrunelle.com) is working on. He is my take on it: ## Problem Isolating the source code changes that are responsible for test failures is time consuming and challenging. ## Solution Source code change are broken down into subsets with the intent of isolating the failure cause. Two subsets, the minimal set of changes that produce the failure, and the complement of the maximum set of changes that pass, are both helpful for finding failure causes. Neither always contains the correct section and the combination leads to greater coverage of failure causes. ## How well did it work? In 78% of the 45 regression failures studied the complement of the maximum set of changes that pass contained relevant information not in the minimal set of changes that produce the failure. ## Related Work M. Eaddy et al. considered changes related to the most cross-cutting concerns. N. Nagappan and T. Ball looked into modules with the highest churn. T. Zimmermann et al. investigated changes to most recent changes. and T. Zimmermann and N. Nagappan worked with changes to modules with the most dependencies. A. Zeller and R. Hildebrandt developed an approach to finding the smallest subset of the recent changes that exhibits test failure. ## Contribution The main contribution of the paper is the observations that the maximum set of changes that pass and the minimum set of changes that fail are often not complementary (rather than any particular approach to finding the complement of the max set or how the two set can be used together). The goals was to point out the counterintuitive finding. ## Questions Even though the goal is to bring attention to the difference between the two sets, what approach was used with the preliminary study? \~ Matt ### H320 Research Paper Response: Fine-Grained Version Control System for Java URL: https://blog.matthewbrunelle.com/h320-research-paper-response-fine-grained-version-control-system-for-java/ Last updated: 2025-08-12T23:58:27.000Z This post is a part of my [blog paleontology series](https://blog.matthewbrunelle.com/tag/paleontology/). I dug up and restored this post on 2025-08-12\. I made corrections to a *number* of spelling mistakes. I've also updated broken links where necessary. When I studied CS at UMass Amherst I took [CS 320](https://people.cs.umass.edu/~brun/class/2013Spring/CS320/?ref=blog.matthewbrunelle.com) Introduction to Software Engineering. There was a 1 credit addon honors students could take. That was my first exposure to reading research papers and was a nice introduction into how to approach them. --- This semester I am currently enrolled in a one credit honors extension to my SE class at the University of Massachusetts Amherst. We recently read this [paper](https://dl.acm.org/citation.cfm?id=2024463&ref=blog.matthewbrunelle.com) and were asked to response. He is my take on it: ## Problem Tracking the evolution of software systems can provide valuable insight. However few solutions exist to track changes on a fine grained (classes, methods, etc) level. ## Solution Historage builds on top of the coarse grained (line-by-line file diff) level version control of git to provide the ability to trace changes of entities (moving, remaining, etc). ## How well did it work? Historage was applied to five open source Java software projects: WTP incubator, Hadoop, Subversion, jEdit, and the Android Framework. It provides fine-grained entity remaining at a precision of 97% (when the matching threshold is 30%). ## Related Work Kim an Notkin provided a method for one-to-one matching techniques (based off syntax and the code graph) as well as origin analysis (renaming and moving) and achieved a function body diff detection rate of 90.2%. Godfrey and Zou proposed a technique for inferring events based on splitting and merging. RefactingCrawler applied the idea of detecting common code refactorings in code changes. Xing and Stroulia proposed an approach for monitoring API evolution. ## Contribution Fine-grained VCS can provide valuable information about software systems, such as historical changes to the code base and the evolution of the system. The fact that it can be implemented on top of existing VCS using pre existing changesets means it can be applied to previously existing projects. ## Questions Do certain languages lend themselves better for fine grained tracking? (For example Haskell is quite concise and fits quite a lot of information into only a few lines). \~ Matt ### H320 Research Paper Response: Simplifying and Isolating Failure-Inducing Input URL: https://blog.matthewbrunelle.com/h320-research-paper-response-simplifying-and-isolating-failure-inducing-input/ Last updated: 2025-08-12T23:57:40.000Z This post is a part of my [blog paleontology series](https://blog.matthewbrunelle.com/tag/paleontology/). I dug up and restored this post on 2025-08-12\. I made corrections to a *number* of spelling mistakes. I've also updated broken links where necessary. When I studied CS at UMass Amherst I took [CS 320](https://people.cs.umass.edu/~brun/class/2013Spring/CS320/?ref=blog.matthewbrunelle.com) Introduction to Software Engineering. There was a 1 credit addon honors students could take. That was my first exposure to reading research papers and was a nice introduction into how to approach them. --- This semester I am currently enrolled in a one credit honors extension to my SE class at the University of Massachusetts Amherst. We recently read this [paper](https://www.cs.purdue.edu/homes/xyzhang/fall07/Papers/delta-debugging.pdf?ref=blog.matthewbrunelle.com) and were asked to response. He is my take on it: ## Problem "Given some test case, a program fails. Which circumstances of the test case are responsible for the particular failure?" ## Solution "...generalizes and simplifies the failing test case to a minimal test case that still produces the failure" by repeatedly dividing the test case to isolate a locally minimal (1-minimal) input. ## How well did it work? Three test cases were presented: In the GCC case study the improved version DD required only 59 tests and points to a relative difference of only two characters. The fuzzed unix input case study saw a similar size reduction as well. The firefox case study was able to reduce a complex description and large (40K character) html document down to a handful of x events and `